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