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