Skip to main content

varve_core/
realm.rs

1//! Realms (REQ-REALM-001) — the pin names its trust universe.
2//!
3//! A machine can serve several *independent* toolchain universes — different
4//! organizations, different trust roots, different registries — in parallel.
5//! A realm binds a name to (registry, trust root); the pin references the
6//! name; a committed `varve-realms.toml` (discovered by the same walk-up as
7//! the pin, so trust travels with the code) carries the definitions.
8//!
9//! Isolation is by construction, not convention: every piece of per-realm
10//! state lives under an effective root namespaced by the TRUST-ROOT
11//! FINGERPRINT — two realms cannot cross-talk even with identical layer
12//! names and counters, and a realm's layers can only ever verify against
13//! that realm's root. When a pin names a realm, the realm is authoritative:
14//! the ambient environment cannot substitute a different trust root.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21/// The realms file name, discovered by walking up from the working
22/// directory (it may sit beside the pin or above it).
23pub const REALMS_FILE: &str = "varve-realms.toml";
24
25/// A resolved realm: everything needed to fetch and verify its layers.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Realm {
28    pub name: String,
29    /// The realm's primary source. Kept as the first element of `sources` too,
30    /// so existing callers that read `registry` keep working unchanged
31    /// (REQ-MIRROR-001 clause 5).
32    pub registry: String,
33    /// Every source, in the realm's stated order of preference, primary first.
34    ///
35    /// Ordered, not raced: an operator must be able to predict which source
36    /// served them, and a run that picked differently each time would make an
37    /// incident unreproducible.
38    pub sources: Vec<String>,
39    /// Raw ed25519 root public key bytes.
40    pub trust_root: Vec<u8>,
41    /// The realm asserts that it publishes a signed line index
42    /// (REQ-INDEXAUTH-001 clause 5). Where true, a missing index is an ERROR
43    /// rather than a silent fall back to the registry's unauthenticated
44    /// listing — otherwise an attacker need only delete the index to disable
45    /// the check. Defaults to false so every existing realm keeps working:
46    /// failing closed by default would break all of them at once.
47    pub signed_index: bool,
48    /// Roots this realm has RETIRED (REQ-ROTATE-002).
49    ///
50    /// Documentation, never authority. Nothing verifies against these — they
51    /// exist so that a signature failure can be EXPLAINED rather than merely
52    /// reported, because nothing otherwise distinguishes "signed by a root
53    /// this realm retired last week" from "signed by a stranger", and the
54    /// consumer cannot deduce which.
55    ///
56    /// This is not a rotation mechanism and must not be read as one. varve
57    /// still has no succession: nothing signs "this new root replaces the old
58    /// one", and no consumer would check such a statement. See
59    /// `varve docs threat-model`.
60    pub retired_roots: Vec<RetiredRoot>,
61}
62
63/// A root a realm used to sign with and has since retired (REQ-ROTATE-002).
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct RetiredRoot {
66    /// Raw ed25519 public key bytes of the retired root. Held so a failing
67    /// signature can be ATTRIBUTED to it; never handed to a verifier.
68    pub key: Vec<u8>,
69    /// The date it was retired, as the realm states it.
70    pub retired: String,
71    /// The last layer it signed, when the realm says. Turns "your pin does
72    /// not verify" into "layers up to this one used the old root".
73    pub last_layer: Option<String>,
74}
75
76impl RetiredRoot {
77    /// The key as the realms file writes it — for messages, so a human can
78    /// match it against what they have.
79    pub fn hex(&self) -> String {
80        self.key.iter().map(|b| format!("{b:02x}")).collect()
81    }
82
83    /// The store partition this root's layers were installed under — computed
84    /// exactly as `Realm::fingerprint`, because it must name the SAME
85    /// directory a consumer already has on disk.
86    pub fn fingerprint(&self) -> String {
87        crate::store::manifest_digest(&self.key)
88            .strip_prefix("sha256:")
89            .expect("digest shape")[..16]
90            .to_string()
91    }
92}
93
94impl Realm {
95    /// Short fingerprint of the trust root — the store namespace. Sixteen
96    /// hex chars of sha256(pubkey): collision-safe for a namespace while
97    /// staying readable in paths.
98    pub fn fingerprint(&self) -> String {
99        crate::store::manifest_digest(&self.trust_root)
100            .strip_prefix("sha256:")
101            .expect("digest shape")[..16]
102            .to_string()
103    }
104
105    /// A human label for a store-partition fingerprint, if this realm
106    /// explains it (REQ-ROTATE-002 clause 5).
107    ///
108    /// `Some(name)` for the live partition. For one a retired root left
109    /// behind, the name plus when it was retired — otherwise `varve list`
110    /// shows a bare hex string and the consumer's first symptom, their tools
111    /// apparently vanishing, has no stated cause. `None` when this realm has
112    /// nothing to say about the fingerprint, so an unrelated partition is
113    /// never misattributed to it.
114    pub fn partition_label(&self, fingerprint: &str) -> Option<String> {
115        if self.fingerprint() == fingerprint {
116            return Some(self.name.clone());
117        }
118        self.retired_roots
119            .iter()
120            .find(|r| r.fingerprint() == fingerprint)
121            .map(|r| {
122                format!(
123                    "{} (retired root, {} — layers here do not verify against the realm's \
124                     current root)",
125                    self.name, r.retired
126                )
127            })
128    }
129
130    /// If `envelope` verifies against a root this realm has RETIRED, an
131    /// explanation naming it (REQ-ROTATE-002 clause 3). `None` otherwise.
132    ///
133    /// This NEVER changes a verdict. The caller has already decided the
134    /// signature does not verify against the live root and is rejecting; this
135    /// only says why the bytes look the way they do. A retired root is
136    /// documentation, not authority — if this function's result ever gated
137    /// acceptance, a rotation would become a way to keep honouring the key you
138    /// rotated away from.
139    ///
140    /// It matters that an UNKNOWN signer returns `None`. If every unverifiable
141    /// signature got the sympathetic "that root was retired" message, the
142    /// diagnostic would tell an operator a rotation happened while they were
143    /// in fact being attacked — worse than the bare error it replaces.
144    pub fn explain_retired_signature(&self, envelope: &[u8], payload_type: &str) -> Option<String> {
145        let retired = self
146            .retired_roots
147            .iter()
148            .find(|r| crate::verify::dsse_verify_typed(envelope, payload_type, &r.key).is_ok())?;
149
150        let live: String = self.trust_root.iter().map(|b| format!("{b:02x}")).collect();
151        let mut why = format!(
152            "this signature verifies against a root the realm '{}' RETIRED on {} ({}), \
153             not against its current trust-root ({live}).",
154            self.name,
155            retired.retired,
156            retired.hex(),
157        );
158        if let Some(last) = &retired.last_layer {
159            why.push_str(&format!(
160                " Layers up to and including {last} were signed by the retired root."
161            ));
162        }
163        why.push_str(
164            " This is not a forgery and not a mistake on your part: the realm changed its \
165             root. Move your pin to a layer signed by the current root — the old layers \
166             are not recoverable under the new root, by design.",
167        );
168        Some(why)
169    }
170
171    /// The per-realm effective root under which core/state/status live.
172    pub fn effective_root(&self, varve_root: &Path) -> PathBuf {
173        varve_root.join("realms").join(self.fingerprint())
174    }
175}
176
177#[derive(Debug, thiserror::Error)]
178pub enum RealmError {
179    #[error(
180        "no {REALMS_FILE} found walking up from {start} — the pin names realm '{realm}' but no realm definitions exist; commit a {REALMS_FILE} defining it"
181    )]
182    NoRealmsFile { start: String, realm: String },
183    #[error("{path}: not a valid realms file: {reason}")]
184    Parse { path: String, reason: String },
185    #[error(
186        "realm '{realm}' is not defined in {path} — defined realms: {defined:?}. Fix the pin or add the realm."
187    )]
188    Undefined {
189        realm: String,
190        path: String,
191        defined: Vec<String>,
192    },
193    #[error("realm '{realm}' in {path}: {reason}")]
194    BadDefinition {
195        realm: String,
196        path: String,
197        reason: String,
198    },
199    #[error("io error at {path}")]
200    Io {
201        path: String,
202        #[source]
203        source: std::io::Error,
204    },
205}
206
207#[derive(Deserialize)]
208#[serde(deny_unknown_fields)]
209struct RawRealmsFile {
210    #[serde(default)]
211    realm: BTreeMap<String, RawRealm>,
212}
213
214#[derive(Deserialize)]
215#[serde(deny_unknown_fields)]
216struct RawRealm {
217    registry: String,
218    /// Additional sources, tried in order after `registry`, when it cannot be
219    /// reached (REQ-MIRROR-001).
220    ///
221    /// Safe by construction: a layer is accepted because its manifest verifies
222    /// against this realm's trust root, so a mirror is transport and not
223    /// authority. A tampered mirror fails the signature check and a truncated
224    /// one fails the digest check — a second source widens availability, never
225    /// the trust surface.
226    #[serde(default)]
227    mirrors: Vec<String>,
228    /// Inline hex-encoded ed25519 public key…
229    #[serde(rename = "trust-root", default)]
230    trust_root: Option<String>,
231    /// …or a key file, relative to the realms file.
232    #[serde(rename = "trust-root-file", default)]
233    trust_root_file: Option<String>,
234    /// `signed-index = true` — this realm publishes a signed line index and
235    /// consumers must not accept an unauthenticated listing for it.
236    #[serde(rename = "signed-index", default)]
237    signed_index: bool,
238    /// Roots this realm has retired (REQ-ROTATE-002). Diagnostic only.
239    #[serde(rename = "retired-roots", default)]
240    retired_roots: Vec<RawRetiredRoot>,
241}
242
243#[derive(Deserialize)]
244#[serde(deny_unknown_fields)]
245struct RawRetiredRoot {
246    key: String,
247    retired: String,
248    #[serde(rename = "last-layer", default)]
249    last_layer: Option<String>,
250}
251
252/// Find the realms file by walking up from `start`.
253pub fn find_realms_file(start: &Path) -> Option<PathBuf> {
254    let mut dir = Some(start);
255    while let Some(d) = dir {
256        let candidate = d.join(REALMS_FILE);
257        if candidate.is_file() {
258            return Some(candidate);
259        }
260        dir = d.parent();
261    }
262    None
263}
264
265/// Every realm name the discovered realms file defines. Used to label store
266/// partitions by realm rather than by trust-root fingerprint — a fingerprint is
267/// unambiguous but tells a human nothing.
268pub fn realm_names(start: &Path) -> Result<Vec<String>, RealmError> {
269    let Some(path) = find_realms_file(start) else {
270        return Ok(Vec::new());
271    };
272    let text = std::fs::read_to_string(&path).map_err(|source| RealmError::Io {
273        path: path.display().to_string(),
274        source,
275    })?;
276    let file: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
277        path: path.display().to_string(),
278        reason: e.to_string(),
279    })?;
280    Ok(file.realm.into_keys().collect())
281}
282
283/// Load one realm by name from the realms file discovered from `start`.
284pub fn resolve_realm(start: &Path, name: &str) -> Result<Realm, RealmError> {
285    let Some(path) = find_realms_file(start) else {
286        return Err(RealmError::NoRealmsFile {
287            start: start.display().to_string(),
288            realm: name.to_string(),
289        });
290    };
291    let text = std::fs::read_to_string(&path).map_err(|source| RealmError::Io {
292        path: path.display().to_string(),
293        source,
294    })?;
295    let raw: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
296        path: path.display().to_string(),
297        reason: e.to_string(),
298    })?;
299    let Some(def) = raw.realm.get(name) else {
300        return Err(RealmError::Undefined {
301            realm: name.to_string(),
302            path: path.display().to_string(),
303            defined: raw.realm.keys().cloned().collect(),
304        });
305    };
306    let bad = |reason: String| RealmError::BadDefinition {
307        realm: name.to_string(),
308        path: path.display().to_string(),
309        reason,
310    };
311    let hex_key = match (&def.trust_root, &def.trust_root_file) {
312        (Some(_), Some(_)) => {
313            return Err(bad(
314                "both trust-root and trust-root-file given — pick one".into()
315            ));
316        }
317        (Some(inline), None) => inline.trim().to_string(),
318        (None, Some(file)) => {
319            let key_path = path.parent().unwrap_or(Path::new(".")).join(file);
320            std::fs::read_to_string(&key_path)
321                .map_err(|e| {
322                    bad(format!(
323                        "cannot read trust-root-file {}: {e}",
324                        key_path.display()
325                    ))
326                })?
327                .trim()
328                .to_string()
329        }
330        (None, None) => return Err(bad("no trust-root or trust-root-file".into())),
331    };
332    if hex_key.len() != 64 || !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
333        return Err(bad(
334            "trust root is not a 64-hex-char ed25519 public key".into()
335        ));
336    }
337    let trust_root = (0..hex_key.len())
338        .step_by(2)
339        .map(|i| u8::from_str_radix(&hex_key[i..i + 2], 16).expect("checked hex"))
340        .collect();
341    // Retired roots are parsed with the SAME strictness as the live one: a
342    // malformed key here would produce a diagnostic naming nonsense, and a
343    // diagnostic nobody can act on is worse than the bare error it replaced.
344    let mut retired_roots = Vec::with_capacity(def.retired_roots.len());
345    for raw in &def.retired_roots {
346        let hex = raw.key.trim().to_ascii_lowercase();
347        if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
348            return Err(bad(format!(
349                "retired root {:?} is not a 64-hex-char ed25519 public key",
350                raw.key
351            )));
352        }
353        let key: Vec<u8> = (0..hex.len())
354            .step_by(2)
355            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("checked hex"))
356            .collect();
357        // The live root listed as retired. No legitimate use, and precisely
358        // the mistake a half-finished rotation makes: update one field, paste
359        // the same value into the other. Refused rather than tolerated,
360        // because the realms file is the one place a rotation is written down
361        // twice and the two copies disagreeing is the whole failure mode.
362        if key == trust_root {
363            return Err(bad(format!(
364                "the realm's live trust-root {hex} is also listed in retired-roots — \
365                 a root cannot be both current and retired; remove it from one"
366            )));
367        }
368        retired_roots.push(RetiredRoot {
369            key,
370            retired: raw.retired.clone(),
371            last_layer: raw.last_layer.clone(),
372        });
373    }
374
375    Ok(Realm {
376        name: name.to_string(),
377        registry: def.registry.clone(),
378        sources: std::iter::once(def.registry.clone())
379            .chain(def.mirrors.iter().cloned())
380            .collect(),
381        trust_root,
382        signed_index: def.signed_index,
383        retired_roots,
384    })
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn realms_dir(content: &str) -> tempfile::TempDir {
392        let tmp = tempfile::tempdir().unwrap();
393        std::fs::write(tmp.path().join(REALMS_FILE), content).unwrap();
394        tmp
395    }
396
397    const NEW: &str = "7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0";
398    const OLD: &str = "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973";
399
400    fn realm_with_retired(retired: &str) -> String {
401        format!(
402            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{NEW}\"\n\
403             retired-roots = [{retired}]\n"
404        )
405    }
406
407    /// A realm can say which roots it has retired, so a signature failure can
408    /// be explained instead of merely reported.
409    // rivet: verifies REQ-ROTATE-002
410    #[test]
411    fn a_realm_can_declare_the_roots_it_has_retired() {
412        let dir = realms_dir(&realm_with_retired(&format!(
413            "{{ key = \"{OLD}\", retired = \"2026-09-07\", last-layer = \"2026.09.1\" }}"
414        )));
415        let realm = resolve_realm(dir.path(), "r").unwrap();
416        assert_eq!(realm.retired_roots.len(), 1);
417        let r = &realm.retired_roots[0];
418        assert_eq!(r.hex(), OLD);
419        assert_eq!(r.retired, "2026-09-07");
420        assert_eq!(r.last_layer.as_deref(), Some("2026.09.1"));
421        assert_ne!(
422            r.key, realm.trust_root,
423            "a retired root is not the live one"
424        );
425    }
426
427    /// THE LINE THIS MUST NOT CROSS. A retired root is documentation, not
428    /// authority. If declaring one ever widened what verifies, this feature
429    /// would be far worse than the confusing error it replaces — it would turn
430    /// a rotation into a way to keep accepting the key you rotated away from.
431    // rivet: verifies REQ-ROTATE-002
432    #[test]
433    fn a_retired_root_is_never_a_key_anything_verifies_against() {
434        let dir = realms_dir(&realm_with_retired(&format!(
435            "{{ key = \"{OLD}\", retired = \"2026-09-07\" }}"
436        )));
437        let realm = resolve_realm(dir.path(), "r").unwrap();
438
439        // The ONLY key the realm offers a verifier is the live root.
440        let live: Vec<u8> = (0..64)
441            .step_by(2)
442            .map(|i| u8::from_str_radix(&NEW[i..i + 2], 16).unwrap())
443            .collect();
444        assert_eq!(realm.trust_root, live);
445        // And the store partition is the live root's, so declaring a retired
446        // root cannot silently reunite a consumer with the old partition.
447        let expected = Realm {
448            retired_roots: Vec::new(),
449            ..realm.clone()
450        };
451        assert_eq!(
452            realm.fingerprint(),
453            expected.fingerprint(),
454            "retired roots must not change the store namespace"
455        );
456    }
457
458    /// Listing the live root as retired has no legitimate use and is exactly
459    /// the mistake a half-finished rotation makes — updating one field and
460    /// pasting the same value into the other.
461    // rivet: verifies REQ-ROTATE-002
462    #[test]
463    fn declaring_the_live_root_as_retired_is_refused() {
464        let dir = realms_dir(&realm_with_retired(&format!(
465            "{{ key = \"{NEW}\", retired = \"2026-09-07\" }}"
466        )));
467        let err = resolve_realm(dir.path(), "r").unwrap_err();
468        let msg = err.to_string();
469        assert!(
470            msg.contains("retired") && msg.contains("trust-root"),
471            "the error must say the live root is listed as retired, got: {msg}"
472        );
473    }
474
475    /// Both halves of the key check, each exercised ALONE. "not-a-key" fails
476    /// length AND alphabet at once, so it cannot tell `||` from `&&` — a
477    /// mutation survivor found exactly that. A 64-char non-hex string and a
478    /// short all-hex string each trip one condition only, so a weakened check
479    /// accepts them.
480    // rivet: verifies REQ-ROTATE-002
481    #[test]
482    fn a_retired_root_that_is_not_a_key_is_refused() {
483        for (key, why) in [
484            ("not-a-key", "fails both length and alphabet"),
485            (
486                "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
487                "right LENGTH, not hex",
488            ),
489            ("abcdef", "hex, wrong LENGTH"),
490            (
491                "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd997",
492                "hex, one char SHORT",
493            ),
494        ] {
495            let dir = realms_dir(&realm_with_retired(&format!(
496                "{{ key = \"{key}\", retired = \"2026-09-07\" }}"
497            )));
498            let err = resolve_realm(dir.path(), "r")
499                .expect_err(&format!("must refuse a retired root that {why}: {key}"))
500                .to_string();
501            assert!(
502                err.contains("64-hex"),
503                "a malformed retired root ({why}) must be refused like a malformed live one, \
504                 got: {err}"
505            );
506        }
507    }
508
509    /// A retired root's fingerprint must name the SAME store directory the
510    /// consumer already has on disk — it is looked up against partitions
511    /// written when that root was live. Asserting only that it DIFFERS from
512    /// the live one lets a constant stand in for it, which mutation testing
513    /// duly proved.
514    // rivet: verifies REQ-ROTATE-002
515    #[test]
516    fn a_retired_roots_fingerprint_is_the_one_its_partition_was_written_under() {
517        use crate::verify::generate_root_keypair;
518        let (_sk, old_pk) = generate_root_keypair();
519        let (_sk2, new_pk) = generate_root_keypair();
520        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
521
522        // The realm as it was BEFORE the rotation: the old key is the live
523        // root, so this is literally the fingerprint its partition was created
524        // under.
525        let before = realms_dir(&format!(
526            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{}\"\n",
527            hex(&old_pk)
528        ));
529        let was_live = resolve_realm(before.path(), "r").unwrap().fingerprint();
530
531        // The realm AFTER, with the old key declared retired.
532        let after = realms_dir(&format!(
533            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{}\"\n\
534             retired-roots = [{{ key = \"{}\", retired = \"2026-09-07\" }}]\n",
535            hex(&new_pk),
536            hex(&old_pk)
537        ));
538        let realm = resolve_realm(after.path(), "r").unwrap();
539
540        assert_eq!(
541            realm.retired_roots[0].fingerprint(),
542            was_live,
543            "a retired root must fingerprint to the partition it wrote, or `varve list` \
544             looks for a directory that does not exist"
545        );
546        assert_eq!(was_live.len(), 16, "the store namespace is 16 hex chars");
547        assert!(was_live.chars().all(|c| c.is_ascii_hexdigit()));
548    }
549
550    /// Clause 6: every realms file written before this feature keeps working,
551    /// unchanged, with no retired roots.
552    // rivet: verifies REQ-ROTATE-002
553    #[test]
554    fn a_realm_file_without_retired_roots_is_unchanged() {
555        let dir = realms_dir(&format!(
556            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{NEW}\"\n"
557        ));
558        let realm = resolve_realm(dir.path(), "r").unwrap();
559        assert!(realm.retired_roots.is_empty());
560    }
561
562    /// Clause 5. "My tools vanished" is the symptom a consumer notices before
563    /// any error message, because the store partitions by root fingerprint and
564    /// the old partition is no longer named by any realm — so `varve list`
565    /// shows it as a bare hex string. Naming it costs nothing and turns a
566    /// mystery into a fact.
567    // rivet: verifies REQ-ROTATE-002
568    #[test]
569    fn a_partition_left_behind_by_a_retired_root_is_named_as_such() {
570        use crate::verify::generate_root_keypair;
571        let (_old_sk, old_pk) = generate_root_keypair();
572        let (_new_sk, new_pk) = generate_root_keypair();
573        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
574        let dir = realms_dir(&format!(
575            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{}\"\n\
576             retired-roots = [{{ key = \"{}\", retired = \"2026-09-07\" }}]\n",
577            hex(&new_pk),
578            hex(&old_pk)
579        ));
580        let realm = resolve_realm(dir.path(), "r").unwrap();
581
582        // The live partition is named plainly.
583        assert_eq!(
584            realm.partition_label(&realm.fingerprint()).as_deref(),
585            Some("r")
586        );
587
588        // The partition the retired root left behind is named AND dated, so a
589        // human can tell it apart from the live one at a glance.
590        let old_fp = realm.retired_roots[0].fingerprint();
591        assert_ne!(old_fp, realm.fingerprint());
592        let label = realm
593            .partition_label(&old_fp)
594            .expect("a retired root's partition must be recognised");
595        assert!(label.contains('r'), "names the realm: {label}");
596        assert!(label.contains("retired"), "says it is retired: {label}");
597        assert!(label.contains("2026-09-07"), "says when: {label}");
598
599        // An unrelated partition stays unrecognised rather than being
600        // misattributed to this realm.
601        assert_eq!(realm.partition_label("0123456789abcdef"), None);
602    }
603
604    /// Clause 3. The whole point: a consumer whose pin was signed by the
605    /// retired root gets told WHICH root, WHEN it was retired, what replaced
606    /// it, and what to do — instead of "No valid signatures", which is
607    /// indistinguishable from a forgery by a stranger.
608    // rivet: verifies REQ-ROTATE-002
609    #[test]
610    fn a_signature_from_a_retired_root_is_attributed_not_merely_rejected() {
611        use crate::verify::generate_root_keypair;
612        let (old_sk, old_pk) = generate_root_keypair();
613        let (_new_sk, new_pk) = generate_root_keypair();
614        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
615
616        let dir = realms_dir(&format!(
617            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{}\"\n\
618             retired-roots = [{{ key = \"{}\", retired = \"2026-09-07\", \
619             last-layer = \"2026.09.1\" }}]\n",
620            hex(&new_pk),
621            hex(&old_pk)
622        ));
623        let realm = resolve_realm(dir.path(), "r").unwrap();
624
625        // Something the OLD root signed — a layer deposited before rotation.
626        let envelope = crate::verify::dsse_sign_typed(b"{}", "application/x.test", &old_sk, "k")
627            .expect("sign with the retired root");
628
629        let why = realm
630            .explain_retired_signature(envelope.as_bytes(), "application/x.test")
631            .expect("a signature from a declared retired root must be attributed");
632        assert!(why.contains("2026-09-07"), "must say WHEN: {why}");
633        assert!(
634            why.contains(&hex(&old_pk)),
635            "must name the retired root: {why}"
636        );
637        assert!(
638            why.contains("2026.09.1"),
639            "must say which layers used it: {why}"
640        );
641        assert!(
642            why.to_lowercase().contains("pin"),
643            "must say the fix is to move the pin: {why}"
644        );
645    }
646
647    /// A forgery by a stranger must stay a forgery. If any unverifiable
648    /// signature got the sympathetic "this realm retired that root" message,
649    /// the diagnostic would be actively misleading — telling an operator a
650    /// rotation happened when they are being attacked.
651    // rivet: verifies REQ-ROTATE-002
652    #[test]
653    fn a_signature_from_an_unknown_key_is_not_blamed_on_a_rotation() {
654        use crate::verify::generate_root_keypair;
655        let (_old_sk, old_pk) = generate_root_keypair();
656        let (_new_sk, new_pk) = generate_root_keypair();
657        let (stranger_sk, _stranger_pk) = generate_root_keypair();
658        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
659
660        let dir = realms_dir(&format!(
661            "[realm.r]\nregistry = \"oci://example/x\"\ntrust-root = \"{}\"\n\
662             retired-roots = [{{ key = \"{}\", retired = \"2026-09-07\" }}]\n",
663            hex(&new_pk),
664            hex(&old_pk)
665        ));
666        let realm = resolve_realm(dir.path(), "r").unwrap();
667        let envelope =
668            crate::verify::dsse_sign_typed(b"{}", "application/x.test", &stranger_sk, "k")
669                .expect("sign with a stranger key");
670        assert!(
671            realm
672                .explain_retired_signature(envelope.as_bytes(), "application/x.test")
673                .is_none(),
674            "an unknown signer must not be explained away as a rotation"
675        );
676    }
677
678    // rivet: verifies REQ-STORE-001
679    #[test]
680    fn every_defined_realm_is_named() {
681        // `list` labels store partitions by realm name rather than by
682        // trust-root fingerprint, which is unambiguous but tells a human
683        // nothing. Mutation testing found this helper replaceable by an empty
684        // vec with nothing noticing: the CLI test that covers it cannot kill
685        // mutants, because the gate runs `--workspace --lib`.
686        let dir = realms_dir(TWO_REALMS);
687        let mut names = realm_names(dir.path()).unwrap();
688        names.sort();
689        assert_eq!(names, ["acme", "pulseengine"], "both realms named");
690
691        // No realms file is not an error — a project may define none.
692        let empty = tempfile::tempdir().unwrap();
693        assert!(realm_names(empty.path()).unwrap().is_empty());
694
695        // A malformed file IS an error: labelling must not paper over a file
696        // the user believes is being read.
697        let bad = realms_dir("this is not toml {{{");
698        assert!(realm_names(bad.path()).is_err());
699    }
700
701    const TWO_REALMS: &str = r#"
702[realm.pulseengine]
703registry = "oci://ghcr.io/pulseengine/layers"
704trust-root = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
705
706[realm.acme]
707registry = "oci://ghcr.io/acme/layers"
708trust-root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
709"#;
710
711    // rivet: verifies REQ-REALM-001
712    #[test]
713    fn realms_resolve_by_name_with_walk_up_discovery() {
714        let tmp = realms_dir(TWO_REALMS);
715        let deep = tmp.path().join("a/b");
716        std::fs::create_dir_all(&deep).unwrap();
717        let realm = resolve_realm(&deep, "acme").unwrap();
718        assert_eq!(realm.registry, "oci://ghcr.io/acme/layers");
719        assert_eq!(realm.trust_root, vec![0xbb; 32]);
720    }
721
722    // rivet: verifies REQ-REALM-001
723    #[test]
724    fn different_roots_mean_different_namespaces() {
725        let tmp = realms_dir(TWO_REALMS);
726        let pe = resolve_realm(tmp.path(), "pulseengine").unwrap();
727        let acme = resolve_realm(tmp.path(), "acme").unwrap();
728        assert_ne!(pe.fingerprint(), acme.fingerprint());
729        let root = Path::new("/var/root");
730        assert_ne!(pe.effective_root(root), acme.effective_root(root));
731        assert!(pe.effective_root(root).starts_with("/var/root/realms"));
732    }
733
734    // rivet: verifies REQ-REALM-001
735    #[test]
736    fn an_undefined_realm_fails_closed_naming_what_exists() {
737        let tmp = realms_dir(TWO_REALMS);
738        let err = resolve_realm(tmp.path(), "evil-corp").unwrap_err();
739        let msg = err.to_string();
740        assert!(msg.contains("evil-corp") && msg.contains("pulseengine") && msg.contains("acme"));
741    }
742
743    // rivet: verifies REQ-REALM-001
744    #[test]
745    fn a_missing_realms_file_fails_closed_with_guidance() {
746        let tmp = tempfile::tempdir().unwrap();
747        let err = resolve_realm(tmp.path(), "pulseengine").unwrap_err();
748        assert!(err.to_string().contains(REALMS_FILE));
749    }
750
751    // rivet: verifies REQ-REALM-001
752    #[test]
753    fn trust_root_file_is_read_relative_to_the_realms_file() {
754        let tmp = tempfile::tempdir().unwrap();
755        std::fs::create_dir_all(tmp.path().join("keys")).unwrap();
756        std::fs::write(tmp.path().join("keys/root.pub"), "cc".repeat(32)).unwrap();
757        std::fs::write(
758            tmp.path().join(REALMS_FILE),
759            "[realm.filekey]\nregistry = \"oci://r/x\"\ntrust-root-file = \"keys/root.pub\"\n",
760        )
761        .unwrap();
762        let realm = resolve_realm(tmp.path(), "filekey").unwrap();
763        assert_eq!(realm.trust_root, vec![0xcc; 32]);
764    }
765
766    // rivet: verifies REQ-REALM-001
767    #[test]
768    fn malformed_definitions_are_refused() {
769        for (name, body) in [
770            ("nokey", "[realm.nokey]\nregistry = \"oci://r/x\"\n"),
771            (
772                "badkey",
773                "[realm.badkey]\nregistry = \"oci://r/x\"\ntrust-root = \"zz\"\n",
774            ),
775            // Wrong-length but PURE-HEX: length and charset must each
776            // reject independently.
777            (
778                "shorthex",
779                "[realm.shorthex]\nregistry = \"oci://r/x\"\ntrust-root = \"cccccccccccccccccccccccccccccccc\"\n",
780            ),
781            (
782                "bothkeys",
783                "[realm.bothkeys]\nregistry = \"oci://r/x\"\ntrust-root = \"aa\"\ntrust-root-file = \"f\"\n",
784            ),
785        ] {
786            let tmp = realms_dir(body);
787            assert!(
788                resolve_realm(tmp.path(), name).is_err(),
789                "{name} must refuse"
790            );
791        }
792    }
793
794    // rivet: verifies REQ-INDEXAUTH-001
795    #[test]
796    fn a_realm_declares_whether_it_publishes_a_signed_index() {
797        // Clause 5. Failing closed by default would break every realm that
798        // exists; failing open with no way to opt in would let an attacker
799        // disable the check by deleting the index. The realm decides, which is
800        // where every other trust question is already settled.
801        let tmp = realms_dir(
802            r#"
803[realm.declaring]
804registry     = "oci://example.test/layers"
805trust-root   = "7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0"
806signed-index = true
807
808[realm.silent]
809registry   = "oci://example.test/other"
810trust-root = "7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0"
811"#,
812        );
813        assert!(
814            resolve_realm(tmp.path(), "declaring").unwrap().signed_index,
815            "a realm that declares an index must be recorded as declaring it"
816        );
817        assert!(
818            !resolve_realm(tmp.path(), "silent").unwrap().signed_index,
819            "the default must be false, or every existing realm breaks at once"
820        );
821    }
822}
823
824#[cfg(test)]
825mod mirror_tests {
826    use super::*;
827
828    fn parse(text: &str, name: &str) -> Realm {
829        let dir = std::env::temp_dir().join(format!("varve-realm-mirror-{name}"));
830        let _ = std::fs::remove_dir_all(&dir);
831        std::fs::create_dir_all(&dir).expect("scratch");
832        std::fs::write(dir.join(REALMS_FILE), text).expect("write");
833        resolve_realm(&dir, name).expect("parses")
834    }
835
836    /// Clause 5. Every realms file in existence names one registry and no
837    /// mirrors; all of them must keep working with no edit.
838    // rivet: verifies REQ-MIRROR-001
839    #[test]
840    fn a_realm_naming_one_registry_still_works_and_has_one_source() {
841        let r = parse(
842            "[realm.solo]\nregistry = \"oci://ghcr.io/o/r\"\n\
843             trust-root = \"7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0\"\n",
844            "solo",
845        );
846        assert_eq!(r.registry, "oci://ghcr.io/o/r");
847        assert_eq!(r.sources, vec!["oci://ghcr.io/o/r".to_string()]);
848    }
849
850    /// Clause 1 and the ordering in clause 2: primary first, then the stated
851    /// mirrors in the order written.
852    // rivet: verifies REQ-MIRROR-001
853    #[test]
854    fn mirrors_follow_the_primary_in_the_order_they_are_written() {
855        let r = parse(
856            "[realm.many]\nregistry = \"oci://primary\"\n\
857             mirrors = [\"oci://second\", \"oci://third\"]\n\
858             trust-root = \"7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0\"\n",
859            "many",
860        );
861        assert_eq!(
862            r.sources,
863            vec![
864                "oci://primary".to_string(),
865                "oci://second".to_string(),
866                "oci://third".to_string()
867            ]
868        );
869        // `registry` still names the primary, so nothing that reads it changes.
870        assert_eq!(r.registry, "oci://primary");
871    }
872
873    /// The trust root is per REALM, not per source. A mirrors list cannot
874    /// introduce a second authority — that is what makes mirroring safe here
875    /// rather than a trust decision.
876    // rivet: verifies REQ-MIRROR-001
877    #[test]
878    fn mirrors_cannot_carry_a_trust_root_of_their_own() {
879        let dir = std::env::temp_dir().join("varve-realm-mirror-root");
880        let _ = std::fs::remove_dir_all(&dir);
881        std::fs::create_dir_all(&dir).expect("scratch");
882        std::fs::write(
883            dir.join(REALMS_FILE),
884            "[realm.x]\nregistry = \"oci://a\"\n\
885             mirrors = [{ registry = \"oci://b\", trust-root = \"dead\" }]\n\
886             trust-root = \"7d3b892e6a33c70043becc708e08042e1cef0d54dd5ae6f23d7d4c68de1da1a0\"\n",
887        )
888        .expect("write");
889        assert!(
890            resolve_realm(&dir, "x").is_err(),
891            "a mirror must not be able to declare its own trust root"
892        );
893    }
894}
895
896#[cfg(test)]
897mod shipped_realm_agrees_with_shipped_key {
898    use super::*;
899
900    /// The repository ships the rolling root TWICE: as key material in
901    /// `trust-roots/rolling.pub` (uploaded as a release asset, and used by
902    /// `deposit-layer.yml` as `VARVE_TRUST_ROOT`) and as a fingerprint in
903    /// `varve-realms.toml` (downloaded by consumers, and authoritative over
904    /// the environment). Nothing compared them.
905    ///
906    /// A rotation touches both, and the half-done rotation is silent in a
907    /// specific and bad way: CI deposits a layer signed against the key file
908    /// and it verifies, because the same file is used on both sides — while
909    /// every consumer resolving the realm rejects that layer with "No valid
910    /// signatures". The break appears downstream, in someone else's repo,
911    /// after the release ships.
912    ///
913    /// A sibling test in `varve`'s docs module pins the DOCUMENTED key to
914    /// `rolling.pub`. This pins the SHIPPED realm to it. Together the three
915    /// copies cannot drift apart.
916    // rivet: verifies REQ-ROTATE-001
917    #[test]
918    fn the_committed_realms_file_names_the_committed_key() {
919        let repo_root = Path::new(env!("CARGO_MANIFEST_DIR"))
920            .parent()
921            .and_then(Path::parent)
922            .expect("crates/varve-core is two levels below the repo root")
923            .to_path_buf();
924
925        let key_file = repo_root.join("trust-roots/rolling.pub");
926        let shipped_key = std::fs::read_to_string(&key_file)
927            .expect("trust-roots/rolling.pub is committed")
928            .trim()
929            .to_ascii_lowercase();
930        assert_eq!(
931            shipped_key.len(),
932            64,
933            "{} must hold one 64-hex ed25519 public key",
934            key_file.display()
935        );
936
937        // The real parser on the real file: whatever a consumer would load.
938        let realm = resolve_realm(&repo_root, "pulseengine")
939            .expect("this repository commits varve-realms.toml with realm 'pulseengine'");
940
941        let named_root = realm
942            .trust_root
943            .iter()
944            .map(|b| format!("{b:02x}"))
945            .collect::<String>();
946
947        assert_eq!(
948            named_root, shipped_key,
949            "varve-realms.toml names a rolling root that is NOT the key in \
950             trust-roots/rolling.pub. A layer signed with the key file will be \
951             REJECTED by every consumer that resolves the realm. Rotate both, \
952             or neither."
953        );
954    }
955}