Skip to main content

eggup_core/
transaction.rs

1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::domain::{AbsentPolicy, CommitOwnership, InstallPlan, MemberId, Ownership};
7use crate::error::{Error, RecoveryError, RecoveryResult, Result};
8use crate::integrity::hash_file;
9use crate::lock::MutationLock;
10use crate::stage::PreparedTransaction;
11
12static NEXT_BACKUP_NONCE: AtomicU64 = AtomicU64::new(0);
13
14/// The terminal disposition of a commit attempt.
15///
16/// A rolled-back or recovery-required result is never equivalent to success;
17/// see `examples/receipts.rs`. The disposition says whether the live state is
18/// coherent, not why the attempt ended: the reason is carried by
19/// [`TransactionReceipt::failure`], [`TransactionReceipt::rollback_failure`],
20/// and [`TransactionReceipt::post_commit_failure`], each of which reports a
21/// [`FailureCategory`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum TransactionDisposition {
24    /// Every member was installed and the live set is coherent.
25    Committed,
26    /// The attempt failed and all old members were restored and verified.
27    RolledBack,
28    /// The live state or cleanup could not be safely restored automatically.
29    RecoveryRequired,
30}
31
32/// How transaction-owned temporary evidence was handled.
33///
34/// This describes transaction-owned temporary evidence. The separate
35/// [`PostCommitFailurePolicy`] selects what to do when a post-commit check
36/// fails.
37///
38/// [`RetainedForRecovery`](Self::RetainedForRecovery) is the disposition that
39/// carries retained evidence, and the real path is in
40/// [`TransactionReceipt::recovery_path`]. Retained evidence is never expressed
41/// as a [`FailureCategory`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum CleanupDisposition {
44    /// Owned temporary state was cleaned up.
45    Cleaned,
46    /// Evidence was retained for an operator because cleanup was unsafe or failed.
47    RetainedForRecovery,
48    /// Cleanup was safely arranged but cannot complete until this process exits.
49    ///
50    /// This is the truthful terminal state for a Windows self-update kept
51    /// installed: the new generation is live and the previous generation is
52    /// still mapped by this very process, so it cannot be unlinked yet. The old
53    /// image is owned and scheduled for deletion rather than abandoned, and no
54    /// operator action is required. It is never reported for an ordinary
55    /// multi-member transaction, where no artifact is still mapped.
56    DeferredToProcessExit,
57}
58
59/// Caller policy for a failed post-commit check.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum PostCommitFailurePolicy {
62    /// Keep the complete new artifact set installed and report the check failure.
63    KeepInstalled,
64    /// Restore and verify the complete previous artifact set.
65    RollBack,
66}
67
68/// The transaction phase that produced a failure.
69///
70/// Phases are ordered by first occurrence in a commit attempt. A phase
71/// classifies *where* a failure happened; the [`FailureCategory`] classifies
72/// *what kind* of failure it was.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum FailurePhase {
76    /// Mutation-lock acquisition or inspection.
77    Lock,
78    /// Destination ownership classification or parent containment.
79    Ownership,
80    /// Re-reading and re-hashing staged bytes under lock.
81    StageRevalidation,
82    /// Moving live destinations into the backup set.
83    Backup,
84    /// Renaming staged members into live destinations.
85    Commit,
86    /// A caller-supplied post-commit check failed.
87    PostCommit,
88    /// Restoring backups after a failure.
89    Rollback,
90    /// Removing the backup set after a successful commit.
91    Finalize,
92}
93
94impl FailurePhase {
95    /// Returns the stable phase name used in receipts and diagnostics.
96    pub fn as_str(&self) -> &'static str {
97        match self {
98            Self::Lock => "lock",
99            Self::Ownership => "ownership",
100            Self::StageRevalidation => "stage-revalidation",
101            Self::Backup => "backup",
102            Self::Commit => "commit",
103            Self::PostCommit => "post-commit",
104            Self::Rollback => "rollback",
105            Self::Finalize => "finalize",
106        }
107    }
108}
109
110/// A stable machine-readable classification of a *produced* transaction
111/// failure.
112///
113/// Every member of this enum describes a failure that can actually occur. Five
114/// of them are produced by the crate's internal error-to-category mapping in a
115/// shipped build, and a sixth — [`Injected`](Self::Injected) — is produced by
116/// that same mapping only in this crate's own test builds.
117/// [`PostCommitCheck`](Self::PostCommitCheck) is produced directly when a
118/// caller-supplied post-commit check fails or panics.
119/// [`Injected`](Self::Injected) is produced only by this crate's own test
120/// fault harness and never by a shipped build.
121///
122/// The enum is `#[non_exhaustive]`, so a downstream matcher must already
123/// carry a wildcard arm; a category added later therefore reaches consumers
124/// without breaking their compilation. The [`as_str`](Self::as_str) string is
125/// the only stable identifier a consumer may persist or aggregate, because a
126/// downstream crate cannot enumerate the categories itself.
127///
128/// Retained evidence is deliberately **not** a category. It is reported as a
129/// [`TransactionDisposition::RecoveryRequired`] disposition together with
130/// [`CleanupDisposition::RetainedForRecovery`] and a real
131/// [`TransactionReceipt::recovery_path`], never as a failure category.
132///
133/// See `architecture/core-transaction.md` §8 "Failure semantics" and
134/// `crates/eggup-core/docs/transaction.md` for the authoritative rules.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[non_exhaustive]
137pub enum FailureCategory {
138    /// Another transaction or ambiguous lock owns the domain.
139    ///
140    /// Produced from [`Error::UpdateInProgress`].
141    LockContention,
142    /// A destination is foreign, unknown, or violates containment/parent rules.
143    ///
144    /// Produced from [`Error::DestinationConflict`].
145    OwnershipConflict,
146    /// Staged bytes changed, are unreadable, or lack required integrity evidence.
147    ///
148    /// Produced from [`Error::VerificationFailed`] and
149    /// [`Error::CandidateExecution`].
150    Verification,
151    /// A live filesystem mutation failed.
152    ///
153    /// Produced from [`Error::Io`].
154    Filesystem,
155    /// Caller input or transaction configuration is invalid.
156    ///
157    /// Produced from [`Error::InvalidInput`] and [`Error::UnknownMember`].
158    InvalidInput,
159    /// The failure was injected by the test fault harness.
160    ///
161    /// Produced only by this crate's own test builds, from the
162    /// `#[cfg(test)]`-only `Error::Injected`. A shipped `eggup-core` build
163    /// never produces this category.
164    Injected,
165    /// A caller-supplied post-commit check failed or panicked.
166    ///
167    /// Produced directly by the commit path, not through the
168    /// error-to-category mapping.
169    PostCommitCheck,
170}
171
172impl FailureCategory {
173    /// Returns the stable category name.
174    ///
175    /// The returned string is the only stable identifier a consumer may
176    /// persist or aggregate: a downstream crate cannot enumerate the members
177    /// of a `#[non_exhaustive]` enum. The strings are a published contract
178    /// and do not change.
179    pub fn as_str(&self) -> &'static str {
180        match self {
181            Self::LockContention => "lock-contention",
182            Self::OwnershipConflict => "ownership-conflict",
183            Self::Verification => "verification",
184            Self::Filesystem => "filesystem",
185            Self::InvalidInput => "invalid-input",
186            Self::Injected => "injected",
187            Self::PostCommitCheck => "post-commit-check",
188        }
189    }
190}
191
192/// A bounded structured report describing one transaction failure.
193///
194/// A report is *evidence*, not a disposition: it names the phase, the
195/// affected member, the stable [`FailureCategory`], and a bounded
196/// human-readable detail string. A report never carries a retained-evidence
197/// path; those are reported through [`TransactionReceipt::recovery_path`].
198///
199/// Reports are produced by the crate's internal error-to-category mapping and
200/// by the post-commit check path. See `crates/eggup-core/docs/transaction.md`
201/// for the authoritative rules.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct FailureReport {
204    phase: FailurePhase,
205    member: Option<MemberId>,
206    category: FailureCategory,
207    detail: String,
208}
209
210impl FailureReport {
211    pub(crate) fn new(
212        phase: FailurePhase,
213        member: Option<MemberId>,
214        category: FailureCategory,
215        detail: impl Into<String>,
216    ) -> Self {
217        let mut detail = detail.into();
218        // Bound human-readable detail; receipts must never embed unbounded output.
219        if detail.len() > 512 {
220            let boundary = detail
221                .char_indices()
222                .map(|(index, _)| index)
223                .take_while(|index| *index <= 512)
224                .last()
225                .unwrap_or(0);
226            detail.truncate(boundary);
227        }
228        Self {
229            phase,
230            member,
231            category,
232            detail,
233        }
234    }
235
236    /// Returns the phase that produced the failure.
237    pub fn phase(&self) -> FailurePhase {
238        self.phase
239    }
240
241    /// Returns the affected member, when the failure is member-specific.
242    pub fn member(&self) -> Option<&MemberId> {
243        self.member.as_ref()
244    }
245
246    /// Returns the stable failure category.
247    pub fn category(&self) -> FailureCategory {
248        self.category
249    }
250
251    /// Returns bounded human-readable detail.
252    pub fn detail(&self) -> &str {
253        &self.detail
254    }
255}
256
257pub(crate) fn report_for_error(
258    phase: FailurePhase,
259    member: Option<MemberId>,
260    error: &Error,
261) -> FailureReport {
262    let (category, detail) = match error {
263        Error::UpdateInProgress { lock } => (
264            FailureCategory::LockContention,
265            format!("lock contention at {}", lock.display()),
266        ),
267        Error::DestinationConflict { destination } => (
268            FailureCategory::OwnershipConflict,
269            format!("destination conflict at {}", destination.display()),
270        ),
271        Error::VerificationFailed(m) => (FailureCategory::Verification, m.clone()),
272        Error::CandidateExecution(m) => (FailureCategory::Verification, m.clone()),
273        #[cfg(test)]
274        Error::Injected(m) => (FailureCategory::Injected, m.clone()),
275        Error::InvalidInput(m) => (FailureCategory::InvalidInput, m.clone()),
276        Error::UnknownMember(m) => (FailureCategory::InvalidInput, format!("unknown member {m}")),
277        Error::Io { operation, source } => (
278            FailureCategory::Filesystem,
279            format!("{operation}: {source}"),
280        ),
281    };
282    FailureReport::new(phase, member, category, detail)
283}
284
285/// Evidence describing the terminal result of a transaction attempt.
286///
287/// A receipt — not an `Err` — is how the outcome of a post-lock attempt is
288/// reported. Only lock contention and setup failures return `Err`; every
289/// outcome reached once the mutation lock is held — an ownership conflict, a
290/// staged-digest mismatch, a failed rename, a failed rollback, a failed
291/// post-commit check — is reported through this type. Read
292/// [`disposition`](Self::disposition) first: `RolledBack` and
293/// `RecoveryRequired` are never equivalent to success.
294///
295/// See `architecture/core-transaction.md` §8 "Failure semantics" and
296/// `crates/eggup-core/docs/transaction.md` for the authoritative rules.
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct TransactionReceipt {
299    product: crate::domain::ProductId,
300    release: crate::domain::ReleaseId,
301    disposition: TransactionDisposition,
302    rollback_performed: bool,
303    rollback_verified: bool,
304    cleanup: CleanupDisposition,
305    recovery_path: Option<PathBuf>,
306    failure: Option<FailureReport>,
307    rollback_failure: Option<FailureReport>,
308    post_commit_failure: Option<FailureReport>,
309}
310
311impl TransactionReceipt {
312    /// Returns the product identity recorded by the transaction.
313    pub fn product(&self) -> &crate::domain::ProductId {
314        &self.product
315    }
316
317    /// Returns the release identity recorded by the transaction.
318    pub fn release(&self) -> &crate::domain::ReleaseId {
319        &self.release
320    }
321
322    /// Returns the terminal disposition.
323    pub fn disposition(&self) -> TransactionDisposition {
324        self.disposition
325    }
326
327    /// Returns whether rollback was attempted.
328    pub fn rollback_performed(&self) -> bool {
329        self.rollback_performed
330    }
331
332    /// Returns whether every restoration check passed.
333    ///
334    /// This describes restoration of live destinations only. A rolled-back
335    /// transaction whose restoration fully succeeded stays `true` even when
336    /// the backup set could not be removed; that cleanup problem is reported
337    /// in [`Self::failure`] with a [`FailurePhase::Finalize`] phase and
338    /// `false` is never reported without a [`Self::rollback_failure`] or a
339    /// recorded cleanup failure to explain it.
340    pub fn rollback_verified(&self) -> bool {
341        self.rollback_verified
342    }
343
344    /// Returns how transaction-owned temporary evidence was handled.
345    pub fn cleanup(&self) -> CleanupDisposition {
346        self.cleanup
347    }
348
349    /// Returns retained backup/lock evidence when manual recovery is required.
350    ///
351    /// When present, the path always refers to real retained evidence. A
352    /// cleanup failure after an otherwise successful commit — or after a fully
353    /// verified rollback — retains the real backup root and reports the cleanup
354    /// problem in [`Self::failure`]; no synthetic non-existent path is ever
355    /// returned.
356    pub fn recovery_path(&self) -> Option<&Path> {
357        self.recovery_path.as_deref()
358    }
359
360    /// Returns the structured report for the triggering failure, if any.
361    ///
362    /// A rolled-back transaction remains a first-class terminal result, but
363    /// the caller can answer "what failed?" from this report without scraping
364    /// logs. Successful commits with cleanup trouble also report the finalize
365    /// problem here, as does a fully verified rollback whose backup set could
366    /// not be cleaned up.
367    pub fn failure(&self) -> Option<&FailureReport> {
368        self.failure.as_ref()
369    }
370
371    /// Returns the rollback-phase failure when restoration itself failed.
372    ///
373    /// When present, [`Self::failure`] still carries the original triggering
374    /// cause; this field carries the distinct recovery failure.
375    pub fn rollback_failure(&self) -> Option<&FailureReport> {
376        self.rollback_failure.as_ref()
377    }
378
379    /// Returns the failed post-commit check, if one occurred.
380    pub fn post_commit_failure(&self) -> Option<&FailureReport> {
381        self.post_commit_failure.as_ref()
382    }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
386#[cfg_attr(not(test), allow(dead_code))]
387pub(crate) enum CommitFault {
388    LockCreation,
389    Backup(MemberId),
390    BeforeFirstCommit,
391    Commit(MemberId),
392    CommitThenRollback(MemberId, MemberId),
393    Finalize,
394    PostCommitRollback,
395}
396
397#[derive(Debug)]
398struct BackupEntry {
399    member: MemberId,
400    destination: PathBuf,
401    backup: Option<PathBuf>,
402}
403
404type PostCommitCheck<'a> = (
405    PostCommitFailurePolicy,
406    Box<dyn FnOnce() -> std::result::Result<(), String> + 'a>,
407);
408
409impl PreparedTransaction {
410    pub(crate) fn commit_inner(
411        self,
412        ownership: CommitOwnership<'_>,
413        verified_digests: &HashMap<MemberId, [u8; 32]>,
414        fault: Option<CommitFault>,
415        post_commit: Option<PostCommitCheck<'_>>,
416        stale_recovery: Option<&dyn crate::lock::StaleLockVerifier>,
417    ) -> RecoveryResult<TransactionReceipt> {
418        #[cfg(test)]
419        if fault == Some(CommitFault::LockCreation) {
420            return Err(Error::injected("injected lock creation failure").into());
421        }
422        // Preflight ownership classification before taking the lock so a
423        // change between preflight and locked revalidation is detectable.
424        let preflight = classify_all(&self, &ownership)?;
425        let mut lock = match stale_recovery {
426            // Recovery is opt-in and never the default: a caller that supplies
427            // no proof keeps the fail-closed acquire that never displaces a
428            // record.
429            None => MutationLock::acquire(
430                self.plan.installation_root(),
431                self.plan.product(),
432                self.plan.release(),
433            )
434            .map_err(RecoveryError::from),
435            Some(verifier) => MutationLock::acquire_with_recovery(
436                self.plan.installation_root(),
437                self.plan.product(),
438                self.plan.release(),
439                verifier,
440            ),
441        }?;
442        // Locked ownership revalidation: must match preflight and authorize
443        // the intended mutation.
444        if let Err(error) = revalidate_ownership_locked(&self, &ownership, &preflight) {
445            let report = report_for_error(FailurePhase::Ownership, None, &error);
446            let verified = true;
447            return finish_failure_no_mutation(self, lock, verified, report);
448        }
449        // Verification-to-commit continuity: re-read and re-hash every staged
450        // member under lock before backing up any live destination.
451        if let Err((member, error)) = revalidate_staged_under_lock(&self, verified_digests) {
452            let report = report_for_error(FailurePhase::StageRevalidation, member, &error);
453            return finish_failure_no_mutation(self, lock, true, report);
454        }
455        let backup_root = match create_backup_directory(self.plan.installation_root()) {
456            Ok(p) => p,
457            Err(error) => {
458                let report = report_for_error(FailurePhase::Backup, None, &error);
459                return finish_failure_no_mutation(self, lock, true, report);
460            }
461        };
462        let mut entries = Vec::with_capacity(self.plan.artifacts().len());
463        if let Err((member, error)) = self.backup_members(&mut entries, &backup_root, fault.clone())
464        {
465            let phase = FailurePhase::Backup;
466            let report = report_for_error(phase, member, &error);
467            let (verified, rollback_failure) =
468                restore_entries(&entries, &HashSet::new(), fault.clone());
469            return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
470        }
471        #[cfg(test)]
472        if fault == Some(CommitFault::BeforeFirstCommit) {
473            let error = Error::injected("injected pre-commit failure");
474            let report = report_for_error(FailurePhase::Commit, None, &error);
475            let (verified, rollback_failure) =
476                restore_entries(&entries, &HashSet::new(), fault.clone());
477            return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
478        }
479
480        let mut committed = HashSet::new();
481        let members = self.plan.artifacts().iter().cloned().collect::<Vec<_>>();
482        for member in members {
483            #[cfg(test)]
484            let commit_should_fail = fault.as_ref().is_some_and(|candidate| match candidate {
485                CommitFault::Commit(id) | CommitFault::CommitThenRollback(id, _) => {
486                    id == member.id()
487                }
488                _ => false,
489            });
490            #[cfg(test)]
491            if commit_should_fail {
492                let error = Error::injected(format!("injected commit failure at {}", member.id()));
493                let report =
494                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
495                let (verified, rollback_failure) =
496                    restore_entries(&entries, &committed, fault.clone());
497                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
498            }
499            let destination = match self.plan.destination(member.id()) {
500                Ok(destination) => destination,
501                Err(error) => {
502                    let report =
503                        report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
504                    let (verified, rollback_failure) =
505                        restore_entries(&entries, &committed, fault.clone());
506                    return finish_failure(
507                        self,
508                        lock,
509                        backup_root,
510                        verified,
511                        rollback_failure,
512                        report,
513                    );
514                }
515            };
516            if let Err(error) = require_ready_parent(&destination, self.plan.installation_root()) {
517                let report =
518                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
519                let (verified, rollback_failure) =
520                    restore_entries(&entries, &committed, fault.clone());
521                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
522            }
523            let staged = match self.staged_path(member.id()) {
524                Ok(staged) => staged,
525                Err(error) => {
526                    let report =
527                        report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
528                    let (verified, rollback_failure) =
529                        restore_entries(&entries, &committed, fault.clone());
530                    return finish_failure(
531                        self,
532                        lock,
533                        backup_root,
534                        verified,
535                        rollback_failure,
536                        report,
537                    );
538                }
539            };
540            if let Err(source) = fs::rename(&staged, &destination) {
541                let error = Error::io("replacing destination from prepared stage", source);
542                let report =
543                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
544                let (verified, rollback_failure) =
545                    restore_entries(&entries, &committed, fault.clone());
546                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
547            }
548            committed.insert(member.id().clone());
549        }
550
551        let mut post_commit_failure = None;
552        if let Some((policy, check)) = post_commit {
553            let checked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(check));
554            let failure_detail = match checked {
555                Ok(Ok(())) => None,
556                Ok(Err(detail)) => Some(detail),
557                Err(_) => Some("post-commit check panicked".to_string()),
558            };
559            if let Some(detail) = failure_detail {
560                let report = FailureReport::new(
561                    FailurePhase::PostCommit,
562                    None,
563                    FailureCategory::PostCommitCheck,
564                    detail,
565                );
566                match policy {
567                    PostCommitFailurePolicy::KeepInstalled => {
568                        post_commit_failure = Some(report);
569                    }
570                    PostCommitFailurePolicy::RollBack => {
571                        let rollback_fault = if fault == Some(CommitFault::PostCommitRollback) {
572                            Some(CommitFault::CommitThenRollback(
573                                self.plan
574                                    .artifacts()
575                                    .iter()
576                                    .next()
577                                    .expect("non-empty artifact set")
578                                    .id()
579                                    .clone(),
580                                self.plan
581                                    .artifacts()
582                                    .iter()
583                                    .next()
584                                    .expect("non-empty artifact set")
585                                    .id()
586                                    .clone(),
587                            ))
588                        } else {
589                            fault.clone()
590                        };
591                        let (verified, rollback_failure) =
592                            restore_entries(&entries, &committed, rollback_fault);
593                        let mut receipt = finish_failure(
594                            self,
595                            lock,
596                            backup_root,
597                            verified,
598                            rollback_failure,
599                            report.clone(),
600                        )?;
601                        receipt.post_commit_failure = Some(report);
602                        return Ok(receipt);
603                    }
604                }
605            }
606        }
607
608        if fault == Some(CommitFault::Finalize) {
609            lock.preserve();
610            let report = FailureReport::new(
611                FailurePhase::Finalize,
612                None,
613                FailureCategory::Injected,
614                "injected finalize failure",
615            );
616            return Ok(TransactionReceipt {
617                product: self.plan.product().clone(),
618                release: self.plan.release().clone(),
619                disposition: TransactionDisposition::Committed,
620                rollback_performed: false,
621                rollback_verified: false,
622                cleanup: CleanupDisposition::RetainedForRecovery,
623                recovery_path: Some(backup_root),
624                failure: Some(report),
625                rollback_failure: None,
626                post_commit_failure,
627            });
628        }
629        // Finalization is the only phase that discards the previous generation,
630        // so it is the only place a running image may be scheduled for removal.
631        // Reaching here means `KeepInstalled` already resolved and no rollback
632        // remains possible, which is exactly the precondition the platform
633        // helper requires.
634        match finalize_backup_set(&self.plan, &backup_root, &entries) {
635            Ok(FinalizeOutcome::Cleaned) => Ok(TransactionReceipt {
636                product: self.plan.product().clone(),
637                release: self.plan.release().clone(),
638                disposition: TransactionDisposition::Committed,
639                rollback_performed: false,
640                rollback_verified: false,
641                cleanup: CleanupDisposition::Cleaned,
642                recovery_path: None,
643                failure: None,
644                rollback_failure: None,
645                post_commit_failure,
646            }),
647            Ok(FinalizeOutcome::Deferred) => Ok(TransactionReceipt {
648                product: self.plan.product().clone(),
649                release: self.plan.release().clone(),
650                disposition: TransactionDisposition::Committed,
651                rollback_performed: false,
652                rollback_verified: false,
653                cleanup: CleanupDisposition::DeferredToProcessExit,
654                // Nothing is retained for an operator: the old image is owned
655                // and scheduled for deletion when this process exits.
656                recovery_path: None,
657                failure: None,
658                rollback_failure: None,
659                post_commit_failure,
660            }),
661            Err(source) => {
662                lock.preserve();
663                let error = Error::io("removing backup set after commit", source);
664                let report = report_for_error(FailurePhase::Finalize, None, &error);
665                Ok(TransactionReceipt {
666                    product: self.plan.product().clone(),
667                    release: self.plan.release().clone(),
668                    disposition: TransactionDisposition::Committed,
669                    rollback_performed: false,
670                    rollback_verified: false,
671                    cleanup: CleanupDisposition::RetainedForRecovery,
672                    recovery_path: Some(backup_root),
673                    failure: Some(report),
674                    rollback_failure: None,
675                    post_commit_failure,
676                })
677            }
678        }
679    }
680
681    fn backup_members(
682        &self,
683        entries: &mut Vec<BackupEntry>,
684        backup_root: &Path,
685        fault: Option<CommitFault>,
686    ) -> std::result::Result<(), (Option<MemberId>, Error)> {
687        #[cfg(not(test))]
688        let _ = &fault;
689        let members = self.plan.artifacts().iter().cloned().collect::<Vec<_>>();
690        for member in members {
691            #[cfg(test)]
692            if fault == Some(CommitFault::Backup(member.id().clone())) {
693                return Err((
694                    Some(member.id().clone()),
695                    Error::injected(format!("injected backup failure at {}", member.id())),
696                ));
697            }
698            let destination = self
699                .plan
700                .destination(member.id())
701                .map_err(|e| (Some(member.id().clone()), e))?;
702            revalidate_destination(&destination, self.plan.installation_root())
703                .map_err(|e| (Some(member.id().clone()), e))?;
704            // Last identity proof before the first live rename: the running
705            // image is moved aside here, and that rename is the point of no
706            // return for the previous generation.
707            self.plan
708                .revalidate_current_executable()
709                .map_err(|e| (Some(member.id().clone()), e))?;
710            let backup = match fs::symlink_metadata(&destination) {
711                Ok(_) => {
712                    let path = backup_root.join(member.destination());
713                    if let Some(parent) = path.parent() {
714                        fs::create_dir_all(parent).map_err(|source| {
715                            (
716                                Some(member.id().clone()),
717                                Error::io("creating backup directory", source),
718                            )
719                        })?;
720                    }
721                    fs::rename(&destination, &path).map_err(|source| {
722                        (
723                            Some(member.id().clone()),
724                            Error::io("backing up existing destination", source),
725                        )
726                    })?;
727                    Some(path)
728                }
729                Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
730                Err(source) => {
731                    return Err((
732                        Some(member.id().clone()),
733                        Error::io("reading destination before backup", source),
734                    ));
735                }
736            };
737            entries.push(BackupEntry {
738                member: member.id().clone(),
739                destination,
740                backup,
741            });
742        }
743        Ok(())
744    }
745}
746
747fn classify_all(
748    txn: &PreparedTransaction,
749    ownership: &CommitOwnership<'_>,
750) -> Result<HashMap<MemberId, Ownership>> {
751    let mut out = HashMap::new();
752    for member in txn.artifacts().iter() {
753        let destination = txn.destination(member.id())?;
754        out.insert(
755            member.id().clone(),
756            ownership.verifier.verify(member.id(), &destination),
757        );
758    }
759    Ok(out)
760}
761
762/// How the previous generation could be discarded after a successful commit.
763#[cfg_attr(not(windows), allow(dead_code))]
764enum FinalizeOutcome {
765    /// Everything transaction-owned is gone.
766    Cleaned,
767    /// The old image is owned and scheduled for deletion after this process exits.
768    Deferred,
769}
770
771/// Discards the previous generation after the caller's policy has resolved.
772///
773/// For an ordinary transaction this is a plain recursive removal. For a
774/// self-update the previous generation is the image this very process is still
775/// executing: on Unix it can be unlinked immediately, but on Windows a mapped
776/// image cannot be deleted until the process exits. The old image has already
777/// been renamed to a transaction-owned path by the time this runs, so the
778/// platform helper can address it exactly — and it is only ever called here,
779/// after `KeepInstalled` has resolved and rollback is no longer possible.
780fn finalize_backup_set(
781    plan: &InstallPlan,
782    backup_root: &Path,
783    entries: &[BackupEntry],
784) -> std::io::Result<FinalizeOutcome> {
785    // Only a self-update plan can hold a still-mapped generation, and only its
786    // single member has a backup of the running image.
787    let running_image = plan
788        .current_executable()
789        .and_then(|_| entries.iter().find_map(|entry| entry.backup.clone()));
790    #[cfg(windows)]
791    if let Some(backup) = running_image {
792        // The image was renamed aside rather than deleted, which is what kept
793        // it rollback-addressable until this point. Scheduling its removal is
794        // irreversible, so it happens only once the policy decision is final.
795        self_replace::self_delete_at(&backup).map_err(|source| {
796            std::io::Error::other(format!(
797                "scheduling removal of the replaced running image: {source}"
798            ))
799        })?;
800        // The scheduled image still occupies its slot, so only an
801        // empty-parent removal is available until the process exits.
802        return match fs::remove_dir(backup_root) {
803            Ok(()) => Ok(FinalizeOutcome::Deferred),
804            Err(source) if source.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
805                Ok(FinalizeOutcome::Deferred)
806            }
807            Err(source) => Err(source),
808        };
809    }
810    #[cfg(not(windows))]
811    let _ = running_image;
812    fs::remove_dir_all(backup_root)?;
813    Ok(FinalizeOutcome::Cleaned)
814}
815
816fn revalidate_ownership_locked(
817    txn: &PreparedTransaction,
818    ownership: &CommitOwnership<'_>,
819    preflight: &HashMap<MemberId, Ownership>,
820) -> Result<()> {
821    for member in txn.artifacts().iter() {
822        let destination = txn.destination(member.id())?;
823        let locked = ownership.verifier.verify(member.id(), &destination);
824        let first = preflight
825            .get(member.id())
826            .copied()
827            .unwrap_or(Ownership::Unknown);
828        // Any change between preflight and locked classification fails: the
829        // live deployment moved under us.
830        if locked != first {
831            return Err(Error::DestinationConflict { destination });
832        }
833        match locked {
834            Ownership::Owned => {}
835            Ownership::Absent if ownership.absent == AbsentPolicy::AllowCreate => {}
836            Ownership::Absent | Ownership::Foreign | Ownership::Unknown => {
837                return Err(Error::DestinationConflict { destination });
838            }
839        }
840        // Parent containment is re-proven under lock; missing parents fail
841        // without creating anything.
842        require_ready_parent(&destination, txn.plan.installation_root())?;
843        revalidate_destination(&destination, txn.plan.installation_root())?;
844    }
845    // The bound current-executable object is re-proven under the same lock, so
846    // a swapped image between preflight and mutation is detected here too.
847    txn.plan.revalidate_current_executable()?;
848    Ok(())
849}
850
851fn revalidate_staged_under_lock(
852    txn: &PreparedTransaction,
853    verified_digests: &HashMap<MemberId, [u8; 32]>,
854) -> std::result::Result<(), (Option<MemberId>, Error)> {
855    for member in txn.artifacts().iter() {
856        let expected = verified_digests.get(member.id()).ok_or_else(|| {
857            (
858                Some(member.id().clone()),
859                Error::VerificationFailed(format!(
860                    "member {} has no verified digest; commit requires verified integrity",
861                    member.id()
862                )),
863            )
864        })?;
865        let staged = txn
866            .staged_path(member.id())
867            .map_err(|e| (Some(member.id().clone()), e))?;
868        let metadata = fs::symlink_metadata(&staged).map_err(|source| {
869            (
870                Some(member.id().clone()),
871                Error::io("reading staged candidate", source),
872            )
873        })?;
874        if !metadata.is_file() {
875            return Err((
876                Some(member.id().clone()),
877                Error::VerificationFailed(format!(
878                    "staged member {} is not a regular file",
879                    member.id()
880                )),
881            ));
882        }
883        #[cfg(unix)]
884        {
885            use std::os::unix::fs::MetadataExt;
886            if metadata.nlink() != 1 {
887                return Err((
888                    Some(member.id().clone()),
889                    Error::VerificationFailed(format!(
890                        "staged member {} is hard-linked",
891                        member.id()
892                    )),
893                ));
894            }
895        }
896        let actual = hash_file(&staged).map_err(|e| (Some(member.id().clone()), e))?;
897        if &actual != expected {
898            return Err((
899                Some(member.id().clone()),
900                Error::VerificationFailed(format!(
901                    "staged member {} changed after verification",
902                    member.id()
903                )),
904            ));
905        }
906    }
907    Ok(())
908}
909
910fn create_backup_directory(root: &Path) -> Result<PathBuf> {
911    use std::time::{SystemTime, UNIX_EPOCH};
912    for _ in 0..32 {
913        let nonce = NEXT_BACKUP_NONCE.fetch_add(1, Ordering::Relaxed);
914        let nanos = SystemTime::now()
915            .duration_since(UNIX_EPOCH)
916            .map(|d| d.as_nanos())
917            .unwrap_or(0);
918        let path = root.join(format!(
919            ".eggup-backup-{}-{nonce}-{nanos}",
920            std::process::id()
921        ));
922        match fs::create_dir(&path) {
923            Ok(()) => {
924                #[cfg(unix)]
925                {
926                    use std::os::unix::fs::PermissionsExt;
927                    let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o700));
928                }
929                return Ok(path);
930            }
931            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
932            Err(source) => return Err(Error::io("creating backup set", source)),
933        }
934    }
935    Err(Error::invalid("could not create a unique backup directory"))
936}
937
938fn revalidate_destination(destination: &Path, root: &Path) -> Result<()> {
939    let root = fs::canonicalize(root)
940        .map_err(|source| Error::io("canonicalizing install root", source))?;
941    let mut ancestor = destination;
942    while !ancestor.exists() {
943        ancestor = ancestor
944            .parent()
945            .ok_or_else(|| Error::invalid("destination has no existing ancestor"))?;
946    }
947    let canonical_ancestor = fs::canonicalize(ancestor)
948        .map_err(|source| Error::io("canonicalizing destination ancestor", source))?;
949    if !canonical_ancestor.starts_with(&root) {
950        return Err(Error::DestinationConflict {
951            destination: destination.to_path_buf(),
952        });
953    }
954    match fs::symlink_metadata(destination) {
955        Ok(metadata) => {
956            if !metadata.is_file() {
957                return Err(Error::DestinationConflict {
958                    destination: destination.to_path_buf(),
959                });
960            }
961            #[cfg(unix)]
962            {
963                use std::os::unix::fs::MetadataExt;
964                if metadata.nlink() != 1 {
965                    return Err(Error::DestinationConflict {
966                        destination: destination.to_path_buf(),
967                    });
968                }
969            }
970        }
971        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
972        Err(source) => return Err(Error::io("reading destination during revalidation", source)),
973    }
974    Ok(())
975}
976
977/// Requires that a live destination's parent already exists, is a real
978/// directory beneath the installation root, and contains no symlink ambiguity.
979///
980/// Unlike the pre-corrective engine, this never calls `create_dir_all` on an
981/// unchecked live path. Missing parents are an actionable error; symlink or
982/// otherwise ambiguous parents fail closed.
983fn require_ready_parent(destination: &Path, root: &Path) -> Result<()> {
984    let parent = destination
985        .parent()
986        .ok_or_else(|| Error::invalid("destination has no parent"))?;
987    let parent_meta = fs::symlink_metadata(parent).map_err(|source| {
988        if source.kind() == std::io::ErrorKind::NotFound {
989            Error::invalid(format!(
990                "destination parent does not exist: {}",
991                parent.display()
992            ))
993        } else {
994            Error::io("reading destination parent", source)
995        }
996    })?;
997    if parent_meta.file_type().is_symlink() {
998        return Err(Error::DestinationConflict {
999            destination: destination.to_path_buf(),
1000        });
1001    }
1002    if !parent_meta.is_dir() {
1003        return Err(Error::DestinationConflict {
1004            destination: destination.to_path_buf(),
1005        });
1006    }
1007    let canonical_root = fs::canonicalize(root)
1008        .map_err(|source| Error::io("canonicalizing install root", source))?;
1009    let canonical_parent = fs::canonicalize(parent)
1010        .map_err(|source| Error::io("canonicalizing destination parent", source))?;
1011    if !canonical_parent.starts_with(&canonical_root) {
1012        return Err(Error::DestinationConflict {
1013            destination: destination.to_path_buf(),
1014        });
1015    }
1016    // Walk ancestors of the parent to reject any symlink component, even when
1017    // the canonical target remains beneath the root.
1018    let mut cursor = Some(parent);
1019    while let Some(p) = cursor {
1020        if p == root {
1021            break;
1022        }
1023        let meta = fs::symlink_metadata(p)
1024            .map_err(|source| Error::io("reading destination ancestor", source))?;
1025        if meta.file_type().is_symlink() {
1026            return Err(Error::DestinationConflict {
1027                destination: destination.to_path_buf(),
1028            });
1029        }
1030        cursor = p.parent();
1031        if let Some(next) = cursor {
1032            if next.as_os_str().is_empty() {
1033                break;
1034            }
1035        } else {
1036            break;
1037        }
1038    }
1039    Ok(())
1040}
1041
1042fn restore_entries(
1043    entries: &[BackupEntry],
1044    committed: &HashSet<MemberId>,
1045    fault: Option<CommitFault>,
1046) -> (bool, Option<FailureReport>) {
1047    let mut verified = true;
1048    let mut rollback_failure: Option<FailureReport> = None;
1049    for entry in entries.iter().rev() {
1050        let rollback_target = match fault.as_ref() {
1051            Some(CommitFault::CommitThenRollback(_, id)) => Some(id),
1052            _ => None,
1053        };
1054        if rollback_target == Some(&entry.member) {
1055            verified = false;
1056            rollback_failure = Some(FailureReport::new(
1057                FailurePhase::Rollback,
1058                Some(entry.member.clone()),
1059                FailureCategory::Injected,
1060                format!("injected rollback failure at {}", entry.member),
1061            ));
1062            continue;
1063        }
1064        if entry.backup.is_some() || committed.contains(&entry.member) {
1065            if let Ok(metadata) = fs::symlink_metadata(&entry.destination) {
1066                if !metadata.is_file() || fs::remove_file(&entry.destination).is_err() {
1067                    verified = false;
1068                    if rollback_failure.is_none() {
1069                        rollback_failure = Some(FailureReport::new(
1070                            FailurePhase::Rollback,
1071                            Some(entry.member.clone()),
1072                            FailureCategory::Filesystem,
1073                            format!("could not remove partial member {}", entry.member),
1074                        ));
1075                    }
1076                    continue;
1077                }
1078            }
1079        }
1080        if let Some(backup) = &entry.backup {
1081            if fs::rename(backup, &entry.destination).is_err() {
1082                verified = false;
1083                if rollback_failure.is_none() {
1084                    rollback_failure = Some(FailureReport::new(
1085                        FailurePhase::Rollback,
1086                        Some(entry.member.clone()),
1087                        FailureCategory::Filesystem,
1088                        format!("could not restore backup for {}", entry.member),
1089                    ));
1090                }
1091            }
1092        }
1093    }
1094    for entry in entries {
1095        let restored = match &entry.backup {
1096            Some(_) => fs::symlink_metadata(&entry.destination).is_ok(),
1097            None => {
1098                !committed.contains(&entry.member)
1099                    || fs::symlink_metadata(&entry.destination).is_err()
1100            }
1101        };
1102        if !restored {
1103            verified = false;
1104        }
1105    }
1106    (verified, rollback_failure)
1107}
1108
1109fn finish_failure_no_mutation(
1110    transaction: PreparedTransaction,
1111    lock: MutationLock,
1112    nothing_mutated: bool,
1113    report: FailureReport,
1114) -> RecoveryResult<TransactionReceipt> {
1115    debug_assert!(nothing_mutated);
1116    drop(lock);
1117    Ok(TransactionReceipt {
1118        product: transaction.plan.product().clone(),
1119        release: transaction.plan.release().clone(),
1120        disposition: TransactionDisposition::RolledBack,
1121        rollback_performed: false,
1122        rollback_verified: true,
1123        cleanup: CleanupDisposition::Cleaned,
1124        recovery_path: None,
1125        failure: Some(report),
1126        rollback_failure: None,
1127        post_commit_failure: None,
1128    })
1129}
1130
1131fn finish_failure(
1132    transaction: PreparedTransaction,
1133    mut lock: MutationLock,
1134    backup_root: PathBuf,
1135    rollback_verified: bool,
1136    rollback_failure: Option<FailureReport>,
1137    report: FailureReport,
1138) -> RecoveryResult<TransactionReceipt> {
1139    if rollback_verified && rollback_failure.is_none() {
1140        match fs::remove_dir_all(&backup_root) {
1141            Ok(()) => {
1142                return Ok(TransactionReceipt {
1143                    product: transaction.plan.product().clone(),
1144                    release: transaction.plan.release().clone(),
1145                    disposition: TransactionDisposition::RolledBack,
1146                    rollback_performed: true,
1147                    rollback_verified: true,
1148                    cleanup: CleanupDisposition::Cleaned,
1149                    recovery_path: None,
1150                    failure: Some(report),
1151                    rollback_failure: None,
1152                    post_commit_failure: None,
1153                });
1154            }
1155            // Restoration itself fully succeeded; only the backup-set cleanup
1156            // failed. Mirror the committed path: keep the receipt `RolledBack`,
1157            // carry the verified flag truthfully, and attribute the problem to
1158            // the `Finalize` phase in `failure()` rather than leaving a
1159            // `RecoveryRequired` receipt that claims unverified rollback with
1160            // no rollback failure recorded.
1161            Err(source) => {
1162                let error = Error::io("removing backup set after rollback", source);
1163                return Ok(TransactionReceipt {
1164                    product: transaction.plan.product().clone(),
1165                    release: transaction.plan.release().clone(),
1166                    disposition: TransactionDisposition::RolledBack,
1167                    rollback_performed: true,
1168                    rollback_verified: true,
1169                    cleanup: CleanupDisposition::RetainedForRecovery,
1170                    recovery_path: Some(backup_root),
1171                    failure: Some(report_for_error(FailurePhase::Finalize, None, &error)),
1172                    rollback_failure: None,
1173                    post_commit_failure: None,
1174                });
1175            }
1176        }
1177    }
1178    lock.preserve();
1179    Ok(TransactionReceipt {
1180        product: transaction.plan.product().clone(),
1181        release: transaction.plan.release().clone(),
1182        disposition: TransactionDisposition::RecoveryRequired,
1183        rollback_performed: true,
1184        rollback_verified,
1185        cleanup: CleanupDisposition::RetainedForRecovery,
1186        recovery_path: Some(backup_root),
1187        failure: Some(report),
1188        rollback_failure,
1189        post_commit_failure: None,
1190    })
1191}