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, MemberId, Ownership};
7use crate::error::{Error, 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 replaces the former `PostCommitFailurePolicy::{Cleaned,
31/// RetainForRecovery}` name, which conflated cleanup disposition with the
32/// ADR-0002 post-commit `KeepInstalled | RollBack` policy. The ADR-0002 policy
33/// is reserved for a future post-commit failure boundary and is not
34/// implemented here.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CleanupDisposition {
37    /// Owned temporary state was cleaned up.
38    Cleaned,
39    /// Evidence was retained for an operator because cleanup was unsafe or failed.
40    RetainedForRecovery,
41}
42
43/// The transaction phase that produced a failure.
44///
45/// Phases are ordered by first occurrence in a commit attempt.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum FailurePhase {
49    /// Mutation-lock acquisition or inspection.
50    Lock,
51    /// Destination ownership classification or parent containment.
52    Ownership,
53    /// Re-reading and re-hashing staged bytes under lock.
54    StageRevalidation,
55    /// Moving live destinations into the backup set.
56    Backup,
57    /// Renaming staged members into live destinations.
58    Commit,
59    /// Restoring backups after a failure.
60    Rollback,
61    /// Removing the backup set after a successful commit.
62    Finalize,
63}
64
65impl FailurePhase {
66    /// Returns the stable phase name used in receipts and diagnostics.
67    pub fn as_str(&self) -> &'static str {
68        match self {
69            Self::Lock => "lock",
70            Self::Ownership => "ownership",
71            Self::StageRevalidation => "stage-revalidation",
72            Self::Backup => "backup",
73            Self::Commit => "commit",
74            Self::Rollback => "rollback",
75            Self::Finalize => "finalize",
76        }
77    }
78}
79
80/// A stable machine-readable failure category.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum FailureCategory {
84    /// Another transaction or ambiguous lock owns the domain.
85    LockContention,
86    /// A destination is foreign, unknown, or violates containment/parent rules.
87    OwnershipConflict,
88    /// Staged bytes changed, are unreadable, or lack required integrity evidence.
89    Verification,
90    /// A live filesystem mutation failed.
91    Filesystem,
92    /// Caller input or transaction configuration is invalid.
93    InvalidInput,
94    /// The failure was injected by the test fault harness.
95    Injected,
96}
97
98impl FailureCategory {
99    /// Returns the stable category name.
100    pub fn as_str(&self) -> &'static str {
101        match self {
102            Self::LockContention => "lock-contention",
103            Self::OwnershipConflict => "ownership-conflict",
104            Self::Verification => "verification",
105            Self::Filesystem => "filesystem",
106            Self::InvalidInput => "invalid-input",
107            Self::Injected => "injected",
108        }
109    }
110}
111
112/// A bounded structured report describing one transaction failure.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct FailureReport {
115    phase: FailurePhase,
116    member: Option<MemberId>,
117    category: FailureCategory,
118    detail: String,
119}
120
121impl FailureReport {
122    pub(crate) fn new(
123        phase: FailurePhase,
124        member: Option<MemberId>,
125        category: FailureCategory,
126        detail: impl Into<String>,
127    ) -> Self {
128        let mut detail = detail.into();
129        // Bound human-readable detail; receipts must never embed unbounded output.
130        if detail.len() > 512 {
131            detail.truncate(512);
132        }
133        Self {
134            phase,
135            member,
136            category,
137            detail,
138        }
139    }
140
141    /// Returns the phase that produced the failure.
142    pub fn phase(&self) -> FailurePhase {
143        self.phase
144    }
145
146    /// Returns the affected member, when the failure is member-specific.
147    pub fn member(&self) -> Option<&MemberId> {
148        self.member.as_ref()
149    }
150
151    /// Returns the stable failure category.
152    pub fn category(&self) -> FailureCategory {
153        self.category
154    }
155
156    /// Returns bounded human-readable detail.
157    pub fn detail(&self) -> &str {
158        &self.detail
159    }
160}
161
162pub(crate) fn report_for_error(
163    phase: FailurePhase,
164    member: Option<MemberId>,
165    error: &Error,
166) -> FailureReport {
167    let (category, detail) = match error {
168        Error::UpdateInProgress { lock } => (
169            FailureCategory::LockContention,
170            format!("lock contention at {}", lock.display()),
171        ),
172        Error::DestinationConflict { destination } => (
173            FailureCategory::OwnershipConflict,
174            format!("destination conflict at {}", destination.display()),
175        ),
176        Error::VerificationFailed(m) => (FailureCategory::Verification, m.clone()),
177        Error::CandidateExecution(m) => (FailureCategory::Verification, m.clone()),
178        Error::InvalidInput(m) => {
179            let category = if m.contains("injected") {
180                FailureCategory::Injected
181            } else {
182                FailureCategory::InvalidInput
183            };
184            (category, m.clone())
185        }
186        Error::UnknownMember(m) => (FailureCategory::InvalidInput, format!("unknown member {m}")),
187        Error::Io { operation, source } => (
188            FailureCategory::Filesystem,
189            format!("{operation}: {source}"),
190        ),
191    };
192    FailureReport::new(phase, member, category, detail)
193}
194
195/// Evidence describing the terminal result of a transaction attempt.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct TransactionReceipt {
198    product: crate::domain::ProductId,
199    release: crate::domain::ReleaseId,
200    disposition: TransactionDisposition,
201    rollback_performed: bool,
202    rollback_verified: bool,
203    cleanup: CleanupDisposition,
204    recovery_path: Option<PathBuf>,
205    failure: Option<FailureReport>,
206    rollback_failure: Option<FailureReport>,
207}
208
209impl TransactionReceipt {
210    /// Returns the product identity recorded by the transaction.
211    pub fn product(&self) -> &crate::domain::ProductId {
212        &self.product
213    }
214
215    /// Returns the release identity recorded by the transaction.
216    pub fn release(&self) -> &crate::domain::ReleaseId {
217        &self.release
218    }
219
220    /// Returns the terminal disposition.
221    pub fn disposition(&self) -> TransactionDisposition {
222        self.disposition
223    }
224
225    /// Returns whether rollback was attempted.
226    pub fn rollback_performed(&self) -> bool {
227        self.rollback_performed
228    }
229
230    /// Returns whether every restoration check passed.
231    pub fn rollback_verified(&self) -> bool {
232        self.rollback_verified
233    }
234
235    /// Returns how transaction-owned temporary evidence was handled.
236    pub fn cleanup(&self) -> CleanupDisposition {
237        self.cleanup
238    }
239
240    /// Returns retained backup/lock evidence when manual recovery is required.
241    ///
242    /// When present, the path always refers to real retained evidence. A
243    /// cleanup failure after an otherwise successful commit retains the real
244    /// backup root and reports the cleanup problem in [`Self::failure`];
245    /// no synthetic non-existent path is ever returned.
246    pub fn recovery_path(&self) -> Option<&Path> {
247        self.recovery_path.as_deref()
248    }
249
250    /// Returns the structured report for the triggering failure, if any.
251    ///
252    /// A rolled-back transaction remains a first-class terminal result, but
253    /// the caller can answer "what failed?" from this report without scraping
254    /// logs. Successful commits with cleanup trouble also report the finalize
255    /// problem here.
256    pub fn failure(&self) -> Option<&FailureReport> {
257        self.failure.as_ref()
258    }
259
260    /// Returns the rollback-phase failure when restoration itself failed.
261    ///
262    /// When present, [`Self::failure`] still carries the original triggering
263    /// cause; this field carries the distinct recovery failure.
264    pub fn rollback_failure(&self) -> Option<&FailureReport> {
265        self.rollback_failure.as_ref()
266    }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270#[cfg_attr(not(test), allow(dead_code))]
271pub(crate) enum CommitFault {
272    LockCreation,
273    Backup(MemberId),
274    BeforeFirstCommit,
275    Commit(MemberId),
276    CommitThenRollback(MemberId, MemberId),
277    Finalize,
278}
279
280#[derive(Debug)]
281struct BackupEntry {
282    member: MemberId,
283    destination: PathBuf,
284    backup: Option<PathBuf>,
285}
286
287impl PreparedTransaction {
288    pub(crate) fn commit_inner(
289        self,
290        ownership: CommitOwnership<'_>,
291        verified_digests: &HashMap<MemberId, [u8; 32]>,
292        fault: Option<CommitFault>,
293    ) -> Result<TransactionReceipt> {
294        if fault == Some(CommitFault::LockCreation) {
295            return Err(Error::invalid("injected lock creation failure"));
296        }
297        // Preflight ownership classification before taking the lock so a
298        // change between preflight and locked revalidation is detectable.
299        let preflight = classify_all(&self, &ownership)?;
300        let mut lock = MutationLock::acquire(
301            self.plan.installation_root(),
302            self.plan.product(),
303            self.plan.release(),
304        )?;
305        // Locked ownership revalidation: must match preflight and authorize
306        // the intended mutation.
307        if let Err(error) = revalidate_ownership_locked(&self, &ownership, &preflight) {
308            let report = report_for_error(FailurePhase::Ownership, None, &error);
309            let verified = true;
310            return finish_failure_no_mutation(self, lock, verified, report);
311        }
312        // Verification-to-commit continuity: re-read and re-hash every staged
313        // member under lock before backing up any live destination.
314        if let Err((member, error)) = revalidate_staged_under_lock(&self, verified_digests) {
315            let report = report_for_error(FailurePhase::StageRevalidation, member, &error);
316            return finish_failure_no_mutation(self, lock, true, report);
317        }
318        let backup_root = match create_backup_directory(self.plan.installation_root()) {
319            Ok(p) => p,
320            Err(error) => {
321                let report = report_for_error(FailurePhase::Backup, None, &error);
322                return finish_failure_no_mutation(self, lock, true, report);
323            }
324        };
325        let mut entries = Vec::with_capacity(self.plan.artifacts().len());
326        if let Err((member, error)) = self.backup_members(&mut entries, &backup_root, fault.clone())
327        {
328            let phase = FailurePhase::Backup;
329            let report = report_for_error(phase, member, &error);
330            let (verified, rollback_failure) =
331                restore_entries(&entries, &HashSet::new(), fault.clone());
332            return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
333        }
334        if fault == Some(CommitFault::BeforeFirstCommit) {
335            let error = Error::invalid("injected pre-commit failure");
336            let report = report_for_error(FailurePhase::Commit, None, &error);
337            let (verified, rollback_failure) =
338                restore_entries(&entries, &HashSet::new(), fault.clone());
339            return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
340        }
341
342        let mut committed = HashSet::new();
343        let members = self.plan.artifacts().iter().cloned().collect::<Vec<_>>();
344        for member in members {
345            let commit_should_fail = fault.as_ref().is_some_and(|candidate| match candidate {
346                CommitFault::Commit(id) | CommitFault::CommitThenRollback(id, _) => {
347                    id == member.id()
348                }
349                _ => false,
350            });
351            if commit_should_fail {
352                let error = Error::invalid(format!("injected commit failure at {}", member.id()));
353                let report =
354                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
355                let (verified, rollback_failure) =
356                    restore_entries(&entries, &committed, fault.clone());
357                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
358            }
359            let destination = match self.plan.destination(member.id()) {
360                Ok(destination) => destination,
361                Err(error) => {
362                    let report =
363                        report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
364                    let (verified, rollback_failure) =
365                        restore_entries(&entries, &committed, fault.clone());
366                    return finish_failure(
367                        self,
368                        lock,
369                        backup_root,
370                        verified,
371                        rollback_failure,
372                        report,
373                    );
374                }
375            };
376            if let Err(error) = require_ready_parent(&destination, self.plan.installation_root()) {
377                let report =
378                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
379                let (verified, rollback_failure) =
380                    restore_entries(&entries, &committed, fault.clone());
381                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
382            }
383            let staged = match self.staged_path(member.id()) {
384                Ok(staged) => staged,
385                Err(error) => {
386                    let report =
387                        report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
388                    let (verified, rollback_failure) =
389                        restore_entries(&entries, &committed, fault.clone());
390                    return finish_failure(
391                        self,
392                        lock,
393                        backup_root,
394                        verified,
395                        rollback_failure,
396                        report,
397                    );
398                }
399            };
400            if let Err(source) = fs::rename(&staged, &destination) {
401                let error = Error::io("replacing destination from prepared stage", source);
402                let report =
403                    report_for_error(FailurePhase::Commit, Some(member.id().clone()), &error);
404                let (verified, rollback_failure) =
405                    restore_entries(&entries, &committed, fault.clone());
406                return finish_failure(self, lock, backup_root, verified, rollback_failure, report);
407            }
408            committed.insert(member.id().clone());
409        }
410
411        if fault == Some(CommitFault::Finalize) {
412            lock.preserve();
413            let report = FailureReport::new(
414                FailurePhase::Finalize,
415                None,
416                FailureCategory::Injected,
417                "injected finalize failure",
418            );
419            return Ok(TransactionReceipt {
420                product: self.plan.product().clone(),
421                release: self.plan.release().clone(),
422                disposition: TransactionDisposition::Committed,
423                rollback_performed: false,
424                rollback_verified: false,
425                cleanup: CleanupDisposition::RetainedForRecovery,
426                recovery_path: Some(backup_root),
427                failure: Some(report),
428                rollback_failure: None,
429            });
430        }
431        if let Err(source) = fs::remove_dir_all(&backup_root) {
432            lock.preserve();
433            let error = Error::io("removing backup set after commit", source);
434            let report = report_for_error(FailurePhase::Finalize, None, &error);
435            return Ok(TransactionReceipt {
436                product: self.plan.product().clone(),
437                release: self.plan.release().clone(),
438                disposition: TransactionDisposition::Committed,
439                rollback_performed: false,
440                rollback_verified: false,
441                cleanup: CleanupDisposition::RetainedForRecovery,
442                recovery_path: Some(backup_root),
443                failure: Some(report),
444                rollback_failure: None,
445            });
446        }
447        Ok(TransactionReceipt {
448            product: self.plan.product().clone(),
449            release: self.plan.release().clone(),
450            disposition: TransactionDisposition::Committed,
451            rollback_performed: false,
452            rollback_verified: false,
453            cleanup: CleanupDisposition::Cleaned,
454            recovery_path: None,
455            failure: None,
456            rollback_failure: None,
457        })
458    }
459
460    fn backup_members(
461        &self,
462        entries: &mut Vec<BackupEntry>,
463        backup_root: &Path,
464        fault: Option<CommitFault>,
465    ) -> std::result::Result<(), (Option<MemberId>, Error)> {
466        let members = self.plan.artifacts().iter().cloned().collect::<Vec<_>>();
467        for member in members {
468            if fault == Some(CommitFault::Backup(member.id().clone())) {
469                return Err((
470                    Some(member.id().clone()),
471                    Error::invalid(format!("injected backup failure at {}", member.id())),
472                ));
473            }
474            let destination = self
475                .plan
476                .destination(member.id())
477                .map_err(|e| (Some(member.id().clone()), e))?;
478            revalidate_destination(&destination, self.plan.installation_root())
479                .map_err(|e| (Some(member.id().clone()), e))?;
480            let backup = match fs::symlink_metadata(&destination) {
481                Ok(_) => {
482                    let path = backup_root.join(member.destination());
483                    if let Some(parent) = path.parent() {
484                        fs::create_dir_all(parent).map_err(|source| {
485                            (
486                                Some(member.id().clone()),
487                                Error::io("creating backup directory", source),
488                            )
489                        })?;
490                    }
491                    fs::rename(&destination, &path).map_err(|source| {
492                        (
493                            Some(member.id().clone()),
494                            Error::io("backing up existing destination", source),
495                        )
496                    })?;
497                    Some(path)
498                }
499                Err(source) if source.kind() == std::io::ErrorKind::NotFound => None,
500                Err(source) => {
501                    return Err((
502                        Some(member.id().clone()),
503                        Error::io("reading destination before backup", source),
504                    ));
505                }
506            };
507            entries.push(BackupEntry {
508                member: member.id().clone(),
509                destination,
510                backup,
511            });
512        }
513        Ok(())
514    }
515}
516
517fn classify_all(
518    txn: &PreparedTransaction,
519    ownership: &CommitOwnership<'_>,
520) -> Result<HashMap<MemberId, Ownership>> {
521    let mut out = HashMap::new();
522    for member in txn.artifacts().iter() {
523        let destination = txn.destination(member.id())?;
524        out.insert(
525            member.id().clone(),
526            ownership.verifier.verify(member.id(), &destination),
527        );
528    }
529    Ok(out)
530}
531
532fn revalidate_ownership_locked(
533    txn: &PreparedTransaction,
534    ownership: &CommitOwnership<'_>,
535    preflight: &HashMap<MemberId, Ownership>,
536) -> Result<()> {
537    for member in txn.artifacts().iter() {
538        let destination = txn.destination(member.id())?;
539        let locked = ownership.verifier.verify(member.id(), &destination);
540        let first = preflight
541            .get(member.id())
542            .copied()
543            .unwrap_or(Ownership::Unknown);
544        // Any change between preflight and locked classification fails: the
545        // live deployment moved under us.
546        if locked != first {
547            return Err(Error::DestinationConflict { destination });
548        }
549        match locked {
550            Ownership::Owned => {}
551            Ownership::Absent if ownership.absent == AbsentPolicy::AllowCreate => {}
552            Ownership::Absent | Ownership::Foreign | Ownership::Unknown => {
553                return Err(Error::DestinationConflict { destination });
554            }
555        }
556        // Parent containment is re-proven under lock; missing parents fail
557        // without creating anything.
558        require_ready_parent(&destination, txn.plan.installation_root())?;
559        revalidate_destination(&destination, txn.plan.installation_root())?;
560    }
561    Ok(())
562}
563
564fn revalidate_staged_under_lock(
565    txn: &PreparedTransaction,
566    verified_digests: &HashMap<MemberId, [u8; 32]>,
567) -> std::result::Result<(), (Option<MemberId>, Error)> {
568    for member in txn.artifacts().iter() {
569        let expected = verified_digests.get(member.id()).ok_or_else(|| {
570            (
571                Some(member.id().clone()),
572                Error::VerificationFailed(format!(
573                    "member {} has no verified digest; commit requires verified integrity",
574                    member.id()
575                )),
576            )
577        })?;
578        let staged = txn
579            .staged_path(member.id())
580            .map_err(|e| (Some(member.id().clone()), e))?;
581        let metadata = fs::symlink_metadata(&staged).map_err(|source| {
582            (
583                Some(member.id().clone()),
584                Error::io("reading staged candidate", source),
585            )
586        })?;
587        if !metadata.is_file() {
588            return Err((
589                Some(member.id().clone()),
590                Error::VerificationFailed(format!(
591                    "staged member {} is not a regular file",
592                    member.id()
593                )),
594            ));
595        }
596        #[cfg(unix)]
597        {
598            use std::os::unix::fs::MetadataExt;
599            if metadata.nlink() != 1 {
600                return Err((
601                    Some(member.id().clone()),
602                    Error::VerificationFailed(format!(
603                        "staged member {} is hard-linked",
604                        member.id()
605                    )),
606                ));
607            }
608        }
609        let actual = hash_file(&staged).map_err(|e| (Some(member.id().clone()), e))?;
610        if &actual != expected {
611            return Err((
612                Some(member.id().clone()),
613                Error::VerificationFailed(format!(
614                    "staged member {} changed after verification",
615                    member.id()
616                )),
617            ));
618        }
619    }
620    Ok(())
621}
622
623fn create_backup_directory(root: &Path) -> Result<PathBuf> {
624    use std::time::{SystemTime, UNIX_EPOCH};
625    for _ in 0..32 {
626        let nonce = NEXT_BACKUP_NONCE.fetch_add(1, Ordering::Relaxed);
627        let nanos = SystemTime::now()
628            .duration_since(UNIX_EPOCH)
629            .map(|d| d.as_nanos())
630            .unwrap_or(0);
631        let path = root.join(format!(
632            ".eggup-backup-{}-{nonce}-{nanos}",
633            std::process::id()
634        ));
635        match fs::create_dir(&path) {
636            Ok(()) => {
637                #[cfg(unix)]
638                {
639                    use std::os::unix::fs::PermissionsExt;
640                    let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o700));
641                }
642                return Ok(path);
643            }
644            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
645            Err(source) => return Err(Error::io("creating backup set", source)),
646        }
647    }
648    Err(Error::invalid("could not create a unique backup directory"))
649}
650
651fn revalidate_destination(destination: &Path, root: &Path) -> Result<()> {
652    let root = fs::canonicalize(root)
653        .map_err(|source| Error::io("canonicalizing install root", source))?;
654    let mut ancestor = destination;
655    while !ancestor.exists() {
656        ancestor = ancestor
657            .parent()
658            .ok_or_else(|| Error::invalid("destination has no existing ancestor"))?;
659    }
660    let canonical_ancestor = fs::canonicalize(ancestor)
661        .map_err(|source| Error::io("canonicalizing destination ancestor", source))?;
662    if !canonical_ancestor.starts_with(&root) {
663        return Err(Error::DestinationConflict {
664            destination: destination.to_path_buf(),
665        });
666    }
667    match fs::symlink_metadata(destination) {
668        Ok(metadata) => {
669            if !metadata.is_file() {
670                return Err(Error::DestinationConflict {
671                    destination: destination.to_path_buf(),
672                });
673            }
674            #[cfg(unix)]
675            {
676                use std::os::unix::fs::MetadataExt;
677                if metadata.nlink() != 1 {
678                    return Err(Error::DestinationConflict {
679                        destination: destination.to_path_buf(),
680                    });
681                }
682            }
683        }
684        Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
685        Err(source) => return Err(Error::io("reading destination during revalidation", source)),
686    }
687    Ok(())
688}
689
690/// Requires that a live destination's parent already exists, is a real
691/// directory beneath the installation root, and contains no symlink ambiguity.
692///
693/// Unlike the pre-corrective engine, this never calls `create_dir_all` on an
694/// unchecked live path. Missing parents are an actionable error; symlink or
695/// otherwise ambiguous parents fail closed.
696fn require_ready_parent(destination: &Path, root: &Path) -> Result<()> {
697    let parent = destination
698        .parent()
699        .ok_or_else(|| Error::invalid("destination has no parent"))?;
700    let parent_meta = fs::symlink_metadata(parent).map_err(|source| {
701        if source.kind() == std::io::ErrorKind::NotFound {
702            Error::invalid(format!(
703                "destination parent does not exist: {}",
704                parent.display()
705            ))
706        } else {
707            Error::io("reading destination parent", source)
708        }
709    })?;
710    if parent_meta.file_type().is_symlink() {
711        return Err(Error::DestinationConflict {
712            destination: destination.to_path_buf(),
713        });
714    }
715    if !parent_meta.is_dir() {
716        return Err(Error::DestinationConflict {
717            destination: destination.to_path_buf(),
718        });
719    }
720    let canonical_root = fs::canonicalize(root)
721        .map_err(|source| Error::io("canonicalizing install root", source))?;
722    let canonical_parent = fs::canonicalize(parent)
723        .map_err(|source| Error::io("canonicalizing destination parent", source))?;
724    if !canonical_parent.starts_with(&canonical_root) {
725        return Err(Error::DestinationConflict {
726            destination: destination.to_path_buf(),
727        });
728    }
729    // Walk ancestors of the parent to reject any symlink component, even when
730    // the canonical target remains beneath the root.
731    let mut cursor = Some(parent);
732    while let Some(p) = cursor {
733        if p == root {
734            break;
735        }
736        let meta = fs::symlink_metadata(p)
737            .map_err(|source| Error::io("reading destination ancestor", source))?;
738        if meta.file_type().is_symlink() {
739            return Err(Error::DestinationConflict {
740                destination: destination.to_path_buf(),
741            });
742        }
743        cursor = p.parent();
744        if let Some(next) = cursor {
745            if next.as_os_str().is_empty() {
746                break;
747            }
748        } else {
749            break;
750        }
751    }
752    Ok(())
753}
754
755fn restore_entries(
756    entries: &[BackupEntry],
757    committed: &HashSet<MemberId>,
758    fault: Option<CommitFault>,
759) -> (bool, Option<FailureReport>) {
760    let mut verified = true;
761    let mut rollback_failure: Option<FailureReport> = None;
762    for entry in entries.iter().rev() {
763        let rollback_target = match fault.as_ref() {
764            Some(CommitFault::CommitThenRollback(_, id)) => Some(id),
765            _ => None,
766        };
767        if rollback_target == Some(&entry.member) {
768            verified = false;
769            rollback_failure = Some(FailureReport::new(
770                FailurePhase::Rollback,
771                Some(entry.member.clone()),
772                FailureCategory::Injected,
773                format!("injected rollback failure at {}", entry.member),
774            ));
775            continue;
776        }
777        if entry.backup.is_some() || committed.contains(&entry.member) {
778            if let Ok(metadata) = fs::symlink_metadata(&entry.destination) {
779                if !metadata.is_file() || fs::remove_file(&entry.destination).is_err() {
780                    verified = false;
781                    if rollback_failure.is_none() {
782                        rollback_failure = Some(FailureReport::new(
783                            FailurePhase::Rollback,
784                            Some(entry.member.clone()),
785                            FailureCategory::Filesystem,
786                            format!("could not remove partial member {}", entry.member),
787                        ));
788                    }
789                    continue;
790                }
791            }
792        }
793        if let Some(backup) = &entry.backup {
794            if fs::rename(backup, &entry.destination).is_err() {
795                verified = false;
796                if rollback_failure.is_none() {
797                    rollback_failure = Some(FailureReport::new(
798                        FailurePhase::Rollback,
799                        Some(entry.member.clone()),
800                        FailureCategory::Filesystem,
801                        format!("could not restore backup for {}", entry.member),
802                    ));
803                }
804            }
805        }
806    }
807    for entry in entries {
808        let restored = match &entry.backup {
809            Some(_) => fs::symlink_metadata(&entry.destination).is_ok(),
810            None => {
811                !committed.contains(&entry.member)
812                    || fs::symlink_metadata(&entry.destination).is_err()
813            }
814        };
815        if !restored {
816            verified = false;
817        }
818    }
819    (verified, rollback_failure)
820}
821
822fn finish_failure_no_mutation(
823    transaction: PreparedTransaction,
824    lock: MutationLock,
825    nothing_mutated: bool,
826    report: FailureReport,
827) -> Result<TransactionReceipt> {
828    debug_assert!(nothing_mutated);
829    drop(lock);
830    Ok(TransactionReceipt {
831        product: transaction.plan.product().clone(),
832        release: transaction.plan.release().clone(),
833        disposition: TransactionDisposition::RolledBack,
834        rollback_performed: false,
835        rollback_verified: true,
836        cleanup: CleanupDisposition::Cleaned,
837        recovery_path: None,
838        failure: Some(report),
839        rollback_failure: None,
840    })
841}
842
843fn finish_failure(
844    transaction: PreparedTransaction,
845    mut lock: MutationLock,
846    backup_root: PathBuf,
847    rollback_verified: bool,
848    rollback_failure: Option<FailureReport>,
849    report: FailureReport,
850) -> Result<TransactionReceipt> {
851    if rollback_verified && rollback_failure.is_none() && fs::remove_dir_all(&backup_root).is_ok() {
852        return Ok(TransactionReceipt {
853            product: transaction.plan.product().clone(),
854            release: transaction.plan.release().clone(),
855            disposition: TransactionDisposition::RolledBack,
856            rollback_performed: true,
857            rollback_verified: true,
858            cleanup: CleanupDisposition::Cleaned,
859            recovery_path: None,
860            failure: Some(report),
861            rollback_failure: None,
862        });
863    }
864    lock.preserve();
865    Ok(TransactionReceipt {
866        product: transaction.plan.product().clone(),
867        release: transaction.plan.release().clone(),
868        disposition: TransactionDisposition::RecoveryRequired,
869        rollback_performed: true,
870        rollback_verified: false,
871        cleanup: CleanupDisposition::RetainedForRecovery,
872        recovery_path: Some(backup_root),
873        failure: Some(report),
874        rollback_failure,
875    })
876}