Skip to main content

projectatlas_cli/
optional_parser_lifecycle.rs

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