gunnar-ssh 2.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
//! **The disk-headroom gate: the community [`PlanGate`], the "free lunch".**
//!
//! Decided 2026-09-04: there are two implementations — 1) the free lunch,
//! basically checking disk has size before a new repo is created; 2) the
//! monetize-bound one. This is the first. It has no monetize dependency, it is always
//! compiled, and it is the first link of every `Control`'s gate chain — on the
//! community binary the only one.
//!
//! # What it refuses, and where
//!
//! | request | asked | answer |
//! |---|---|---|
//! | `git-receive-pack`, LFS upload (`Dispatch::authorise_service`) | [`PlanGate::pack_quota`] | `Refuse` under the floor, **before a pack byte** |
//! | push-to-create (the first-repository door) | the same, because it is the same call | the same |
//! | `Repos.Create` (`gunnar-server::control_plane`) | [`DiskHeadroomGate::refusal`] | `RESOURCE_EXHAUSTED` with the same line |
//! | any read, anonymous or not | nothing | never — a full disk still serves clones |
//!
//! One function, [`DiskHeadroomGate::refusal`], produces the line for all three
//! doors (one implementation): "the disk is full" must be spelled identically to a `git
//! push` and to a `gunnar control repo create`, or an operator debugging the
//! second will not recognise the first.
//!
//! # The floor: 5 % or 2 GiB, whichever is LARGER
//!
//! Two numbers because each is wrong alone:
//!
//! * **5 %** is the size of the reserve the box needs to keep *operating*
//!   after it stops accepting: `znippy`'s append-side gc needs scratch to
//!   compact a pack, the control store commits a new document by writing a
//!   fresh blob beside the old one, and `replication.json` / `webhooks.json`
//!   are rewritten whole. It is also `mkfs.ext4`'s own reserved-blocks default,
//!   and refusing at the same line means gunnar says no BEFORE root's reserve is
//!   what keeps the box alive. On a 224.9 GiB `/data` (gunnar.rs) that is
//!   ~11 GiB — enough for the largest single push measured there.
//! * **2 GiB** is the absolute floor for the small disk, where 5 % is nothing:
//!   on a 25 GiB appliance volume 5 % is 1.25 GiB, smaller than one pack of
//!   `alice/linux-mirror`, so the percentage alone would admit a push that
//!   fills the disk mid-stream — and a pack that dies mid-write is the power-cut
//!   power-cut shape (a torn file) without the power cut.
//!
//! The larger of the two wins, so a big disk keeps a proportionate reserve and
//! a small one keeps a survivable one. `--disk-floor` overrides both; see
//! [`DiskFloor::parse`].
//!
//! # Measured with `statvfs`, never `df`
//!
//! Pure Rust: `rustix::fs::statvfs` on the store root, one syscall, no
//! subprocess. `f_bavail` — the blocks available to an UNPRIVILEGED caller —
//! not `f_bfree`, because the appliance runs as root and `f_bfree` would count
//! the reserved blocks the box must never eat. A `statvfs` that fails is a
//! **refusal**, not an allow: a gate that cannot measure and says yes is the
//! always-allow gate that proves nothing, and the line says why so the operator
//! learns the mount is the problem, not the space.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::plan::{PlanAction, PlanGate, PlanVerdict};
use crate::space::SpaceLedger;

/// **The start of the floor's refusal** (wiring tracer B 2026-10-08): the data set is AT its
/// floor, final until space is freed or the storage grown — a kit stops on it. Plain words for
/// the customer; the numbers go to the journal (`push.space.at_floor`). Not
/// [`crate::space::FULL_NOW`], which is "try again".
pub const AT_FLOOR: &str = "Gunnar's storage is full";

/// The default percentage floor. See the module header for why 5.
pub const DEFAULT_FLOOR_PERCENT: u8 = 5;
/// The default absolute floor, 2 GiB. See the module header for why.
pub const DEFAULT_FLOOR_BYTES: u64 = 2 * GIB;

const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
const TIB: u64 = 1024 * GIB;

/// **How much must stay free**, as a percentage of the filesystem and as an
/// absolute byte count; the larger of the two is the floor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiskFloor {
    /// Percent of `f_blocks` that must remain available. `0` disables the
    /// percentage term.
    pub percent: u8,
    /// Bytes that must remain available. `0` disables the absolute term.
    pub bytes: u64,
}

impl Default for DiskFloor {
    fn default() -> Self {
        DiskFloor {
            percent: DEFAULT_FLOOR_PERCENT,
            bytes: DEFAULT_FLOOR_BYTES,
        }
    }
}

impl DiskFloor {
    /// **`--disk-floor`'s grammar.** A comma-separated list of terms, each
    /// either a percentage (`5%`) or a byte count with a unit (`2GiB`, `500M`,
    /// `1T`, `4096`); the larger of the two kinds wins at measurement time. A
    /// term of one kind replaces the default of that kind, so `--disk-floor
    /// 10%` keeps the 2 GiB absolute floor and `--disk-floor 0%,512MiB` is a
    /// pure byte floor. `0` on both is refused by name: a gate that can never
    /// refuse is not a gate, and an operator who wants none should not have
    /// one that merely looks like one.
    pub fn parse(spec: &str) -> Result<DiskFloor, String> {
        let mut floor = DiskFloor::default();
        let mut saw_percent = false;
        let mut saw_bytes = false;
        for raw in spec.split(',') {
            let term = raw.trim();
            if term.is_empty() {
                return Err(format!("--disk-floor {spec:?}: an empty term"));
            }
            if let Some(number) = term.strip_suffix('%') {
                let percent: u8 = number
                    .trim()
                    .parse()
                    .map_err(|_| format!("--disk-floor {spec:?}: {term:?} is not a percentage"))?;
                if percent > 99 {
                    return Err(format!(
                        "--disk-floor {spec:?}: {percent}% would refuse every push forever"
                    ));
                }
                if saw_percent {
                    return Err(format!("--disk-floor {spec:?}: two percentage terms"));
                }
                saw_percent = true;
                floor.percent = percent;
                continue;
            }
            let bytes = parse_bytes(term)
                .ok_or_else(|| format!("--disk-floor {spec:?}: {term:?} is not a byte count (try 2GiB, 500M, 4096)"))?;
            if saw_bytes {
                return Err(format!("--disk-floor {spec:?}: two byte terms"));
            }
            saw_bytes = true;
            floor.bytes = bytes;
        }
        if floor.percent == 0 && floor.bytes == 0 {
            return Err(format!(
                "--disk-floor {spec:?} disables both terms; a gate that can never refuse is not \
                 a gate. Give a percentage, a byte count, or leave the flag off for the default \
                 ({DEFAULT_FLOOR_PERCENT}%,{})",
                human(DEFAULT_FLOOR_BYTES)
            ));
        }
        Ok(floor)
    }

    /// The floor in bytes for a filesystem of `total` bytes.
    pub fn bytes_for(&self, total: u64) -> u64 {
        let percent = (total / 100).saturating_mul(u64::from(self.percent));
        percent.max(self.bytes)
    }
}

impl std::fmt::Display for DiskFloor {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}%,{}", self.percent, human(self.bytes))
    }
}

fn parse_bytes(term: &str) -> Option<u64> {
    let term = term.trim();
    let split = term
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(term.len());
    let (digits, unit) = term.split_at(split);
    let value: u64 = digits.parse().ok()?;
    let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1,
        "k" | "kib" | "kb" => KIB,
        "m" | "mib" | "mb" => MIB,
        "g" | "gib" | "gb" => GIB,
        "t" | "tib" | "tb" => TIB,
        _ => return None,
    };
    value.checked_mul(multiplier)
}

/// Bytes as an operator reads them: `1.2 GiB`, `512.0 MiB`, `4096 B`.
pub fn human(bytes: u64) -> String {
    if bytes >= TIB {
        format!("{:.1} TiB", bytes as f64 / TIB as f64)
    } else if bytes >= GIB {
        format!("{:.1} GiB", bytes as f64 / GIB as f64)
    } else if bytes >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB as f64)
    } else if bytes >= KIB {
        format!("{:.1} KiB", bytes as f64 / KIB as f64)
    } else {
        format!("{bytes} B")
    }
}

/// One measurement of the store root's filesystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Headroom {
    /// Bytes available to an unprivileged writer (`f_bavail * f_frsize`).
    pub free: u64,
    /// The filesystem's size (`f_blocks * f_frsize`).
    pub total: u64,
    /// The floor in force for this filesystem, [`DiskFloor::bytes_for`].
    pub floor: u64,
}

impl Headroom {
    /// Is there room? `false` is a refusal.
    pub fn has_room(&self) -> bool {
        self.free > self.floor
    }
}

/// **The community gate.** `verdict` is always `Allow` — this gate knows no
/// plan; `pack_quota` refuses when the store root is under the floor, or when
/// everything above the floor is already promised to transfers still landing.
///
/// It owns the data set's [`SpaceLedger`] (`crate::space`): the same floor and
/// the same measurement, so the door before the first byte and the receive
/// loop after it cannot disagree about where the line is. `gunnar serve` hands
/// [`Self::ledger`] to every door that writes.
#[derive(Debug, Clone)]
pub struct DiskHeadroomGate {
    root: PathBuf,
    floor: DiskFloor,
    ledger: Arc<SpaceLedger>,
}

impl DiskHeadroomGate {
    /// Guard the filesystem `root` lives on. `root` is the store root
    /// (`/data` on the appliance); the measurement is of its filesystem, so a
    /// path anywhere on the same mount reads the same.
    pub fn new(root: impl Into<PathBuf>, floor: DiskFloor) -> Self {
        let root = root.into();
        let ledger = Arc::new(SpaceLedger::new(root.clone(), floor));
        DiskHeadroomGate { root, floor, ledger }
    }

    /// The gate over an existing ledger — its root, its floor, its probe (a
    /// test's fake disk measures through here too).
    pub fn over(ledger: Arc<SpaceLedger>) -> Self {
        DiskHeadroomGate {
            root: ledger.root().to_path_buf(),
            floor: ledger.floor(),
            ledger,
        }
    }

    /// **The data set's space ledger**: what every writing door asks before
    /// each chunk it takes.
    pub fn ledger(&self) -> &Arc<SpaceLedger> {
        &self.ledger
    }

    /// The path this gate measures.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// The floor this gate was configured with.
    pub fn floor(&self) -> DiskFloor {
        self.floor
    }

    /// Measure. One `statvfs` (the ledger's probe); the error is the OS's.
    ///
    /// The RAW reading — what is on the disk, no promises subtracted. This is
    /// what `Admin.Capacity` reports and monetize's growth trigger reads
    /// (`used = total − servable`): a promise is not a byte used, and a refused
    /// burst must not read as a full set (see `crate::space`).
    pub fn headroom(&self) -> std::io::Result<Headroom> {
        let (free, total) = self.ledger.probe().measure()?;
        Ok(Headroom {
            free,
            total,
            floor: self.floor.bytes_for(total),
        })
    }

    /// **The one refusal, for every door.** `None` is room; `Some(line)` is
    /// what the client is told, naming the free bytes and the floor. A
    /// measurement that fails is a refusal that says so.
    ///
    /// Two lines, and the difference is the point: UNDER the floor is the
    /// floor's line (final until space is reclaimed or grown); above it but
    /// with everything above it promised to transfers still landing is
    /// [`crate::space::FULL_NOW`]'s (try again in a moment).
    pub fn refusal(&self) -> Option<String> {
        match self.headroom() {
            Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
            // The customer is told in plain words (wiring tracer B 2026-10-08: a customer
            // never sees room left); what was free, the floor and the root are the journal's.
            Ok(room) => {
                tracing::warn!(
                    target: "gunnar::space",
                    event = "push.space.at_floor",
                    root = %self.root.display(),
                    free = %human(room.free),
                    floor = %human(room.floor),
                    disk_floor = %self.floor,
                    "the data set is under its disk floor: no new repository and no push until space is reclaimed (`gunnar control repo delete --purge`, `gunnar gc`) or the storage is grown"
                );
                Some(format!(
                    "{AT_FLOOR}: no new repository and no push until space is freed or the \
                     storage is grown. Nothing was received"
                ))
            }
            Err(err) => {
                tracing::error!(
                    target: "gunnar::space",
                    event = "push.space.unmeasured",
                    root = %self.root.display(),
                    %err,
                    "the data set could not be measured: writes are refused rather than guessing there is room"
                );
                Some(
                    "Gunnar could not check its storage, so this was refused rather than risk it; \
                     try again in a while"
                        .to_string(),
                )
            }
        }
    }
}

impl PlanGate for DiskHeadroomGate {
    fn verdict(&self, _namespace: &str, _action: PlanAction) -> PlanVerdict {
        PlanVerdict::Allow
    }

    fn pack_quota(&self, _namespace: &str) -> PlanVerdict {
        match self.refusal() {
            None => PlanVerdict::Allow,
            Some(line) => PlanVerdict::Refuse(line),
        }
    }

    fn applies_to_machine(&self) -> bool {
        true
    }

    fn name(&self) -> &'static str {
        "disk-headroom"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_default_floor_is_five_percent_or_two_gib_whichever_is_larger() {
        let floor = DiskFloor::default();
        // A 25 GiB disk: 5 % is 1.25 GiB, so the 2 GiB absolute floor wins.
        assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
        // gunnar.rs's 224.9 GiB /data: 5 % is ~11.2 GiB and wins.
        let big = 2249 * GIB / 10;
        assert_eq!(floor.bytes_for(big), (big / 100) * 5);
        assert!(floor.bytes_for(big) > 2 * GIB);
    }

    #[test]
    fn the_flag_grammar_takes_a_percent_a_byte_count_or_both_and_refuses_nothing_at_all() {
        assert_eq!(
            DiskFloor::parse("10%").unwrap(),
            DiskFloor {
                percent: 10,
                bytes: DEFAULT_FLOOR_BYTES
            }
        );
        assert_eq!(
            DiskFloor::parse("512MiB").unwrap(),
            DiskFloor {
                percent: DEFAULT_FLOOR_PERCENT,
                bytes: 512 * MIB
            }
        );
        assert_eq!(
            DiskFloor::parse("0%, 4096").unwrap(),
            DiskFloor {
                percent: 0,
                bytes: 4096
            }
        );
        assert_eq!(DiskFloor::parse("1T").unwrap().bytes, TIB);
        assert_eq!(DiskFloor::parse("3g").unwrap().bytes, 3 * GIB);
        for bad in ["", "abc", "5%%", "100%", "2GiB,3GiB", "5%,6%", "0%,0"] {
            let err = DiskFloor::parse(bad).expect_err(bad);
            assert!(err.contains("--disk-floor"), "{bad:?}: {err}");
        }
        assert!(
            DiskFloor::parse("0%,0")
                .unwrap_err()
                .contains("can never refuse"),
            "the all-zero floor must be refused BY NAME as a non-gate"
        );
    }

    #[test]
    fn a_floor_above_the_free_space_refuses_and_names_the_free_bytes_and_the_floor() {
        let dir = tempfile::tempdir().expect("tempdir");
        // u64::MAX bytes can never be free.
        let gate = DiskHeadroomGate::new(
            dir.path(),
            DiskFloor {
                percent: 0,
                bytes: u64::MAX,
            },
        );
        let room = gate.headroom().expect("statvfs on a tempdir");
        assert!(room.total > 0, "a real filesystem has a size: {room:?}");
        assert!(!room.has_room());
        let line = gate.refusal().expect("under the floor is a refusal");
        assert!(line.starts_with(AT_FLOOR), "the floor's own, final line: {line}");
        assert!(
            matches!(gate.pack_quota("anyone"), PlanVerdict::Refuse(l) if l == line),
            "pack_quota is the same refusal"
        );
        // Reads are never refused by this gate, whatever the disk says.
        assert_eq!(gate.verdict("anyone", PlanAction::Write), PlanVerdict::Allow);
        assert_eq!(
            gate.verdict("anyone", PlanAction::AnonymousRead),
            PlanVerdict::Allow
        );
        assert!(gate.applies_to_machine(), "a full standby refuses its primary too");
    }

    /// ★ **The customer is told the storage is full in plain words** (wiring
    /// tracer B 2026-10-08; owner: a customer never sees caps or room left):
    /// no free bytes, no floor, no `--disk-floor`, no path, no operator
    /// command. The line starts with a stable phrase a kit can stop on; the
    /// numbers are the journal's.
    #[test]
    fn the_floor_line_is_plain_words_and_the_numbers_go_to_the_journal() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(dir.path(), DiskFloor { percent: 0, bytes: u64::MAX });
        let line = gate.refusal().expect("under the floor is a refusal");
        assert!(line.starts_with("Gunnar's storage is full"), "{line}");
        assert!(line.contains("no new repository and no push"), "{line}");
        for internal in ["--disk-floor", "gunnar gc", "repo delete", "MiB", "GiB", " free, under the "] {
            assert!(!line.contains(internal), "{internal:?} reaches the customer: {line}");
        }
        assert!(!line.contains(&dir.path().display().to_string()), "the data root reaches the customer: {line}");
        assert!(!line.chars().any(|c| c.is_ascii_digit()), "a number reaches the customer: {line}");
        let gone = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
        let line = gone.refusal().expect("an unmeasurable root refuses");
        assert!(!line.contains("does-not-exist") && line.contains("try again"), "{line}");
    }

    #[test]
    fn a_floor_below_the_free_space_allows() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(
            dir.path(),
            DiskFloor {
                percent: 0,
                bytes: 1,
            },
        );
        let room = gate.headroom().expect("statvfs");
        assert!(room.free > 1, "this test needs a disk with more than one free byte");
        assert_eq!(gate.refusal(), None);
        assert_eq!(gate.pack_quota("anyone"), PlanVerdict::Allow);
    }

    #[test]
    fn a_root_that_cannot_be_measured_is_a_refusal_that_says_so() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
        let line = gate.refusal().expect("an unmeasurable root refuses");
        assert!(line.contains("could not check its storage"), "{line}");
    }

    #[test]
    fn human_units_read_as_an_operator_reads_them() {
        assert_eq!(human(512), "512 B");
        assert_eq!(human(2 * GIB), "2.0 GiB");
        assert_eq!(human(1_288_490_189), "1.2 GiB");
        assert_eq!(human(3 * MIB / 2), "1.5 MiB");
    }
}