Skip to main content

varve_core/
reverify.rs

1//! Re-verification of an installed layer (REQ-VERIFY-001) — `varve verify`.
2//!
3//! The install-time verdict, repeatable forever after: the retained envelope
4//! must verify against the trust root, its payload must be byte-identical to
5//! the layer.json in the core, and every tool binary must match its digest in
6//! the signed manifest. Corruption, tampering, and bit-rot all surface as the
7//! same loud failure — and "I cannot check" (no envelope retained) is its own
8//! distinct verdict, never silently treated as success.
9
10use crate::install::{ManifestVerifier, VerifyError};
11use crate::manifest::{LayerManifest, ManifestError};
12use crate::store::{InstalledLayer, Store, StoreError, manifest_digest};
13
14/// The file the install pipeline retains alongside `layer.json` so the
15/// signature verdict stays reproducible offline.
16pub const ENVELOPE_FILE: &str = "layer.dsse.json";
17
18#[derive(Debug, thiserror::Error)]
19pub enum ReverifyError {
20    #[error(
21        "layer {digest} has no retained signature envelope ({ENVELOPE_FILE}) — cannot re-verify \
22         its signature; reinstall from a signed source"
23    )]
24    NoEnvelope { digest: String },
25    #[error(transparent)]
26    Verify(#[from] VerifyError),
27    #[error(
28        "retained envelope verifies, but its payload does not match layer.json — the core entry \
29         was modified after install"
30    )]
31    PayloadMismatch,
32    #[error(transparent)]
33    Manifest(#[from] ManifestError),
34    #[error("payload '{tool}' is missing from the installed layer")]
35    MissingTool { tool: String },
36    #[error("payload '{tool}' does not match its signed digest {digest} — its bytes were altered")]
37    ToolDigestMismatch { tool: String, digest: String },
38    #[error(transparent)]
39    Store(#[from] StoreError),
40    #[error("io error at {path}: {source}")]
41    Io {
42        path: String,
43        #[source]
44        source: std::io::Error,
45    },
46}
47
48/// How to name a payload in a verdict. A layer may hold several versions of one
49/// name, so the bare name no longer identifies WHICH payload failed — and a
50/// verification tool that cannot say which artifact is wrong has not reported
51/// the fault (REQ-STORE-002 clause 4).
52fn named(entry: &crate::manifest::ManifestEntry, name: &str) -> String {
53    match crate::store::entry_version(entry) {
54        Some(version) => format!("{name}@{version}"),
55        None => name.to_string(),
56    }
57}
58
59/// Re-verify one installed layer against the trust root. Returns the number
60/// of tool binaries checked.
61pub fn verify_installed(
62    store: &Store,
63    layer: &InstalledLayer,
64    verifier: &dyn ManifestVerifier,
65    platform: &str,
66) -> Result<usize, ReverifyError> {
67    let io = |path: &std::path::Path, source: std::io::Error| ReverifyError::Io {
68        path: path.display().to_string(),
69        source,
70    };
71
72    // 1. The retained envelope must exist and verify against the trust root.
73    let envelope_path = layer.root.join(ENVELOPE_FILE);
74    let envelope = match std::fs::read(&envelope_path) {
75        Ok(bytes) => bytes,
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
77            return Err(ReverifyError::NoEnvelope {
78                digest: layer.digest.clone(),
79            });
80        }
81        Err(e) => return Err(io(&envelope_path, e)),
82    };
83    let payload = verifier.verify(&envelope)?;
84
85    // 2. The verified payload must be byte-identical to the stored manifest.
86    let manifest_path = layer.root.join("layer.json");
87    let stored = std::fs::read(&manifest_path).map_err(|e| io(&manifest_path, e))?;
88    if payload != stored {
89        return Err(ReverifyError::PayloadMismatch);
90    }
91
92    // 3. Every tool the signed manifest names must be present and unaltered.
93    let manifest = LayerManifest::parse(&payload)?;
94    let mut checked = 0;
95    for entry in &manifest.entries {
96        if !crate::platform::entry_matches(
97            entry
98                .annotations
99                .get(crate::platform::ANN_PLATFORM)
100                .map(String::as_str),
101            platform,
102        ) {
103            continue;
104        }
105        // A composed layer is a REFERENCE to another layer's manifest, not a
106        // blob laid down here; install skips it and so must re-verification.
107        if entry.kind() == Ok(crate::kind::PayloadKind::Layer) {
108            continue;
109        }
110        let Some(tool) = entry.annotations.get("eu.pulseengine.tool") else {
111            continue;
112        };
113        // Locate by ENTRY, not by name: several versions of one name coexist,
114        // and each must be checked against its own signed digest
115        // (REQ-STORE-002 clause 4).
116        let Some(path) = store.entry_path(layer, entry) else {
117            return Err(ReverifyError::MissingTool {
118                tool: named(entry, tool),
119            });
120        };
121        let bytes = std::fs::read(&path).map_err(|e| io(&path, e))?;
122        if manifest_digest(&bytes) != entry.digest {
123            return Err(ReverifyError::ToolDigestMismatch {
124                tool: named(entry, tool),
125                digest: entry.digest.clone(),
126            });
127        }
128        checked += 1;
129    }
130    Ok(checked)
131}
132
133#[cfg(test)]
134mod tests {
135    /// True when mode 000 does not actually deny a read here (running as root,
136    /// or a filesystem that ignores permission bits) — so the unreadable-file
137    /// tests cannot hold their premise and must skip rather than fail.
138    #[cfg(unix)]
139    fn premise_unavailable() -> bool {
140        use std::os::unix::fs::PermissionsExt;
141        let Ok(dir) = tempfile::tempdir() else {
142            return true;
143        };
144        let probe = dir.path().join("probe");
145        if std::fs::write(&probe, b"x").is_err() {
146            return true;
147        }
148        if std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o000)).is_err() {
149            return true;
150        }
151        let readable = std::fs::read(&probe).is_ok();
152        let _ = std::fs::set_permissions(&probe, std::fs::Permissions::from_mode(0o644));
153        readable
154    }
155
156    use super::*;
157    use crate::install::{InstallPolicy, install};
158    use crate::manifest::fixtures::manifest_with_tools;
159    use crate::pin::Pin;
160    use crate::rollback::HighWaterMarks;
161    use crate::source::MemorySource;
162    use crate::verify::{PinnedKeyVerifier, generate_root_keypair, sign_layer_manifest};
163
164    struct Installed {
165        _tmp: tempfile::TempDir,
166        store: Store,
167        layer: InstalledLayer,
168        verifier: PinnedKeyVerifier,
169    }
170
171    fn installed_layer() -> Installed {
172        let (sk, pk) = generate_root_keypair();
173        let synth = b"synth-bytes".to_vec();
174        let blob_digest = manifest_digest(&synth);
175        let payload = manifest_with_tools(
176            "2026.07.0",
177            "qualified",
178            1,
179            "2026-07-31T09:14:00Z",
180            &[("synth", &blob_digest)],
181        );
182        let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
183        let source = MemorySource::new()
184            .with_manifest(envelope.as_bytes())
185            .with_blob(&blob_digest, &synth);
186        let pin = Pin::parse(
187            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
188            "varve.toml",
189        )
190        .unwrap();
191        let tmp = tempfile::tempdir().unwrap();
192        let root = tmp.path().join("root");
193        let store = Store::at(&root);
194        let mut marks = HighWaterMarks::load(&root).unwrap();
195        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
196        let policy = InstallPolicy {
197            index: None,
198            now: "2026-08-07T00:00:00Z",
199            staleness_threshold_days: 90,
200            platform: "test-platform",
201        };
202        let outcome = install(&pin, &source, &verifier, &store, &mut marks, &policy).unwrap();
203        let layer = store.get(&outcome.digest).unwrap().unwrap();
204        Installed {
205            _tmp: tmp,
206            store,
207            layer,
208            verifier,
209        }
210    }
211
212    // rivet: verifies REQ-VERIFY-001
213    #[test]
214    fn a_freshly_installed_layer_reverifies() {
215        let ctx = installed_layer();
216        let checked =
217            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap();
218        assert_eq!(checked, 1);
219    }
220
221    /// A signed layer holding TWO versions of one crate, installed.
222    fn installed_two_versions() -> (Installed, Vec<u8>, Vec<u8>) {
223        use crate::manifest::fixtures::manifest_with_payloads;
224        let (sk, pk) = generate_root_keypair();
225        let a = b"serde-1.0.200-crate".to_vec();
226        let b = b"serde-1.0.210-crate".to_vec();
227        let (da, db) = (manifest_digest(&a), manifest_digest(&b));
228        let payload = manifest_with_payloads(
229            "2026.07.0",
230            "qualified",
231            1,
232            "2026-07-31T09:14:00Z",
233            &[
234                ("serde", "1.0.200", "crate", &da),
235                ("serde", "1.0.210", "crate", &db),
236            ],
237        );
238        let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
239        let source = MemorySource::new()
240            .with_manifest(envelope.as_bytes())
241            .with_blob(&da, &a)
242            .with_blob(&db, &b);
243        let pin = Pin::parse(
244            "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"2026.07.0\"\n",
245            "varve.toml",
246        )
247        .unwrap();
248        let tmp = tempfile::tempdir().unwrap();
249        let root = tmp.path().join("root");
250        let store = Store::at(&root);
251        let mut marks = HighWaterMarks::load(&root).unwrap();
252        let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
253        let policy = InstallPolicy {
254            index: None,
255            now: "2026-08-07T00:00:00Z",
256            staleness_threshold_days: 90,
257            platform: "test-platform",
258        };
259        let outcome = install(&pin, &source, &verifier, &store, &mut marks, &policy).unwrap();
260        let layer = store.get(&outcome.digest).unwrap().unwrap();
261        (
262            Installed {
263                _tmp: tmp,
264                store,
265                layer,
266                verifier,
267            },
268            a,
269            b,
270        )
271    }
272
273    // rivet: verifies REQ-STORE-002
274    #[test]
275    fn each_version_of_one_name_is_checked_against_its_own_signed_digest() {
276        // Clause 4, verification half. `verify_installed` looked payloads up by
277        // NAME, so with two versions present it would have hashed one file
278        // twice — passing the entry whose bytes happened to land and failing
279        // the other, with a verdict that named only "serde" and could not say
280        // which. Both are checked here, and tampering with EITHER is caught and
281        // named with its version.
282        let (ctx, a, b) = installed_two_versions();
283        assert_eq!(
284            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap(),
285            2,
286            "both versions must be checked, not one file twice"
287        );
288        // The bytes on disk really are each version's own.
289        assert_eq!(
290            std::fs::read(ctx.layer.root.join("payloads/serde/1.0.200")).unwrap(),
291            a
292        );
293        assert_eq!(
294            std::fs::read(ctx.layer.root.join("payloads/serde/1.0.210")).unwrap(),
295            b
296        );
297
298        for (version, path) in [
299            ("1.0.200", "payloads/serde/1.0.200"),
300            ("1.0.210", "payloads/serde/1.0.210"),
301        ] {
302            let (ctx, ..) = installed_two_versions();
303            std::fs::write(ctx.layer.root.join(path), b"EVIL").unwrap();
304            let err = verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform")
305                .unwrap_err();
306            assert!(
307                matches!(&err, ReverifyError::ToolDigestMismatch { tool, .. }
308                    if tool == &format!("serde@{version}")),
309                "tampering with {version} must be caught AND named: {err}"
310            );
311        }
312
313        // A payload that is simply gone is named with its version too — with
314        // two versions present, "serde is missing" would not say which.
315        let (ctx, ..) = installed_two_versions();
316        std::fs::remove_file(ctx.layer.root.join("payloads/serde/1.0.210")).unwrap();
317        let err =
318            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap_err();
319        assert!(
320            matches!(&err, ReverifyError::MissingTool { tool } if tool == "serde@1.0.210"),
321            "got: {err}"
322        );
323    }
324
325    // rivet: verifies REQ-VERIFY-001
326    #[test]
327    fn install_retains_the_envelope_for_offline_reverification() {
328        let ctx = installed_layer();
329        assert!(
330            ctx.layer.root.join(ENVELOPE_FILE).is_file(),
331            "install must retain the signature envelope"
332        );
333    }
334
335    // rivet: verifies REQ-VERIFY-001
336    #[test]
337    fn an_altered_tool_binary_is_detected() {
338        let ctx = installed_layer();
339        std::fs::write(ctx.layer.root.join("bin/synth"), b"EVIL").unwrap();
340        let err =
341            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap_err();
342        assert!(
343            matches!(err, ReverifyError::ToolDigestMismatch { ref tool, .. } if tool == "synth"),
344            "got: {err}"
345        );
346    }
347
348    // rivet: verifies REQ-VERIFY-001
349    #[test]
350    fn an_altered_manifest_payload_is_detected() {
351        let ctx = installed_layer();
352        let path = ctx.layer.root.join("layer.json");
353        let mut bytes = std::fs::read(&path).unwrap();
354        bytes.extend_from_slice(b" ");
355        std::fs::write(&path, bytes).unwrap();
356        let err =
357            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap_err();
358        assert!(matches!(err, ReverifyError::PayloadMismatch), "got: {err}");
359    }
360
361    // rivet: verifies REQ-PROOF-001
362    #[cfg(unix)]
363    #[test]
364    fn an_unreadable_envelope_is_an_io_error_not_a_missing_one() {
365        // An envelope that EXISTS but cannot be read is not a layer installed
366        // without one. Collapsing the two would report "no retained envelope"
367        // for what is really a permissions fault — a verification tool must
368        // name the fault it actually hit. (Found by cargo-mutants: the
369        // NotFound guard survived being replaced with `true`.)
370        // chmod(000) does not stop root, and some filesystems ignore modes, so
371        // this case cannot always be exercised. Test the PREMISE directly
372        // rather than guessing at uid: if an unreadable file is still readable
373        // here, skip. A test that cannot hold its premise should say so, not go
374        // red for the wrong reason.
375        if premise_unavailable() {
376            eprintln!("skipping: this environment does not deny reads on mode 000");
377            return;
378        }
379        use std::os::unix::fs::PermissionsExt;
380        let ctx = installed_layer();
381        let path = ctx.layer.root.join(ENVELOPE_FILE);
382        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
383        let err =
384            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap_err();
385        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644));
386        assert!(
387            matches!(err, ReverifyError::Io { .. }),
388            "an unreadable envelope must be an Io error, got: {err}"
389        );
390    }
391
392    // rivet: verifies REQ-VERIFY-001
393    #[test]
394    fn a_missing_envelope_is_its_own_loud_verdict() {
395        let ctx = installed_layer();
396        std::fs::remove_file(ctx.layer.root.join(ENVELOPE_FILE)).unwrap();
397        let err =
398            verify_installed(&ctx.store, &ctx.layer, &ctx.verifier, "test-platform").unwrap_err();
399        assert!(
400            matches!(err, ReverifyError::NoEnvelope { .. }),
401            "got: {err}"
402        );
403    }
404}