Skip to main content

gunnar_server/
layout_config.rs

1//! **The layout config: gunnar is TOLD where its data lives, and obeys.**
2//!
3//! # The rule, and why it is the whole design
4//!
5//! gunnar has **no disk logic**. It does not survey `/sys/block`, it does not
6//! read the mount table to work out what `/data` is, and it does not probe to
7//! check what it was told. `korp-installer` chooses the layout, partitions it,
8//! RAIDs it, lays the directories — and then installs gunnar **with parameters
9//! saying where to fetch data**. That is not a new idea: `GUNNAR_DATA_ROOT=/data`
10//! is a boot parameter the installer bakes in today, and this file is that idea
11//! carried to completion.
12//!
13//! Inferring is how *"why is this disk unused?"* gets answered wrongly. A
14//! current mount table can say what exists; it can never say **why** a disk was
15//! rejected, which group won `/data`, or what the installer was aiming at. So
16//! the reasoning is written down at install time by the thing that did the
17//! reasoning, and read back verbatim.
18//!
19//! # Where it is, and why there
20//!
21//! `/boot/korp/layout.conf` — on the ESP, mounted read-only at `/boot`, beside
22//! the trust records (`korp_installer::layout::CONFIG_ESP_PATH`). It is not on
23//! `/data` for a plain reason the installer states: it is the thing that SAYS
24//! where `/data` is, and a config that has to be found by first knowing what it
25//! tells you is not a config.
26//!
27//! # One artifact, two readers
28//!
29//! The same file is read for **placement** (`crate::slow`) and rendered for the
30//! **admin** (`Admin.StorageInfo`'s storage reality). Not two views of the same
31//! facts that can drift apart: one parse, one set of key-value pairs, handed to
32//! both.
33//!
34//! # The format
35//!
36//! `korp_installer::layout::LayoutConfig::body` + `seal` — version FIRST, then
37//! `key=value`, one per line, keys unique, values escaped (`\n`, `\\`) so no
38//! value can forge a line; lists are indexed keys with a `.count`:
39//!
40//! ```text
41//! korp-layout-config 1
42//! install.unix=1789…
43//! boot.dev=/dev/vda
44//! boot.why=largest-group-remainder
45//! data.count=4
46//! data.0.dev=/dev/vdb
47//! unused.0.why=unequal, not used
48//! dir.log.path=/data/log          ← the parameters: what the app is TOLD
49//! dir.log.device=serialdat
50//! dir.log.off_stripe_satisfied=yes
51//! auth.domain=korp.layout-config.v1
52//! auth.blake3=…
53//! auth.signature=
54//! ```
55//!
56//! Unknown KEYS are ignored — that is what lets version 1 gain keys — but an
57//! unknown VERSION is refused, because a key whose meaning changed underneath
58//! is worse than no config.
59//!
60//! # `dir.<name>.path` is obeyed, and there is no second placement mechanism
61//!
62//! The installer lays `crash`, `log`, `gc`, `quarantine` (and the two that are
63//! not gunnar's) PHYSICALLY on `/serialdat` and makes `/data/<name>` a
64//! **symlink** to each. So `dir.log.path` is `/data/log` and writing there
65//! already lands on the other spindle. gunnar writes the path it is given and
66//! adds nothing on top: two things deciding the same thing is how they start
67//! disagreeing.
68//!
69//! # Absent is a first-class answer
70//!
71//! Every appliance installed before this config existed has none, and gunnar
72//! must start on it. So the absent case is not an error and not a silence: it
73//! is [`Load::Absent`] carrying **the sentence that says why**, which travels
74//! all the way to the admin's screen.
75//!
76//! # Authenticity — and the third state
77//!
78//! [`verify`] is `korp_installer::layout::verify`, arm for arm:
79//!
80//! * a first line that is not this format and version, or a body whose blake3
81//!   does not match what is written beside it ⇒ `Err`. A config that does not
82//!   hold together is not read for its values, and the caller treats it as
83//!   **absent rather than trusted** — this file says where the forge's data
84//!   lives, so forging it redirects the forge's storage.
85//! * a signature present AND an enrolled key present ⇒ it must verify, or
86//!   `Err`. **A failed verify is never downgraded to integrity-only**; that
87//!   downgrade is how an attacker turns "signed" into "either will do".
88//! * `auth.signature=` EMPTY, or no enrolled key ⇒
89//!   [`Authenticity::IntegrityOnly`].
90//!
91//! That third state is the one that matters today. **No `KORP_ROOT_SIGNING_KEY`
92//! is enrolled on this estate**, so every config built today carries an empty
93//! signature and says so in its own bytes (`auth.note`). An empty signature is
94//! not a failed signature and not a passed one — it is *nobody signed this*.
95//! The installer's console prints `INTEGRITY ONLY`; [`Authenticity::word`]
96//! prints the same two words, and nothing anywhere calls it verified.
97
98use std::collections::BTreeMap;
99use std::path::{Path, PathBuf};
100
101/// `korp_installer::layout::CONFIG_ESP_PATH`, as the full path on a running
102/// appliance: the ESP is mounted read-only at `/boot`.
103pub const DEFAULT_PATH: &str = "/boot/korp/layout.conf";
104
105/// The boot parameter that names the config, when it is not at the default.
106/// Rides the same channel `GUNNAR_DATA_ROOT` already rides (Linux turns an
107/// unrecognised `key=value` boot parameter into an environment variable for
108/// init).
109pub const CONFIG_ENV: &str = "GUNNAR_LAYOUT_CONFIG";
110
111/// The enrolled ed25519 root public key, 64 hex characters — the key an
112/// `auth.signature` must verify against.
113///
114/// **Unset on this estate.** With no key enrolled, a signature that is present
115/// cannot be checked and the answer is [`Authenticity::IntegrityOnly`], never
116/// "verified".
117pub const ROOT_KEY_ENV: &str = "GUNNAR_LAYOUT_ROOT_KEY";
118
119/// The first token of the first line.
120pub const HEADER: &str = "korp-layout-config";
121
122/// The only version this build reads.
123pub const VERSION: u32 = 1;
124
125/// `korp_installer::layout::CONFIG_DOMAIN` — the domain tag the signature
126/// covers, so a signature over this config can never be replayed as a
127/// signature over anything else the constellation signs.
128pub const DOMAIN: &[u8] = b"korp.layout-config.v1\0";
129
130/// Where the authenticity block begins. The body is everything before it, and
131/// the body is what the digest and the signature are over.
132const AUTH_MARKER: &str = "auth.domain=";
133
134/// What is known about the config's authenticity.
135///
136/// Three states, and the third is the point: an EMPTY `auth.signature` is not
137/// a failed signature and not a passed one. A failure is not in this enum at
138/// all — it is an `Err` out of [`verify`], which the caller turns into an
139/// absent config.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum Authenticity {
142    /// An ed25519 signature over the domain-separated body digest verified
143    /// against the enrolled root key.
144    Verified,
145    /// The body's blake3 matches what is written beside it, and **nothing else
146    /// is claimed**. Integrity, not authenticity: anyone who can write the
147    /// plaintext ESP can rewrite the body and its digest together. This is the
148    /// state every appliance built today is in.
149    IntegrityOnly,
150}
151
152impl Authenticity {
153    /// The words the console prints — **the same words the installer's console
154    /// prints**, so an admin comparing the two is not left wondering whether
155    /// they mean the same thing.
156    pub fn word(self) -> &'static str {
157        match self {
158            Authenticity::Verified => "verified",
159            Authenticity::IntegrityOnly => "INTEGRITY ONLY",
160        }
161    }
162
163    /// Only [`Authenticity::Verified`]. An unsigned config is never presented
164    /// as verified.
165    pub fn is_verified(self) -> bool {
166        matches!(self, Authenticity::Verified)
167    }
168}
169
170/// The parsed config: the version, where it came from, how authentic it is,
171/// and every `key=value` in it, verbatim.
172#[derive(Clone, Debug)]
173pub struct LayoutConfig {
174    path: PathBuf,
175    version: u32,
176    authenticity: Authenticity,
177    /// The `auth.note` the installer wrote — the sentence saying, in the
178    /// file's own bytes, what is NOT verified. Empty on a signed config.
179    note: String,
180    fields: BTreeMap<String, String>,
181}
182
183impl LayoutConfig {
184    pub fn path(&self) -> &Path {
185        &self.path
186    }
187
188    pub fn version(&self) -> u32 {
189        self.version
190    }
191
192    pub fn authenticity(&self) -> Authenticity {
193        self.authenticity
194    }
195
196    /// The installer's own sentence about what is not verified, or empty.
197    pub fn note(&self) -> &str {
198        &self.note
199    }
200
201    /// One value, exactly as written. `None` is "the config does not carry
202    /// this key" and is never a zero, an empty string or a guess.
203    pub fn get(&self, key: &str) -> Option<&str> {
204        self.fields
205            .get(key)
206            .map(String::as_str)
207            .filter(|v| !v.is_empty())
208    }
209
210    /// One value as a number, or `None` — including for a value that does not
211    /// parse, because a number that could not be read must not be reported as
212    /// one.
213    pub fn u64(&self, key: &str) -> Option<u64> {
214        self.get(key).and_then(|v| v.parse().ok())
215    }
216
217    /// A `yes`/`no` value. Anything else, or absent, is `None`.
218    pub fn yes_no(&self, key: &str) -> Option<bool> {
219        match self.get(key)? {
220            "yes" => Some(true),
221            "no" => Some(false),
222            _ => None,
223        }
224    }
225
226    /// `<prefix>.count`, or 0.
227    pub fn count(&self, prefix: &str) -> usize {
228        self.u64(&format!("{prefix}.count")).unwrap_or(0) as usize
229    }
230
231    /// Every field, in key order — what the admin view renders.
232    pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
233        self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
234    }
235
236    /// **`dir.<name>.path` — the parameter gunnar obeys.**
237    pub fn dir_path(&self, name: &str) -> Option<PathBuf> {
238        self.get(&format!("dir.{name}.path")).map(PathBuf::from)
239    }
240
241    /// `dir.<name>.off_stripe_required`.
242    pub fn dir_requires_off_stripe(&self, name: &str) -> Option<bool> {
243        self.yes_no(&format!("dir.{name}.off_stripe_required"))
244    }
245
246    /// `dir.<name>.off_stripe_satisfied` — **the installer's verdict**, not
247    /// gunnar's re-derivation of it.
248    pub fn dir_off_stripe_satisfied(&self, name: &str) -> Option<bool> {
249        self.yes_no(&format!("dir.{name}.off_stripe_satisfied"))
250    }
251
252    /// Parse a SEALED config's bytes. `Err` is the sentence that says why not.
253    ///
254    /// Parsing does not verify; [`load`] verifies first and parses only what
255    /// held together.
256    pub fn parse(path: PathBuf, text: &str, authenticity: Authenticity) -> Result<Self, String> {
257        let mut lines = text.lines();
258        let header = lines.next().unwrap_or_default().trim();
259        let mut tokens = header.split_whitespace();
260        if tokens.next() != Some(HEADER) {
261            return Err(format!(
262                "{} does not begin with `{HEADER}`; it is not a layout config",
263                path.display()
264            ));
265        }
266        let version: u32 = tokens
267            .next()
268            .and_then(|v| v.parse().ok())
269            .ok_or_else(|| format!("{} carries no format version", path.display()))?;
270        if version != VERSION {
271            return Err(format!(
272                "{} is format version {version}; this build reads version {VERSION} only",
273                path.display()
274            ));
275        }
276        let mut fields = BTreeMap::new();
277        for line in lines {
278            let line = line.trim_end_matches('\r');
279            if line.trim().is_empty() || line.starts_with('#') {
280                continue;
281            }
282            let Some((key, value)) = line.split_once('=') else {
283                continue;
284            };
285            fields.insert(key.to_owned(), unescape(value));
286        }
287        let note = fields.get("auth.note").cloned().unwrap_or_default();
288        Ok(LayoutConfig {
289            path,
290            version,
291            authenticity,
292            note,
293            fields,
294        })
295    }
296}
297
298/// The writer escapes `\n` and `\\`; unescape in one pass so a trailing
299/// backslash cannot swallow the next character.
300fn unescape(value: &str) -> String {
301    let mut out = String::with_capacity(value.len());
302    let mut chars = value.chars();
303    while let Some(c) = chars.next() {
304        if c != '\\' {
305            out.push(c);
306            continue;
307        }
308        match chars.next() {
309            Some('n') => out.push('\n'),
310            Some('\\') => out.push('\\'),
311            Some(other) => {
312                out.push('\\');
313                out.push(other);
314            }
315            None => out.push('\\'),
316        }
317    }
318    out
319}
320
321/// The answer to "is there a layout config?", and never anything in between.
322#[derive(Clone, Debug)]
323pub enum Load {
324    Found(Box<LayoutConfig>),
325    /// **The sentence that says why there is none.** It reaches the admin's
326    /// screen unchanged: "the config is missing" is an answer, and inferring
327    /// the installer's decisions from the current mount table is not.
328    Absent(String),
329}
330
331impl Load {
332    pub fn config(&self) -> Option<&LayoutConfig> {
333        match self {
334            Load::Found(c) => Some(c),
335            Load::Absent(_) => None,
336        }
337    }
338
339    pub fn absent_reason(&self) -> Option<&str> {
340        match self {
341            Load::Absent(why) => Some(why),
342            Load::Found(_) => None,
343        }
344    }
345}
346
347/// Where the config lives: [`CONFIG_ENV`], else [`DEFAULT_PATH`]. **One place,
348/// not a search** — a config found by looking around is a config an attacker
349/// can put somewhere.
350pub fn config_path() -> PathBuf {
351    std::env::var_os(CONFIG_ENV)
352        .filter(|v| !v.is_empty())
353        .map(PathBuf::from)
354        .unwrap_or_else(|| PathBuf::from(DEFAULT_PATH))
355}
356
357/// Read, verify, and parse the layout config.
358///
359/// Never an error and never a panic: an old appliance has none and must still
360/// start.
361pub fn load() -> Load {
362    load_from(&config_path(), enrolled_root_key().as_ref())
363}
364
365/// [`load`] with the path and the enrolled key named — the seam the tests
366/// drive, so none of them touches a process-global.
367pub fn load_from(path: &Path, key: Option<&[u8; 32]>) -> Load {
368    let text = match std::fs::read_to_string(path) {
369        Ok(text) => text,
370        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
371            return Load::Absent(format!(
372                "no layout config at {}. An appliance installed before the config existed has \
373                 none, and nothing here infers the installer's decisions from the current \
374                 machine",
375                path.display()
376            ))
377        }
378        Err(err) => return Load::Absent(format!("{} could not be read: {err}", path.display())),
379    };
380    // **Verify BEFORE reading a single value.** A config that does not hold
381    // together is treated as absent rather than trusted: it says where the
382    // forge's data lives.
383    let authenticity = match verify(&text, key) {
384        Ok(a) => a,
385        Err(why) => {
386            return Load::Absent(format!(
387                "{} is present but does not verify ({why}); it is treated as absent rather \
388                 than trusted",
389                path.display()
390            ))
391        }
392    };
393    match LayoutConfig::parse(path.to_path_buf(), &text, authenticity) {
394        Ok(config) => Load::Found(Box::new(config)),
395        Err(why) => Load::Absent(why),
396    }
397}
398
399/// [`ROOT_KEY_ENV`] as 32 raw bytes, or `None`.
400///
401/// A malformed value is `None` and not a refusal: the honest consequence is
402/// that a present signature cannot be checked, which is
403/// [`Authenticity::IntegrityOnly`] — never "verified".
404pub fn enrolled_root_key() -> Option<[u8; 32]> {
405    let spec = std::env::var(ROOT_KEY_ENV).ok()?;
406    let spec = spec.trim();
407    if spec.len() != 64 {
408        return None;
409    }
410    let mut out = [0u8; 32];
411    for (i, byte) in out.iter_mut().enumerate() {
412        *byte = u8::from_str_radix(spec.get(i * 2..i * 2 + 2)?, 16).ok()?;
413    }
414    Some(out)
415}
416
417/// **Verify a sealed config** — `korp_installer::layout::verify`, arm for arm.
418///
419/// See this module's header for the three outcomes and why the middle one may
420/// never be reached by downgrading the last.
421pub fn verify(sealed: &str, pubkey: Option<&[u8; 32]>) -> Result<Authenticity, String> {
422    let header = sealed.lines().next().unwrap_or_default();
423    let want = format!("{HEADER} {VERSION}");
424    if header != want {
425        return Err(format!(
426            "first line is {header:?}, not {want:?} — refusing to read a shape this version \
427             does not know"
428        ));
429    }
430    let (body, auth) = sealed
431        .split_once(AUTH_MARKER)
432        .ok_or_else(|| "no auth block — it was never sealed".to_string())?;
433    let field =
434        |k: &str| -> Option<&str> { auth.lines().find_map(|l| l.strip_prefix(&format!("{k}="))) };
435    let want_hash = field("auth.blake3").ok_or_else(|| "no auth.blake3".to_string())?;
436    let got = blake3::hash(body.as_bytes()).to_hex().to_string();
437    if got != want_hash {
438        return Err(format!(
439            "body blake3 is {got}, the file claims {want_hash} — it has been altered since it \
440             was written"
441        ));
442    }
443    let sig_hex = field("auth.signature").unwrap_or_default();
444    // **The third state.** Nobody signed it: not a failure, not a pass.
445    if sig_hex.is_empty() {
446        return Ok(Authenticity::IntegrityOnly);
447    }
448    // A signature nobody can check is not evidence of anything either. It is
449    // still integrity-only, and it is never called verified.
450    let Some(key) = pubkey else {
451        return Ok(Authenticity::IntegrityOnly);
452    };
453    let sig_bytes: [u8; 64] = decode_hex(sig_hex)
454        .and_then(|v| v.try_into().ok())
455        .ok_or_else(|| "auth.signature is not 64 hex bytes".to_string())?;
456    let vk = ed25519_dalek::VerifyingKey::from_bytes(key)
457        .map_err(|err| format!("the enrolled pubkey is malformed: {err}"))?;
458    // The same refusal the installer makes: a small-order key verifies a
459    // forged signature over ANY config, so it is refused rather than trusted.
460    if vk.is_weak() {
461        return Err(
462            "the enrolled pubkey is a small-order point — such a key verifies a forged \
463             signature over ANY config, so it is refused rather than trusted"
464                .to_string(),
465        );
466    }
467    use ed25519_dalek::Verifier as _;
468    vk.verify(
469        &signing_message(body),
470        &ed25519_dalek::Signature::from_bytes(&sig_bytes),
471    )
472    .map_err(|_| {
473        "the signature does not verify against the enrolled root key — the config is NOT the \
474         one the installer signed"
475            .to_string()
476    })?;
477    Ok(Authenticity::Verified)
478}
479
480/// The bytes an ed25519 signature covers: the domain tag, then the body's
481/// blake3. Fixed width, one interpretation, and the tag is what stops a
482/// signature over this being replayed as a signature over something else.
483pub fn signing_message(body: &str) -> Vec<u8> {
484    let mut m = Vec::with_capacity(DOMAIN.len() + 32);
485    m.extend_from_slice(DOMAIN);
486    m.extend_from_slice(blake3::hash(body.as_bytes()).as_bytes());
487    m
488}
489
490fn decode_hex(s: &str) -> Option<Vec<u8>> {
491    if s.len() % 2 != 0 {
492        return None;
493    }
494    (0..s.len() / 2)
495        .map(|i| u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok())
496        .collect()
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    const BODY: &str = concat!(
504        "korp-layout-config 1\n",
505        "install.unix=1789000000\n",
506        "install.product=gunnar\n",
507        "install.mode=fresh\n",
508        "boot.dev=/dev/vda\n",
509        "boot.why=largest-group-remainder\n",
510        "boot.p3_dev=/dev/vda3\n",
511        "data.mountpoint=/data\n",
512        "data.shape=raid0\n",
513        "data.striped=yes\n",
514        "data.count=4\n",
515        "data.0.dev=/dev/vdb\n",
516        "data.3.dev=/dev/vde\n",
517        "serialdat.mountpoint=/serialdat\n",
518        "serialdat.class=p3\n",
519        "serialdat.dev=/dev/vda3\n",
520        "serialdat.off_stripe=yes\n",
521        "unused.count=1\n",
522        "unused.0.name=vdf\n",
523        "unused.0.why=unequal, not used\n",
524        "dir.users_dir=repos\n",
525        "dir.count=8\n",
526        "dir.repos.path=/data/repos\n",
527        "dir.repos.device=data\n",
528        "dir.repos.off_stripe_required=no\n",
529        "dir.repos.off_stripe_satisfied=yes\n",
530        "dir.meta.path=/serialdat/meta\n",
531        "dir.meta.device=serialdat\n",
532        "dir.meta.off_stripe_required=yes\n",
533        "dir.meta.off_stripe_satisfied=yes\n",
534        "dir.log.path=/data/log\n",
535        "dir.log.device=serialdat\n",
536        "dir.log.off_stripe_required=yes\n",
537        "dir.log.off_stripe_satisfied=yes\n",
538        "threshold.rootfs_measured_bytes=12163481\n",
539    );
540
541    /// Seal exactly as `korp_installer::layout::seal` does with no key: an
542    /// empty signature and the note that says so.
543    fn seal_unsigned(body: &str) -> String {
544        format!(
545            "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature=\n\
546             auth.pubkey=\nauth.note=NOT SIGNED: no ed25519 root key was enrolled at build \
547             time (KORP_ROOT_SIGNING_KEY unset)\n",
548            blake3::hash(body.as_bytes()).to_hex()
549        )
550    }
551
552    fn seal_signed(body: &str, seed: &[u8; 32]) -> (String, [u8; 32]) {
553        use ed25519_dalek::Signer;
554        let sk = ed25519_dalek::SigningKey::from_bytes(seed);
555        let sig = sk.sign(&signing_message(body));
556        let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
557        (
558            format!(
559                "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature={}\n\
560                 auth.pubkey={}\nauth.note=\n",
561                blake3::hash(body.as_bytes()).to_hex(),
562                hex(&sig.to_bytes()),
563                hex(sk.verifying_key().as_bytes())
564            ),
565            sk.verifying_key().to_bytes(),
566        )
567    }
568
569    // ── the format ─────────────────────────────────────────────────────────
570
571    #[test]
572    fn the_config_parses_into_its_keys() {
573        let c = LayoutConfig::parse(
574            PathBuf::from(DEFAULT_PATH),
575            &seal_unsigned(BODY),
576            Authenticity::IntegrityOnly,
577        )
578        .expect("the sample parses");
579        assert_eq!(c.version(), 1);
580        assert_eq!(c.get("boot.why"), Some("largest-group-remainder"));
581        assert_eq!(c.count("data"), 4);
582        assert_eq!(c.get("data.3.dev"), Some("/dev/vde"));
583        assert_eq!(c.get("unused.0.why"), Some("unequal, not used"));
584        assert_eq!(c.u64("threshold.rootfs_measured_bytes"), Some(12_163_481));
585    }
586
587    /// The parameters gunnar obeys, read as parameters.
588    #[test]
589    fn the_dir_parameters_are_what_the_app_is_told() {
590        let c = LayoutConfig::parse(
591            PathBuf::from(DEFAULT_PATH),
592            &seal_unsigned(BODY),
593            Authenticity::IntegrityOnly,
594        )
595        .expect("parses");
596        // The consumer's path stays under /data — it is a SYMLINK onto the
597        // other spindle, which is exactly why nothing in gunnar has to build a
598        // second placement mechanism.
599        assert_eq!(c.dir_path("log"), Some(PathBuf::from("/data/log")));
600        // The metadata mirror is a second copy and has no /data twin.
601        assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
602        assert_eq!(c.dir_path("repos"), Some(PathBuf::from("/data/repos")));
603        assert_eq!(c.dir_requires_off_stripe("log"), Some(true));
604        assert_eq!(c.dir_requires_off_stripe("repos"), Some(false));
605        assert_eq!(c.dir_off_stripe_satisfied("log"), Some(true));
606        assert_eq!(c.dir_path("nothing-like-this"), None);
607    }
608
609    /// **Never report a number you did not read.**
610    #[test]
611    fn an_absent_key_is_none_and_never_a_zero() {
612        let c = LayoutConfig::parse(
613            PathBuf::from("/x"),
614            &seal_unsigned(BODY),
615            Authenticity::IntegrityOnly,
616        )
617        .expect("parses");
618        assert_eq!(c.get("data.bytes"), None);
619        assert_eq!(c.u64("data.bytes"), None);
620        assert_eq!(
621            c.u64("boot.why"),
622            None,
623            "a value that is not a number is not one"
624        );
625        assert_eq!(c.yes_no("boot.why"), None);
626        assert_eq!(c.count("nothing"), 0);
627    }
628
629    /// Unknown KEYS are kept; an unknown VERSION is refused.
630    #[test]
631    fn version_1_may_gain_keys_but_never_change_version_silently() {
632        let grown = format!("{BODY}future.key=from a later installer\n");
633        let c = LayoutConfig::parse(
634            PathBuf::from("/x"),
635            &seal_unsigned(&grown),
636            Authenticity::IntegrityOnly,
637        )
638        .expect("parses");
639        assert_eq!(c.get("future.key"), Some("from a later installer"));
640
641        let err = LayoutConfig::parse(
642            PathBuf::from("/x"),
643            "korp-layout-config 2\nboot.dev=/dev/vda\n",
644            Authenticity::IntegrityOnly,
645        )
646        .expect_err("version 2 must be refused");
647        assert!(err.contains("version 2"), "{err}");
648    }
649
650    #[test]
651    fn escaped_values_come_back_whole() {
652        assert_eq!(
653            unescape("two\\nlines and a \\\\ backslash"),
654            "two\nlines and a \\ backslash"
655        );
656    }
657
658    // ── authenticity: three states, and a failure is not one of them ───────
659
660    /// ★ **Today's reality on every appliance.** `KORP_ROOT_SIGNING_KEY` is not
661    /// enrolled, so the signature is empty — *nobody signed this* — and the
662    /// words are the installer's own: `INTEGRITY ONLY`.
663    #[test]
664    fn an_empty_signature_is_integrity_only_and_never_verified() {
665        let a = verify(&seal_unsigned(BODY), None).expect("an unsigned config still holds");
666        assert_eq!(a, Authenticity::IntegrityOnly);
667        assert!(!a.is_verified());
668        assert_eq!(a.word(), "INTEGRITY ONLY");
669    }
670
671    /// And it stays integrity-only even where a key IS enrolled: an empty
672    /// signature is the absence of a claim, not a broken one.
673    #[test]
674    fn an_empty_signature_with_a_key_enrolled_is_still_integrity_only() {
675        let (_, key) = seal_signed(BODY, &[7u8; 32]);
676        assert_eq!(
677            verify(&seal_unsigned(BODY), Some(&key)),
678            Ok(Authenticity::IntegrityOnly)
679        );
680    }
681
682    #[test]
683    fn a_real_signature_against_the_enrolled_key_verifies() {
684        let (sealed, key) = seal_signed(BODY, &[9u8; 32]);
685        let a = verify(&sealed, Some(&key)).expect("it must verify");
686        assert_eq!(a, Authenticity::Verified);
687        assert_eq!(a.word(), "verified");
688    }
689
690    /// ★ **A failed verify is NEVER downgraded to integrity-only.** That
691    /// downgrade is how an attacker turns "signed" into "either will do".
692    #[test]
693    fn a_signature_that_does_not_verify_is_an_error_not_a_downgrade() {
694        let (sealed, _) = seal_signed(BODY, &[9u8; 32]);
695        let (_, other_key) = seal_signed(BODY, &[11u8; 32]);
696        let err = verify(&sealed, Some(&other_key)).expect_err("it must not pass");
697        assert!(err.contains("does not verify"), "{err}");
698    }
699
700    /// A body edited after sealing does not hold together, and is not read for
701    /// its values at all.
702    #[test]
703    fn an_altered_body_is_refused_by_name() {
704        let sealed = seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz");
705        let err = verify(&sealed, None).expect_err("the digest must catch it");
706        assert!(err.contains("altered since it was written"), "{err}");
707    }
708
709    #[test]
710    fn something_that_was_never_sealed_is_refused() {
711        let err = verify(BODY, None).expect_err("no auth block");
712        assert!(err.contains("never sealed"), "{err}");
713    }
714
715    // ── loading ────────────────────────────────────────────────────────────
716
717    #[test]
718    fn an_absent_config_names_where_it_looked_and_is_not_an_error() {
719        let dir = tempfile::tempdir().expect("a temp dir");
720        let load = load_from(&dir.path().join("layout.conf"), None);
721        let why = load.absent_reason().expect("absent");
722        assert!(why.contains("no layout config at"), "{why}");
723        assert!(why.contains("layout.conf"), "{why}");
724        assert!(load.config().is_none());
725    }
726
727    #[test]
728    fn a_sealed_config_on_disk_loads_as_integrity_only() {
729        let dir = tempfile::tempdir().expect("a temp dir");
730        let path = dir.path().join("layout.conf");
731        std::fs::write(&path, seal_unsigned(BODY)).expect("write");
732        let load = load_from(&path, None);
733        let c = load.config().expect("found");
734        assert_eq!(c.authenticity(), Authenticity::IntegrityOnly);
735        assert!(!c.authenticity().is_verified());
736        assert!(c.note().contains("NOT SIGNED"), "{}", c.note());
737        assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
738    }
739
740    /// **Tampered is treated as ABSENT, not as trusted** — and the sentence
741    /// says which, because "there is no config" and "the config on this box
742    /// has been altered" send an admin to two different places.
743    #[test]
744    fn a_tampered_config_is_absent_rather_than_trusted() {
745        let dir = tempfile::tempdir().expect("a temp dir");
746        let path = dir.path().join("layout.conf");
747        std::fs::write(&path, seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz")).expect("write");
748        let load = load_from(&path, None);
749        assert!(
750            load.config().is_none(),
751            "not one value may be read out of it"
752        );
753        let why = load.absent_reason().expect("absent");
754        assert!(why.contains("does not verify"), "{why}");
755        assert!(why.contains("altered"), "{why}");
756        assert!(why.contains("rather than trusted"), "{why}");
757    }
758}