Skip to main content

varve_core/
exportstamp.rs

1//! Export provenance stamp (REQ-EXPORT-SYNC-001).
2//!
3//! An export adapter materialises offline byte sources (a Cargo local registry,
4//! a cargo-vendor tree, a Bazel distdir) from a verified layer. Left unmarked,
5//! a committed export goes silently stale the moment the project's pin moves to
6//! a new layer — it keeps serving the old crates. To make that loud, every
7//! export writes a `.varve-export.json` stamp binding its bytes to the layer
8//! that produced them: `{layer, manifest_digest, kind}`, where `manifest_digest`
9//! is the sha256 of the DSSE-signed layer manifest — the same join key the rest
10//! of varve uses. `varve verify --export <DIR>` re-derives the current pin's
11//! manifest digest and fails when a stamped export diverges from it.
12
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17/// The stamp file written into an export directory's root.
18pub const STAMP_FILE: &str = ".varve-export.json";
19
20/// The recorded provenance of an export: which layer produced it, that layer's
21/// signed-manifest digest (the join key), and which export shape it is.
22#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Clone)]
23pub struct ExportStamp {
24    /// The layer identity, e.g. `2026.08.0`.
25    pub layer: String,
26    /// `sha256:<hex>` of the DSSE-signed layer manifest.
27    pub manifest_digest: String,
28    /// The export shape: `cargo` | `crates-vendor` | `bazel-distdir`.
29    pub kind: String,
30}
31
32/// Why an export stamp could not be read or trusted.
33#[derive(Debug, thiserror::Error)]
34pub enum ExportStampError {
35    #[error(
36        "no export stamp ({STAMP_FILE}) in {0} — not a varve export \
37         (or produced before stamping); re-run the export"
38    )]
39    Missing(String),
40    #[error("export stamp in {0} is malformed: {1}")]
41    Malformed(String, String),
42    #[error("i/o error on export stamp in {0}: {1}")]
43    Io(String, String),
44}
45
46/// The drift verdict for a stamped export against the current pin.
47#[derive(Debug, PartialEq, Eq)]
48pub enum ExportStatus {
49    /// The stamp's manifest digest matches the current pin — export is fresh.
50    Current,
51    /// The pin has moved: the export was produced from a different layer.
52    Stale { stamped: String, current: String },
53}
54
55/// Write the stamp into `dir`, creating `dir` if needed.
56pub fn write_stamp(dir: &Path, stamp: &ExportStamp) -> Result<(), ExportStampError> {
57    let path = dir.join(STAMP_FILE);
58    std::fs::create_dir_all(dir)
59        .map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))?;
60    let json = serde_json::to_string_pretty(stamp)
61        .map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))?;
62    std::fs::write(&path, json)
63        .map_err(|e| ExportStampError::Io(dir.display().to_string(), e.to_string()))
64}
65
66/// Read and parse the stamp from `dir`. A missing file is `Missing`; unparseable
67/// JSON is `Malformed` — both are failures for a directory claimed to be an export.
68pub fn read_stamp(dir: &Path) -> Result<ExportStamp, ExportStampError> {
69    let path = dir.join(STAMP_FILE);
70    let bytes = match std::fs::read(&path) {
71        Ok(b) => b,
72        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
73            return Err(ExportStampError::Missing(dir.display().to_string()));
74        }
75        Err(e) => {
76            return Err(ExportStampError::Io(
77                dir.display().to_string(),
78                e.to_string(),
79            ));
80        }
81    };
82    serde_json::from_slice(&bytes)
83        .map_err(|e| ExportStampError::Malformed(dir.display().to_string(), e.to_string()))
84}
85
86/// Compare a stamp against the current pin's manifest digest.
87pub fn status(stamp: &ExportStamp, current_manifest_digest: &str) -> ExportStatus {
88    if stamp.manifest_digest == current_manifest_digest {
89        ExportStatus::Current
90    } else {
91        ExportStatus::Stale {
92            stamped: stamp.manifest_digest.clone(),
93            current: current_manifest_digest.to_string(),
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    /// True when mode 000 does not actually deny a read here (running as root,
101    /// or a filesystem that ignores permission bits) — so the unreadable-file
102    /// tests cannot hold their premise and must skip rather than fail.
103    #[cfg(unix)]
104    fn premise_unavailable() -> bool {
105        use std::os::unix::fs::PermissionsExt;
106        let Ok(dir) = tempfile::tempdir() else {
107            return true;
108        };
109        let probe = dir.path().join("probe");
110        if std::fs::write(&probe, b"x").is_err() {
111            return true;
112        }
113        if std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).is_err() {
114            return true;
115        }
116        let readable = std::fs::read(&probe).is_ok();
117        let _ = std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o644));
118        readable
119    }
120
121    use super::*;
122
123    fn sample() -> ExportStamp {
124        ExportStamp {
125            layer: "2026.08.0".into(),
126            manifest_digest: "sha256:aaaa".into(),
127            kind: "cargo".into(),
128        }
129    }
130
131    // rivet: verifies REQ-EXPORT-SYNC-001
132    #[test]
133    fn write_then_read_round_trips() {
134        let dir = tempfile::tempdir().unwrap();
135        let s = sample();
136        write_stamp(dir.path(), &s).unwrap();
137        assert!(dir.path().join(STAMP_FILE).exists());
138        assert_eq!(read_stamp(dir.path()).unwrap(), s);
139    }
140
141    // rivet: verifies REQ-EXPORT-SYNC-001
142    #[test]
143    fn read_missing_stamp_is_missing_error() {
144        let dir = tempfile::tempdir().unwrap();
145        match read_stamp(dir.path()) {
146            Err(ExportStampError::Missing(_)) => {}
147            other => panic!("expected Missing, got {other:?}"),
148        }
149    }
150
151    // rivet: verifies REQ-EXPORT-SYNC-001
152    #[cfg(unix)]
153    #[test]
154    fn an_unreadable_stamp_is_an_io_error_not_a_missing_one() {
155        // A stamp that EXISTS but cannot be read is not the same as no stamp.
156        // Reporting it as Missing would tell the user "re-run the export" when
157        // the real fault is permissions — advice that cannot work. (Found by
158        // cargo-mutants: the NotFound guard survived being replaced with true.)
159        // chmod(000) does not stop root, and some filesystems ignore modes, so
160        // this case cannot always be exercised. Test the PREMISE directly
161        // rather than guessing at uid: if an unreadable file is still readable
162        // here, skip. A test that cannot hold its premise should say so, not go
163        // red for the wrong reason.
164        if premise_unavailable() {
165            eprintln!("skipping: this environment does not deny reads on mode 000");
166            return;
167        }
168        use std::os::unix::fs::PermissionsExt;
169        let dir = tempfile::tempdir().unwrap();
170        let path = dir.path().join(STAMP_FILE);
171        std::fs::write(&path, b"{}").unwrap();
172        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
173        let got = read_stamp(dir.path());
174        // Restore before asserting, so a failure cannot leave an unremovable dir.
175        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
176        match got {
177            Err(ExportStampError::Io(..)) => {}
178            other => panic!("expected Io for an unreadable stamp, got {other:?}"),
179        }
180    }
181
182    // rivet: verifies REQ-EXPORT-SYNC-001
183    #[test]
184    fn read_malformed_stamp_is_malformed_error() {
185        let dir = tempfile::tempdir().unwrap();
186        std::fs::write(dir.path().join(STAMP_FILE), b"{not json").unwrap();
187        match read_stamp(dir.path()) {
188            Err(ExportStampError::Malformed(..)) => {}
189            other => panic!("expected Malformed, got {other:?}"),
190        }
191    }
192
193    // rivet: verifies REQ-EXPORT-SYNC-001
194    #[test]
195    fn status_is_current_when_digests_match() {
196        assert_eq!(status(&sample(), "sha256:aaaa"), ExportStatus::Current);
197    }
198
199    // rivet: verifies REQ-EXPORT-SYNC-001
200    #[test]
201    fn status_is_stale_when_digests_differ() {
202        assert_eq!(
203            status(&sample(), "sha256:bbbb"),
204            ExportStatus::Stale {
205                stamped: "sha256:aaaa".into(),
206                current: "sha256:bbbb".into(),
207            }
208        );
209    }
210}