Skip to main content

gunnar_server/
slow.rs

1//! **Where each kind of data goes, and what gunnar may claim about it.**
2//!
3//! ```text
4//!  17:40:44  layout /boot/korp/layout.conf  INTEGRITY ONLY · off-stripe: p3 /dev/vda3
5//!  17:40:44  layout /boot/korp/layout.conf  INTEGRITY ONLY · SAME DEVICE as /data — not off-stripe
6//!  17:40:44  layout (no config)  nothing says what these are on — not confirmed off-stripe
7//! ```
8//!
9//! # The machine this is written for
10//!
11//! The appliance `korp-installer` lays down has two storage areas, and they are
12//! not "fast" and "slow" so much as **one failure domain and another**:
13//!
14//! * `/data` — the MaxIOPS volumes as one XFS over an md RAID0. Fast, and
15//!   **zero redundancy**: one member disk dies and all of it is gone.
16//! * `/serialdat` — a standard-tier area, usually the boot disk's third
17//!   partition. Its value is NOT speed. It is another spindle (every IO there
18//!   is one the stripe does not serve) and it is a **different failure
19//!   domain**.
20//!
21//! # gunnar adds NO placement mechanism
22//!
23//! The installer already solved placement, and not with paths in this code: it
24//! lays `crash`, `log`, `gc` and `quarantine` PHYSICALLY on `/serialdat` and
25//! makes `/data/<name>` a **symlink** to each. So `dir.log.path` is `/data/log`
26//! and writing there already lands on the other spindle without gunnar knowing.
27//! `meta` — the roster, principals and enrolled keys — is a second COPY, on
28//! `/serialdat/meta` only, with no `/data` twin. `repos` stays on the stripe.
29//!
30//! This module reads `dir.<name>.path` out of [`crate::layout_config`] and
31//! writes exactly there. It does not compute a path, does not prefer one root
32//! over another and does not check what it was told: two things deciding the
33//! same thing is how they start disagreeing.
34//!
35//! # The one rule: never branch on whether the slow area is real
36//!
37//! `korp-installer` guarantees every directory resolves — on a machine with no
38//! room for a real `/serialdat` it lays a directory under `/data` and
39//! bind-mounts it there anyway. So gunnar has ONE code path: it writes the path
40//! it was given, always.
41//!
42//! What the config records instead is, per directory,
43//! `dir.<name>.off_stripe_required` and `dir.<name>.off_stripe_satisfied`.
44//! Reading them changes **nothing** about where gunnar writes. It changes what
45//! gunnar is allowed to CLAIM. The settled principle, and the reason this
46//! module is shaped the way it is:
47//!
48//! > **Branch on what you claim, never on what you do.**
49//!
50//! # Why a dishonest claim is worse than no claim
51//!
52//! Three of the things placed here — the metadata mirror, the crash and
53//! diagnostic records, the audit trail — are the only copies that survive
54//! losing one data disk. When the installer had no second device, they are on
55//! the same device as the thing they protect, and a health check that answers
56//! "mirror OK" for a mirror that dies with its original is worse than no mirror
57//! at all, because somebody will plan a recovery around it.
58//!
59//! So [`Claim`] has three values and none of them is a boolean:
60//! `off-stripe` / `same-device` / `absent`. `SameDevice` is reported as
61//! **present, same-device, not off-stripe** — never "OK", never "missing".
62//! `Absent` (no config: nothing has said either way) is likewise never
63//! promoted, because an unread file is not evidence of a separate disk.
64//!
65//! # Without a config
66//!
67//! Every appliance installed before the config existed has none. gunnar must
68//! start on it, so each directory falls back to `<data root>/<name>` — the
69//! documented default, said out loud — and the claim is `absent`. That is a
70//! fallback, never a discovery mechanism: nothing probes for a `/serialdat` to
71//! use instead.
72//!
73//! On a box laid by the installer but whose config could not be read, four of
74//! those fallbacks are the installer's own symlinks and land on the other
75//! spindle anyway. gunnar still does not claim it, because gunnar was not told.
76//!
77//! # The table
78//!
79//! | data | `dir.<name>` | needs off-stripe | gunnar writes it |
80//! |---|---|---|---|
81//! | git objects, packfiles, LFS, refs | [`REPOS_DIR`] | no | yes — unchanged |
82//! | metadata mirror (roster, principals, keys) | [`META_DIR`] | **yes** | yes |
83//! | crash + diagnostics | [`CRASH_DIR`] | **yes** | yes |
84//! | audit / console log | [`LOG_DIR`] | **yes** | yes |
85//! | git gc / repack scratch | [`GC_DIR`] | no | **not yet** |
86//! | push quarantine | [`QUARANTINE_DIR`] | no | **not yet** |
87//!
88//! The last two are said plainly rather than implied by a directory that
89//! exists, and [`PLACEMENT`] carries the `wired` flag the admin view prints:
90//!
91//! * gc/repack scratch is the storage engine's, not gunnar's — `Repos.Gc`
92//!   seals logs and compacts the archive inside the engine, which chooses its
93//!   own temporaries. Pointing it here is an engine change, not a placement
94//!   one.
95//! * push quarantine would move the `incoming-*.pack` landing spot
96//!   (`crate::storage`) off the stripe, which turns the final rename into a
97//!   whole-pack cross-device COPY on the hot push path. That is a trade to
98//!   measure, not to make blind.
99//!
100//! `typeset` and `generations` are in the installer's tree and are NOT gunnar's
101//! — the first is gunnar-ui's, the second korp-installer's — so nothing here
102//! names them.
103
104use std::collections::BTreeMap;
105use std::path::{Path, PathBuf};
106use std::sync::OnceLock;
107
108use anyhow::{Context, Result};
109
110use crate::layout_config::LayoutConfig;
111
112/// `korp_installer::layout::USERS_DIR` — the per-user namespace directory,
113/// spelled the same on both roots: `/data/repos/<user>` and, mirrored,
114/// `/serialdat/repos/<user>`.
115pub const REPOS_DIR: &str = "repos";
116/// The metadata mirror: the replication roster, principals, enrolled keys.
117pub const META_DIR: &str = "meta";
118/// Crash records and diagnostics.
119pub const CRASH_DIR: &str = "crash";
120/// The audit trail and the durable console log.
121pub const LOG_DIR: &str = "log";
122/// gc / repack scratch.
123pub const GC_DIR: &str = "gc";
124/// Push quarantine.
125pub const QUARANTINE_DIR: &str = "quarantine";
126
127/// **A path as a reader must see it**: the configured path, and where it REALLY
128/// is when that differs — `/data/crash (really /serialdat/crash)`. The
129/// installer lays the off-stripe directories as symlinks under `/data`, so the
130/// bare configured path read as "on the data stripe" beside the words
131/// "off-stripe" (owner, gunnar.rs 2026-10-14). Read off the filesystem, never
132/// computed; a path that cannot be resolved is shown as configured.
133pub fn shown_path(configured: &Path) -> String {
134    match std::fs::canonicalize(configured) {
135        Ok(real) if real != configured => format!("{} (really {})", configured.display(), real.display()),
136        _ => configured.display().to_string(),
137    }
138}
139
140/// The tracing target every row from this module carries.
141pub const TARGET_SLOW: &str = "gunnar::slow";
142
143// ───────────────────────────────────────────────────────────────────────────
144// the placement table, as data
145// ───────────────────────────────────────────────────────────────────────────
146
147/// One kind of data, and whether gunnar actually puts anything there.
148#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub struct Placement {
150    /// What it holds, in the admin's words.
151    pub what: &'static str,
152    /// The `dir.<name>` the config spells it under.
153    pub dir: &'static str,
154    /// Is this one of the copies that must be on another failure domain?
155    pub requires_off_stripe: bool,
156    /// **Does gunnar write here today?** False is reported as false; a
157    /// directory that exists is not a feature.
158    pub wired: bool,
159    /// When `wired` is false, why not — printed beside the row.
160    pub note: &'static str,
161}
162
163/// **The placement table gunnar is actually configured with** — layer 3 of the
164/// admin view, and the specification the tests assert against.
165///
166/// The `requires_off_stripe` column is gunnar's own statement of what it needs.
167/// Whether that need is MET is never decided here: it comes from the config's
168/// `dir.<name>.off_stripe_satisfied`, which is the installer's verdict about
169/// the machine it partitioned.
170pub const PLACEMENT: &[Placement] = &[
171    Placement {
172        what: "git objects, packfiles, LFS, refs",
173        dir: REPOS_DIR,
174        requires_off_stripe: false,
175        wired: true,
176        note: "",
177    },
178    Placement {
179        what: "metadata mirror (roster, principals, enrolled keys)",
180        dir: META_DIR,
181        requires_off_stripe: true,
182        wired: true,
183        note: "",
184    },
185    Placement {
186        what: "crash + diagnostics",
187        dir: CRASH_DIR,
188        requires_off_stripe: true,
189        wired: true,
190        note: "",
191    },
192    Placement {
193        what: "audit / console log",
194        dir: LOG_DIR,
195        requires_off_stripe: true,
196        wired: true,
197        note: "",
198    },
199    Placement {
200        what: "git gc / repack scratch",
201        dir: GC_DIR,
202        requires_off_stripe: false,
203        wired: false,
204        note: "the storage engine chooses its own temporaries; pointing them here is an \
205               engine change, not a placement one",
206    },
207    Placement {
208        what: "push quarantine",
209        dir: QUARANTINE_DIR,
210        requires_off_stripe: false,
211        wired: false,
212        note: "moving the incoming-*.pack landing spot off the stripe turns the final rename \
213               into a whole-pack cross-device copy on the push path; measure before trading",
214    },
215];
216
217// ───────────────────────────────────────────────────────────────────────────
218// the claim
219// ───────────────────────────────────────────────────────────────────────────
220
221/// **What gunnar is allowed to say** about the copies that need another failure
222/// domain.
223///
224/// Three values, and deliberately not a `bool` with a comment: the middle one
225/// is the whole point. A mirror that is present and dies with its original is
226/// not "OK" and is not "missing", and the two words a boolean offers are both
227/// lies about it.
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum Claim {
230    /// The installer says this is a different failure domain. The copy
231    /// survives losing a data disk.
232    OffStripe,
233    /// The installer says it is not. Present, written, readable — and on the
234    /// same device as what it protects.
235    SameDevice,
236    /// Nothing has said either way. Not confirmed off-stripe, and never
237    /// reported as if it were.
238    Absent,
239}
240
241impl Claim {
242    /// The one word a status column carries. Never `ok`.
243    pub fn word(self) -> &'static str {
244        match self {
245            Claim::OffStripe => "off-stripe",
246            Claim::SameDevice => "same-device",
247            Claim::Absent => "absent",
248        }
249    }
250
251    /// Is the off-stripe requirement satisfied? Only [`Claim::OffStripe`].
252    pub fn satisfied(self) -> bool {
253        matches!(self, Claim::OffStripe)
254    }
255
256    /// Does this deserve a warning row rather than an informational one?
257    pub fn warns(self) -> bool {
258        !self.satisfied()
259    }
260
261    /// `yes`/`no`/absent, as the config's `off_stripe_satisfied` writes it.
262    pub fn from_satisfied(satisfied: Option<bool>) -> Claim {
263        match satisfied {
264            Some(true) => Claim::OffStripe,
265            Some(false) => Claim::SameDevice,
266            None => Claim::Absent,
267        }
268    }
269}
270
271// ───────────────────────────────────────────────────────────────────────────
272// where the paths came from
273// ───────────────────────────────────────────────────────────────────────────
274
275/// **How gunnar learned where to write.** Reported, because "we were told" and
276/// "we fell back to the documented default" are different facts, and an admin
277/// looking at this page is often looking at it because something broke.
278#[derive(Clone, Copy, Debug, PartialEq, Eq)]
279pub enum Source {
280    /// The layout config named every path. The normal case on a box installed
281    /// today.
282    Config,
283    /// There is no config (or it did not verify). Every directory is
284    /// `<data root>/<name>`, the documented default — a FALLBACK, not a
285    /// discovery.
286    Fallback,
287}
288
289impl Source {
290    pub fn word(self) -> &'static str {
291        match self {
292            Source::Config => "named by the layout config",
293            Source::Fallback => {
294                "no layout config: the documented default under the data root, and nothing \
295                 here went looking for a slow area"
296            }
297        }
298    }
299}
300
301// ───────────────────────────────────────────────────────────────────────────
302// the resolved layout
303// ───────────────────────────────────────────────────────────────────────────
304
305/// The resolved directories, how they were learned, and what may be claimed
306/// about each.
307#[derive(Clone, Debug)]
308pub struct SlowRoot {
309    data_root: PathBuf,
310    source: Source,
311    dirs: BTreeMap<&'static str, PathBuf>,
312    claims: BTreeMap<&'static str, Claim>,
313    /// `serialdat.mountpoint` as the config gives it, or empty. Reported, not
314    /// used to build a path: every path comes from `dir.<name>.path`.
315    serialdat: String,
316    /// `serialdat.class` — `p3` / `device` / `fallback` — or empty.
317    class: String,
318    /// `serialdat.dev`, or empty.
319    device: String,
320}
321
322impl SlowRoot {
323    /// Resolve every directory from the config, or fall back under the data
324    /// root when there is none.
325    pub fn resolve(data_root: &Path, config: Option<&LayoutConfig>) -> SlowRoot {
326        let source = if config.is_some() {
327            Source::Config
328        } else {
329            Source::Fallback
330        };
331        let mut dirs = BTreeMap::new();
332        let mut claims = BTreeMap::new();
333        for p in PLACEMENT {
334            // **Obeyed, not computed.** The config's path is taken verbatim;
335            // the fallback is the documented default and nothing else.
336            let path = config
337                .and_then(|c| c.dir_path(p.dir))
338                .unwrap_or_else(|| data_root.join(p.dir));
339            dirs.insert(p.dir, path);
340            // The verdict is the INSTALLER's, read out of the config. gunnar
341            // does not re-derive it from anything it can see.
342            let claim = if p.requires_off_stripe {
343                Claim::from_satisfied(config.and_then(|c| c.dir_off_stripe_satisfied(p.dir)))
344            } else {
345                Claim::Absent
346            };
347            claims.insert(p.dir, claim);
348        }
349        let field = |key: &str| {
350            config
351                .and_then(|c| c.get(key))
352                .unwrap_or_default()
353                .to_owned()
354        };
355        SlowRoot {
356            data_root: data_root.to_path_buf(),
357            source,
358            dirs,
359            claims,
360            serialdat: field("serialdat.mountpoint"),
361            class: field("serialdat.class"),
362            device: field("serialdat.dev"),
363        }
364    }
365
366    /// How gunnar learned where to write.
367    pub fn source(&self) -> Source {
368        self.source
369    }
370
371    /// `serialdat.mountpoint`, or empty when nothing said. Reported only.
372    pub fn serialdat(&self) -> &str {
373        &self.serialdat
374    }
375
376    /// `serialdat.class` — `p3` / `device` / `fallback` — or empty when
377    /// nothing said. A class that was not read is not reported as one.
378    pub fn class(&self) -> &str {
379        &self.class
380    }
381
382    /// `serialdat.dev`, or empty.
383    pub fn device(&self) -> &str {
384        &self.device
385    }
386
387    /// The path for one `dir.<name>`, exactly as configured. Never computed
388    /// relative to anything else.
389    pub fn path_of(&self, name: &str) -> PathBuf {
390        self.dirs
391            .get(name)
392            .cloned()
393            .unwrap_or_else(|| self.data_root.join(name))
394    }
395
396    /// **The one verdict**, over the copies that need another failure domain.
397    ///
398    /// The three of them are laid by one installer decision, so they agree by
399    /// construction; if they ever did not, the honest summary is the WORST of
400    /// them, never the best.
401    pub fn claim(&self) -> Claim {
402        let mut worst = Claim::OffStripe;
403        let mut seen = false;
404        for p in PLACEMENT.iter().filter(|p| p.requires_off_stripe) {
405            seen = true;
406            let c = self.claim_of(p.dir);
407            worst = match (worst, c) {
408                (Claim::SameDevice, _) | (_, Claim::SameDevice) => Claim::SameDevice,
409                (Claim::Absent, _) | (_, Claim::Absent) => Claim::Absent,
410                _ => Claim::OffStripe,
411            };
412        }
413        if seen {
414            worst
415        } else {
416            Claim::Absent
417        }
418    }
419
420    /// The verdict for one directory.
421    pub fn claim_of(&self, name: &str) -> Claim {
422        self.claims.get(name).copied().unwrap_or(Claim::Absent)
423    }
424
425    /// One of the directories, created on demand.
426    ///
427    /// Creation is here and not at resolve time on purpose: a slow area that
428    /// has gone read-only must fail the write that needed it, with the path in
429    /// the message, rather than fail the boot of a server whose git objects are
430    /// all still on a working disk.
431    pub fn dir(&self, name: &str) -> Result<PathBuf> {
432        let dir = self.path_of(name);
433        std::fs::create_dir_all(&dir)
434            .with_context(|| format!("creating the configured directory {}", dir.display()))?;
435        Ok(dir)
436    }
437
438    /// The metadata mirror: roster, principals, enrolled keys. Needs off-stripe.
439    pub fn meta_dir(&self) -> Result<PathBuf> {
440        self.dir(META_DIR)
441    }
442
443    /// Crash records and diagnostics. Needs off-stripe.
444    pub fn crash_dir(&self) -> Result<PathBuf> {
445        self.dir(CRASH_DIR)
446    }
447
448    /// The audit trail and the durable console log. Needs off-stripe.
449    pub fn log_dir(&self) -> Result<PathBuf> {
450        self.dir(LOG_DIR)
451    }
452
453    /// gc / repack scratch. Does not need off-stripe; nothing writes here yet.
454    pub fn gc_dir(&self) -> Result<PathBuf> {
455        self.dir(GC_DIR)
456    }
457
458    /// Push quarantine. Does not need off-stripe; nothing writes here yet.
459    pub fn quarantine_dir(&self) -> Result<PathBuf> {
460        self.dir(QUARANTINE_DIR)
461    }
462
463    /// **The honest sentence about one off-stripe-requiring copy.**
464    ///
465    /// Never "OK" and never "missing" for a copy that is present on the same
466    /// device: the whole failure this module exists to prevent is a health
467    /// check that answers `mirror OK` for a mirror that dies with its original.
468    pub fn health_line(&self, what: &str, dir: &str) -> String {
469        let where_ = shown_path(&self.path_of(dir));
470        match self.claim_of(dir) {
471            Claim::OffStripe => format!(
472                "{what}: present at {}, off-stripe ({}) — survives losing a data disk",
473                where_,
474                self.backing_phrase()
475            ),
476            Claim::SameDevice => format!(
477                "{what}: present at {}, SAME DEVICE as the data it protects ({}) — NOT \
478                 off-stripe; it does not survive losing a data disk",
479                where_,
480                self.backing_phrase()
481            ),
482            Claim::Absent => format!(
483                "{what}: present at {}, and nothing says what it is on ({}) — NOT confirmed \
484                 off-stripe; treat it as same-device until the layout config says otherwise",
485                where_,
486                self.backing_phrase()
487            ),
488        }
489    }
490
491    /// The three off-stripe-requiring copies, each said honestly.
492    pub fn health(&self) -> Vec<String> {
493        PLACEMENT
494            .iter()
495            .filter(|p| p.requires_off_stripe)
496            .map(|p| self.health_line(p.what, p.dir))
497            .collect()
498    }
499
500    /// What the config says is under `/serialdat`, or that nothing does.
501    fn backing_phrase(&self) -> String {
502        match (self.class.as_str(), self.device.as_str()) {
503            ("", _) => "no layout config".to_owned(),
504            (class, "") => class.to_owned(),
505            (class, dev) => format!("{class} {dev}"),
506        }
507    }
508
509    /// The console row: the same shape `crate::disktest` uses, one line, and a
510    /// `warn` flag so the dashboard colours it.
511    pub fn row(&self) -> (String, bool) {
512        let claim = self.claim();
513        let text = match claim {
514            Claim::OffStripe => format!("off-stripe: {}", self.backing_phrase()),
515            Claim::SameDevice => format!(
516                "SAME DEVICE as /data \u{2014} not off-stripe ({})",
517                self.backing_phrase()
518            ),
519            Claim::Absent => format!(
520                "nothing says what these are on \u{2014} not confirmed off-stripe ({})",
521                self.backing_phrase()
522            ),
523        };
524        (text, claim.warns())
525    }
526
527    /// Say the row as the ONE tracing line the log tap turns into the `layout`
528    /// row (`dashboard::describe`), exactly as `disktest` does.
529    ///
530    /// `authenticity` is [`crate::layout_config::Authenticity::word`] — the
531    /// SAME two words the installer's own console prints, so an admin
532    /// comparing the two screens is not left wondering whether they mean the
533    /// same thing.
534    pub fn announce(&self, config_path: &str, authenticity: &str) {
535        let (text, warn) = self.row();
536        let text = if authenticity.is_empty() {
537            text
538        } else {
539            format!("{authenticity} \u{b7} {text}")
540        };
541        if warn {
542            tracing::warn!(
543                target: TARGET_SLOW,
544                event = "layout",
545                path = config_path,
546                claim = self.claim().word(),
547                source = self.source.word(),
548                "{text}"
549            );
550        } else {
551            tracing::info!(
552                target: TARGET_SLOW,
553                event = "layout",
554                path = config_path,
555                claim = self.claim().word(),
556                source = self.source.word(),
557                "{text}"
558            );
559        }
560    }
561}
562
563// ───────────────────────────────────────────────────────────────────────────
564// crash records
565// ───────────────────────────────────────────────────────────────────────────
566
567/// Where [`record_crash`] writes, registered once the layout is known.
568///
569/// A process-global because its reader is the **panic hook**, which is
570/// installed in `main` before a data root has been parsed and which takes no
571/// arguments. Nothing else reads it, and a hook that finds it unset simply
572/// writes no file — the panic still reaches the console either way.
573static CRASH_DIR_REGISTERED: OnceLock<PathBuf> = OnceLock::new();
574
575/// Register the crash directory for the panic hook. First caller wins.
576pub fn register_crash_dir(dir: PathBuf) {
577    let _ = CRASH_DIR_REGISTERED.set(dir);
578}
579
580/// Write one crash record where the config said crash records go, best effort,
581/// and answer where it went.
582///
583/// Best effort on purpose: this runs inside a panic hook, where a failure has
584/// nowhere to be reported and an unwrap would replace a readable panic message
585/// with an unreadable double one.
586pub fn record_crash(text: &str) -> Option<PathBuf> {
587    let dir = CRASH_DIR_REGISTERED.get()?;
588    let stamp = std::time::SystemTime::now()
589        .duration_since(std::time::UNIX_EPOCH)
590        .map(|d| d.as_millis())
591        .unwrap_or_default();
592    let path = dir.join(format!("panic-{stamp}.txt"));
593    std::fs::create_dir_all(dir).ok()?;
594    std::fs::write(&path, text.as_bytes()).ok()?;
595    Some(path)
596}
597
598// ───────────────────────────────────────────────────────────────────────────
599// adopting a file that used to live somewhere else
600// ───────────────────────────────────────────────────────────────────────────
601
602/// Move `old` to `new` once, and answer which path to use.
603///
604/// `rename` is not enough: the two paths are on DIFFERENT DEVICES whenever the
605/// configured directory is a symlink onto the other spindle, which is the case
606/// this exists for, and `rename` answers `EXDEV` there. So it is copy, fsync,
607/// then unlink — and if any step fails the answer is `old`, unchanged and
608/// untouched. A server that could not move its audit trail keeps writing the
609/// audit trail.
610///
611/// Does nothing when `new` already exists: the move happens once, on the first
612/// boot of a build that reads the layout config.
613pub fn adopt_file(old: &Path, new: &Path) -> PathBuf {
614    if new.exists() || !old.exists() {
615        return new.to_path_buf();
616    }
617    let Some(parent) = new.parent() else {
618        return old.to_path_buf();
619    };
620    if std::fs::create_dir_all(parent).is_err() {
621        return old.to_path_buf();
622    }
623    // Through a `.adopting` sibling and a rename WITHIN the destination
624    // directory, so a cut in the middle of the copy can never leave a half file
625    // under the real name — the same discipline `crate::durable` writes control
626    // files with, and for the same reason.
627    let temp = new.with_extension("adopting");
628    let copied = std::fs::copy(old, &temp).is_ok()
629        && std::fs::File::open(&temp)
630            .and_then(|f| f.sync_all())
631            .is_ok()
632        && std::fs::rename(&temp, new).is_ok();
633    if !copied {
634        let _ = std::fs::remove_file(&temp);
635        return old.to_path_buf();
636    }
637    if let Ok(dir) = std::fs::File::open(parent) {
638        let _ = dir.sync_all();
639    }
640    // The original goes only after the copy is durable under its final name.
641    // A failure to unlink leaves a stale copy behind, which is untidy and
642    // harmless; a failure the other way round loses the trail.
643    if let Err(err) = std::fs::remove_file(old) {
644        tracing::warn!(
645            target: TARGET_SLOW,
646            old = %old.display(),
647            new = %new.display(),
648            %err,
649            "the file was copied to its configured directory but the old copy could not be \
650             removed; the one in use is the new one"
651        );
652    }
653    new.to_path_buf()
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::layout_config::{Authenticity, LayoutConfig};
660
661    /// A config body in the installer's shape, with the three dirs that matter
662    /// and a chosen `off_stripe_satisfied`.
663    fn config(satisfied: bool, class: &str, dev: &str) -> LayoutConfig {
664        let yn = if satisfied { "yes" } else { "no" };
665        let mut text = format!(
666            "korp-layout-config 1\n\
667             data.mountpoint=/data\n\
668             serialdat.mountpoint=/serialdat\n\
669             serialdat.class={class}\n\
670             serialdat.dev={dev}\n\
671             serialdat.off_stripe={yn}\n\
672             dir.users_dir=repos\n\
673             dir.repos.path=/data/repos\n\
674             dir.repos.off_stripe_required=no\n\
675             dir.repos.off_stripe_satisfied=yes\n\
676             dir.meta.path=/serialdat/meta\n\
677             dir.meta.off_stripe_required=yes\n\
678             dir.meta.off_stripe_satisfied={yn}\n"
679        );
680        for d in [CRASH_DIR, LOG_DIR] {
681            text.push_str(&format!(
682                "dir.{d}.path=/data/{d}\n\
683                 dir.{d}.off_stripe_required=yes\n\
684                 dir.{d}.off_stripe_satisfied={yn}\n"
685            ));
686        }
687        for d in [GC_DIR, QUARANTINE_DIR] {
688            text.push_str(&format!(
689                "dir.{d}.path=/data/{d}\n\
690                 dir.{d}.off_stripe_required=no\n\
691                 dir.{d}.off_stripe_satisfied=yes\n"
692            ));
693        }
694        LayoutConfig::parse(
695            PathBuf::from("/boot/korp/layout.conf"),
696            &text,
697            Authenticity::IntegrityOnly,
698        )
699        .expect("the fixture parses")
700    }
701
702    /// ★ Owner 2026-10-14: "present at /data/crash, off-stripe (p3 /dev/vda3)"
703    /// while /data is the stripe. True — /data/crash is the installer's symlink
704    /// onto /serialdat — and unreadable. The line names the real place.
705    #[cfg(unix)]
706    #[test]
707    fn a_symlinked_directory_is_shown_with_where_it_really_is() {
708        let t = tempfile::tempdir().unwrap();
709        let root = t.path().canonicalize().unwrap();
710        let (data, slow) = (root.join("data"), root.join("serialdat"));
711        std::fs::create_dir_all(&data).unwrap();
712        std::fs::create_dir_all(slow.join("crash")).unwrap();
713        std::os::unix::fs::symlink(slow.join("crash"), data.join("crash")).unwrap();
714        assert_eq!(shown_path(&data.join("crash")), format!("{} (really {})", data.join("crash").display(), slow.join("crash").display()));
715        // A plain directory, and one that does not exist, are shown as configured.
716        assert_eq!(shown_path(&data), data.display().to_string());
717        assert_eq!(shown_path(Path::new("/nowhere/at/all")), "/nowhere/at/all");
718    }
719
720    // ── obeying the config ─────────────────────────────────────────────────
721
722    /// **The config's paths are taken verbatim.** `/data/log` is a symlink the
723    /// installer laid onto the other spindle; gunnar writes there and adds no
724    /// second placement mechanism on top of it.
725    #[test]
726    fn every_path_comes_from_the_config_unchanged() {
727        let c = config(true, "p3", "/dev/vda3");
728        let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
729        assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
730        assert_eq!(s.path_of(CRASH_DIR), PathBuf::from("/data/crash"));
731        assert_eq!(s.path_of(REPOS_DIR), PathBuf::from("/data/repos"));
732        // The metadata mirror is a second COPY and has no /data twin.
733        assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
734        assert_eq!(s.source(), Source::Config);
735    }
736
737    /// A path gunnar was not told about is not invented from `/serialdat`: it
738    /// is the documented default under the data root.
739    #[test]
740    fn a_config_missing_one_dir_falls_back_for_that_one_only() {
741        let text = "korp-layout-config 1\ndir.meta.path=/serialdat/meta\n\
742                    dir.meta.off_stripe_required=yes\ndir.meta.off_stripe_satisfied=yes\n";
743        let c = LayoutConfig::parse(PathBuf::from("/x"), text, Authenticity::IntegrityOnly)
744            .expect("parses");
745        let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
746        assert_eq!(s.path_of(META_DIR), PathBuf::from("/serialdat/meta"));
747        assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/data/log"));
748    }
749
750    // ── the claim, in each of the three states ─────────────────────────────
751
752    #[test]
753    fn a_satisfied_config_claims_off_stripe_and_does_not_warn() {
754        let c = config(true, "p3", "/dev/vda3");
755        let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
756        assert_eq!(s.claim(), Claim::OffStripe);
757        assert!(s.claim().satisfied());
758        let (text, warn) = s.row();
759        assert!(!warn, "a real spindle is not a warning: {text}");
760        assert!(text.contains("off-stripe: p3 /dev/vda3"), "{text}");
761    }
762
763    /// ★ **Never "OK", never "missing".** The one assertion that is the whole
764    /// point: a mirror on the same device as its original must be reported as
765    /// present AND as not off-stripe, in the same sentence.
766    #[test]
767    fn a_same_device_layout_is_never_reported_as_satisfied() {
768        let c = config(false, "fallback", "/data");
769        let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
770        assert_eq!(s.claim(), Claim::SameDevice);
771        assert!(!s.claim().satisfied(), "a mirror that dies with its original satisfies nothing");
772        assert!(s.claim().warns());
773        assert_eq!(s.claim().word(), "same-device");
774
775        let lines = s.health();
776        assert_eq!(lines.len(), 3, "three copies need off-stripe");
777        for line in lines {
778            let lower = line.to_lowercase();
779            assert!(
780                lower.contains("present"),
781                "it IS there, and saying `missing` sends someone looking for a bug: {line}"
782            );
783            assert!(
784                lower.contains("not off-stripe"),
785                "the sentence has to carry the limitation: {line}"
786            );
787            assert!(
788                !lower.contains(" ok"),
789                "`OK` for a same-device mirror is the lie this module exists to stop: {line}"
790            );
791        }
792        // And the row that reaches the console is a WARN.
793        let (text, warn) = s.row();
794        assert!(warn, "{text}");
795        assert!(text.contains("SAME DEVICE"), "{text}");
796    }
797
798    /// **No config: nothing has said either way**, and that is its own answer.
799    #[test]
800    fn without_a_config_everything_is_absent_and_nothing_is_inferred() {
801        let s = SlowRoot::resolve(Path::new("/var/lib/gunnar"), None);
802        assert_eq!(s.source(), Source::Fallback);
803        assert_eq!(s.claim(), Claim::Absent);
804        assert_eq!(s.claim().word(), "absent");
805        assert!(!s.claim().satisfied());
806        // The documented default, and NOT a `/serialdat` gunnar went looking
807        // for.
808        assert_eq!(s.path_of(META_DIR), PathBuf::from("/var/lib/gunnar/meta"));
809        assert_eq!(s.path_of(LOG_DIR), PathBuf::from("/var/lib/gunnar/log"));
810        // Nothing that was not read is reported.
811        assert_eq!(s.serialdat(), "");
812        assert_eq!(s.class(), "");
813        assert_eq!(s.device(), "");
814        for line in s.health() {
815            assert!(line.contains("present"), "{line}");
816            assert!(
817                line.to_lowercase().contains("not confirmed off-stripe"),
818                "{line}"
819            );
820        }
821    }
822
823    /// The summary of three verdicts is the WORST of them, never the best.
824    #[test]
825    fn a_disagreeing_config_summarises_downwards() {
826        let mut text = String::from("korp-layout-config 1\n");
827        for (d, yn) in [(META_DIR, "yes"), (CRASH_DIR, "yes"), (LOG_DIR, "no")] {
828            text.push_str(&format!(
829                "dir.{d}.path=/data/{d}\ndir.{d}.off_stripe_required=yes\n\
830                 dir.{d}.off_stripe_satisfied={yn}\n"
831            ));
832        }
833        let c = LayoutConfig::parse(PathBuf::from("/x"), &text, Authenticity::IntegrityOnly)
834            .expect("parses");
835        let s = SlowRoot::resolve(Path::new("/data"), Some(&c));
836        assert_eq!(s.claim_of(META_DIR), Claim::OffStripe);
837        assert_eq!(s.claim_of(LOG_DIR), Claim::SameDevice);
838        assert_eq!(
839            s.claim(),
840            Claim::SameDevice,
841            "one copy that cannot survive a disk makes the summary same-device"
842        );
843    }
844
845    // ── the placement table ────────────────────────────────────────────────
846
847    #[test]
848    fn the_placement_table_holds() {
849        // Exactly one row does not require another failure domain AND is git's
850        // objects; exactly three require one.
851        let need: Vec<_> = PLACEMENT.iter().filter(|p| p.requires_off_stripe).collect();
852        assert_eq!(
853            need.iter().map(|p| p.dir).collect::<Vec<_>>(),
854            [META_DIR, CRASH_DIR, LOG_DIR]
855        );
856        assert!(
857            need.iter().all(|p| p.wired),
858            "a copy that must survive a disk and is not written is not a placement"
859        );
860        assert_eq!(PLACEMENT[0].dir, REPOS_DIR);
861        assert!(!PLACEMENT[0].requires_off_stripe);
862
863        // The two that are NOT wired say so, and say why.
864        let unwired: Vec<_> = PLACEMENT.iter().filter(|p| !p.wired).collect();
865        assert_eq!(
866            unwired.iter().map(|p| p.dir).collect::<Vec<_>>(),
867            [GC_DIR, QUARANTINE_DIR]
868        );
869        assert!(
870            unwired.iter().all(|p| !p.note.is_empty()),
871            "an unwired row without a reason is a directory pretending to be a feature"
872        );
873        // `typeset` and `generations` are somebody else's and are not here.
874        assert!(PLACEMENT.iter().all(|p| p.dir != "typeset" && p.dir != "generations"));
875    }
876
877    /// The SAME paths whatever the verdict says: only the claim moves.
878    #[test]
879    fn the_layout_is_identical_in_every_state() {
880        let paths = |c: Option<&LayoutConfig>| {
881            let s = SlowRoot::resolve(Path::new("/data"), c);
882            PLACEMENT
883                .iter()
884                .map(|p| s.path_of(p.dir))
885                .collect::<Vec<_>>()
886        };
887        let satisfied = config(true, "p3", "/dev/vda3");
888        let not = config(false, "fallback", "/data");
889        assert_eq!(
890            paths(Some(&satisfied)),
891            paths(Some(&not)),
892            "the verdict must not move one byte of the layout"
893        );
894    }
895
896    // ── directories ────────────────────────────────────────────────────────
897
898    #[test]
899    fn every_directory_is_created_on_demand_under_the_configured_path() {
900        let dir = tempfile::tempdir().expect("a temp dir");
901        let s = SlowRoot::resolve(dir.path(), None);
902        for d in [
903            s.meta_dir().expect("meta"),
904            s.crash_dir().expect("crash"),
905            s.log_dir().expect("log"),
906            s.gc_dir().expect("gc"),
907            s.quarantine_dir().expect("quarantine"),
908        ] {
909            assert!(d.is_dir(), "{} was not created", d.display());
910            assert!(d.starts_with(dir.path()), "{}", d.display());
911        }
912    }
913
914    // ── adopting ───────────────────────────────────────────────────────────
915
916    #[test]
917    fn adopting_moves_the_file_once_and_leaves_nothing_behind() {
918        let dir = tempfile::tempdir().expect("a temp dir");
919        let old = dir.path().join("control/audit-chain.jsonl");
920        std::fs::create_dir_all(old.parent().unwrap()).expect("mkdir");
921        std::fs::write(&old, b"{\"seq\":0}\n").expect("write");
922        let new = dir.path().join("log/audit-chain.jsonl");
923
924        assert_eq!(adopt_file(&old, &new), new);
925        assert_eq!(std::fs::read(&new).expect("the moved file"), b"{\"seq\":0}\n");
926        assert!(!old.exists(), "the old copy is gone");
927        assert!(!new.with_extension("adopting").exists());
928
929        // Idempotent: a second boot does not re-adopt and does not clobber.
930        std::fs::write(&new, b"{\"seq\":1}\n").expect("append");
931        assert_eq!(adopt_file(&old, &new), new);
932        assert_eq!(std::fs::read(&new).expect("untouched"), b"{\"seq\":1}\n");
933    }
934
935    #[test]
936    fn adopting_nothing_still_names_the_new_path() {
937        let dir = tempfile::tempdir().expect("a temp dir");
938        let old = dir.path().join("control/audit-chain.jsonl");
939        let new = dir.path().join("log/audit-chain.jsonl");
940        assert_eq!(adopt_file(&old, &new), new, "a fresh box starts in the right place");
941    }
942
943    /// A destination that cannot be made leaves the file exactly where it is
944    /// and answers with it.
945    #[test]
946    fn adopting_into_an_impossible_place_keeps_the_original() {
947        let dir = tempfile::tempdir().expect("a temp dir");
948        let old = dir.path().join("audit-chain.jsonl");
949        std::fs::write(&old, b"x").expect("write");
950        let blocker = dir.path().join("blocker");
951        std::fs::write(&blocker, b"not a directory").expect("write");
952        let new = blocker.join("log/audit-chain.jsonl");
953        assert_eq!(adopt_file(&old, &new), old);
954        assert!(old.exists(), "the original is untouched");
955    }
956}