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