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