1use crate::parser_supervisor::{
4 OptionalParserSupervisor, ParserSupervisorError, admit_optional_parser_artifact,
5};
6use projectatlas_core::optional_parser_pack::{
7 OPTIONAL_PARSER_PACK_ID, OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES,
8 OPTIONAL_PARSER_PACK_MAX_ARCHIVE_BYTES, OPTIONAL_PARSER_PACK_MAX_EXPANDED_BYTES,
9 OPTIONAL_PARSER_PACK_MAX_FILE_BYTES, OPTIONAL_PARSER_PACK_MAX_FILE_ENTRIES,
10 OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION, OptionalParserCapability,
11 OptionalParserPackArtifactManifest, OptionalParserPackManifest,
12 OptionalParserPackManifestError, PackPlatform, PackRelativePath,
13};
14use projectatlas_core::optional_parser_protocol::{ParserArtifactIdentity, ParserContentDigest};
15use serde::{Deserialize, Serialize};
16use sha2::{Digest as _, Sha256};
17use std::collections::BTreeMap;
18use std::env;
19use std::fs::{self, File, OpenOptions};
20use std::io::{self, BufReader, Read, Write};
21use std::path::{Path, PathBuf};
22#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
23use std::process::{Command, Stdio};
24use std::sync::OnceLock;
25#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
26use std::thread;
27#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
28use std::time::{Duration, Instant};
29#[cfg(test)]
30use tar::EntryType;
31use tempfile::{NamedTempFile, TempDir};
32use thiserror::Error;
33
34const ARCHIVE_ROOT: &str = "projectatlas-broad-parser";
36const ACCEPTED_MANIFEST_FILE_NAME: &str = "accepted-capabilities.json";
38const ARTIFACT_MANIFEST_FILE_NAME: &str = "artifact-manifest.json";
40pub const OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH: &str =
42 ".projectatlas/optional-parser-pack.json";
43const PROJECT_SELECTION_SCHEMA_VERSION: u32 = 1;
45const PROJECT_SELECTION_MAX_BYTES: u64 = 16 * 1024;
47const TAR_FRAMING_ALLOWANCE_BYTES: u64 = 1024 * 1024;
49const PAYLOAD_MODE: u32 = 0o644;
51const WORKER_MODE: u32 = 0o755;
53const LIFECYCLE_METADATA_ENTRY_LIMIT: usize = 1_024;
55const OPTIONAL_PARSER_PACK_LEASE_FILE_NAME: &str = ".projectatlas-broad-parser.lifecycle.lock";
57const OPTIONAL_PARSER_SELECTION_LEASE_FILE_NAME: &str = "optional-parser-pack.selection.lock";
60#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
62const WINDOWS_CONTAINMENT_BROKER_FILE_NAME: &str = "projectatlas-parser-containment.exe";
63#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
65const WINDOWS_PROFILE_CLEANUP_ARGUMENT: &str = "cleanup-artifact-profile";
66#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
68const WINDOWS_PROFILE_CLEANUP_RESULT: &str = "[parser-containment] artifact profile cleanup passed";
69#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
71const WINDOWS_PROFILE_CLEANUP_TIMEOUT: Duration = Duration::from_secs(30);
72#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
74const WINDOWS_PROFILE_CLEANUP_REAP_TIMEOUT: Duration = Duration::from_secs(5);
75#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
77const WINDOWS_PROFILE_CLEANUP_OUTPUT_BYTES: u64 = 64 * 1024;
78#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
80const WINDOWS_REMOVING_TOMBSTONE_PREFIX: &str = ".pa-r-";
81#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
83const WINDOWS_CLEANED_TOMBSTONE_PREFIX: &str = ".pa-c-";
84#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
86const WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS: usize = 32;
87#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
89const WINDOWS_PROCESS_CURRENT_DIRECTORY_MAX_UTF16_UNITS: usize = 258;
90
91#[derive(Debug, Error)]
93pub enum OptionalParserPackLifecycleError {
94 #[error("optional parser containment is unsupported on {os}/{architecture}")]
96 UnsupportedContainment {
97 os: &'static str,
99 architecture: &'static str,
101 },
102 #[error("could not determine the user-owned optional parser-pack storage root")]
104 StorageRootUnavailable,
105 #[error(
107 "optional parser-pack lifecycle is busy at {path:?}; retry after the active operation finishes"
108 )]
109 Busy {
110 path: PathBuf,
112 },
113 #[error("{operation} failed for {path:?}: {source}")]
115 Io {
116 operation: &'static str,
118 path: PathBuf,
120 #[source]
122 source: io::Error,
123 },
124 #[error("optional parser-pack lifecycle data is invalid: {reason}")]
126 InvalidData {
127 reason: String,
129 },
130 #[error("{0}")]
132 Manifest(#[from] OptionalParserPackManifestError),
133 #[error("{0}")]
135 Supervisor(#[from] ParserSupervisorError),
136 #[error("optional parser-pack JSON is invalid: {0}")]
138 Json(#[from] serde_json::Error),
139 #[error("optional parser-pack cleanup was incomplete: {message}")]
141 CleanupIncomplete {
142 message: String,
144 },
145 #[error("optional parser-pack lifecycle operation failed and cleanup also failed")]
147 OperationAndCleanup {
148 operation: Box<Self>,
150 cleanup: Box<Self>,
152 },
153}
154
155impl OptionalParserPackLifecycleError {
156 #[must_use]
158 pub const fn is_unsupported_containment(&self) -> bool {
159 matches!(self, Self::UnsupportedContainment { .. })
160 }
161}
162
163fn finish_with_cleanup<T>(
165 operation: Result<T, OptionalParserPackLifecycleError>,
166 cleanup: Result<(), OptionalParserPackLifecycleError>,
167) -> Result<T, OptionalParserPackLifecycleError> {
168 match (operation, cleanup) {
169 (Ok(value), Ok(())) => Ok(value),
170 (Err(operation), Ok(())) => Err(operation),
171 (Ok(_), Err(cleanup)) => Err(cleanup),
172 (Err(operation), Err(cleanup)) => {
173 Err(OptionalParserPackLifecycleError::OperationAndCleanup {
174 operation: Box::new(operation),
175 cleanup: Box::new(cleanup),
176 })
177 }
178 }
179}
180
181#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
183#[serde(rename_all = "snake_case")]
184pub enum OptionalParserPackOperation {
185 Verify,
187 Install,
189 Enable,
191 Update,
193 Disable,
195 Remove,
197 Status,
199}
200
201#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
203#[serde(rename_all = "snake_case")]
204pub enum OptionalParserPackState {
205 UnsupportedContainment,
207 Absent,
209 InstalledDisabled,
211 Enabled,
213 RollbackReady,
215 Stale,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
221pub struct OptionalParserPackSlotReport {
222 pub projectatlas_version: String,
224 pub artifact: String,
226 pub present: bool,
228}
229
230#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
232pub struct OptionalParserPackLifecycleReport {
233 pub operation: OptionalParserPackOperation,
235 pub state: OptionalParserPackState,
237 pub pack_id: &'static str,
239 pub supported: bool,
241 pub capability: OptionalParserCapability,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub platform: Option<&'static str>,
246 pub installed_slots: usize,
248 pub installed_slots_truncated: bool,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 pub selected: Option<OptionalParserPackSlotReport>,
253 #[serde(skip_serializing_if = "Option::is_none")]
255 pub rollback: Option<OptionalParserPackSlotReport>,
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub artifact: Option<OptionalParserPackSlotReport>,
259 pub changed: bool,
261}
262
263#[must_use = "temporary parser artifact profiles require cleanup or installed-slot transfer"]
270pub struct TemporaryParserArtifactProfile {
271 pack_root: PathBuf,
273 artifact: ParserArtifactIdentity,
275 cleanup_pending: bool,
277}
278
279impl TemporaryParserArtifactProfile {
280 pub fn for_verified_supervisor(supervisor: &OptionalParserSupervisor) -> Self {
285 Self {
286 pack_root: supervisor.pack_root().to_path_buf(),
287 artifact: supervisor.artifact_identity().clone(),
288 cleanup_pending: true,
289 }
290 }
291
292 #[cfg(test)]
294 fn new(pack_root: impl Into<PathBuf>, artifact: ParserArtifactIdentity) -> Self {
295 Self {
296 pack_root: pack_root.into(),
297 artifact,
298 cleanup_pending: true,
299 }
300 }
301
302 pub fn cleanup(mut self) -> Result<(), OptionalParserPackLifecycleError> {
309 self.cleanup_pending_profile()
310 }
311
312 fn transfer_to_installed_slot(&mut self) {
314 self.cleanup_pending = false;
315 }
316
317 fn cleanup_pending_profile(&mut self) -> Result<(), OptionalParserPackLifecycleError> {
319 if !self.cleanup_pending {
320 return Ok(());
321 }
322 cleanup_platform_profile(&self.pack_root, &self.artifact)?;
323 self.cleanup_pending = false;
324 Ok(())
325 }
326}
327
328impl Drop for TemporaryParserArtifactProfile {
329 fn drop(&mut self) {
330 drop(self.cleanup_pending_profile());
331 }
332}
333
334#[derive(Clone, Debug)]
336pub struct OptionalParserPackLifecycle {
337 project_root: PathBuf,
339 storage_root: OnceLock<Option<PathBuf>>,
341 capability: OptionalParserCapability,
343 #[cfg(test)]
345 admission_failure: Option<fn(&Path) -> ParserSupervisorError>,
346 #[cfg(test)]
348 selection_publication_failure: bool,
349}
350
351#[derive(Clone, Debug, Eq, Hash, PartialEq)]
353pub struct OptionalParserPackSelectionKey {
354 value: String,
356 projectatlas_version: String,
358 artifact: ParserArtifactIdentity,
360}
361
362impl OptionalParserPackSelectionKey {
363 #[must_use]
365 pub fn as_str(&self) -> &str {
366 &self.value
367 }
368
369 #[must_use]
371 pub const fn artifact(&self) -> &ParserArtifactIdentity {
372 &self.artifact
373 }
374}
375
376#[derive(Clone, Debug, Eq, PartialEq)]
378pub enum OptionalParserPackProjectSelection {
379 Inactive,
381 Selected(OptionalParserPackSelectionKey),
383}
384
385impl OptionalParserPackProjectSelection {
386 #[must_use]
388 pub const fn selection_key(&self) -> Option<&OptionalParserPackSelectionKey> {
389 match self {
390 Self::Inactive => None,
391 Self::Selected(selection) => Some(selection),
392 }
393 }
394
395 #[must_use]
397 pub const fn artifact(&self) -> Option<&ParserArtifactIdentity> {
398 match self {
399 Self::Inactive => None,
400 Self::Selected(selection) => Some(selection.artifact()),
401 }
402 }
403}
404
405struct OpenedOptionalParserPackSlot {
407 selection_key: OptionalParserPackSelectionKey,
409 supervisor: OptionalParserSupervisor,
411}
412
413#[derive(Clone, Copy, Debug, Eq, PartialEq)]
415enum OptionalParserPackLeaseMode {
416 Shared,
418 Exclusive,
420}
421
422struct OptionalParserPackLease {
424 file: File,
426}
427
428impl Drop for OptionalParserPackLease {
429 fn drop(&mut self) {
430 drop(self.file.unlock());
431 }
432}
433
434pub struct VerifiedOptionalParserPackSelection {
439 selection_key: OptionalParserPackSelectionKey,
441 supervisor: OptionalParserSupervisor,
443 _execution_lease: OptionalParserPackLease,
445}
446
447impl VerifiedOptionalParserPackSelection {
448 #[must_use]
450 pub const fn selection_key(&self) -> &OptionalParserPackSelectionKey {
451 &self.selection_key
452 }
453
454 #[must_use]
456 pub const fn artifact(&self) -> &ParserArtifactIdentity {
457 self.selection_key.artifact()
458 }
459
460 #[must_use]
462 pub fn accepts_language(&self, language_id: &str) -> bool {
463 self.supervisor.accepts_language(language_id)
464 }
465
466 pub fn supervisor_mut(&mut self) -> &mut OptionalParserSupervisor {
468 &mut self.supervisor
469 }
470}
471
472impl OptionalParserPackLifecycle {
473 pub fn new(
486 project_root: impl Into<PathBuf>,
487 storage_root: Option<PathBuf>,
488 ) -> Result<Self, OptionalParserPackLifecycleError> {
489 let deferred_storage_root = OnceLock::new();
490 if let Some(storage_root) = storage_root
491 && deferred_storage_root.set(Some(storage_root)).is_err()
492 {
493 return Err(OptionalParserPackLifecycleError::StorageRootUnavailable);
494 }
495 let capability = OptionalParserCapability::current();
496 Ok(Self {
497 project_root: project_root.into(),
498 storage_root: deferred_storage_root,
499 capability,
500 #[cfg(test)]
501 admission_failure: None,
502 #[cfg(test)]
503 selection_publication_failure: false,
504 })
505 }
506
507 pub fn verify(
514 &self,
515 archive: &Path,
516 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
517 let platform = self.require_supported()?;
518 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
519 let _lease = self.acquire_pack_lease(OptionalParserPackLeaseMode::Exclusive)?;
520 #[cfg(test)]
521 self.fail_admission_if_injected(archive)?;
522 let verified = Self::verify_archive(archive, platform, None)?;
523 let artifact = verified.slot_report(false);
524 verified.cleanup_profile()?;
525 Ok(self.report(OptionalParserPackOperation::Verify, false, Some(artifact)))
526 }
527
528 pub fn install(
537 &self,
538 archive: &Path,
539 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
540 let platform = self.require_supported()?;
541 let _lease = self.acquire_pack_lease(OptionalParserPackLeaseMode::Exclusive)?;
542 let (slot, changed) = self.install_archive(archive, platform)?;
543 Ok(self.report(
544 OptionalParserPackOperation::Install,
545 changed,
546 Some(slot.report(true)),
547 ))
548 }
549
550 pub fn enable(
560 &self,
561 artifact: &str,
562 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
563 let _platform = self.require_supported()?;
564 let _pack_lease = self.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?;
565 let _selection_lease = self.acquire_selection_mutation_lease()?;
566 let slot = PackSlotIdentity::current(artifact)?;
567 self.admit_installed_slot(&slot)?;
568 let previous = self.read_selection()?;
569 let changed = previous.as_ref().map(|value| &value.selected) != Some(&slot);
570 if changed {
571 let rollback = previous.map(|value| value.selected);
572 self.write_selection(&ProjectSelection::new(slot.clone(), rollback))?;
573 }
574 Ok(self.report(
575 OptionalParserPackOperation::Enable,
576 changed,
577 Some(slot.report(true)),
578 ))
579 }
580
581 pub fn update(
593 &self,
594 archive: &Path,
595 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
596 let platform = self.require_supported()?;
597 let _pack_lease = self.acquire_pack_lease(OptionalParserPackLeaseMode::Exclusive)?;
598 let _selection_lease = self.acquire_selection_mutation_lease()?;
599 let previous = self.read_selection()?.ok_or_else(|| {
600 invalid_data("update requires an enabled current-project parser-pack selection")
601 })?;
602 self.open_verified_installed_slot(&previous.selected)?;
603 let (slot, installed) = self.install_archive(archive, platform)?;
604 let selection_changed = self.publish_installed_update(&previous, &slot)?;
605 Ok(self.report(
606 OptionalParserPackOperation::Update,
607 installed || selection_changed,
608 Some(slot.report(true)),
609 ))
610 }
611
612 fn publish_installed_update(
614 &self,
615 previous: &ProjectSelection,
616 slot: &PackSlotIdentity,
617 ) -> Result<bool, OptionalParserPackLifecycleError> {
618 if slot == &previous.selected {
619 return Ok(false);
620 }
621 self.write_selection(&ProjectSelection::new(
622 slot.clone(),
623 Some(previous.selected.clone()),
624 ))?;
625 Ok(true)
626 }
627
628 pub fn disable(
637 &self,
638 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
639 if !self.selection_mutation_needed()? {
640 return Ok(self.report(OptionalParserPackOperation::Disable, false, None));
641 }
642 let _selection_lease = self.acquire_selection_mutation_lease()?;
643 let changed = self.remove_selection_if_present()?;
644 Ok(self.report(OptionalParserPackOperation::Disable, changed, None))
645 }
646
647 pub fn remove(
656 &self,
657 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
658 let acquire_pack_lease = if self.capability.pack_platform().is_none() {
659 let storage_root = self.storage_root()?;
660 match direct_directory_state(storage_root)? {
661 DirectDirectoryState::Missing => false,
662 DirectDirectoryState::Real => !matches!(
663 direct_directory_state(&storage_root.join(OPTIONAL_PARSER_PACK_ID))?,
664 DirectDirectoryState::Missing
665 ),
666 DirectDirectoryState::Unsafe => true,
667 }
668 } else {
669 true
670 };
671 let _pack_lease = acquire_pack_lease
672 .then(|| self.acquire_pack_lease(OptionalParserPackLeaseMode::Exclusive))
673 .transpose()?;
674 let _selection_lease = self
675 .selection_mutation_needed()?
676 .then(|| self.acquire_selection_mutation_lease())
677 .transpose()?;
678 let pack_root = self.pack_root()?;
679 let slots = installed_slot_paths(&pack_root)?;
680 let selection_changed = self.remove_selection_if_present()?;
681 let storage_changed = self.remove_installed_pack(&pack_root, slots)?;
682 Ok(self.report(
683 OptionalParserPackOperation::Remove,
684 selection_changed || storage_changed,
685 None,
686 ))
687 }
688
689 pub fn status(
695 &self,
696 ) -> Result<OptionalParserPackLifecycleReport, OptionalParserPackLifecycleError> {
697 Ok(self.report(OptionalParserPackOperation::Status, false, None))
698 }
699
700 pub fn derive_project_selection(
713 &self,
714 ) -> Result<OptionalParserPackProjectSelection, OptionalParserPackLifecycleError> {
715 if !self.selection_entry_present()? {
716 return Ok(OptionalParserPackProjectSelection::Inactive);
717 }
718 self.require_supported()?;
719 let selection = self
720 .read_selection()?
721 .ok_or_else(|| invalid_data("project parser-pack selection disappeared"))?;
722 Ok(OptionalParserPackProjectSelection::Selected(
723 selection.selected.selection_key()?,
724 ))
725 }
726
727 pub fn resolve_selected_pack(
739 &self,
740 ) -> Result<Option<VerifiedOptionalParserPackSelection>, OptionalParserPackLifecycleError> {
741 let selection = self.derive_project_selection()?;
742 let OptionalParserPackProjectSelection::Selected(selection_key) = selection else {
743 return Ok(None);
744 };
745 let execution_lease = self.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?;
746 let slot = PackSlotIdentity::from_selection_key(&selection_key);
747 let verified = self.open_verified_installed_slot(&slot)?;
748 if verified.selection_key != selection_key {
749 return Err(invalid_data(
750 "verified optional parser-pack slot differs from project selection",
751 ));
752 }
753 Ok(Some(VerifiedOptionalParserPackSelection {
754 selection_key: verified.selection_key,
755 supervisor: verified.supervisor,
756 _execution_lease: execution_lease,
757 }))
758 }
759
760 fn require_supported(&self) -> Result<PackPlatform, OptionalParserPackLifecycleError> {
762 match self.capability {
763 OptionalParserCapability::Pack { platform } => Ok(platform),
764 OptionalParserCapability::BuiltInOnly => {
765 Err(OptionalParserPackLifecycleError::UnsupportedContainment {
766 os: env::consts::OS,
767 architecture: env::consts::ARCH,
768 })
769 }
770 }
771 }
772
773 fn install_archive(
775 &self,
776 archive: &Path,
777 platform: PackPlatform,
778 ) -> Result<(PackSlotIdentity, bool), OptionalParserPackLifecycleError> {
779 #[cfg(test)]
780 self.fail_admission_if_injected(archive)?;
781 self.ensure_storage_roots()?;
782 let versions_root = self.versions_root()?;
783 let mut verified = Self::verify_archive(archive, platform, Some(&versions_root))?;
784 let slot = verified.slot_identity();
785 let operation = (|| {
786 let version_root = versions_root.join(&slot.projectatlas_version);
787 ensure_direct_directory(&versions_root, &version_root)?;
788 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
789 if windows_slot_cleanup_in_progress(&version_root, &slot.artifact)? {
790 return Err(invalid_data(
791 "the selected parser-pack artifact still has a cleanup tombstone",
792 ));
793 }
794 let destination = self.slot_path(&slot)?;
795 if self.slot_path_is_real(&slot)? {
796 self.open_verified_installed_slot(&slot)?;
797 return Ok((slot.clone(), false));
798 }
799 if fs::symlink_metadata(&destination).is_ok() {
800 return Err(invalid_data(
801 "immutable slot path is occupied by a non-directory entry",
802 ));
803 }
804 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
805 let _publication_staging = {
806 let staging = stage_parser_pack_for_atomic_publication(
807 &mut verified.pack_root,
808 &version_root,
809 )?;
810 verified
811 .temporary_profile
812 .pack_root
813 .clone_from(&verified.pack_root);
814 staging
815 };
816 if let Err(operation) = seal_immutable_tree(&verified.pack_root) {
817 return finish_with_cleanup(
818 Err(operation),
819 make_tree_writable(&verified.pack_root),
820 );
821 }
822 match fs::rename(&verified.pack_root, &destination) {
823 Ok(()) => {
824 verified.transfer_profile_to_installed_slot();
825 Ok((slot.clone(), true))
826 }
827 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
828 make_tree_writable(&verified.pack_root)?;
829 self.open_verified_installed_slot(&slot)?;
830 Ok((slot.clone(), false))
831 }
832 Err(source) => finish_with_cleanup(
833 Err(io_error(
834 "publish immutable parser-pack slot",
835 destination,
836 source,
837 )),
838 make_tree_writable(&verified.pack_root),
839 ),
840 }
841 })();
842 let cleanup = verified.cleanup_profile();
843 finish_with_cleanup(operation, cleanup)
844 }
845
846 fn verify_archive(
848 archive: &Path,
849 platform: PackPlatform,
850 staging_parent: Option<&Path>,
851 ) -> Result<VerifiedArchive, OptionalParserPackLifecycleError> {
852 let before = sha256_file(archive, OPTIONAL_PARSER_PACK_MAX_ARCHIVE_BYTES)?;
853 let extracted = extract_archive(archive, staging_parent)?;
854 let accepted_bytes = read_bounded_file(
855 &extracted.pack_root.join(ACCEPTED_MANIFEST_FILE_NAME),
856 u64::try_from(OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES)
857 .map_err(|source| invalid_data(source.to_string()))?,
858 )?;
859 let logical = OptionalParserPackManifest::from_json(&accepted_bytes)?;
860 let artifact_bytes = read_bounded_file(
861 &extracted.pack_root.join(ARTIFACT_MANIFEST_FILE_NAME),
862 u64::try_from(OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES)
863 .map_err(|source| invalid_data(source.to_string()))?,
864 )?;
865 let artifact: OptionalParserPackArtifactManifest = serde_json::from_slice(&artifact_bytes)?;
866 artifact.validate(&logical)?;
867 if artifact.platform != platform {
868 return Err(invalid_data(format!(
869 "archive target {} does not match current host target {}",
870 artifact.platform.as_str(),
871 platform.as_str()
872 )));
873 }
874 require_archive_name(archive, platform)?;
875 validate_observed_inventory(&extracted.observed, &artifact)?;
876 let projectatlas_version = artifact.projectatlas_version;
877 let supervisor = OptionalParserSupervisor::open(&extracted.pack_root)?;
878 let artifact_identity = supervisor.artifact_identity().clone();
879 let temporary_profile =
880 TemporaryParserArtifactProfile::for_verified_supervisor(&supervisor);
881 if let Err(error) = admit_optional_parser_artifact(supervisor, &logical) {
882 return finish_with_cleanup(Err(error.into()), temporary_profile.cleanup());
883 }
884 let after = match sha256_file(archive, OPTIONAL_PARSER_PACK_MAX_ARCHIVE_BYTES) {
885 Ok(after) => after,
886 Err(operation) => {
887 return finish_with_cleanup(Err(operation), temporary_profile.cleanup());
888 }
889 };
890 if before != after {
891 return finish_with_cleanup(
892 Err(invalid_data(
893 "completed archive changed during verification",
894 )),
895 temporary_profile.cleanup(),
896 );
897 }
898 Ok(VerifiedArchive {
899 temporary_profile,
900 _directory: extracted.directory,
901 pack_root: extracted.pack_root,
902 artifact: artifact_identity,
903 projectatlas_version,
904 })
905 }
906
907 fn admit_installed_slot(
909 &self,
910 slot: &PackSlotIdentity,
911 ) -> Result<(), OptionalParserPackLifecycleError> {
912 let root = self.slot_path(slot)?;
913 #[cfg(test)]
914 if let Some(failure) = self.admission_failure {
915 return Err(failure(&root).into());
916 }
917 let logical_bytes = read_bounded_file(
918 &root.join(ACCEPTED_MANIFEST_FILE_NAME),
919 u64::try_from(OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES)
920 .map_err(|source| invalid_data(source.to_string()))?,
921 )?;
922 let logical = OptionalParserPackManifest::from_json(&logical_bytes)?;
923 let verified = self.open_verified_installed_slot(slot)?;
924 admit_optional_parser_artifact(verified.supervisor, &logical)?;
925 Ok(())
926 }
927
928 fn open_verified_installed_slot(
930 &self,
931 slot: &PackSlotIdentity,
932 ) -> Result<OpenedOptionalParserPackSlot, OptionalParserPackLifecycleError> {
933 let root = self.slot_path(slot)?;
934 if !self.slot_path_is_real(slot)? {
935 return Err(invalid_data(
936 "selected optional parser-pack slot is not installed",
937 ));
938 }
939 verify_immutable_tree(&root)?;
940 let selection_key = slot.selection_key()?;
941 let supervisor = OptionalParserSupervisor::open(root)?;
942 if supervisor.artifact_identity() != selection_key.artifact() {
943 return Err(invalid_data(
944 "installed slot identity differs from its artifact manifest",
945 ));
946 }
947 Ok(OpenedOptionalParserPackSlot {
948 selection_key,
949 supervisor,
950 })
951 }
952
953 fn remove_installed_pack(
955 &self,
956 pack_root: &Path,
957 slots: Vec<InstalledSlotPath>,
958 ) -> Result<bool, OptionalParserPackLifecycleError> {
959 let mut changed = false;
960 let mut failures = Vec::new();
961 for slot in slots {
962 let result = self.remove_installed_slot(&slot);
963 match result {
964 Ok(slot_changed) => changed |= slot_changed,
965 Err(error) => {
966 if failures.len() < 16 {
967 failures.push(format!("{}: {error}", slot.bounded_label()));
968 }
969 }
970 }
971 }
972 if !failures.is_empty() {
973 return Err(OptionalParserPackLifecycleError::CleanupIncomplete {
974 message: failures.join("; "),
975 });
976 }
977 Ok(remove_tree_if_present(pack_root)? || changed)
978 }
979
980 fn remove_installed_slot(
982 &self,
983 slot: &InstalledSlotPath,
984 ) -> Result<bool, OptionalParserPackLifecycleError> {
985 if self.capability.pack_platform() == Some(PackPlatform::WindowsX86_64) {
986 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
987 {
988 return Self::remove_windows_slot(slot);
989 }
990 #[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
991 {
992 return Err(OptionalParserPackLifecycleError::UnsupportedContainment {
993 os: env::consts::OS,
994 architecture: env::consts::ARCH,
995 });
996 }
997 }
998 remove_tree_if_present(&slot.entry_root)
999 }
1000
1001 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1003 fn remove_windows_slot(
1004 slot: &InstalledSlotPath,
1005 ) -> Result<bool, OptionalParserPackLifecycleError> {
1006 if slot.state == InstalledSlotCleanupState::ProfileCleaned {
1007 return remove_tree_if_present(&slot.entry_root);
1008 }
1009 let pending = if slot.state == InstalledSlotCleanupState::Installed {
1010 transition_slot_to_removing_tombstone(slot)?
1011 } else {
1012 slot.clone()
1013 };
1014 let pack_root = pending
1015 .pack_root
1016 .as_deref()
1017 .ok_or_else(|| invalid_data("pending parser-pack tombstone has no pack root"))?;
1018 let identity = PackSlotIdentity {
1019 projectatlas_version: pending.projectatlas_version.clone(),
1020 artifact: pending.artifact.clone(),
1021 };
1022 identity.validate()?;
1023 verify_immutable_tree(pack_root)?;
1024 let supervisor = OptionalParserSupervisor::open(pack_root)?;
1025 if supervisor.artifact_identity().digest().as_str() != identity.artifact {
1026 return Err(invalid_data(
1027 "cleanup tombstone identity differs from its artifact manifest",
1028 ));
1029 }
1030 cleanup_platform_profile(supervisor.pack_root(), supervisor.artifact_identity())?;
1031 drop(supervisor);
1032 let cleaned = transition_tombstone_to_profile_cleaned(&pending)?;
1033 remove_tree_if_present(&cleaned.entry_root)?;
1034 Ok(true)
1035 }
1036
1037 fn report(
1039 &self,
1040 operation: OptionalParserPackOperation,
1041 changed: bool,
1042 artifact: Option<OptionalParserPackSlotReport>,
1043 ) -> OptionalParserPackLifecycleReport {
1044 let selection = self.read_selection_for_status();
1045 let (installed_slots, installed_slots_truncated, cleanup_pending, mut unsafe_storage) =
1046 match self
1047 .pack_root()
1048 .and_then(|root| count_installed_slots(&root))
1049 {
1050 Ok(value) => (value.0, value.1, value.2, false),
1051 Err(_) => (0, false, false, true),
1052 };
1053 let selection_stale = selection.is_err();
1054 let selection = selection.ok().flatten();
1055 let selected = selection.as_ref().map(|value| {
1056 let (present, unsafe_path) = self.slot_presence(&value.selected);
1057 unsafe_storage |= unsafe_path;
1058 value.selected.report(present)
1059 });
1060 let rollback = selection.as_ref().and_then(|value| {
1061 value.rollback.as_ref().map(|slot| {
1062 let (present, unsafe_path) = self.slot_presence(slot);
1063 unsafe_storage |= unsafe_path;
1064 slot.report(present)
1065 })
1066 });
1067 let selected_missing = selected.as_ref().is_some_and(|slot| !slot.present);
1068 let rollback_present = rollback.as_ref().is_some_and(|slot| slot.present);
1069 let state = if selection_stale
1070 || unsafe_storage
1071 || selected_missing
1072 || installed_slots_truncated
1073 || cleanup_pending
1074 {
1075 OptionalParserPackState::Stale
1076 } else if selected.is_some() && rollback_present {
1077 OptionalParserPackState::RollbackReady
1078 } else if selected.is_some() {
1079 OptionalParserPackState::Enabled
1080 } else if installed_slots > 0 {
1081 OptionalParserPackState::InstalledDisabled
1082 } else if self.capability.pack_platform().is_none() {
1083 OptionalParserPackState::UnsupportedContainment
1084 } else {
1085 OptionalParserPackState::Absent
1086 };
1087 OptionalParserPackLifecycleReport {
1088 operation,
1089 state,
1090 pack_id: OPTIONAL_PARSER_PACK_ID,
1091 supported: self.capability.pack_platform().is_some(),
1092 capability: self.capability,
1093 platform: self.capability.pack_platform().map(PackPlatform::as_str),
1094 installed_slots,
1095 installed_slots_truncated,
1096 selected,
1097 rollback,
1098 artifact,
1099 changed,
1100 }
1101 }
1102
1103 fn selection_entry_present(&self) -> Result<bool, OptionalParserPackLifecycleError> {
1105 match direct_directory_state(&self.selection_parent())? {
1106 DirectDirectoryState::Missing => return Ok(false),
1107 DirectDirectoryState::Real => {}
1108 DirectDirectoryState::Unsafe => {
1109 return Err(invalid_data(
1110 "project .projectatlas selection parent is not a real directory",
1111 ));
1112 }
1113 }
1114 let path = self.selection_path();
1115 match fs::symlink_metadata(&path) {
1116 Ok(_) => Ok(true),
1117 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
1118 Err(source) => Err(io_error(
1119 "inspect project parser-pack selection",
1120 path,
1121 source,
1122 )),
1123 }
1124 }
1125
1126 fn read_selection(&self) -> Result<Option<ProjectSelection>, OptionalParserPackLifecycleError> {
1128 match direct_directory_state(&self.selection_parent())? {
1129 DirectDirectoryState::Missing => return Ok(None),
1130 DirectDirectoryState::Real => {}
1131 DirectDirectoryState::Unsafe => {
1132 return Err(invalid_data(
1133 "project .projectatlas selection parent is not a real directory",
1134 ));
1135 }
1136 }
1137 let path = self.selection_path();
1138 match fs::symlink_metadata(&path) {
1139 Ok(metadata) if metadata.file_type().is_file() => {
1140 let bytes = read_bounded_file(&path, PROJECT_SELECTION_MAX_BYTES)?;
1141 let selection: ProjectSelection = serde_json::from_slice(&bytes)?;
1142 selection.validate()?;
1143 Ok(Some(selection))
1144 }
1145 Ok(_) => Err(invalid_data(
1146 "project parser-pack selection is not a regular file",
1147 )),
1148 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None),
1149 Err(source) => Err(io_error(
1150 "inspect project parser-pack selection",
1151 path,
1152 source,
1153 )),
1154 }
1155 }
1156
1157 fn read_selection_for_status(
1159 &self,
1160 ) -> Result<Option<ProjectSelection>, OptionalParserPackLifecycleError> {
1161 self.read_selection()
1162 }
1163
1164 fn write_selection(
1166 &self,
1167 selection: &ProjectSelection,
1168 ) -> Result<(), OptionalParserPackLifecycleError> {
1169 selection.validate()?;
1170 let path = self.selection_path();
1171 ensure_anchor_directory(&self.project_root)?;
1172 let parent = self.selection_parent();
1173 ensure_direct_directory(&self.project_root, &parent)?;
1174 let bytes = serde_json::to_vec_pretty(selection)?;
1175 if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > PROJECT_SELECTION_MAX_BYTES {
1176 return Err(invalid_data(
1177 "project parser-pack selection exceeds its byte bound",
1178 ));
1179 }
1180 let mut temporary = NamedTempFile::new_in(&parent)
1181 .map_err(|source| io_error("create temporary project selection", &parent, source))?;
1182 temporary.write_all(&bytes).map_err(|source| {
1183 io_error("write temporary project selection", path.clone(), source)
1184 })?;
1185 temporary
1186 .as_file()
1187 .sync_all()
1188 .map_err(|source| io_error("sync temporary project selection", path.clone(), source))?;
1189 #[cfg(test)]
1190 if self.selection_publication_failure {
1191 return Err(invalid_data(
1192 "injected project selection publication failure",
1193 ));
1194 }
1195 temporary.persist(&path).map_err(|error| {
1196 io_error("publish project parser-pack selection", path, error.error)
1197 })?;
1198 Ok(())
1199 }
1200
1201 fn selection_path(&self) -> PathBuf {
1203 self.project_root
1204 .join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH)
1205 }
1206
1207 fn selection_parent(&self) -> PathBuf {
1209 self.project_root.join(".projectatlas")
1210 }
1211
1212 fn selection_mutation_needed(&self) -> Result<bool, OptionalParserPackLifecycleError> {
1214 match direct_directory_state(&self.selection_parent())? {
1215 DirectDirectoryState::Missing | DirectDirectoryState::Unsafe => Ok(false),
1216 DirectDirectoryState::Real => self.selection_entry_present(),
1217 }
1218 }
1219
1220 fn acquire_selection_mutation_lease(
1222 &self,
1223 ) -> Result<OptionalParserPackLease, OptionalParserPackLifecycleError> {
1224 ensure_anchor_directory(&self.project_root)?;
1225 let parent = self.selection_parent();
1226 ensure_direct_directory(&self.project_root, &parent)?;
1227 let path = parent.join(OPTIONAL_PARSER_SELECTION_LEASE_FILE_NAME);
1228 let file = open_or_create_direct_lease_file(&path)?;
1229 match file.try_lock() {
1230 Ok(()) => {
1231 require_direct_lease_path(&path)?;
1232 Ok(OptionalParserPackLease { file })
1233 }
1234 Err(fs::TryLockError::WouldBlock) => {
1235 Err(OptionalParserPackLifecycleError::Busy { path })
1236 }
1237 Err(fs::TryLockError::Error(source)) => {
1238 Err(io_error("lock project parser-pack selection", path, source))
1239 }
1240 }
1241 }
1242
1243 fn remove_selection_if_present(&self) -> Result<bool, OptionalParserPackLifecycleError> {
1245 match direct_directory_state(&self.selection_parent())? {
1246 DirectDirectoryState::Missing | DirectDirectoryState::Unsafe => Ok(false),
1247 DirectDirectoryState::Real => remove_file_if_present(&self.selection_path()),
1248 }
1249 }
1250
1251 fn storage_root(&self) -> Result<&Path, OptionalParserPackLifecycleError> {
1253 self.storage_root
1254 .get_or_init(|| default_storage_root().ok())
1255 .as_deref()
1256 .ok_or(OptionalParserPackLifecycleError::StorageRootUnavailable)
1257 }
1258
1259 fn pack_root(&self) -> Result<PathBuf, OptionalParserPackLifecycleError> {
1261 Ok(self.storage_root()?.join(OPTIONAL_PARSER_PACK_ID))
1262 }
1263
1264 fn versions_root(&self) -> Result<PathBuf, OptionalParserPackLifecycleError> {
1266 Ok(self.pack_root()?.join("versions"))
1267 }
1268
1269 fn slot_path(
1271 &self,
1272 slot: &PackSlotIdentity,
1273 ) -> Result<PathBuf, OptionalParserPackLifecycleError> {
1274 Ok(self
1275 .versions_root()?
1276 .join(&slot.projectatlas_version)
1277 .join(&slot.artifact))
1278 }
1279
1280 fn slot_path_is_real(
1282 &self,
1283 slot: &PackSlotIdentity,
1284 ) -> Result<bool, OptionalParserPackLifecycleError> {
1285 let pack_root = self.pack_root()?;
1286 let versions_root = self.versions_root()?;
1287 for component in [
1288 pack_root,
1289 versions_root.clone(),
1290 versions_root.join(&slot.projectatlas_version),
1291 self.slot_path(slot)?,
1292 ] {
1293 match direct_directory_state(&component)? {
1294 DirectDirectoryState::Real => {}
1295 DirectDirectoryState::Missing => return Ok(false),
1296 DirectDirectoryState::Unsafe => {
1297 return Err(invalid_data(
1298 "optional parser-pack slot path contains an unsafe owned component",
1299 ));
1300 }
1301 }
1302 }
1303 Ok(true)
1304 }
1305
1306 fn slot_presence(&self, slot: &PackSlotIdentity) -> (bool, bool) {
1308 match self.slot_path_is_real(slot) {
1309 Ok(present) => (present, false),
1310 Err(_) => (false, true),
1311 }
1312 }
1313
1314 fn ensure_storage_roots(&self) -> Result<(), OptionalParserPackLifecycleError> {
1316 let storage_root = self.storage_root()?;
1317 ensure_anchor_directory(storage_root)?;
1318 let pack_root = self.pack_root()?;
1319 ensure_direct_directory(storage_root, &pack_root)?;
1320 ensure_direct_directory(&pack_root, &self.versions_root()?)
1321 }
1322
1323 fn acquire_pack_lease(
1325 &self,
1326 mode: OptionalParserPackLeaseMode,
1327 ) -> Result<OptionalParserPackLease, OptionalParserPackLifecycleError> {
1328 let storage_root = self.storage_root()?;
1329 ensure_anchor_directory(storage_root)?;
1330 let path = storage_root.join(OPTIONAL_PARSER_PACK_LEASE_FILE_NAME);
1331 let file = open_or_create_direct_lease_file(&path)?;
1332 let result = match mode {
1333 OptionalParserPackLeaseMode::Shared => file.try_lock_shared(),
1334 OptionalParserPackLeaseMode::Exclusive => file.try_lock(),
1335 };
1336 match result {
1337 Ok(()) => {
1338 require_direct_lease_path(&path)?;
1339 Ok(OptionalParserPackLease { file })
1340 }
1341 Err(fs::TryLockError::WouldBlock) => {
1342 Err(OptionalParserPackLifecycleError::Busy { path })
1343 }
1344 Err(fs::TryLockError::Error(source)) => Err(io_error(
1345 "lock optional parser-pack lifecycle",
1346 path,
1347 source,
1348 )),
1349 }
1350 }
1351
1352 #[cfg(test)]
1353 fn for_test(
1354 project_root: PathBuf,
1355 storage_root: PathBuf,
1356 platform: Option<PackPlatform>,
1357 ) -> Self {
1358 let capability = platform.map_or(OptionalParserCapability::BuiltInOnly, |platform| {
1359 OptionalParserCapability::Pack { platform }
1360 });
1361 Self {
1362 project_root,
1363 storage_root: OnceLock::from(Some(storage_root)),
1364 capability,
1365 admission_failure: None,
1366 selection_publication_failure: false,
1367 }
1368 }
1369
1370 #[cfg(test)]
1372 fn with_admission_failure(mut self, failure: fn(&Path) -> ParserSupervisorError) -> Self {
1373 self.admission_failure = Some(failure);
1374 self
1375 }
1376
1377 #[cfg(test)]
1379 fn with_selection_publication_failure(mut self) -> Self {
1380 self.selection_publication_failure = true;
1381 self
1382 }
1383
1384 #[cfg(test)]
1386 fn fail_admission_if_injected(
1387 &self,
1388 artifact: &Path,
1389 ) -> Result<(), OptionalParserPackLifecycleError> {
1390 match self.admission_failure {
1391 Some(failure) => Err(failure(artifact).into()),
1392 None => Ok(()),
1393 }
1394 }
1395}
1396
1397#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1399#[serde(deny_unknown_fields)]
1400struct ProjectSelection {
1401 schema_version: u32,
1403 pack_id: String,
1405 selected: PackSlotIdentity,
1407 #[serde(skip_serializing_if = "Option::is_none")]
1409 rollback: Option<PackSlotIdentity>,
1410}
1411
1412impl ProjectSelection {
1413 fn new(selected: PackSlotIdentity, rollback: Option<PackSlotIdentity>) -> Self {
1415 Self {
1416 schema_version: PROJECT_SELECTION_SCHEMA_VERSION,
1417 pack_id: OPTIONAL_PARSER_PACK_ID.to_owned(),
1418 selected,
1419 rollback,
1420 }
1421 }
1422
1423 fn validate(&self) -> Result<(), OptionalParserPackLifecycleError> {
1425 if self.schema_version != PROJECT_SELECTION_SCHEMA_VERSION {
1426 return Err(invalid_data(
1427 "project parser-pack selection schema is unsupported",
1428 ));
1429 }
1430 if self.pack_id != OPTIONAL_PARSER_PACK_ID {
1431 return Err(invalid_data(
1432 "project parser-pack selection has another pack identity",
1433 ));
1434 }
1435 self.selected.validate()?;
1436 if let Some(rollback) = &self.rollback {
1437 rollback.validate()?;
1438 if rollback == &self.selected {
1439 return Err(invalid_data(
1440 "project parser-pack rollback duplicates selected slot",
1441 ));
1442 }
1443 }
1444 Ok(())
1445 }
1446}
1447
1448#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1450#[serde(deny_unknown_fields)]
1451struct PackSlotIdentity {
1452 projectatlas_version: String,
1454 artifact: String,
1456}
1457
1458impl PackSlotIdentity {
1459 fn current(artifact: &str) -> Result<Self, OptionalParserPackLifecycleError> {
1461 let slot = Self {
1462 projectatlas_version: OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION.to_owned(),
1463 artifact: artifact.to_owned(),
1464 };
1465 slot.validate()?;
1466 Ok(slot)
1467 }
1468
1469 fn validate(&self) -> Result<(), OptionalParserPackLifecycleError> {
1471 if self.projectatlas_version != OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION {
1472 return Err(invalid_data(
1473 "parser-pack slot belongs to another ProjectAtlas release line",
1474 ));
1475 }
1476 ParserContentDigest::new(self.artifact.clone())
1477 .map_err(|source| invalid_data(source.to_string()))?;
1478 Ok(())
1479 }
1480
1481 fn selection_key(
1483 &self,
1484 ) -> Result<OptionalParserPackSelectionKey, OptionalParserPackLifecycleError> {
1485 self.validate()?;
1486 let artifact = ParserArtifactIdentity::new(
1487 ParserContentDigest::new(self.artifact.clone())
1488 .map_err(|source| invalid_data(source.to_string()))?,
1489 );
1490 Ok(OptionalParserPackSelectionKey {
1491 value: format!(
1492 "{}:{}:{}",
1493 OPTIONAL_PARSER_PACK_ID, self.projectatlas_version, self.artifact
1494 ),
1495 projectatlas_version: self.projectatlas_version.clone(),
1496 artifact,
1497 })
1498 }
1499
1500 fn from_selection_key(selection: &OptionalParserPackSelectionKey) -> Self {
1502 Self {
1503 projectatlas_version: selection.projectatlas_version.clone(),
1504 artifact: selection.artifact.digest().as_str().to_owned(),
1505 }
1506 }
1507
1508 fn report(&self, present: bool) -> OptionalParserPackSlotReport {
1510 OptionalParserPackSlotReport {
1511 projectatlas_version: self.projectatlas_version.clone(),
1512 artifact: self.artifact.clone(),
1513 present,
1514 }
1515 }
1516}
1517
1518struct VerifiedArchive {
1520 temporary_profile: TemporaryParserArtifactProfile,
1523 _directory: TempDir,
1525 pack_root: PathBuf,
1527 artifact: ParserArtifactIdentity,
1529 projectatlas_version: String,
1531}
1532
1533impl VerifiedArchive {
1534 fn slot_identity(&self) -> PackSlotIdentity {
1536 PackSlotIdentity {
1537 projectatlas_version: self.projectatlas_version.clone(),
1538 artifact: self.artifact.digest().as_str().to_owned(),
1539 }
1540 }
1541
1542 fn slot_report(&self, present: bool) -> OptionalParserPackSlotReport {
1544 self.slot_identity().report(present)
1545 }
1546
1547 fn cleanup_profile(self) -> Result<(), OptionalParserPackLifecycleError> {
1549 let Self {
1550 temporary_profile,
1551 _directory: directory,
1552 ..
1553 } = self;
1554 let result = temporary_profile.cleanup();
1555 drop(directory);
1556 result
1557 }
1558
1559 fn transfer_profile_to_installed_slot(&mut self) {
1561 self.temporary_profile.transfer_to_installed_slot();
1562 }
1563}
1564
1565struct ObservedFile {
1567 bytes: u64,
1569 sha256: String,
1571}
1572
1573struct ExtractedArchive {
1575 directory: TempDir,
1577 pack_root: PathBuf,
1579 observed: BTreeMap<String, ObservedFile>,
1581}
1582
1583#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1585#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1586enum InstalledSlotCleanupState {
1587 Installed,
1589 ProfilePending,
1591 ProfileCleaned,
1593}
1594
1595#[derive(Clone, Debug)]
1597struct InstalledSlotPath {
1598 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1600 projectatlas_version: String,
1601 artifact: String,
1603 entry_root: PathBuf,
1605 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1607 pack_root: Option<PathBuf>,
1608 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
1610 state: InstalledSlotCleanupState,
1611}
1612
1613impl InstalledSlotPath {
1614 fn bounded_label(&self) -> String {
1616 self.artifact.chars().take(64).collect()
1617 }
1618}
1619
1620struct BoundedReader<R> {
1622 inner: R,
1624 maximum: u64,
1626 consumed: u64,
1628}
1629
1630impl<R> BoundedReader<R> {
1631 const fn new(inner: R, maximum: u64) -> Self {
1633 Self {
1634 inner,
1635 maximum,
1636 consumed: 0,
1637 }
1638 }
1639}
1640
1641impl<R: Read> Read for BoundedReader<R> {
1642 fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
1643 if buffer.is_empty() {
1644 return Ok(0);
1645 }
1646 if self.consumed >= self.maximum {
1647 let mut probe = [0u8; 1];
1648 return match self.inner.read(&mut probe)? {
1649 0 => Ok(0),
1650 _ => Err(io::Error::new(
1651 io::ErrorKind::InvalidData,
1652 "expanded archive exceeded its hard byte ceiling",
1653 )),
1654 };
1655 }
1656 let remaining = self.maximum.saturating_sub(self.consumed);
1657 let allowed = usize::try_from(remaining.min(buffer.len() as u64))
1658 .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?;
1659 let read = self.inner.read(&mut buffer[..allowed])?;
1660 self.consumed = self
1661 .consumed
1662 .checked_add(u64::try_from(read).map_err(io::Error::other)?)
1663 .ok_or_else(|| {
1664 io::Error::new(io::ErrorKind::InvalidData, "archive byte count overflowed")
1665 })?;
1666 Ok(read)
1667 }
1668}
1669
1670fn extract_archive(
1672 path: &Path,
1673 staging_parent: Option<&Path>,
1674) -> Result<ExtractedArchive, OptionalParserPackLifecycleError> {
1675 let metadata = fs::symlink_metadata(path)
1676 .map_err(|source| io_error("inspect optional parser-pack archive", path, source))?;
1677 if !metadata.file_type().is_file()
1678 || metadata.len() == 0
1679 || metadata.len() > OPTIONAL_PARSER_PACK_MAX_ARCHIVE_BYTES
1680 {
1681 return Err(invalid_data(
1682 "archive is not a bounded non-empty regular file",
1683 ));
1684 }
1685 let directory = match staging_parent {
1686 Some(parent) => TempDir::new_in(parent)
1687 .map_err(|source| io_error("create parser-pack staging directory", parent, source))?,
1688 None => TempDir::new().map_err(|source| {
1689 io_error("create parser-pack verification directory", path, source)
1690 })?,
1691 };
1692 let pack_root = directory.path().join(ARCHIVE_ROOT);
1693 fs::create_dir(&pack_root)
1694 .map_err(|source| io_error("create extracted parser-pack root", &pack_root, source))?;
1695 let input = File::open(path)
1696 .map_err(|source| io_error("open optional parser-pack archive", path, source))?;
1697 let decoder = zstd::Decoder::new(BufReader::new(input))
1698 .map_err(|source| io_error("decode optional parser-pack archive", path, source))?;
1699 let maximum_tar_bytes = OPTIONAL_PARSER_PACK_MAX_EXPANDED_BYTES
1700 .checked_add(TAR_FRAMING_ALLOWANCE_BYTES)
1701 .ok_or_else(|| invalid_data("tar expansion bound overflowed"))?;
1702 let bounded = BoundedReader::new(decoder, maximum_tar_bytes);
1703 let mut archive = tar::Archive::new(bounded);
1704 let mut observed = BTreeMap::new();
1705 let mut previous_path: Option<String> = None;
1706 let mut expanded_bytes = 0u64;
1707 let entries = archive
1708 .entries()
1709 .map_err(|source| io_error("read optional parser-pack archive entries", path, source))?;
1710 for entry in entries {
1711 let mut entry = entry
1712 .map_err(|source| io_error("read optional parser-pack archive entry", path, source))?;
1713 if observed.len() >= OPTIONAL_PARSER_PACK_MAX_FILE_ENTRIES.saturating_add(1) {
1714 return Err(invalid_data("archive exceeded its file-entry ceiling"));
1715 }
1716 if !entry.header().entry_type().is_file() {
1717 return Err(invalid_data("archive contains a non-regular entry"));
1718 }
1719 let raw_path = entry.path_bytes();
1720 let archive_path = std::str::from_utf8(raw_path.as_ref())
1721 .map_err(|source| invalid_data(source.to_string()))?;
1722 let prefix = format!("{ARCHIVE_ROOT}/");
1723 let relative = archive_path
1724 .strip_prefix(&prefix)
1725 .ok_or_else(|| invalid_data("archive entry is outside the canonical pack root"))?;
1726 let relative = PackRelativePath::new(relative)?;
1727 if previous_path
1728 .as_ref()
1729 .is_some_and(|previous| previous.as_str() >= relative.as_str())
1730 {
1731 return Err(invalid_data(
1732 "archive entries are not strictly path-sorted and unique",
1733 ));
1734 }
1735 previous_path = Some(relative.as_str().to_owned());
1736 let bytes = entry
1737 .header()
1738 .size()
1739 .map_err(|source| io_error("read parser-pack entry size", path, source))?;
1740 if bytes == 0 || bytes > OPTIONAL_PARSER_PACK_MAX_FILE_BYTES {
1741 return Err(invalid_data(
1742 "archive entry is empty or exceeds its file bound",
1743 ));
1744 }
1745 let expected_mode = if matches!(
1746 relative.as_str(),
1747 "projectatlas-parser-worker" | "projectatlas-parser-worker.exe"
1748 ) {
1749 WORKER_MODE
1750 } else {
1751 PAYLOAD_MODE
1752 };
1753 let header = entry.header();
1754 if header
1755 .uid()
1756 .map_err(|source| invalid_data(source.to_string()))?
1757 != 0
1758 || header
1759 .gid()
1760 .map_err(|source| invalid_data(source.to_string()))?
1761 != 0
1762 || header
1763 .mtime()
1764 .map_err(|source| invalid_data(source.to_string()))?
1765 != 0
1766 || header
1767 .mode()
1768 .map_err(|source| invalid_data(source.to_string()))?
1769 != expected_mode
1770 {
1771 return Err(invalid_data("archive entry metadata is not canonical"));
1772 }
1773 expanded_bytes = expanded_bytes
1774 .checked_add(bytes)
1775 .ok_or_else(|| invalid_data("expanded payload byte count overflowed"))?;
1776 if expanded_bytes > OPTIONAL_PARSER_PACK_MAX_EXPANDED_BYTES {
1777 return Err(invalid_data(
1778 "archive exceeded its expanded payload ceiling",
1779 ));
1780 }
1781 let destination = pack_root.join(Path::new(relative.as_str()));
1782 if let Some(parent) = destination.parent() {
1783 fs::create_dir_all(parent).map_err(|source| {
1784 io_error("create parser-pack payload directory", parent, source)
1785 })?;
1786 }
1787 let mut output = OpenOptions::new()
1788 .write(true)
1789 .create_new(true)
1790 .open(&destination)
1791 .map_err(|source| {
1792 io_error("create extracted parser-pack file", &destination, source)
1793 })?;
1794 let mut hasher = Sha256::new();
1795 let copied = copy_and_hash(&mut entry, &mut output, &mut hasher)?;
1796 if copied != bytes {
1797 return Err(invalid_data(
1798 "archive entry size differs from its tar header",
1799 ));
1800 }
1801 output
1802 .sync_all()
1803 .map_err(|source| io_error("sync extracted parser-pack file", &destination, source))?;
1804 #[cfg(unix)]
1805 set_extracted_mode(&destination, expected_mode)?;
1806 let key = relative.as_str().to_owned();
1807 if observed
1808 .insert(
1809 key,
1810 ObservedFile {
1811 bytes,
1812 sha256: lowercase_hex(hasher.finalize().as_ref()),
1813 },
1814 )
1815 .is_some()
1816 {
1817 return Err(invalid_data("archive contains a duplicate payload path"));
1818 }
1819 }
1820 let mut bounded = archive.into_inner();
1821 require_zero_tar_padding(&mut bounded)?;
1822 Ok(ExtractedArchive {
1823 directory,
1824 pack_root,
1825 observed,
1826 })
1827}
1828
1829fn validate_observed_inventory(
1831 observed: &BTreeMap<String, ObservedFile>,
1832 artifact: &OptionalParserPackArtifactManifest,
1833) -> Result<(), OptionalParserPackLifecycleError> {
1834 let expected_count = artifact
1835 .files
1836 .len()
1837 .checked_add(1)
1838 .ok_or_else(|| invalid_data("expected artifact file count overflowed"))?;
1839 if observed.len() != expected_count {
1840 return Err(invalid_data(format!(
1841 "artifact contains {} files; expected {expected_count}",
1842 observed.len()
1843 )));
1844 }
1845 for file in &artifact.files {
1846 let actual = observed
1847 .get(file.path.as_str())
1848 .ok_or_else(|| invalid_data(format!("artifact is missing {:?}", file.path.as_str())))?;
1849 if actual.bytes != file.bytes || actual.sha256 != file.sha256.as_str() {
1850 return Err(invalid_data(format!(
1851 "payload {:?} differs from its artifact manifest",
1852 file.path.as_str()
1853 )));
1854 }
1855 }
1856 if !observed.contains_key(ARTIFACT_MANIFEST_FILE_NAME) {
1857 return Err(invalid_data(
1858 "artifact manifest is missing from archive inventory",
1859 ));
1860 }
1861 Ok(())
1862}
1863
1864fn require_archive_name(
1866 path: &Path,
1867 platform: PackPlatform,
1868) -> Result<(), OptionalParserPackLifecycleError> {
1869 let expected = format!("{ARCHIVE_ROOT}-{}.tar.zst", platform.as_str());
1870 if path.file_name().and_then(std::ffi::OsStr::to_str) != Some(expected.as_str()) {
1871 return Err(invalid_data(format!(
1872 "archive basename must be {expected:?} for {}",
1873 platform.as_str()
1874 )));
1875 }
1876 Ok(())
1877}
1878
1879fn sha256_file(
1881 path: &Path,
1882 maximum: u64,
1883) -> Result<(String, u64), OptionalParserPackLifecycleError> {
1884 let metadata = fs::symlink_metadata(path)
1885 .map_err(|source| io_error("inspect bounded lifecycle file", path, source))?;
1886 if !metadata.file_type().is_file() || metadata.len() == 0 || metadata.len() > maximum {
1887 return Err(invalid_data(
1888 "lifecycle file is not a bounded non-empty regular file",
1889 ));
1890 }
1891 let mut input = BufReader::new(
1892 File::open(path).map_err(|source| io_error("open bounded lifecycle file", path, source))?,
1893 );
1894 let mut hasher = Sha256::new();
1895 let mut buffer = vec![0u8; 64 * 1024].into_boxed_slice();
1896 let mut total = 0u64;
1897 loop {
1898 let read = input
1899 .read(&mut buffer)
1900 .map_err(|source| io_error("hash bounded lifecycle file", path, source))?;
1901 if read == 0 {
1902 break;
1903 }
1904 hasher.update(&buffer[..read]);
1905 total = total
1906 .checked_add(u64::try_from(read).map_err(|source| invalid_data(source.to_string()))?)
1907 .ok_or_else(|| invalid_data("lifecycle file byte count overflowed"))?;
1908 if total > maximum {
1909 return Err(invalid_data("lifecycle file exceeded its byte ceiling"));
1910 }
1911 }
1912 if total != metadata.len() {
1913 return Err(invalid_data(
1914 "lifecycle file changed while it was being hashed",
1915 ));
1916 }
1917 Ok((lowercase_hex(hasher.finalize().as_ref()), total))
1918}
1919
1920fn read_bounded_file(
1922 path: &Path,
1923 maximum: u64,
1924) -> Result<Vec<u8>, OptionalParserPackLifecycleError> {
1925 let metadata = fs::symlink_metadata(path)
1926 .map_err(|source| io_error("inspect bounded lifecycle file", path, source))?;
1927 if !metadata.file_type().is_file() || metadata.len() == 0 || metadata.len() > maximum {
1928 return Err(invalid_data(
1929 "lifecycle file is not a bounded non-empty regular file",
1930 ));
1931 }
1932 let capacity =
1933 usize::try_from(metadata.len()).map_err(|source| invalid_data(source.to_string()))?;
1934 let mut bytes = Vec::with_capacity(capacity);
1935 File::open(path)
1936 .map_err(|source| io_error("open bounded lifecycle file", path, source))?
1937 .take(maximum.saturating_add(1))
1938 .read_to_end(&mut bytes)
1939 .map_err(|source| io_error("read bounded lifecycle file", path, source))?;
1940 if bytes.len() != capacity {
1941 return Err(invalid_data(
1942 "lifecycle file changed while it was being read",
1943 ));
1944 }
1945 Ok(bytes)
1946}
1947
1948fn copy_and_hash(
1950 input: &mut impl Read,
1951 output: &mut File,
1952 hasher: &mut Sha256,
1953) -> Result<u64, OptionalParserPackLifecycleError> {
1954 let mut buffer = vec![0u8; 64 * 1024].into_boxed_slice();
1955 let mut total = 0u64;
1956 loop {
1957 let read = input.read(&mut buffer).map_err(|source| {
1958 io_error(
1959 "read parser-pack archive entry",
1960 Path::new(ARCHIVE_ROOT),
1961 source,
1962 )
1963 })?;
1964 if read == 0 {
1965 break;
1966 }
1967 output.write_all(&buffer[..read]).map_err(|source| {
1968 io_error(
1969 "write extracted parser-pack file",
1970 Path::new(ARCHIVE_ROOT),
1971 source,
1972 )
1973 })?;
1974 hasher.update(&buffer[..read]);
1975 total = total
1976 .checked_add(u64::try_from(read).map_err(|source| invalid_data(source.to_string()))?)
1977 .ok_or_else(|| invalid_data("archive entry byte count overflowed"))?;
1978 if total > OPTIONAL_PARSER_PACK_MAX_FILE_BYTES {
1979 return Err(invalid_data("archive entry exceeded its file byte ceiling"));
1980 }
1981 }
1982 Ok(total)
1983}
1984
1985fn require_zero_tar_padding(input: &mut impl Read) -> Result<(), OptionalParserPackLifecycleError> {
1987 let mut buffer = vec![0u8; 64 * 1024].into_boxed_slice();
1988 loop {
1989 let read = input.read(&mut buffer).map_err(|source| {
1990 io_error(
1991 "read parser-pack tar padding",
1992 Path::new(ARCHIVE_ROOT),
1993 source,
1994 )
1995 })?;
1996 if read == 0 {
1997 return Ok(());
1998 }
1999 if buffer[..read].iter().any(|byte| *byte != 0) {
2000 return Err(invalid_data(
2001 "archive contains non-zero data after its tar terminator",
2002 ));
2003 }
2004 }
2005}
2006
2007fn installed_slot_paths(
2009 pack_root: &Path,
2010) -> Result<Vec<InstalledSlotPath>, OptionalParserPackLifecycleError> {
2011 match fs::symlink_metadata(pack_root) {
2012 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
2013 Ok(metadata) if metadata.file_type().is_dir() => {}
2014 Ok(_) => return Ok(Vec::new()),
2015 Err(source) => {
2016 return Err(io_error(
2017 "inspect parser-pack storage root",
2018 pack_root,
2019 source,
2020 ));
2021 }
2022 }
2023 let versions = pack_root.join("versions");
2024 match fs::symlink_metadata(&versions) {
2025 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
2026 Ok(metadata) if metadata.file_type().is_dir() => {}
2027 Ok(_) => return Ok(Vec::new()),
2028 Err(source) => {
2029 return Err(io_error(
2030 "inspect parser-pack versions root",
2031 versions,
2032 source,
2033 ));
2034 }
2035 }
2036 let mut slots = Vec::new();
2037 let mut observed_entries = 0usize;
2038 for version in fs::read_dir(&versions)
2039 .map_err(|source| io_error("list parser-pack versions", &versions, source))?
2040 {
2041 if observed_entries == LIFECYCLE_METADATA_ENTRY_LIMIT {
2042 return Err(invalid_data(
2043 "parser-pack metadata entries exceed the cleanup bound",
2044 ));
2045 }
2046 observed_entries = observed_entries.saturating_add(1);
2047 let version = version
2048 .map_err(|source| io_error("read parser-pack version entry", &versions, source))?;
2049 if !version
2050 .file_type()
2051 .map_err(|source| {
2052 io_error("inspect parser-pack version entry", version.path(), source)
2053 })?
2054 .is_dir()
2055 {
2056 continue;
2057 }
2058 let projectatlas_version_name = version
2059 .file_name()
2060 .into_string()
2061 .map_err(|_name| invalid_data("parser-pack version directory is not UTF-8"))?;
2062 for slot in fs::read_dir(version.path())
2063 .map_err(|source| io_error("list parser-pack slots", version.path(), source))?
2064 {
2065 if observed_entries == LIFECYCLE_METADATA_ENTRY_LIMIT {
2066 return Err(invalid_data(
2067 "parser-pack metadata entries exceed the cleanup bound",
2068 ));
2069 }
2070 observed_entries = observed_entries.saturating_add(1);
2071 let slot = slot.map_err(|source| {
2072 io_error("read parser-pack slot entry", version.path(), source)
2073 })?;
2074 if !slot
2075 .file_type()
2076 .map_err(|source| io_error("inspect parser-pack slot entry", slot.path(), source))?
2077 .is_dir()
2078 {
2079 continue;
2080 }
2081 let entry_name = slot
2082 .file_name()
2083 .into_string()
2084 .map_err(|_name| invalid_data("parser-pack artifact directory is not UTF-8"))?;
2085 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2086 if let Some((state, artifact_prefix)) = parse_windows_tombstone_name(&entry_name) {
2087 let entry_root = slot.path();
2088 let artifact = windows_tombstone_artifact(&entry_root, &artifact_prefix)?;
2089 slots.push(InstalledSlotPath {
2090 projectatlas_version: projectatlas_version_name.clone(),
2091 artifact,
2092 pack_root: (state == InstalledSlotCleanupState::ProfilePending)
2093 .then(|| entry_root.clone()),
2094 entry_root,
2095 state,
2096 });
2097 continue;
2098 }
2099 let entry_root = slot.path();
2100 slots.push(InstalledSlotPath {
2101 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2102 projectatlas_version: projectatlas_version_name.clone(),
2103 artifact: entry_name,
2104 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2105 pack_root: Some(entry_root.clone()),
2106 entry_root,
2107 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2108 state: InstalledSlotCleanupState::Installed,
2109 });
2110 }
2111 #[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
2112 drop(projectatlas_version_name);
2113 }
2114 Ok(slots)
2115}
2116
2117#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2119fn parse_windows_tombstone_name(name: &str) -> Option<(InstalledSlotCleanupState, String)> {
2120 let (state, remainder) =
2121 if let Some(remainder) = name.strip_prefix(WINDOWS_REMOVING_TOMBSTONE_PREFIX) {
2122 (InstalledSlotCleanupState::ProfilePending, remainder)
2123 } else {
2124 let remainder = name.strip_prefix(WINDOWS_CLEANED_TOMBSTONE_PREFIX)?;
2125 (InstalledSlotCleanupState::ProfileCleaned, remainder)
2126 };
2127 if remainder.len() <= WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS {
2128 return None;
2129 }
2130 let (artifact_prefix, suffix) = remainder.split_at(WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS);
2131 if !artifact_prefix
2132 .bytes()
2133 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
2134 || !suffix.starts_with('-')
2135 || suffix.len() < 2
2136 || suffix.len() > 33
2137 || !suffix[1..].bytes().all(|byte| byte.is_ascii_alphanumeric())
2138 {
2139 return None;
2140 }
2141 Some((state, artifact_prefix.to_owned()))
2142}
2143
2144#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2146fn windows_tombstone_artifact(
2147 root: &Path,
2148 expected_prefix: &str,
2149) -> Result<String, OptionalParserPackLifecycleError> {
2150 let manifest_bytes = read_bounded_file(
2151 &root.join(ARTIFACT_MANIFEST_FILE_NAME),
2152 u64::try_from(OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES)
2153 .map_err(|source| invalid_data(source.to_string()))?,
2154 )?;
2155 let artifact = ParserArtifactIdentity::for_bytes(&manifest_bytes)
2156 .digest()
2157 .as_str()
2158 .to_owned();
2159 if !artifact.starts_with(expected_prefix) {
2160 return Err(invalid_data(
2161 "parser-pack cleanup tombstone differs from its artifact manifest",
2162 ));
2163 }
2164 Ok(artifact)
2165}
2166
2167#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2169fn windows_slot_cleanup_in_progress(
2170 version_root: &Path,
2171 artifact: &str,
2172) -> Result<bool, OptionalParserPackLifecycleError> {
2173 ParserContentDigest::new(artifact.to_owned())
2174 .map_err(|source| invalid_data(source.to_string()))?;
2175 let entries = fs::read_dir(version_root)
2176 .map_err(|source| io_error("list parser-pack cleanup tombstones", version_root, source))?;
2177 for (index, entry) in entries.enumerate() {
2178 if index == LIFECYCLE_METADATA_ENTRY_LIMIT {
2179 return Err(invalid_data(
2180 "parser-pack cleanup tombstones exceed the lifecycle bound",
2181 ));
2182 }
2183 let entry = entry.map_err(|source| {
2184 io_error("read parser-pack cleanup tombstone", version_root, source)
2185 })?;
2186 if !entry
2187 .file_type()
2188 .map_err(|source| {
2189 io_error(
2190 "inspect parser-pack cleanup tombstone",
2191 entry.path(),
2192 source,
2193 )
2194 })?
2195 .is_dir()
2196 {
2197 continue;
2198 }
2199 let Ok(name) = entry.file_name().into_string() else {
2200 continue;
2201 };
2202 if let Some((_state, artifact_prefix)) = parse_windows_tombstone_name(&name)
2203 && artifact.starts_with(&artifact_prefix)
2204 && windows_tombstone_artifact(&entry.path(), &artifact_prefix)? == artifact
2205 {
2206 return Ok(true);
2207 }
2208 }
2209 Ok(false)
2210}
2211
2212#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2214fn transition_slot_to_removing_tombstone(
2215 slot: &InstalledSlotPath,
2216) -> Result<InstalledSlotPath, OptionalParserPackLifecycleError> {
2217 if slot.state != InstalledSlotCleanupState::Installed {
2218 return Err(invalid_data(
2219 "only an installed slot can enter the removing tombstone state",
2220 ));
2221 }
2222 ParserContentDigest::new(slot.artifact.clone())
2223 .map_err(|source| invalid_data(source.to_string()))?;
2224 let parent = slot
2225 .entry_root
2226 .parent()
2227 .ok_or_else(|| invalid_data("parser-pack slot has no version parent"))?;
2228 let tombstone_prefix = format!(
2229 "{WINDOWS_REMOVING_TOMBSTONE_PREFIX}{}-",
2230 &slot.artifact[..WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS]
2231 );
2232 let reservation = tempfile::Builder::new()
2233 .prefix(&tombstone_prefix)
2234 .tempfile_in(parent)
2235 .map_err(|source| io_error("reserve unique parser-pack tombstone", parent, source))?;
2236 let tombstone = reservation.path().to_path_buf();
2237 if let Err(error) = require_windows_cleanup_paths(&slot.entry_root, &tombstone) {
2238 reservation.close().map_err(|source| {
2239 io_error(
2240 "release unsupported parser-pack tombstone reservation",
2241 &tombstone,
2242 source,
2243 )
2244 })?;
2245 return Err(error);
2246 }
2247 reservation.close().map_err(|source| {
2248 io_error(
2249 "release parser-pack tombstone reservation",
2250 &tombstone,
2251 source,
2252 )
2253 })?;
2254 fs::rename(&slot.entry_root, &tombstone).map_err(|source| {
2255 io_error(
2256 "move parser-pack slot into removing tombstone",
2257 &slot.entry_root,
2258 source,
2259 )
2260 })?;
2261 Ok(InstalledSlotPath {
2262 projectatlas_version: slot.projectatlas_version.clone(),
2263 artifact: slot.artifact.clone(),
2264 entry_root: tombstone.clone(),
2265 pack_root: Some(tombstone),
2266 state: InstalledSlotCleanupState::ProfilePending,
2267 })
2268}
2269
2270#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2272fn transition_tombstone_to_profile_cleaned(
2273 slot: &InstalledSlotPath,
2274) -> Result<InstalledSlotPath, OptionalParserPackLifecycleError> {
2275 if slot.state != InstalledSlotCleanupState::ProfilePending {
2276 return Err(invalid_data(
2277 "only a profile-pending tombstone can become profile-cleaned",
2278 ));
2279 }
2280 let name = slot
2281 .entry_root
2282 .file_name()
2283 .and_then(|name| name.to_str())
2284 .ok_or_else(|| invalid_data("parser-pack cleanup tombstone name is not UTF-8"))?;
2285 let remainder = name
2286 .strip_prefix(WINDOWS_REMOVING_TOMBSTONE_PREFIX)
2287 .ok_or_else(|| invalid_data("parser-pack removing tombstone prefix is invalid"))?;
2288 let parent = slot
2289 .entry_root
2290 .parent()
2291 .ok_or_else(|| invalid_data("parser-pack cleanup tombstone has no parent"))?;
2292 let cleaned_root = parent.join(format!("{WINDOWS_CLEANED_TOMBSTONE_PREFIX}{remainder}"));
2293 require_windows_cleanup_paths(&slot.entry_root, &cleaned_root)?;
2294 if fs::symlink_metadata(&cleaned_root).is_ok() {
2295 return Err(invalid_data(
2296 "profile-cleaned parser-pack tombstone path is already occupied",
2297 ));
2298 }
2299 fs::rename(&slot.entry_root, &cleaned_root).map_err(|source| {
2300 io_error(
2301 "record parser-pack profile cleanup in tombstone state",
2302 &slot.entry_root,
2303 source,
2304 )
2305 })?;
2306 Ok(InstalledSlotPath {
2307 projectatlas_version: slot.projectatlas_version.clone(),
2308 artifact: slot.artifact.clone(),
2309 entry_root: cleaned_root,
2310 pack_root: None,
2311 state: InstalledSlotCleanupState::ProfileCleaned,
2312 })
2313}
2314
2315#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2317fn windows_verbatim_path(path: &Path) -> PathBuf {
2318 use std::ffi::OsString;
2319 use std::os::windows::ffi::{OsStrExt as _, OsStringExt as _};
2320
2321 const DIRECTORY_SEPARATOR: u16 = b'\\' as u16;
2322 const VERBATIM_PREFIX: &[u16] = &[
2323 DIRECTORY_SEPARATOR,
2324 DIRECTORY_SEPARATOR,
2325 b'?' as u16,
2326 DIRECTORY_SEPARATOR,
2327 ];
2328 const VERBATIM_UNC_PREFIX: &[u16] = &[
2329 DIRECTORY_SEPARATOR,
2330 DIRECTORY_SEPARATOR,
2331 b'?' as u16,
2332 DIRECTORY_SEPARATOR,
2333 b'U' as u16,
2334 b'N' as u16,
2335 b'C' as u16,
2336 DIRECTORY_SEPARATOR,
2337 ];
2338
2339 let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
2340 if path.starts_with(VERBATIM_PREFIX) {
2341 return PathBuf::from(OsString::from_wide(&path));
2342 }
2343 let (prefix, suffix) = if path.starts_with(&[DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR]) {
2344 (VERBATIM_UNC_PREFIX, &path[2..])
2345 } else {
2346 (VERBATIM_PREFIX, path.as_slice())
2347 };
2348 let mut extended = Vec::with_capacity(prefix.len().saturating_add(suffix.len()));
2349 extended.extend_from_slice(prefix);
2350 extended.extend_from_slice(suffix);
2351 PathBuf::from(OsString::from_wide(&extended))
2352}
2353
2354#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2356fn require_windows_framework_path(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
2357 use std::os::windows::ffi::OsStrExt as _;
2358
2359 let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
2360 if path.starts_with(&[
2361 u16::from(b'\\'),
2362 u16::from(b'\\'),
2363 u16::from(b'?'),
2364 u16::from(b'\\'),
2365 ]) || path.len() > WINDOWS_PROCESS_CURRENT_DIRECTORY_MAX_UTF16_UNITS
2366 {
2367 return Err(invalid_data(
2368 "Windows parser-pack cleanup path exceeds the .NET Framework path contract",
2369 ));
2370 }
2371 Ok(())
2372}
2373
2374#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2376fn require_windows_cleanup_paths(
2377 source_root: &Path,
2378 cleanup_root: &Path,
2379) -> Result<(), OptionalParserPackLifecycleError> {
2380 let mut pending = vec![(source_root.to_path_buf(), cleanup_root.to_path_buf())];
2381 let mut observed = 0_usize;
2382 while let Some((source, cleanup)) = pending.pop() {
2383 if observed == LIFECYCLE_METADATA_ENTRY_LIMIT {
2384 return Err(invalid_data(
2385 "parser-pack cleanup tree exceeds the lifecycle bound",
2386 ));
2387 }
2388 observed = observed.saturating_add(1);
2389 require_windows_framework_path(&cleanup)?;
2390 let metadata = fs::symlink_metadata(&source)
2391 .map_err(|error| io_error("inspect parser-pack cleanup path", &source, error))?;
2392 if metadata.is_dir() && !metadata.file_type().is_symlink() {
2393 for entry in fs::read_dir(&source)
2394 .map_err(|error| io_error("list parser-pack cleanup path", &source, error))?
2395 {
2396 let entry = entry
2397 .map_err(|error| io_error("read parser-pack cleanup path", &source, error))?;
2398 pending.push((entry.path(), cleanup.join(entry.file_name())));
2399 }
2400 }
2401 }
2402 Ok(())
2403}
2404
2405#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2407fn cleanup_platform_profile(
2408 root: &Path,
2409 artifact: &ParserArtifactIdentity,
2410) -> Result<(), OptionalParserPackLifecycleError> {
2411 use std::os::windows::process::CommandExt as _;
2412
2413 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
2414 let manifest_path = root.join(ARTIFACT_MANIFEST_FILE_NAME);
2415 let manifest_bytes = read_bounded_file(
2416 &manifest_path,
2417 u64::try_from(OPTIONAL_PARSER_PACK_MANIFEST_MAX_BYTES)
2418 .map_err(|source| invalid_data(source.to_string()))?,
2419 )?;
2420 let observed = ParserArtifactIdentity::for_bytes(&manifest_bytes);
2421 if &observed != artifact {
2422 return Err(invalid_data(
2423 "artifact manifest changed before profile cleanup",
2424 ));
2425 }
2426 let sha256 = Sha256::digest(&manifest_bytes);
2427 let profile_name = format!("projectatlas.parser.{}", lowercase_hex(&sha256[..20]));
2428 require_windows_cleanup_paths(root, root)?;
2429 let broker = windows_verbatim_path(&root.join(WINDOWS_CONTAINMENT_BROKER_FILE_NAME));
2430 let windows_directory = validated_windows_directory()?;
2431 let mut command = Command::new(&broker);
2432 command
2433 .arg(WINDOWS_PROFILE_CLEANUP_ARGUMENT)
2434 .current_dir(root)
2435 .env_clear()
2436 .env("SystemRoot", &windows_directory)
2437 .env("WINDIR", &windows_directory)
2438 .stdin(Stdio::null())
2439 .stdout(Stdio::piped())
2440 .stderr(Stdio::piped())
2441 .creation_flags(CREATE_NO_WINDOW);
2442 let mut child = command
2443 .spawn()
2444 .map_err(|source| io_error("start artifact profile cleanup broker", &broker, source))?;
2445 supervise_cleanup_broker(
2446 &mut child,
2447 &broker,
2448 &profile_name,
2449 WINDOWS_PROFILE_CLEANUP_TIMEOUT,
2450 WINDOWS_PROFILE_CLEANUP_REAP_TIMEOUT,
2451 )
2452}
2453
2454#[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
2456#[allow(clippy::unnecessary_wraps)]
2459fn cleanup_platform_profile(
2460 _root: &Path,
2461 _artifact: &ParserArtifactIdentity,
2462) -> Result<(), OptionalParserPackLifecycleError> {
2463 Ok(())
2464}
2465
2466#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2468fn supervise_cleanup_broker(
2469 child: &mut std::process::Child,
2470 broker: &Path,
2471 profile_name: &str,
2472 operation_timeout: Duration,
2473 cleanup_timeout: Duration,
2474) -> Result<(), OptionalParserPackLifecycleError> {
2475 let mut operation_failure = None;
2476 let stdout_reader = if let Some(stdout) = child.stdout.take() {
2477 Some(thread::spawn(move || read_bounded_cleanup_output(stdout)))
2478 } else {
2479 operation_failure = Some(invalid_data("profile cleanup broker stdout was not piped"));
2480 None
2481 };
2482 let stderr_reader = if let Some(stderr) = child.stderr.take() {
2483 Some(thread::spawn(move || read_bounded_cleanup_output(stderr)))
2484 } else {
2485 operation_failure
2486 .get_or_insert_with(|| invalid_data("profile cleanup broker stderr was not piped"));
2487 None
2488 };
2489 let operation_deadline = Instant::now()
2490 .checked_add(operation_timeout)
2491 .unwrap_or_else(Instant::now);
2492 let mut status = None;
2493 while operation_failure.is_none() && status.is_none() {
2494 match child.try_wait() {
2495 Ok(observed) => status = observed,
2496 Err(source) => {
2497 operation_failure = Some(io_error(
2498 "wait for artifact profile cleanup broker",
2499 broker,
2500 source,
2501 ));
2502 }
2503 }
2504 if status.is_none() && Instant::now() >= operation_deadline {
2505 operation_failure = Some(invalid_data(format!(
2506 "profile {profile_name} cleanup exceeded its deadline"
2507 )));
2508 }
2509 if operation_failure.is_none() && status.is_none() {
2510 thread::sleep(Duration::from_millis(10));
2511 }
2512 }
2513
2514 let cleanup_deadline = Instant::now()
2515 .checked_add(cleanup_timeout)
2516 .unwrap_or_else(Instant::now);
2517 let mut cleanup_failures = Vec::new();
2518 if status.is_none()
2519 && let Err(kill_source) = child.kill()
2520 {
2521 match child.try_wait() {
2522 Ok(Some(observed)) => status = Some(observed),
2523 Ok(None) => cleanup_failures.push(format!(
2524 "terminate cleanup broker failed: {kill_source}"
2525 )),
2526 Err(wait_source) => cleanup_failures.push(format!(
2527 "terminate cleanup broker failed: {kill_source}; observe child failed: {wait_source}"
2528 )),
2529 }
2530 }
2531 while status.is_none() && Instant::now() < cleanup_deadline {
2532 match child.try_wait() {
2533 Ok(observed) => status = observed,
2534 Err(source) => {
2535 cleanup_failures.push(format!("reap cleanup broker failed: {source}"));
2536 break;
2537 }
2538 }
2539 if status.is_none() {
2540 thread::sleep(Duration::from_millis(10));
2541 }
2542 }
2543 if status.is_none() {
2544 cleanup_failures
2545 .push("cleanup broker was not reaped within its cleanup deadline".to_owned());
2546 }
2547
2548 while (!cleanup_reader_finished(stdout_reader.as_ref())
2549 || !cleanup_reader_finished(stderr_reader.as_ref()))
2550 && Instant::now() < cleanup_deadline
2551 {
2552 thread::sleep(Duration::from_millis(10));
2553 }
2554 let stdout = join_cleanup_reader(stdout_reader, "stdout", broker, &mut cleanup_failures);
2555 let stderr = join_cleanup_reader(stderr_reader, "stderr", broker, &mut cleanup_failures);
2556
2557 if operation_failure.is_none() {
2558 let operation_result = match (status, stdout, stderr) {
2559 (Some(status), Some(Ok(stdout)), Some(Ok(stderr))) => match String::from_utf8(stdout) {
2560 Ok(stdout)
2561 if status.success()
2562 && stdout.trim_end() == WINDOWS_PROFILE_CLEANUP_RESULT
2563 && stderr.is_empty() =>
2564 {
2565 Ok(())
2566 }
2567 Ok(_) => Err(invalid_data(format!(
2568 "profile {profile_name} cleanup broker returned an invalid bounded result"
2569 ))),
2570 Err(source) => Err(invalid_data(source.to_string())),
2571 },
2572 (_, Some(Err(source)), _) => Err(io_error(
2573 "read artifact profile cleanup stdout",
2574 broker,
2575 source,
2576 )),
2577 (_, _, Some(Err(source))) => Err(io_error(
2578 "read artifact profile cleanup stderr",
2579 broker,
2580 source,
2581 )),
2582 _ => Err(invalid_data(
2583 "profile cleanup broker result was incomplete after reap",
2584 )),
2585 };
2586 if let Err(error) = operation_result {
2587 operation_failure = Some(error);
2588 }
2589 }
2590
2591 let cleanup_failure = (!cleanup_failures.is_empty()).then(|| {
2592 OptionalParserPackLifecycleError::CleanupIncomplete {
2593 message: cleanup_failures.join("; ").chars().take(4_096).collect(),
2594 }
2595 });
2596 match (operation_failure, cleanup_failure) {
2597 (None, None) => Ok(()),
2598 (Some(operation), None) => Err(operation),
2599 (None, Some(cleanup)) => Err(cleanup),
2600 (Some(operation), Some(cleanup)) => {
2601 Err(OptionalParserPackLifecycleError::OperationAndCleanup {
2602 operation: Box::new(operation),
2603 cleanup: Box::new(cleanup),
2604 })
2605 }
2606 }
2607}
2608
2609#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2611fn cleanup_reader_finished(
2612 reader: Option<&thread::JoinHandle<Result<Vec<u8>, io::Error>>>,
2613) -> bool {
2614 reader.is_none_or(thread::JoinHandle::is_finished)
2615}
2616
2617#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2619fn join_cleanup_reader(
2620 reader: Option<thread::JoinHandle<Result<Vec<u8>, io::Error>>>,
2621 stream: &'static str,
2622 broker: &Path,
2623 cleanup_failures: &mut Vec<String>,
2624) -> Option<Result<Vec<u8>, io::Error>> {
2625 let reader = reader?;
2626 if !reader.is_finished() {
2627 cleanup_failures.push(format!(
2628 "cleanup broker {stream} reader did not drain within its cleanup deadline"
2629 ));
2630 return None;
2631 }
2632 match reader.join() {
2633 Ok(result) => Some(result),
2634 Err(_panic) => {
2635 cleanup_failures.push(format!(
2636 "cleanup broker {stream} reader panicked for {}",
2637 broker.display()
2638 ));
2639 None
2640 }
2641 }
2642}
2643
2644#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2646fn validated_windows_directory() -> Result<PathBuf, OptionalParserPackLifecycleError> {
2647 let configured = env::var_os("SystemRoot")
2648 .or_else(|| env::var_os("WINDIR"))
2649 .ok_or_else(|| invalid_data("Windows directory environment is unavailable"))?;
2650 let path = PathBuf::from(configured);
2651 if !path.is_absolute() {
2652 return Err(invalid_data("Windows directory is not absolute"));
2653 }
2654 let metadata = fs::symlink_metadata(&path)
2655 .map_err(|source| io_error("inspect Windows directory", &path, source))?;
2656 if !metadata.file_type().is_dir() {
2657 return Err(invalid_data("Windows directory is not a real directory"));
2658 }
2659 fs::canonicalize(&path)
2660 .map_err(|source| io_error("canonicalize Windows directory", path, source))
2661}
2662
2663#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2665fn read_bounded_cleanup_output(input: impl Read) -> Result<Vec<u8>, io::Error> {
2666 let mut output = Vec::new();
2667 input
2668 .take(WINDOWS_PROFILE_CLEANUP_OUTPUT_BYTES.saturating_add(1))
2669 .read_to_end(&mut output)?;
2670 if u64::try_from(output.len()).unwrap_or(u64::MAX) > WINDOWS_PROFILE_CLEANUP_OUTPUT_BYTES {
2671 return Err(io::Error::new(
2672 io::ErrorKind::InvalidData,
2673 "profile cleanup output exceeded its byte ceiling",
2674 ));
2675 }
2676 Ok(output)
2677}
2678
2679fn count_installed_slots(
2681 pack_root: &Path,
2682) -> Result<(usize, bool, bool), OptionalParserPackLifecycleError> {
2683 let versions = pack_root.join("versions");
2684 match fs::symlink_metadata(pack_root) {
2685 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok((0, false, false)),
2686 Ok(metadata) if !metadata.file_type().is_dir() => {
2687 return Err(invalid_data("parser-pack storage root is not a directory"));
2688 }
2689 Ok(_) => {}
2690 Err(source) => {
2691 return Err(io_error(
2692 "inspect parser-pack storage root",
2693 pack_root,
2694 source,
2695 ));
2696 }
2697 }
2698 match fs::symlink_metadata(&versions) {
2699 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok((0, false, false)),
2700 Ok(metadata) if !metadata.file_type().is_dir() => {
2701 return Err(invalid_data("parser-pack versions root is not a directory"));
2702 }
2703 Ok(_) => {}
2704 Err(source) => {
2705 return Err(io_error(
2706 "inspect parser-pack versions root",
2707 versions,
2708 source,
2709 ));
2710 }
2711 }
2712 let mut count = 0usize;
2713 let mut observed_entries = 0usize;
2714 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2715 let mut cleanup_pending = false;
2716 #[cfg(not(all(target_os = "windows", target_arch = "x86_64")))]
2717 let cleanup_pending = false;
2718 let version_entries = fs::read_dir(&versions)
2719 .map_err(|source| io_error("list parser-pack versions", &versions, source))?;
2720 for version in version_entries {
2721 if observed_entries == LIFECYCLE_METADATA_ENTRY_LIMIT {
2722 return Ok((count, true, cleanup_pending));
2723 }
2724 observed_entries = observed_entries.saturating_add(1);
2725 let version = version
2726 .map_err(|source| io_error("read parser-pack version entry", &versions, source))?;
2727 if !version
2728 .file_type()
2729 .map_err(|source| {
2730 io_error("inspect parser-pack version entry", version.path(), source)
2731 })?
2732 .is_dir()
2733 {
2734 continue;
2735 }
2736 let slots = fs::read_dir(version.path())
2737 .map_err(|source| io_error("list parser-pack slots", version.path(), source))?;
2738 for slot in slots {
2739 if observed_entries == LIFECYCLE_METADATA_ENTRY_LIMIT {
2740 return Ok((count, true, cleanup_pending));
2741 }
2742 observed_entries = observed_entries.saturating_add(1);
2743 let slot = slot.map_err(|source| {
2744 io_error("read parser-pack slot entry", version.path(), source)
2745 })?;
2746 if !slot
2747 .file_type()
2748 .map_err(|source| io_error("inspect parser-pack slot entry", slot.path(), source))?
2749 .is_dir()
2750 {
2751 continue;
2752 }
2753 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
2754 if slot
2755 .file_name()
2756 .to_str()
2757 .and_then(parse_windows_tombstone_name)
2758 .is_some()
2759 {
2760 cleanup_pending = true;
2761 continue;
2762 }
2763 count = count.saturating_add(1);
2764 }
2765 }
2766 Ok((count, false, cleanup_pending))
2767}
2768
2769fn open_or_create_direct_lease_file(path: &Path) -> Result<File, OptionalParserPackLifecycleError> {
2771 loop {
2772 match fs::symlink_metadata(path) {
2773 Ok(metadata) => {
2774 require_direct_lease_file(path, &metadata)?;
2775 let file = OpenOptions::new()
2776 .read(true)
2777 .write(true)
2778 .open(path)
2779 .map_err(|source| {
2780 io_error("open optional parser-pack lifecycle lease", path, source)
2781 })?;
2782 require_direct_lease_path(path)?;
2783 return Ok(file);
2784 }
2785 Err(source) if source.kind() == io::ErrorKind::NotFound => {
2786 match OpenOptions::new()
2787 .read(true)
2788 .write(true)
2789 .create_new(true)
2790 .open(path)
2791 {
2792 Ok(file) => {
2793 require_direct_lease_path(path)?;
2794 return Ok(file);
2795 }
2796 Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {}
2797 Err(source) => {
2798 return Err(io_error(
2799 "create optional parser-pack lifecycle lease",
2800 path,
2801 source,
2802 ));
2803 }
2804 }
2805 }
2806 Err(source) => {
2807 return Err(io_error(
2808 "inspect optional parser-pack lifecycle lease",
2809 path,
2810 source,
2811 ));
2812 }
2813 }
2814 }
2815}
2816
2817fn require_direct_lease_path(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
2819 let metadata = fs::symlink_metadata(path).map_err(|source| {
2820 io_error(
2821 "revalidate optional parser-pack lifecycle lease",
2822 path,
2823 source,
2824 )
2825 })?;
2826 require_direct_lease_file(path, &metadata)?;
2827 Ok(())
2828}
2829
2830fn require_direct_lease_file(
2832 path: &Path,
2833 metadata: &fs::Metadata,
2834) -> Result<(), OptionalParserPackLifecycleError> {
2835 #[cfg(windows)]
2836 let indirect = {
2837 use std::os::windows::fs::MetadataExt as _;
2838
2839 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
2840 metadata.file_type().is_symlink()
2841 || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
2842 };
2843 #[cfg(not(windows))]
2844 let indirect = metadata.file_type().is_symlink();
2845 if indirect || !metadata.file_type().is_file() {
2846 return Err(invalid_data(format!(
2847 "optional parser-pack lifecycle lease is not a direct regular file: {}",
2848 path.display()
2849 )));
2850 }
2851 Ok(())
2852}
2853
2854fn ensure_anchor_directory(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
2856 match fs::symlink_metadata(path) {
2857 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
2858 Ok(_) => Err(invalid_data(
2859 "parser-pack storage anchor is not a directory",
2860 )),
2861 Err(source) if source.kind() == io::ErrorKind::NotFound => fs::create_dir_all(path)
2862 .map_err(|source| io_error("create parser-pack storage anchor", path, source)),
2863 Err(source) => Err(io_error("inspect parser-pack storage anchor", path, source)),
2864 }
2865}
2866
2867fn ensure_direct_directory(
2869 parent: &Path,
2870 path: &Path,
2871) -> Result<(), OptionalParserPackLifecycleError> {
2872 if path.parent() != Some(parent) {
2873 return Err(invalid_data(
2874 "lifecycle directory is not a direct owned child",
2875 ));
2876 }
2877 match fs::symlink_metadata(path) {
2878 Ok(metadata) if metadata.file_type().is_dir() => Ok(()),
2879 Ok(_) => Err(invalid_data(
2880 "product-owned lifecycle component is not a real directory",
2881 )),
2882 Err(source) if source.kind() == io::ErrorKind::NotFound => fs::create_dir(path)
2883 .map_err(|source| io_error("create product-owned lifecycle directory", path, source)),
2884 Err(source) => Err(io_error(
2885 "inspect product-owned lifecycle directory",
2886 path,
2887 source,
2888 )),
2889 }
2890}
2891
2892#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2894enum DirectDirectoryState {
2895 Missing,
2897 Real,
2899 Unsafe,
2901}
2902
2903fn direct_directory_state(
2905 path: &Path,
2906) -> Result<DirectDirectoryState, OptionalParserPackLifecycleError> {
2907 match fs::symlink_metadata(path) {
2908 Ok(metadata) if metadata.file_type().is_dir() => Ok(DirectDirectoryState::Real),
2909 Ok(_) => Ok(DirectDirectoryState::Unsafe),
2910 Err(source) if source.kind() == io::ErrorKind::NotFound => {
2911 Ok(DirectDirectoryState::Missing)
2912 }
2913 Err(source) => Err(io_error(
2914 "inspect product-owned lifecycle component",
2915 path,
2916 source,
2917 )),
2918 }
2919}
2920
2921fn remove_file_if_present(path: &Path) -> Result<bool, OptionalParserPackLifecycleError> {
2923 match fs::symlink_metadata(path) {
2924 Ok(metadata) if metadata.file_type().is_dir() => Err(invalid_data(
2925 "project parser-pack selection path is a directory",
2926 )),
2927 Ok(_) => {
2928 make_path_writable(path)?;
2929 fs::remove_file(path)
2930 .map_err(|source| io_error("remove project parser-pack selection", path, source))?;
2931 Ok(true)
2932 }
2933 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
2934 Err(source) => Err(io_error(
2935 "inspect project parser-pack selection",
2936 path,
2937 source,
2938 )),
2939 }
2940}
2941
2942fn remove_tree_if_present(path: &Path) -> Result<bool, OptionalParserPackLifecycleError> {
2944 match fs::symlink_metadata(path) {
2945 Ok(metadata) if metadata.file_type().is_symlink() => {
2946 make_path_writable(path)?;
2947 remove_symlink_leaf(path)?;
2948 Ok(true)
2949 }
2950 Ok(metadata) if metadata.file_type().is_dir() => {
2951 make_tree_writable(path)?;
2952 fs::remove_dir_all(path)
2953 .map_err(|source| io_error("remove parser-pack storage", path, source))?;
2954 Ok(true)
2955 }
2956 Ok(_) => {
2957 make_path_writable(path)?;
2958 fs::remove_file(path)
2959 .map_err(|source| io_error("remove parser-pack storage entry", path, source))?;
2960 Ok(true)
2961 }
2962 Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false),
2963 Err(source) => Err(io_error("inspect parser-pack storage", path, source)),
2964 }
2965}
2966
2967#[cfg(windows)]
2969fn remove_symlink_leaf(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
2970 match fs::remove_dir(path) {
2971 Ok(()) => Ok(()),
2972 Err(directory_error) => fs::remove_file(path).map_err(|file_error| {
2973 invalid_data(format!(
2974 "could not remove parser-pack storage link: directory={directory_error}; file={file_error}"
2975 ))
2976 }),
2977 }
2978}
2979
2980#[cfg(not(windows))]
2982fn remove_symlink_leaf(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
2983 fs::remove_file(path)
2984 .map_err(|source| io_error("remove parser-pack storage symlink", path, source))
2985}
2986
2987#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
2993fn stage_parser_pack_for_atomic_publication(
2994 pack_root: &mut PathBuf,
2995 publication_parent: &Path,
2996) -> Result<TempDir, OptionalParserPackLifecycleError> {
2997 let staging = tempfile::Builder::new()
2998 .prefix(".projectatlas-install-")
2999 .tempdir_in(publication_parent)
3000 .map_err(|source| {
3001 io_error(
3002 "create parser-pack publication staging directory",
3003 publication_parent,
3004 source,
3005 )
3006 })?;
3007 for entry in fs::read_dir(&*pack_root).map_err(|source| {
3008 io_error(
3009 "list parser-pack extraction for publication",
3010 pack_root.as_path(),
3011 source,
3012 )
3013 })? {
3014 let entry = entry.map_err(|source| {
3015 io_error(
3016 "read parser-pack extraction entry for publication",
3017 pack_root.as_path(),
3018 source,
3019 )
3020 })?;
3021 let destination = staging.path().join(entry.file_name());
3022 fs::rename(entry.path(), &destination).map_err(|source| {
3023 io_error(
3024 "stage parser-pack extraction entry for publication",
3025 destination,
3026 source,
3027 )
3028 })?;
3029 }
3030 fs::remove_dir(&*pack_root).map_err(|source| {
3031 io_error(
3032 "remove empty parser-pack extraction root",
3033 pack_root.as_path(),
3034 source,
3035 )
3036 })?;
3037 pack_root.clone_from(&staging.path().to_path_buf());
3038 Ok(staging)
3039}
3040
3041fn seal_immutable_tree(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3043 let metadata = fs::symlink_metadata(path)
3044 .map_err(|source| io_error("inspect parser-pack staging tree", path, source))?;
3045 if metadata.file_type().is_symlink() {
3046 return Err(invalid_data("parser-pack staging tree contains a symlink"));
3047 }
3048 if metadata.is_dir() {
3049 for entry in fs::read_dir(path)
3050 .map_err(|source| io_error("list parser-pack staging tree", path, source))?
3051 {
3052 let entry =
3053 entry.map_err(|source| io_error("read parser-pack staging entry", path, source))?;
3054 seal_immutable_tree(&entry.path())?;
3055 }
3056 }
3057 set_path_immutable(path, metadata.is_dir())
3058}
3059
3060fn verify_immutable_tree(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3062 let metadata = fs::symlink_metadata(path)
3063 .map_err(|source| io_error("inspect immutable parser-pack slot", path, source))?;
3064 if metadata.file_type().is_symlink() {
3065 return Err(invalid_data(
3066 "immutable parser-pack slot contains a symlink",
3067 ));
3068 }
3069 verify_path_immutable(path, &metadata)?;
3070 if metadata.is_dir() {
3071 for entry in fs::read_dir(path)
3072 .map_err(|source| io_error("list immutable parser-pack slot", path, source))?
3073 {
3074 let entry = entry.map_err(|source| {
3075 io_error("read immutable parser-pack slot entry", path, source)
3076 })?;
3077 verify_immutable_tree(&entry.path())?;
3078 }
3079 }
3080 Ok(())
3081}
3082
3083fn make_tree_writable(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3085 let metadata = match fs::symlink_metadata(path) {
3086 Ok(metadata) => metadata,
3087 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(()),
3088 Err(source) => return Err(io_error("inspect parser-pack cleanup tree", path, source)),
3089 };
3090 make_path_writable(path)?;
3091 if metadata.is_dir() && !metadata.file_type().is_symlink() {
3092 for entry in fs::read_dir(path)
3093 .map_err(|source| io_error("list parser-pack cleanup tree", path, source))?
3094 {
3095 let entry =
3096 entry.map_err(|source| io_error("read parser-pack cleanup entry", path, source))?;
3097 make_tree_writable(&entry.path())?;
3098 }
3099 }
3100 Ok(())
3101}
3102
3103#[cfg(unix)]
3104fn set_extracted_mode(path: &Path, mode: u32) -> Result<(), OptionalParserPackLifecycleError> {
3106 use std::os::unix::fs::PermissionsExt as _;
3107 fs::set_permissions(path, fs::Permissions::from_mode(mode))
3108 .map_err(|source| io_error("set extracted parser-pack mode", path, source))
3109}
3110
3111#[cfg(unix)]
3112fn set_path_immutable(
3114 path: &Path,
3115 _directory: bool,
3116) -> Result<(), OptionalParserPackLifecycleError> {
3117 use std::os::unix::fs::PermissionsExt as _;
3118 let metadata = fs::symlink_metadata(path)
3119 .map_err(|source| io_error("inspect parser-pack permissions", path, source))?;
3120 let mode = metadata.permissions().mode() & !0o222;
3121 fs::set_permissions(path, fs::Permissions::from_mode(mode))
3122 .map_err(|source| io_error("seal parser-pack permissions", path, source))
3123}
3124
3125#[cfg(windows)]
3126fn set_path_immutable(
3128 path: &Path,
3129 directory: bool,
3130) -> Result<(), OptionalParserPackLifecycleError> {
3131 if directory {
3132 return Ok(());
3133 }
3134 let mut permissions = fs::symlink_metadata(path)
3135 .map_err(|source| io_error("inspect parser-pack permissions", path, source))?
3136 .permissions();
3137 permissions.set_readonly(true);
3138 fs::set_permissions(path, permissions)
3139 .map_err(|source| io_error("seal parser-pack permissions", path, source))
3140}
3141
3142#[cfg(not(any(unix, windows)))]
3143fn set_path_immutable(
3145 _path: &Path,
3146 _directory: bool,
3147) -> Result<(), OptionalParserPackLifecycleError> {
3148 Ok(())
3149}
3150
3151#[cfg(unix)]
3152fn verify_path_immutable(
3154 path: &Path,
3155 metadata: &fs::Metadata,
3156) -> Result<(), OptionalParserPackLifecycleError> {
3157 use std::os::unix::fs::PermissionsExt as _;
3158 if metadata.permissions().mode() & 0o222 != 0 {
3159 return Err(invalid_data(format!(
3160 "installed parser-pack entry {} is writable",
3161 path.file_name().unwrap_or_default().display()
3162 )));
3163 }
3164 Ok(())
3165}
3166
3167#[cfg(windows)]
3168fn verify_path_immutable(
3170 _path: &Path,
3171 metadata: &fs::Metadata,
3172) -> Result<(), OptionalParserPackLifecycleError> {
3173 if metadata.is_file() && !metadata.permissions().readonly() {
3174 return Err(invalid_data("installed parser-pack file is writable"));
3175 }
3176 Ok(())
3177}
3178
3179#[cfg(not(any(unix, windows)))]
3180fn verify_path_immutable(
3182 _path: &Path,
3183 _metadata: &fs::Metadata,
3184) -> Result<(), OptionalParserPackLifecycleError> {
3185 Ok(())
3186}
3187
3188#[cfg(unix)]
3189fn make_path_writable(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3191 use std::os::unix::fs::PermissionsExt as _;
3192 let metadata = fs::symlink_metadata(path)
3193 .map_err(|source| io_error("inspect parser-pack cleanup permissions", path, source))?;
3194 if metadata.file_type().is_symlink() {
3195 return Ok(());
3196 }
3197 let mode = metadata.permissions().mode() | 0o700;
3198 fs::set_permissions(path, fs::Permissions::from_mode(mode))
3199 .map_err(|source| io_error("restore parser-pack cleanup permissions", path, source))
3200}
3201
3202#[cfg(windows)]
3203#[allow(clippy::permissions_set_readonly_false)]
3205fn make_path_writable(path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3206 let metadata = fs::symlink_metadata(path)
3207 .map_err(|source| io_error("inspect parser-pack cleanup permissions", path, source))?;
3208 if metadata.file_type().is_symlink() {
3209 return Ok(());
3210 }
3211 let mut permissions = metadata.permissions();
3212 permissions.set_readonly(false);
3215 fs::set_permissions(path, permissions)
3216 .map_err(|source| io_error("restore parser-pack cleanup permissions", path, source))
3217}
3218
3219#[cfg(not(any(unix, windows)))]
3220fn make_path_writable(_path: &Path) -> Result<(), OptionalParserPackLifecycleError> {
3222 Ok(())
3223}
3224
3225fn default_storage_root() -> Result<PathBuf, OptionalParserPackLifecycleError> {
3227 #[cfg(windows)]
3228 {
3229 env::var_os("LOCALAPPDATA")
3230 .map(PathBuf::from)
3231 .map(|root| root.join("ProjectAtlas").join("parser-packs"))
3232 .ok_or(OptionalParserPackLifecycleError::StorageRootUnavailable)
3233 }
3234 #[cfg(target_os = "macos")]
3235 {
3236 env::var_os("HOME")
3237 .map(PathBuf::from)
3238 .map(|root| {
3239 root.join("Library")
3240 .join("Application Support")
3241 .join("ProjectAtlas")
3242 .join("parser-packs")
3243 })
3244 .ok_or(OptionalParserPackLifecycleError::StorageRootUnavailable)
3245 }
3246 #[cfg(all(unix, not(target_os = "macos")))]
3247 {
3248 if let Some(root) = env::var_os("XDG_DATA_HOME") {
3249 return Ok(PathBuf::from(root)
3250 .join("projectatlas")
3251 .join("parser-packs"));
3252 }
3253 env::var_os("HOME")
3254 .map(PathBuf::from)
3255 .map(|root| root.join(".local/share/projectatlas/parser-packs"))
3256 .ok_or(OptionalParserPackLifecycleError::StorageRootUnavailable)
3257 }
3258 #[cfg(not(any(unix, windows)))]
3259 Err(OptionalParserPackLifecycleError::StorageRootUnavailable)
3260}
3261
3262#[cfg(test)]
3264fn host_pack_platform() -> Option<PackPlatform> {
3265 OptionalParserCapability::current().pack_platform()
3266}
3267
3268fn lowercase_hex(bytes: &[u8]) -> String {
3270 const HEX: &[u8; 16] = b"0123456789abcdef";
3271 let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
3272 for byte in bytes {
3273 encoded.push(char::from(HEX[usize::from(byte >> 4)]));
3274 encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
3275 }
3276 encoded
3277}
3278
3279fn invalid_data(reason: impl Into<String>) -> OptionalParserPackLifecycleError {
3281 OptionalParserPackLifecycleError::InvalidData {
3282 reason: reason.into(),
3283 }
3284}
3285
3286fn io_error(
3288 operation: &'static str,
3289 path: impl Into<PathBuf>,
3290 source: io::Error,
3291) -> OptionalParserPackLifecycleError {
3292 OptionalParserPackLifecycleError::Io {
3293 operation,
3294 path: path.into(),
3295 source,
3296 }
3297}
3298
3299#[cfg(test)]
3300mod tests {
3301 use super::*;
3302 use std::error::Error;
3303
3304 const ABRUPT_LEASE_STORAGE_ENV: &str = "PROJECTATLAS_TEST_ABRUPT_LEASE_STORAGE";
3306 const ABRUPT_LEASE_MARKER_ENV: &str = "PROJECTATLAS_TEST_ABRUPT_LEASE_MARKER";
3308 const ABRUPT_LEASE_KIND_ENV: &str = "PROJECTATLAS_TEST_ABRUPT_LEASE_KIND";
3310 const ABRUPT_LEASE_EXIT_CODE: i32 = 86;
3312
3313 type TestResult = Result<(), Box<dyn Error>>;
3314
3315 fn require(condition: bool, message: &str) -> TestResult {
3316 if condition {
3317 Ok(())
3318 } else {
3319 Err(Box::new(io::Error::other(message.to_owned())))
3320 }
3321 }
3322
3323 fn require_lifecycle_error<T>(
3325 result: Result<T, OptionalParserPackLifecycleError>,
3326 message: &str,
3327 ) -> Result<OptionalParserPackLifecycleError, Box<dyn Error>> {
3328 match result {
3329 Ok(_) => Err(Box::new(io::Error::other(message.to_owned()))),
3330 Err(error) => Ok(error),
3331 }
3332 }
3333
3334 fn test_slot(byte: char) -> PackSlotIdentity {
3335 PackSlotIdentity {
3336 projectatlas_version: OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION.to_owned(),
3337 artifact: std::iter::repeat_n(byte, 64).collect(),
3338 }
3339 }
3340
3341 #[cfg(all(target_os = "linux", target_arch = "x86_64"))]
3342 #[test]
3343 fn linux_publication_stages_before_immutable_same_parent_rename() -> TestResult {
3344 let root = tempfile::tempdir()?;
3345 let versions = root.path().join("versions");
3346 let version = versions.join(OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION);
3347 fs::create_dir_all(&version)?;
3348 let extraction = TempDir::new_in(&versions)?;
3349 let mut pack_root = extraction.path().join(ARCHIVE_ROOT);
3350 fs::create_dir(&pack_root)?;
3351 fs::write(pack_root.join("payload"), b"parser")?;
3352
3353 let staging = stage_parser_pack_for_atomic_publication(&mut pack_root, &version)?;
3354 require(
3355 pack_root.parent() == Some(version.as_path()),
3356 "Linux publication staging was not a direct destination sibling",
3357 )?;
3358 seal_immutable_tree(&pack_root)?;
3359 let destination = version.join(std::iter::repeat_n('a', 64).collect::<String>());
3360 fs::rename(&pack_root, &destination)?;
3361 drop(staging);
3362
3363 verify_immutable_tree(&destination)?;
3364 require(
3365 fs::read(destination.join("payload"))? == b"parser",
3366 "Linux immutable publication changed staged bytes",
3367 )
3368 }
3369
3370 #[test]
3371 fn installed_slot_transfer_disarms_temporary_profile_cleanup() -> TestResult {
3372 let directory = tempfile::tempdir()?;
3373 let mut profile = TemporaryParserArtifactProfile::new(
3374 directory.path(),
3375 ParserArtifactIdentity::for_bytes(b"artifact-manifest"),
3376 );
3377 profile.transfer_to_installed_slot();
3378 require(
3379 !profile.cleanup_pending,
3380 "installed-slot transfer retained temporary cleanup ownership",
3381 )?;
3382 profile.cleanup()?;
3383 Ok(())
3384 }
3385
3386 #[test]
3387 fn lifecycle_operation_and_cleanup_failures_are_both_retained() -> TestResult {
3388 let error = require_lifecycle_error(
3389 finish_with_cleanup::<()>(
3390 Err(invalid_data("operation failed")),
3391 Err(invalid_data("cleanup failed")),
3392 ),
3393 "dual lifecycle failure was accepted",
3394 )?;
3395 match error {
3396 OptionalParserPackLifecycleError::OperationAndCleanup { operation, cleanup } => {
3397 require(
3398 operation.to_string().contains("operation failed")
3399 && cleanup.to_string().contains("cleanup failed"),
3400 "dual lifecycle failure lost one typed cause",
3401 )
3402 }
3403 other => Err(Box::new(io::Error::other(format!(
3404 "dual lifecycle failure returned the wrong variant: {other}"
3405 )))),
3406 }
3407 }
3408
3409 #[test]
3410 fn shared_pack_leases_exclude_storage_mutation_until_every_reader_releases() -> TestResult {
3411 let root = tempfile::tempdir()?;
3412 let storage = root.path().join("storage");
3413 let lifecycle = OptionalParserPackLifecycle::for_test(
3414 root.path().join("project"),
3415 storage.clone(),
3416 Some(PackPlatform::LinuxX86_64),
3417 );
3418 fs::create_dir_all(lifecycle.versions_root()?)?;
3419
3420 let first_reader = lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?;
3421 let second_reader = lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?;
3422 let error = require_lifecycle_error(
3423 lifecycle.remove(),
3424 "exclusive removal succeeded while shared leases were retained",
3425 )?;
3426 require(
3427 matches!(error, OptionalParserPackLifecycleError::Busy { .. }),
3428 "shared/exclusive contention did not retain a typed busy failure",
3429 )?;
3430 require(
3431 lifecycle.pack_root()?.is_dir(),
3432 "contended removal changed immutable pack storage",
3433 )?;
3434
3435 drop(first_reader);
3436 require(
3437 matches!(
3438 lifecycle.remove(),
3439 Err(OptionalParserPackLifecycleError::Busy { .. })
3440 ),
3441 "one remaining shared lease did not retain exclusion",
3442 )?;
3443 drop(second_reader);
3444 require(
3445 lifecycle.remove()?.changed,
3446 "removal did not proceed after every shared lease released",
3447 )?;
3448
3449 let writer = lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Exclusive)?;
3450 require(
3451 matches!(
3452 lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Shared),
3453 Err(OptionalParserPackLifecycleError::Busy { .. })
3454 ),
3455 "exclusive lease did not exclude a later shared reader",
3456 )?;
3457 drop(writer);
3458 let _reader_after_release =
3459 lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?;
3460 require(
3461 storage.join(OPTIONAL_PARSER_PACK_LEASE_FILE_NAME).is_file(),
3462 "stable lifecycle lease disappeared with logical pack storage",
3463 )
3464 }
3465
3466 #[test]
3467 fn abrupt_lease_holder_process() -> TestResult {
3468 let Some(storage) = env::var_os(ABRUPT_LEASE_STORAGE_ENV).map(PathBuf::from) else {
3469 return Ok(());
3470 };
3471 let marker = env::var_os(ABRUPT_LEASE_MARKER_ENV)
3472 .map(PathBuf::from)
3473 .ok_or_else(|| io::Error::other("abrupt lease marker path is missing"))?;
3474 let lifecycle = OptionalParserPackLifecycle::for_test(
3475 storage.join("project"),
3476 storage,
3477 Some(PackPlatform::LinuxX86_64),
3478 );
3479 let kind = env::var(ABRUPT_LEASE_KIND_ENV)?;
3480 let _lease = match kind.as_str() {
3481 "pack" => lifecycle.acquire_pack_lease(OptionalParserPackLeaseMode::Shared)?,
3482 "selection" => lifecycle.acquire_selection_mutation_lease()?,
3483 _ => return Err(io::Error::other("unknown abrupt lease kind").into()),
3484 };
3485 fs::write(marker, kind)?;
3486 std::thread::sleep(std::time::Duration::from_secs(30));
3487 std::process::exit(ABRUPT_LEASE_EXIT_CODE);
3488 }
3489
3490 fn wait_for_child_marker(marker: &Path) -> TestResult {
3491 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
3492 while !marker.is_file() {
3493 if std::time::Instant::now() >= deadline {
3494 return Err(
3495 io::Error::other("lease-holder child did not publish readiness").into(),
3496 );
3497 }
3498 std::thread::sleep(std::time::Duration::from_millis(10));
3499 }
3500 Ok(())
3501 }
3502
3503 #[test]
3504 fn live_child_excludes_pack_mutation_and_abrupt_exit_releases_lease() -> TestResult {
3505 let root = tempfile::tempdir()?;
3506 let storage = root.path().join("storage");
3507 let marker = root.path().join("lease-acquired");
3508 let lifecycle = OptionalParserPackLifecycle::for_test(
3509 root.path().join("project"),
3510 storage.clone(),
3511 Some(PackPlatform::LinuxX86_64),
3512 );
3513 fs::create_dir_all(lifecycle.versions_root()?.join("retained"))?;
3514 let mut child = std::process::Command::new(std::env::current_exe()?)
3515 .arg("--exact")
3516 .arg("optional_parser_lifecycle::tests::abrupt_lease_holder_process")
3517 .arg("--nocapture")
3518 .env(ABRUPT_LEASE_STORAGE_ENV, &storage)
3519 .env(ABRUPT_LEASE_MARKER_ENV, &marker)
3520 .env(ABRUPT_LEASE_KIND_ENV, "pack")
3521 .spawn()?;
3522 wait_for_child_marker(&marker)?;
3523 require(
3524 matches!(
3525 lifecycle.remove(),
3526 Err(OptionalParserPackLifecycleError::Busy { .. })
3527 ),
3528 "live child execution did not exclude immutable storage removal",
3529 )?;
3530 child.kill()?;
3531 let _status = child.wait()?;
3532 require(
3533 lifecycle.remove()?.changed,
3534 "post-exit removal did not succeed",
3535 )
3536 }
3537
3538 #[test]
3539 fn live_child_serializes_selection_mutation_and_abrupt_exit_releases_lease() -> TestResult {
3540 let root = tempfile::tempdir()?;
3541 let storage = root.path().join("storage");
3542 let project = storage.join("project");
3543 let marker = root.path().join("selection-lease-acquired");
3544 let lifecycle = OptionalParserPackLifecycle::for_test(
3545 project,
3546 storage.clone(),
3547 Some(PackPlatform::LinuxX86_64),
3548 );
3549 lifecycle.write_selection(&ProjectSelection::new(test_slot('a'), None))?;
3550 let mut child = std::process::Command::new(std::env::current_exe()?)
3551 .arg("--exact")
3552 .arg("optional_parser_lifecycle::tests::abrupt_lease_holder_process")
3553 .arg("--nocapture")
3554 .env(ABRUPT_LEASE_STORAGE_ENV, &storage)
3555 .env(ABRUPT_LEASE_MARKER_ENV, &marker)
3556 .env(ABRUPT_LEASE_KIND_ENV, "selection")
3557 .spawn()?;
3558 wait_for_child_marker(&marker)?;
3559 for (operation, result) in [
3560 ("enable", lifecycle.enable(&test_slot('b').artifact)),
3561 (
3562 "update",
3563 lifecycle.update(&root.path().join("candidate.tar.zst")),
3564 ),
3565 ("disable", lifecycle.disable()),
3566 ("remove", lifecycle.remove()),
3567 ] {
3568 require(
3569 matches!(result, Err(OptionalParserPackLifecycleError::Busy { .. })),
3570 &format!("live child selection transition did not serialize {operation}"),
3571 )?;
3572 }
3573 require(
3574 lifecycle.selection_path().is_file(),
3575 "contended selection operations changed project selection",
3576 )?;
3577 child.kill()?;
3578 let _status = child.wait()?;
3579 require(
3580 lifecycle.disable()?.changed,
3581 "post-exit disable did not succeed",
3582 )
3583 }
3584
3585 #[cfg(unix)]
3587 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
3588 std::os::unix::fs::symlink(target, link)
3589 }
3590
3591 #[cfg(windows)]
3593 fn create_directory_link(target: &Path, link: &Path) -> io::Result<()> {
3594 match std::os::windows::fs::symlink_dir(target, link) {
3595 Ok(()) => Ok(()),
3596 Err(source) if source.raw_os_error() == Some(1314) => {
3597 let status = std::process::Command::new("cmd")
3598 .arg("/C")
3599 .arg("mklink")
3600 .arg("/J")
3601 .arg(link)
3602 .arg(target)
3603 .status()?;
3604 if status.success() {
3605 Ok(())
3606 } else {
3607 Err(source)
3608 }
3609 }
3610 Err(source) => Err(source),
3611 }
3612 }
3613
3614 #[test]
3615 fn unsupported_operations_refuse_before_archive_or_state_access() -> TestResult {
3616 let root = tempfile::tempdir()?;
3617 let project = root.path().join("missing-project");
3618 let storage = root.path().join("missing-storage");
3619 let archive = root.path().join("missing-archive.tar.zst");
3620 let lifecycle = OptionalParserPackLifecycle::for_test(project, storage.clone(), None);
3621
3622 for error in [
3623 require_lifecycle_error(lifecycle.verify(&archive), "verify must refuse")?,
3624 require_lifecycle_error(lifecycle.install(&archive), "install must refuse")?,
3625 require_lifecycle_error(lifecycle.enable(&"a".repeat(64)), "enable must refuse")?,
3626 require_lifecycle_error(lifecycle.update(&archive), "update must refuse")?,
3627 ] {
3628 require(
3629 error.is_unsupported_containment(),
3630 "failure was not typed unsupported containment",
3631 )?;
3632 }
3633 require(!storage.exists(), "unsupported operation created storage")
3634 }
3635
3636 #[test]
3637 fn runtime_handoff_is_absent_everywhere_and_refuses_present_unsupported_state() -> TestResult {
3638 let root = tempfile::tempdir()?;
3639 let project = root.path().join("project");
3640 let storage = root.path().join("storage");
3641 let lifecycle =
3642 OptionalParserPackLifecycle::for_test(project.clone(), storage.clone(), None);
3643 require(
3644 lifecycle.resolve_selected_pack()?.is_none(),
3645 "absent selection did not preserve default-core operation",
3646 )?;
3647 let selection = project.join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH);
3648 fs::create_dir_all(
3649 selection
3650 .parent()
3651 .ok_or_else(|| io::Error::other("selection parent missing"))?,
3652 )?;
3653 fs::write(&selection, b"not inspected on an unsupported host")?;
3654 let error = require_lifecycle_error(
3655 lifecycle.resolve_selected_pack(),
3656 "present unsupported selection must refuse",
3657 )?;
3658 require(
3659 error.is_unsupported_containment(),
3660 "present selection was not typed unsupported containment",
3661 )?;
3662 require(!storage.exists(), "runtime handoff touched pack storage")
3663 }
3664
3665 #[test]
3666 fn project_selection_derivation_is_content_free_strict_and_storage_independent() -> TestResult {
3667 let root = tempfile::tempdir()?;
3668 let project = root.path().join("project");
3669 let storage = root.path().join("storage-is-a-file");
3670 fs::write(&storage, b"must not be inspected")?;
3671 let lifecycle = OptionalParserPackLifecycle::for_test(
3672 project,
3673 storage,
3674 Some(PackPlatform::LinuxX86_64),
3675 );
3676
3677 require(
3678 lifecycle.derive_project_selection()? == OptionalParserPackProjectSelection::Inactive,
3679 "absent project selection was not inactive",
3680 )?;
3681 let selected = test_slot('a');
3682 lifecycle.write_selection(&ProjectSelection::new(selected.clone(), None))?;
3683 let derivation = lifecycle.derive_project_selection()?;
3684 let key = derivation
3685 .selection_key()
3686 .ok_or_else(|| io::Error::other("selected derivation omitted its key"))?;
3687 require(
3688 key.as_str()
3689 == format!(
3690 "{}:{}:{}",
3691 OPTIONAL_PARSER_PACK_ID, selected.projectatlas_version, selected.artifact
3692 ),
3693 "selected derivation key was not stable",
3694 )?;
3695 require(
3696 derivation.artifact() == Some(key.artifact()),
3697 "selected derivation artifact did not delegate to its key",
3698 )?;
3699 require(
3700 OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH == ".projectatlas/optional-parser-pack.json",
3701 "public selection policy path drifted",
3702 )?;
3703
3704 fs::write(lifecycle.selection_path(), b"malformed")?;
3705 require(
3706 lifecycle.derive_project_selection().is_err(),
3707 "supported derivation accepted malformed selection JSON",
3708 )
3709 }
3710
3711 #[test]
3712 fn public_constructor_defers_storage_and_prioritizes_unsupported_state() -> TestResult {
3713 let root = tempfile::tempdir()?;
3714 let project = root.path().join("project");
3715 let mut lifecycle = OptionalParserPackLifecycle::new(project.clone(), None)?;
3716 require(
3717 lifecycle.storage_root.get().is_none(),
3718 "public constructor eagerly resolved the user storage root",
3719 )?;
3720 lifecycle.capability = OptionalParserCapability::BuiltInOnly;
3721 let archive = root.path().join("missing.tar.zst");
3722 let error = require_lifecycle_error(
3723 lifecycle.verify(&archive),
3724 "unsupported verify did not fail",
3725 )?;
3726 require(
3727 error.is_unsupported_containment(),
3728 "unsupported verify lost typed priority",
3729 )?;
3730 require(
3731 lifecycle.storage_root.get().is_none(),
3732 "unsupported verify resolved user storage",
3733 )?;
3734
3735 let selection = project.join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH);
3736 fs::create_dir_all(
3737 selection
3738 .parent()
3739 .ok_or_else(|| io::Error::other("selection parent missing"))?,
3740 )?;
3741 fs::write(&selection, b"stale")?;
3742 if lifecycle.storage_root.set(None).is_err() {
3743 return Err(io::Error::other("deferred storage root was already initialized").into());
3744 }
3745 require(
3746 lifecycle.disable()?.changed,
3747 "disable required a user storage root",
3748 )?;
3749 require(
3750 !selection.exists(),
3751 "disable did not remove project selection",
3752 )
3753 }
3754
3755 #[test]
3756 fn present_unsupported_derivation_refuses_before_malformed_content() -> TestResult {
3757 let root = tempfile::tempdir()?;
3758 let project = root.path().join("project");
3759 let selection = project.join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH);
3760 fs::create_dir_all(
3761 selection
3762 .parent()
3763 .ok_or_else(|| io::Error::other("selection parent missing"))?,
3764 )?;
3765 fs::write(&selection, b"malformed and must not be read")?;
3766 let lifecycle =
3767 OptionalParserPackLifecycle::for_test(project, root.path().join("storage"), None);
3768 let error = require_lifecycle_error(
3769 lifecycle.derive_project_selection(),
3770 "present unsupported derivation did not fail",
3771 )?;
3772 require(
3773 error.is_unsupported_containment(),
3774 "present unsupported derivation inspected malformed contents",
3775 )
3776 }
3777
3778 #[test]
3779 fn unsupported_cleanup_is_idempotent_for_stale_metadata() -> TestResult {
3780 let root = tempfile::tempdir()?;
3781 let project = root.path().join("project");
3782 let storage = root.path().join("storage");
3783 let selection = project.join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH);
3784 fs::create_dir_all(
3785 selection
3786 .parent()
3787 .ok_or_else(|| io::Error::other("selection parent missing"))?,
3788 )?;
3789 fs::write(&selection, b"stale")?;
3790 let pack_root = storage.join(OPTIONAL_PARSER_PACK_ID);
3791 fs::create_dir_all(pack_root.join("versions/stale/slot"))?;
3792 let payload = pack_root.join("versions/stale/slot/payload.bin");
3793 fs::write(&payload, b"payload")?;
3794 let mut permissions = fs::metadata(&payload)?.permissions();
3795 permissions.set_readonly(true);
3796 fs::set_permissions(&payload, permissions)?;
3797 let lifecycle = OptionalParserPackLifecycle::for_test(project, storage, None);
3798
3799 require(
3800 lifecycle.disable()?.changed,
3801 "first disable did not remove stale selection",
3802 )?;
3803 require(
3804 !lifecycle.disable()?.changed,
3805 "second disable was not idempotent",
3806 )?;
3807 require(
3808 lifecycle.remove()?.changed,
3809 "first remove did not delete storage",
3810 )?;
3811 require(
3812 !lifecycle.remove()?.changed,
3813 "second remove was not idempotent",
3814 )
3815 }
3816
3817 #[test]
3818 fn unsupported_remove_does_not_create_absent_storage() -> TestResult {
3819 let root = tempfile::tempdir()?;
3820 let project = root.path().join("project");
3821 let storage = root.path().join("storage");
3822 let source = project.join("src/lib.rs");
3823 let selection = project.join(OPTIONAL_PARSER_PACK_SELECTION_POLICY_PATH);
3824 fs::create_dir_all(
3825 selection
3826 .parent()
3827 .ok_or_else(|| io::Error::other("selection parent missing"))?,
3828 )?;
3829 fs::create_dir_all(
3830 source
3831 .parent()
3832 .ok_or_else(|| io::Error::other("source parent missing"))?,
3833 )?;
3834 fs::write(&source, b"source must survive")?;
3835 fs::write(&selection, b"stale")?;
3836 let lifecycle = OptionalParserPackLifecycle::for_test(project, storage.clone(), None);
3837
3838 require(
3839 lifecycle.remove()?.changed,
3840 "first remove did not delete stale selection",
3841 )?;
3842 require(!selection.exists(), "stale selection survived remove")?;
3843 require(!storage.exists(), "remove created absent storage")?;
3844 require(
3845 !lifecycle.remove()?.changed,
3846 "second remove was not idempotent",
3847 )?;
3848 require(
3849 fs::read(&source)? == b"source must survive",
3850 "unsupported remove touched source",
3851 )
3852 }
3853
3854 #[test]
3855 fn removal_never_follows_product_owned_storage_links() -> TestResult {
3856 let root = tempfile::tempdir()?;
3857 let project = root.path().join("project");
3858 let storage = root.path().join("storage");
3859 let external_pack = root.path().join("external-pack");
3860 fs::create_dir_all(&storage)?;
3861 fs::create_dir_all(&external_pack)?;
3862 let external_marker = external_pack.join("must-survive.txt");
3863 fs::write(&external_marker, b"outside")?;
3864 let pack_link = storage.join(OPTIONAL_PARSER_PACK_ID);
3865 create_directory_link(&external_pack, &pack_link)?;
3866 let lifecycle = OptionalParserPackLifecycle::for_test(project, storage.clone(), None);
3867
3868 require(
3869 lifecycle.remove()?.changed,
3870 "pack-root link was not removed",
3871 )?;
3872 require(
3873 external_marker.is_file(),
3874 "pack-root link target was deleted",
3875 )?;
3876 require(!pack_link.exists(), "pack-root link leaf survived")?;
3877
3878 let pack_root = storage.join(OPTIONAL_PARSER_PACK_ID);
3879 fs::create_dir_all(&pack_root)?;
3880 let external_versions = root.path().join("external-versions");
3881 fs::create_dir_all(&external_versions)?;
3882 let versions_marker = external_versions.join("must-survive.txt");
3883 fs::write(&versions_marker, b"outside")?;
3884 create_directory_link(&external_versions, &pack_root.join("versions"))?;
3885
3886 require(lifecycle.remove()?.changed, "versions link was not removed")?;
3887 require(
3888 versions_marker.is_file(),
3889 "versions link target was deleted",
3890 )
3891 }
3892
3893 #[test]
3894 fn selection_operations_never_follow_project_state_parent_link() -> TestResult {
3895 let root = tempfile::tempdir()?;
3896 let project = root.path().join("project");
3897 let external_state = root.path().join("external-state");
3898 fs::create_dir_all(&project)?;
3899 fs::create_dir_all(&external_state)?;
3900 let external_selection = external_state.join("optional-parser-pack.json");
3901 fs::write(&external_selection, b"must survive")?;
3902 create_directory_link(&external_state, &project.join(".projectatlas"))?;
3903 let lifecycle =
3904 OptionalParserPackLifecycle::for_test(project, root.path().join("storage"), None);
3905
3906 require(
3907 lifecycle.status()?.state == OptionalParserPackState::Stale,
3908 "linked selection parent was not reported stale",
3909 )?;
3910 require(
3911 !lifecycle.disable()?.changed,
3912 "disable claimed to mutate a linked selection parent",
3913 )?;
3914 require(
3915 external_selection.is_file(),
3916 "disable deleted an external selection through a linked parent",
3917 )?;
3918 let _report = lifecycle.remove()?;
3919 require(
3920 external_selection.is_file(),
3921 "remove deleted an external selection through a linked parent",
3922 )
3923 }
3924
3925 #[test]
3926 fn status_rejects_and_removal_does_not_follow_selected_slot_link() -> TestResult {
3927 let root = tempfile::tempdir()?;
3928 let lifecycle = OptionalParserPackLifecycle::for_test(
3929 root.path().join("project"),
3930 root.path().join("storage"),
3931 None,
3932 );
3933 let selected = test_slot('d');
3934 let slot = lifecycle.slot_path(&selected)?;
3935 fs::create_dir_all(
3936 slot.parent()
3937 .ok_or_else(|| io::Error::other("slot parent missing"))?,
3938 )?;
3939 let external = root.path().join("external-slot");
3940 fs::create_dir_all(&external)?;
3941 let marker = external.join("must-survive.txt");
3942 fs::write(&marker, b"outside")?;
3943 create_directory_link(&external, &slot)?;
3944 lifecycle.write_selection(&ProjectSelection::new(selected, None))?;
3945
3946 require(
3947 lifecycle.status()?.state == OptionalParserPackState::Stale,
3948 "linked selected slot was reported present",
3949 )?;
3950 let _report = lifecycle.remove()?;
3951 require(marker.is_file(), "selected slot link target was deleted")
3952 }
3953
3954 #[test]
3955 fn install_refuses_product_owned_storage_link_before_archive_open() -> TestResult {
3956 let Some(platform) = host_pack_platform() else {
3957 return Ok(());
3958 };
3959 let root = tempfile::tempdir()?;
3960 let storage = root.path().join("storage");
3961 let external = root.path().join("external");
3962 fs::create_dir_all(&storage)?;
3963 fs::create_dir_all(&external)?;
3964 let marker = external.join("must-survive.txt");
3965 fs::write(&marker, b"outside")?;
3966 create_directory_link(&external, &storage.join(OPTIONAL_PARSER_PACK_ID))?;
3967 let lifecycle = OptionalParserPackLifecycle::for_test(
3968 root.path().join("project"),
3969 storage,
3970 Some(platform),
3971 );
3972 let missing_archive = root.path().join("missing.tar.zst");
3973
3974 require(
3975 lifecycle.install(&missing_archive).is_err(),
3976 "install followed a product-owned storage link",
3977 )?;
3978 require(marker.is_file(), "install mutated the link target")
3979 }
3980
3981 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
3982 #[test]
3983 fn windows_cleanup_paths_preserve_verbatim_forms_and_bound_current_directory() -> TestResult {
3984 require(
3985 windows_verbatim_path(Path::new(r"C:\pack\parser")) == Path::new(r"\\?\C:\pack\parser"),
3986 "drive path did not gain its verbatim prefix",
3987 )?;
3988 require(
3989 windows_verbatim_path(Path::new(r"\\server\share\pack"))
3990 == Path::new(r"\\?\UNC\server\share\pack"),
3991 "UNC path did not gain its verbatim prefix",
3992 )?;
3993 require(
3994 windows_verbatim_path(Path::new(r"\\?\C:\pack\parser"))
3995 == Path::new(r"\\?\C:\pack\parser"),
3996 "existing verbatim path changed",
3997 )?;
3998 let maximum = PathBuf::from(format!(
3999 r"C:\{}",
4000 "a".repeat(WINDOWS_PROCESS_CURRENT_DIRECTORY_MAX_UTF16_UNITS - 3)
4001 ));
4002 require(
4003 require_windows_framework_path(&maximum).is_ok(),
4004 "maximum supported working directory was rejected",
4005 )?;
4006 let overlong = PathBuf::from(format!(
4007 r"C:\{}",
4008 "a".repeat(WINDOWS_PROCESS_CURRENT_DIRECTORY_MAX_UTF16_UNITS - 2)
4009 ));
4010 require(
4011 require_windows_framework_path(&overlong).is_err(),
4012 "overlong working directory was accepted",
4013 )?;
4014 require(
4015 require_windows_framework_path(Path::new(r"\\?\C:\pack")).is_err(),
4016 "verbatim working directory was accepted",
4017 )
4018 }
4019
4020 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
4021 #[test]
4022 fn cleaned_tombstone_makes_partial_slot_removal_retryable() -> TestResult {
4023 let root = tempfile::tempdir()?;
4024 let lifecycle = OptionalParserPackLifecycle::for_test(
4025 root.path().join("project"),
4026 root.path().join("storage"),
4027 Some(PackPlatform::WindowsX86_64),
4028 );
4029 let artifact_manifest = b"cleaned-tombstone-artifact";
4030 let identity = PackSlotIdentity {
4031 projectatlas_version: OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION.to_owned(),
4032 artifact: ParserArtifactIdentity::for_bytes(artifact_manifest)
4033 .digest()
4034 .as_str()
4035 .to_owned(),
4036 };
4037 let version_root = lifecycle
4038 .versions_root()?
4039 .join(&identity.projectatlas_version);
4040 fs::create_dir_all(&version_root)?;
4041 let tombstone = version_root.join(format!(
4042 "{WINDOWS_CLEANED_TOMBSTONE_PREFIX}{}-retry1",
4043 &identity.artifact[..WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS]
4044 ));
4045 fs::create_dir(&tombstone)?;
4046 fs::write(
4047 tombstone.join(ARTIFACT_MANIFEST_FILE_NAME),
4048 artifact_manifest,
4049 )?;
4050 let partial_file = tombstone.join("partial.bin");
4051 fs::write(&partial_file, b"partial")?;
4052
4053 require(
4054 lifecycle.remove()?.changed,
4055 "partial cleaned tombstone retry did not remove storage",
4056 )?;
4057 require(!tombstone.exists(), "successful retry left its tombstone")?;
4058 require(
4059 !lifecycle.remove()?.changed,
4060 "partial tombstone retry was not idempotent",
4061 )
4062 }
4063
4064 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
4065 #[test]
4066 fn slot_cleanup_uses_unique_atomic_tombstone_states() -> TestResult {
4067 let root = tempfile::tempdir()?;
4068 let lifecycle = OptionalParserPackLifecycle::for_test(
4069 root.path().join("project"),
4070 root.path().join("storage"),
4071 Some(PackPlatform::WindowsX86_64),
4072 );
4073 let artifact_manifest = b"atomic-tombstone-artifact";
4074 let identity = PackSlotIdentity {
4075 projectatlas_version: OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION.to_owned(),
4076 artifact: ParserArtifactIdentity::for_bytes(artifact_manifest)
4077 .digest()
4078 .as_str()
4079 .to_owned(),
4080 };
4081 let slot_root = lifecycle.slot_path(&identity)?;
4082 fs::create_dir_all(&slot_root)?;
4083 fs::write(
4084 slot_root.join(ARTIFACT_MANIFEST_FILE_NAME),
4085 artifact_manifest,
4086 )?;
4087 fs::write(slot_root.join("must-move.bin"), b"slot")?;
4088 let installed = InstalledSlotPath {
4089 projectatlas_version: identity.projectatlas_version.clone(),
4090 artifact: identity.artifact.clone(),
4091 entry_root: slot_root.clone(),
4092 pack_root: Some(slot_root.clone()),
4093 state: InstalledSlotCleanupState::Installed,
4094 };
4095
4096 let pending = transition_slot_to_removing_tombstone(&installed)?;
4097 require(
4098 !slot_root.exists(),
4099 "deterministic slot survived transition",
4100 )?;
4101 require(
4102 pending.entry_root.is_dir(),
4103 "profile-pending tombstone was not published",
4104 )?;
4105 require(
4106 parse_windows_tombstone_name(
4107 pending
4108 .entry_root
4109 .file_name()
4110 .and_then(|name| name.to_str())
4111 .ok_or_else(|| io::Error::other("pending tombstone name missing"))?,
4112 ) == Some((
4113 InstalledSlotCleanupState::ProfilePending,
4114 identity.artifact[..WINDOWS_TOMBSTONE_ARTIFACT_PREFIX_HEX_CHARS].to_owned(),
4115 )),
4116 "profile-pending tombstone name was not strict",
4117 )?;
4118 require(
4119 windows_slot_cleanup_in_progress(
4120 pending
4121 .entry_root
4122 .parent()
4123 .ok_or_else(|| io::Error::other("pending tombstone parent missing"))?,
4124 &identity.artifact,
4125 )?,
4126 "pending cleanup did not block artifact reuse",
4127 )?;
4128
4129 let cleaned = transition_tombstone_to_profile_cleaned(&pending)?;
4130 require(
4131 !pending.entry_root.exists() && cleaned.entry_root.is_dir(),
4132 "profile-cleaned tombstone transition was not atomic",
4133 )?;
4134 require(
4135 windows_slot_cleanup_in_progress(
4136 cleaned
4137 .entry_root
4138 .parent()
4139 .ok_or_else(|| io::Error::other("cleaned tombstone parent missing"))?,
4140 &identity.artifact,
4141 )?,
4142 "cleaned tombstone did not block artifact reuse",
4143 )?;
4144 require(
4145 remove_tree_if_present(&cleaned.entry_root)?,
4146 "cleaned tombstone was not removable",
4147 )
4148 }
4149
4150 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
4151 #[test]
4152 fn reusable_sibling_marker_never_authorizes_profile_cleanup_skip() -> TestResult {
4153 let root = tempfile::tempdir()?;
4154 let lifecycle = OptionalParserPackLifecycle::for_test(
4155 root.path().join("project"),
4156 root.path().join("storage"),
4157 Some(PackPlatform::WindowsX86_64),
4158 );
4159 let artifact_manifest = b"invalid";
4160 let identity = PackSlotIdentity {
4161 projectatlas_version: OPTIONAL_PARSER_PACK_PROJECTATLAS_VERSION.to_owned(),
4162 artifact: ParserArtifactIdentity::for_bytes(artifact_manifest)
4163 .digest()
4164 .as_str()
4165 .to_owned(),
4166 };
4167 let slot_root = lifecycle.slot_path(&identity)?;
4168 fs::create_dir_all(&slot_root)?;
4169 fs::write(
4170 slot_root.join(ARTIFACT_MANIFEST_FILE_NAME),
4171 artifact_manifest,
4172 )?;
4173 let marker = slot_root
4174 .parent()
4175 .ok_or_else(|| io::Error::other("slot parent missing"))?
4176 .join(format!(".{}.profile-cleaned", identity.artifact));
4177 fs::write(&marker, b"projectatlas-parser-profile-cleaned-v1\n")?;
4178
4179 let error = require_lifecycle_error(
4180 lifecycle.remove(),
4181 "reusable sibling marker bypassed profile verification",
4182 )?;
4183 require(
4184 matches!(
4185 error,
4186 OptionalParserPackLifecycleError::CleanupIncomplete { .. }
4187 ),
4188 "invalid marked slot did not fail closed",
4189 )?;
4190 require(
4191 marker.is_file(),
4192 "legacy marker was unexpectedly consumed as cleanup authority",
4193 )?;
4194 require(
4195 !slot_root.exists(),
4196 "invalid slot was not isolated from its deterministic path",
4197 )?;
4198 let tombstones = installed_slot_paths(&lifecycle.pack_root()?)?;
4199 require(
4200 tombstones.iter().any(|slot| {
4201 slot.artifact == identity.artifact
4202 && slot.state == InstalledSlotCleanupState::ProfilePending
4203 }),
4204 "failed cleanup did not retain a unique retry tombstone",
4205 )
4206 }
4207
4208 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
4209 #[test]
4210 fn cleanup_broker_timeout_terminates_reaps_and_drains() -> TestResult {
4211 let ping = validated_windows_directory()?
4212 .join("System32")
4213 .join("PING.EXE");
4214 let mut child = Command::new(&ping)
4215 .arg("-n")
4216 .arg("30")
4217 .arg("127.0.0.1")
4218 .stdin(Stdio::null())
4219 .stdout(Stdio::piped())
4220 .stderr(Stdio::piped())
4221 .spawn()?;
4222 let broker = PathBuf::from("cleanup-timeout-test");
4223 let error = require_lifecycle_error(
4224 supervise_cleanup_broker(
4225 &mut child,
4226 &broker,
4227 "test-profile",
4228 Duration::from_millis(50),
4229 Duration::from_secs(2),
4230 ),
4231 "hung cleanup broker unexpectedly succeeded",
4232 )?;
4233 require(
4234 matches!(error, OptionalParserPackLifecycleError::InvalidData { .. }),
4235 "timeout operation was misclassified as a cleanup failure",
4236 )?;
4237 require(
4238 child.try_wait()?.is_some(),
4239 "hung cleanup broker was not reaped",
4240 )
4241 }
4242
4243 #[cfg(all(target_os = "windows", target_arch = "x86_64"))]
4244 #[test]
4245 fn cleanup_broker_post_spawn_pipe_fault_still_reaps_child() -> TestResult {
4246 let ping = validated_windows_directory()?
4247 .join("System32")
4248 .join("PING.EXE");
4249 let mut child = Command::new(&ping)
4250 .arg("-n")
4251 .arg("30")
4252 .arg("127.0.0.1")
4253 .stdin(Stdio::null())
4254 .stdout(Stdio::piped())
4255 .stderr(Stdio::piped())
4256 .spawn()?;
4257 let retained_stdout = child.stdout.take();
4258 let broker = PathBuf::from("cleanup-pipe-fault-test");
4259 let error = require_lifecycle_error(
4260 supervise_cleanup_broker(
4261 &mut child,
4262 &broker,
4263 "test-profile",
4264 Duration::from_secs(1),
4265 Duration::from_secs(2),
4266 ),
4267 "missing cleanup pipe unexpectedly succeeded",
4268 )?;
4269 require(
4270 matches!(error, OptionalParserPackLifecycleError::InvalidData { .. }),
4271 "pipe-fault operation was misclassified as a cleanup failure",
4272 )?;
4273 require(
4274 child.try_wait()?.is_some(),
4275 "cleanup broker with a post-spawn pipe fault was not reaped",
4276 )?;
4277 drop(retained_stdout);
4278 Ok(())
4279 }
4280
4281 #[test]
4282 fn status_reports_installed_enabled_rollback_and_stale_states() -> TestResult {
4283 let root = tempfile::tempdir()?;
4284 let project = root.path().join("project");
4285 let storage = root.path().join("storage");
4286 let lifecycle = OptionalParserPackLifecycle::for_test(
4287 project,
4288 storage,
4289 Some(PackPlatform::LinuxX86_64),
4290 );
4291 require(
4292 lifecycle.status()?.state == OptionalParserPackState::Absent,
4293 "initial state was not absent",
4294 )?;
4295 let selected = test_slot('a');
4296 fs::create_dir_all(lifecycle.slot_path(&selected)?)?;
4297 require(
4298 lifecycle.status()?.state == OptionalParserPackState::InstalledDisabled,
4299 "installed slot was not reported disabled",
4300 )?;
4301 lifecycle.write_selection(&ProjectSelection::new(selected.clone(), None))?;
4302 require(
4303 lifecycle.status()?.state == OptionalParserPackState::Enabled,
4304 "enabled state missing",
4305 )?;
4306 let rollback = test_slot('b');
4307 fs::create_dir_all(lifecycle.slot_path(&rollback)?)?;
4308 lifecycle.write_selection(&ProjectSelection::new(selected.clone(), Some(rollback)))?;
4309 require(
4310 lifecycle.status()?.state == OptionalParserPackState::RollbackReady,
4311 "rollback-ready state missing",
4312 )?;
4313 fs::remove_dir_all(lifecycle.slot_path(&selected)?)?;
4314 require(
4315 lifecycle.status()?.state == OptionalParserPackState::Stale,
4316 "missing slot was not stale",
4317 )
4318 }
4319
4320 #[test]
4321 fn lifecycle_metadata_entry_bound_precedes_remove_mutation() -> TestResult {
4322 let root = tempfile::tempdir()?;
4323 let project = root.path().join("project");
4324 let storage = root.path().join("storage");
4325 let lifecycle = OptionalParserPackLifecycle::for_test(
4326 project,
4327 storage,
4328 Some(PackPlatform::LinuxX86_64),
4329 );
4330 lifecycle.write_selection(&ProjectSelection::new(test_slot('a'), None))?;
4331 let selection_before = fs::read(lifecycle.selection_path())?;
4332 let versions = lifecycle.pack_root()?.join("versions");
4333 fs::create_dir_all(&versions)?;
4334 for index in 0..=LIFECYCLE_METADATA_ENTRY_LIMIT {
4335 fs::create_dir(versions.join(format!("empty-{index:04}")))?;
4336 }
4337
4338 let status = lifecycle.status()?;
4339 require(
4340 status.installed_slots == 0
4341 && status.installed_slots_truncated
4342 && status.state == OptionalParserPackState::Stale,
4343 "over-limit empty version metadata was not reported as bounded stale state",
4344 )?;
4345 let error = require_lifecycle_error(
4346 lifecycle.remove(),
4347 "over-limit lifecycle metadata unexpectedly allowed removal",
4348 )?;
4349 require(
4350 matches!(
4351 error,
4352 OptionalParserPackLifecycleError::InvalidData { ref reason }
4353 if reason == "parser-pack metadata entries exceed the cleanup bound"
4354 ),
4355 "over-limit lifecycle metadata returned the wrong removal failure",
4356 )?;
4357 require(
4358 fs::read(lifecycle.selection_path())? == selection_before
4359 && fs::read_dir(&versions)?.count()
4360 == LIFECYCLE_METADATA_ENTRY_LIMIT.saturating_add(1),
4361 "bounded removal partially mutated selection or storage",
4362 )?;
4363
4364 let child_root = tempfile::tempdir()?;
4365 let child_lifecycle = OptionalParserPackLifecycle::for_test(
4366 child_root.path().join("project"),
4367 child_root.path().join("storage"),
4368 Some(PackPlatform::LinuxX86_64),
4369 );
4370 child_lifecycle.write_selection(&ProjectSelection::new(test_slot('b'), None))?;
4371 let child_selection_before = fs::read(child_lifecycle.selection_path())?;
4372 let child_version = child_lifecycle.pack_root()?.join("versions").join("0.4.0");
4373 fs::create_dir_all(&child_version)?;
4374 for index in 0..LIFECYCLE_METADATA_ENTRY_LIMIT {
4375 fs::write(child_version.join(format!("stale-{index:04}")), [])?;
4376 }
4377 let child_status = child_lifecycle.status()?;
4378 require(
4379 child_status.installed_slots == 0
4380 && child_status.installed_slots_truncated
4381 && child_status.state == OptionalParserPackState::Stale,
4382 "over-limit non-directory slot metadata was not reported as bounded stale state",
4383 )?;
4384 let child_error = require_lifecycle_error(
4385 child_lifecycle.remove(),
4386 "over-limit child metadata unexpectedly allowed removal",
4387 )?;
4388 require(
4389 matches!(
4390 child_error,
4391 OptionalParserPackLifecycleError::InvalidData { ref reason }
4392 if reason == "parser-pack metadata entries exceed the cleanup bound"
4393 ),
4394 "over-limit child metadata returned the wrong removal failure",
4395 )?;
4396 require(
4397 fs::read(child_lifecycle.selection_path())? == child_selection_before
4398 && fs::read_dir(&child_version)?.count() == LIFECYCLE_METADATA_ENTRY_LIMIT,
4399 "child metadata overflow partially mutated selection or storage",
4400 )?;
4401
4402 let exact_root = tempfile::tempdir()?;
4403 let exact_lifecycle = OptionalParserPackLifecycle::for_test(
4404 exact_root.path().join("project"),
4405 exact_root.path().join("storage"),
4406 Some(PackPlatform::LinuxX86_64),
4407 );
4408 exact_lifecycle.write_selection(&ProjectSelection::new(test_slot('c'), None))?;
4409 let exact_pack_root = exact_lifecycle.pack_root()?;
4410 let exact_version = exact_pack_root.join("versions").join("0.4.0");
4411 fs::create_dir_all(&exact_version)?;
4412 for index in 0..LIFECYCLE_METADATA_ENTRY_LIMIT.saturating_sub(1) {
4413 fs::write(exact_version.join(format!("stale-{index:04}")), [])?;
4414 }
4415 require(
4416 !exact_lifecycle.status()?.installed_slots_truncated,
4417 "exact lifecycle metadata bound was reported as truncated",
4418 )?;
4419 let removed = exact_lifecycle.remove()?;
4420 require(
4421 removed.changed
4422 && removed.state == OptionalParserPackState::Absent
4423 && !exact_lifecycle.selection_path().exists()
4424 && !exact_pack_root.exists(),
4425 "exact lifecycle metadata bound did not permit complete removal",
4426 )
4427 }
4428
4429 fn injected_admission_failure(_path: &Path) -> ParserSupervisorError {
4431 ParserSupervisorError::Cancelled {
4432 phase: "test artifact admission",
4433 }
4434 }
4435
4436 #[test]
4437 fn failed_archive_admission_publishes_no_slot_and_preserves_selection() -> TestResult {
4438 let root = tempfile::tempdir()?;
4439 let project = root.path().join("project");
4440 let storage = root.path().join("storage");
4441 let lifecycle = OptionalParserPackLifecycle::for_test(
4442 project,
4443 storage,
4444 Some(PackPlatform::LinuxX86_64),
4445 )
4446 .with_admission_failure(injected_admission_failure);
4447 let selected = test_slot('a');
4448 lifecycle.write_selection(&ProjectSelection::new(selected, None))?;
4449 let selection_before = fs::read(lifecycle.selection_path())?;
4450
4451 let error = require_lifecycle_error(
4452 lifecycle.install(&root.path().join("candidate.tar.zst")),
4453 "failed admission unexpectedly installed an archive",
4454 )?;
4455 require(
4456 matches!(
4457 error,
4458 OptionalParserPackLifecycleError::Supervisor(
4459 ParserSupervisorError::Cancelled { .. }
4460 )
4461 ),
4462 "injected archive admission failure lost its typed source",
4463 )?;
4464 let (installed, truncated, cleanup_pending) =
4465 count_installed_slots(&lifecycle.pack_root()?)?;
4466 require(
4467 installed == 0 && !truncated && !cleanup_pending,
4468 "failed archive admission published an installed slot",
4469 )?;
4470 require(
4471 fs::read(lifecycle.selection_path())? == selection_before,
4472 "failed archive admission changed project selection",
4473 )
4474 }
4475
4476 #[test]
4477 fn failed_enable_admission_preserves_previous_selection_bytes() -> TestResult {
4478 let root = tempfile::tempdir()?;
4479 let project = root.path().join("project");
4480 let storage = root.path().join("storage");
4481 let lifecycle = OptionalParserPackLifecycle::for_test(
4482 project,
4483 storage,
4484 Some(PackPlatform::LinuxX86_64),
4485 )
4486 .with_admission_failure(injected_admission_failure);
4487 let selected = test_slot('a');
4488 let candidate = test_slot('b');
4489 lifecycle.write_selection(&ProjectSelection::new(selected, None))?;
4490 let selection_before = fs::read(lifecycle.selection_path())?;
4491
4492 let error = require_lifecycle_error(
4493 lifecycle.enable(&candidate.artifact),
4494 "failed admission unexpectedly selected a candidate",
4495 )?;
4496 require(
4497 matches!(
4498 error,
4499 OptionalParserPackLifecycleError::Supervisor(
4500 ParserSupervisorError::Cancelled { .. }
4501 )
4502 ),
4503 "injected enable admission failure lost its typed source",
4504 )?;
4505 require(
4506 fs::read(lifecycle.selection_path())? == selection_before,
4507 "failed enable admission changed project selection",
4508 )
4509 }
4510
4511 #[test]
4512 fn failed_update_preserves_previous_selection_bytes() -> TestResult {
4513 let Some(platform) = host_pack_platform() else {
4514 return Ok(());
4515 };
4516 let root = tempfile::tempdir()?;
4517 let project = root.path().join("project");
4518 let storage = root.path().join("storage");
4519 let lifecycle = OptionalParserPackLifecycle::for_test(project, storage, Some(platform));
4520 let selected = test_slot('a');
4521 let slot_root = lifecycle.slot_path(&selected)?;
4522 fs::create_dir_all(&slot_root)?;
4523 fs::write(slot_root.join(ARTIFACT_MANIFEST_FILE_NAME), b"invalid")?;
4524 lifecycle.write_selection(&ProjectSelection::new(selected, None))?;
4525 let before = fs::read(lifecycle.selection_path())?;
4526 let archive = root.path().join("invalid.tar.zst");
4527 fs::write(&archive, b"invalid")?;
4528
4529 require(
4530 lifecycle.update(&archive).is_err(),
4531 "invalid update unexpectedly succeeded",
4532 )?;
4533 let after = fs::read(lifecycle.selection_path())?;
4534 require(before == after, "failed update changed the prior selection")
4535 }
4536
4537 #[test]
4538 fn failed_selection_publication_keeps_candidate_for_deterministic_retry() -> TestResult {
4539 let root = tempfile::tempdir()?;
4540 let project = root.path().join("project");
4541 let storage = root.path().join("storage");
4542 let lifecycle = OptionalParserPackLifecycle::for_test(
4543 project.clone(),
4544 storage.clone(),
4545 Some(PackPlatform::LinuxX86_64),
4546 );
4547 let selected = test_slot('a');
4548 let candidate = test_slot('b');
4549 let prior_rollback = test_slot('c');
4550 for slot in [&selected, &candidate, &prior_rollback] {
4551 fs::create_dir_all(lifecycle.slot_path(slot)?)?;
4552 }
4553 let previous = ProjectSelection::new(selected.clone(), Some(prior_rollback.clone()));
4554 lifecycle.write_selection(&previous)?;
4555 let selection_before = fs::read(lifecycle.selection_path())?;
4556
4557 let failing = OptionalParserPackLifecycle::for_test(
4558 project.clone(),
4559 storage.clone(),
4560 Some(PackPlatform::LinuxX86_64),
4561 )
4562 .with_selection_publication_failure();
4563 let error = require_lifecycle_error(
4564 failing.publish_installed_update(&previous, &candidate),
4565 "injected selection publication unexpectedly succeeded",
4566 )?;
4567 require(
4568 matches!(
4569 error,
4570 OptionalParserPackLifecycleError::InvalidData { ref reason }
4571 if reason == "injected project selection publication failure"
4572 ),
4573 "selection publication failure lost its typed source",
4574 )?;
4575 require(
4576 fs::read(failing.selection_path())? == selection_before
4577 && failing.read_selection()?.as_ref() == Some(&previous),
4578 "failed selection publication changed selected or rollback state",
4579 )?;
4580 require(
4581 failing.slot_path(&selected)?.is_dir()
4582 && failing.slot_path(&prior_rollback)?.is_dir()
4583 && failing.slot_path(&candidate)?.is_dir(),
4584 "failed selection publication removed an immutable lifecycle slot",
4585 )?;
4586
4587 let retry = OptionalParserPackLifecycle::for_test(
4588 project,
4589 storage,
4590 Some(PackPlatform::LinuxX86_64),
4591 );
4592 require(
4593 retry.publish_installed_update(&previous, &candidate)?,
4594 "retry did not publish the retained candidate",
4595 )?;
4596 let selected_candidate = retry
4597 .read_selection()?
4598 .ok_or_else(|| invalid_data("retried selection is absent"))?;
4599 require(
4600 selected_candidate == ProjectSelection::new(candidate.clone(), Some(selected)),
4601 "retry did not select the candidate with the immediate prior slot as rollback",
4602 )?;
4603 let selection_after_retry = fs::read(retry.selection_path())?;
4604 require(
4605 !retry.publish_installed_update(&selected_candidate, &candidate)?
4606 && fs::read(retry.selection_path())? == selection_after_retry,
4607 "identical retry rewrote or changed the selected candidate",
4608 )
4609 }
4610
4611 #[test]
4612 fn archive_extraction_rejects_non_regular_entries() -> TestResult {
4613 let root = tempfile::tempdir()?;
4614 let archive_path = root.path().join("invalid.tar.zst");
4615 let output = File::create(&archive_path)?;
4616 let encoder = zstd::Encoder::new(output, 1)?;
4617 let mut builder = tar::Builder::new(encoder);
4618 let mut header = tar::Header::new_gnu();
4619 header.set_entry_type(EntryType::Symlink);
4620 header.set_size(0);
4621 header.set_uid(0);
4622 header.set_gid(0);
4623 header.set_mtime(0);
4624 header.set_mode(PAYLOAD_MODE);
4625 header.set_cksum();
4626 builder.append_link(&mut header, format!("{ARCHIVE_ROOT}/payload"), "target")?;
4627 let encoder = builder.into_inner()?;
4628 encoder.finish()?.sync_all()?;
4629
4630 require(
4631 extract_archive(&archive_path, None).is_err(),
4632 "non-regular archive entry was accepted",
4633 )
4634 }
4635}