Skip to main content

receipts/
receipts.rs

1//! Interpreting terminal receipts.
2//!
3//! A rolled-back or recovery-required result is never equivalent to success.
4//! This example shows the three dispositions and how to read the structured
5//! failure report. Crash durability beyond process-level rollback is not
6//! claimed; stale locks require manual operator action. Run with
7//! `cargo run -p eggup-core --example receipts`.
8
9use eggup_core::{
10    AbsentPolicy, AllValidators, ArtifactMember, ArtifactSet, CommitOwnership,
11    ExistingAsOwnedVerifier, InstallPlan, IntegrityRequirement, MemberId, Ownership,
12    OwnershipVerifier, PostCommitFailurePolicy, ProductId, ReleaseId, TransactionDisposition,
13};
14use std::fmt::Debug;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19#[derive(Debug)]
20struct DenyAll;
21impl OwnershipVerifier for DenyAll {
22    fn verify(&self, _member: &MemberId, _destination: &Path) -> Ownership {
23        Ownership::Foreign
24    }
25}
26
27fn temp_root(prefix: &str) -> PathBuf {
28    let nanos = SystemTime::now()
29        .duration_since(UNIX_EPOCH)
30        .map(|d| d.as_nanos())
31        .unwrap_or(0);
32    std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
33}
34
35fn build(
36    new: &[u8],
37    old: Option<&[u8]>,
38) -> Result<(PathBuf, InstallPlan), Box<dyn std::error::Error>> {
39    let base = temp_root("eggup-receipts");
40    let inputs = base.join("inputs");
41    let root = base.join("install");
42    fs::create_dir_all(&inputs)?;
43    fs::create_dir_all(root.join("bin"))?;
44    fs::write(inputs.join("app"), new)?;
45    if let Some(old) = old {
46        fs::write(root.join("bin/app"), old)?;
47    }
48    let digest = eggup_core::hash_file(&inputs.join("app"))?;
49    let member = ArtifactMember::new(MemberId::new("main")?, inputs.join("app"), "bin/app")?
50        .with_integrity(IntegrityRequirement::Sha256(digest));
51    let plan = InstallPlan::new(
52        ProductId::new("demo")?,
53        ReleaseId::new("5.0.0")?,
54        &root,
55        ArtifactSet::single(member)?,
56    )?;
57    // Leak the base via the plan's root; caller cleans `root.parent()`.
58    Ok((base, plan))
59}
60
61fn main() -> Result<(), Box<dyn std::error::Error>> {
62    // 1. Committed: success.
63    let (base, plan) = build(b"new", Some(b"old"))?;
64    let receipt = plan
65        .prepare()?
66        .verify_integrity()?
67        .validate(&AllValidators::new())?
68        .commit(CommitOwnership::new(
69            &ExistingAsOwnedVerifier,
70            AbsentPolicy::AllowCreate,
71        ))?;
72    assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
73    assert!(receipt.failure().is_none());
74    println!("committed: {:?}", receipt.disposition());
75    let _ = fs::remove_dir_all(&base);
76
77    // 2. RolledBack: ownership denied, live state unchanged, failure explains why.
78    let (base, plan) = build(b"new", Some(b"old"))?;
79    let root = base.join("install");
80    let receipt = plan
81        .prepare()?
82        .verify_integrity()?
83        .validate(&AllValidators::new())?
84        .commit(CommitOwnership::new(&DenyAll, AbsentPolicy::AllowCreate))?;
85    assert_eq!(receipt.disposition(), TransactionDisposition::RolledBack);
86    assert!(receipt.rollback_verified());
87    let failure = receipt.failure().expect("rolled back carries a cause");
88    println!(
89        "rolled back: phase={} category={} detail={}",
90        failure.phase().as_str(),
91        failure.category().as_str(),
92        failure.detail()
93    );
94    assert_eq!(fs::read(root.join("bin/app"))?, b"old");
95    assert_ne!(
96        receipt.disposition(),
97        TransactionDisposition::Committed,
98        "rollback is not success"
99    );
100    let _ = fs::remove_dir_all(&base);
101
102    // 3. RecoveryRequired is distinct and high severity: it means Eggup cannot
103    // prove either generation is coherent. It is produced by rollback failure
104    // or unclean finalization; operators must inspect `recovery_path` and the
105    // preserved lock. See `docs/transaction.md` for the full contract.
106    println!("recovery-required: inspect recovery_path + lock; do not retry blindly");
107
108    // 4. A failed post-commit check can keep the coherent new generation,
109    // while remaining machine-readable as a failed check.
110    let (base, plan) = build(b"new", Some(b"old"))?;
111    let receipt = plan
112        .prepare()?
113        .verify_integrity()?
114        .validate(&AllValidators::new())?
115        .commit_with_post_commit(
116            CommitOwnership::new(&ExistingAsOwnedVerifier, AbsentPolicy::AllowCreate),
117            PostCommitFailurePolicy::KeepInstalled,
118            || Err("example health check failed"),
119        )?;
120    assert_eq!(receipt.disposition(), TransactionDisposition::Committed);
121    assert!(receipt.post_commit_failure().is_some());
122    println!(
123        "kept installed after failed check: {:?}",
124        receipt.disposition()
125    );
126    let _ = fs::remove_dir_all(base);
127    Ok(())
128}