gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
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
//! **What emptying an account actually means, in one place.**
//!
//! [`crate::purge_queue`] decides *when* a purge runs and guarantees the
//! lockout comes off; this module is the walk itself, and it is the only
//! implementation of it (one implementation) — the gRPC verb, the CLI verb and monetize all
//! arrive here.
//!
//! # The four classes of bytes an account holds
//!
//! The per-repository reclaim was never the hard part. **Finding everything
//! was**, and it is why a purge that looped over `repo list` would report
//! success with the account's disk still full:
//!
//! | class | which listing shows it | which one does not |
//! |---|---|---|
//! | live repository | `Repos.List` | — |
//! | soft-delete tombstone | `Repos.ListDeleted` | `List` (the row is gone) |
//! | orphan store | `Repos.ListOrphans` | both of the above |
//! | nested namespace | `Repos.ListNamespaces` | — |
//!
//! An **orphan** is the one that bites. It has no catalog row and no
//! tombstone, so before 2026-08-30 nothing on the box could see it: on the
//! appliance 2026-08-30 that was ~25-39 GB standing at `alice/linux` that
//! `list` could not show, `list-deleted` could not show, `delete` answered
//! `NoSuchRepo` for, and `create` refused the name of. A purge that skipped
//! that class would leave the single largest thing in the account behind and
//! call the account empty.
//!
//! Together those four are exhaustive over `repos/<account>/`, and that is not
//! a claim about this module's cleverness — it is `Repos.ListOrphans`'s own
//! guarantee: "every `<name>.git` directory under the repository root is in
//! exactly one of the three listings". The purge inherits it by walking all
//! three, and `tests/account_purge.rs` puts one of each on a disk and measures
//! what is left.
//!
//! # Nested namespaces, and the asymmetry that is worth knowing about
//!
//! `alice/team` is a **different** namespace from `alice`: `Scope::Namespace`
//! matches exactly, and `AuthTable::owns_account("alice", "alice/team")` is a
//! string equality that answers false. So alice does not own `alice/team` in
//! the ACL's sense.
//!
//! On the disk it is `repos/alice/team/…`, inside her account's directory, and
//! it goes with the account. That asymmetry is deliberate and it points the
//! safe way: a purge that left it behind would leave bytes on the disk, on the
//! bill, and under a name a later `Repos.Create` would refuse — after an
//! operator had been told the account was emptied. `gunnar_core::gate` closes
//! the matching hole from the other end, by walking ancestors so a gate on
//! `alice` also shuts `alice/team` while this runs.
//!
//! # Why the walk keeps going after a failure
//!
//! One unreadable directory in an account of two hundred must not leave the
//! other hundred and ninety-nine on the disk — the operator would have to run
//! the purge again anyway, and the second run would trip on the same directory.
//! So each failure is recorded by name and the walk continues; the purge as a
//! whole then FAILS, so nothing upstream records the account as finished, and
//! [`PurgeReport::failures`] is the list of what is still there.

use anyhow::{Context, Result};
use gunnar_core::RepoRef;

use crate::stores::ServerStores;

/// What `Accounts.Purge` was asked for.
#[derive(Debug, Clone)]
pub struct PurgeRequest {
    /// The account: a principal id, which is also its namespace.
    pub account: String,
    /// The principal that asked, for the audit trail.
    pub principal_id: String,
    /// Why. Carried into the log line for every store removed, because a purge
    /// is the one operation whose consequence cannot be read back off the box
    /// afterwards — the rows it removes are the rows that would have explained
    /// it.
    pub reason: String,
    /// Also remove the principal: its keys, grants, tokens and memberships.
    pub remove_principal: bool,
    /// Refuse, destroying nothing, unless the account holds no live
    /// repositories.
    pub expect_empty: bool,
}

/// What a purge did.
#[derive(Debug, Clone, Default)]
pub struct PurgeReport {
    /// Stores the walk found across all four classes.
    pub stores_found: u32,
    /// How many are gone.
    pub stores_purged: u32,
    /// Bytes that left the filesystem, measured as `du` would.
    pub bytes_reclaimed: u64,
    /// Whether the principal itself was removed.
    pub principal_removed: bool,
    /// One line per store still on the disk, naming it and why. **The recovery
    /// list.**
    pub failures: Vec<String>,
    /// **Frames erased from the transaction log** — the purged repositories'
    /// packs, refs and LFS objects, rewritten in place as `Redacted` (owner
    /// ruling g-nonpay). The twin erases the same frames when it stores the
    /// `Erase` frame this purge appended.
    pub log_frames_erased: u64,
    /// Their blob bytes, gone from the segment files.
    pub log_bytes_erased: u64,
}

/// Progress a caller wants to see while the walk runs.
///
/// A purge of a large account runs for minutes with a console polling the whole
/// time, so it reports as it goes rather than returning a summary that can only
/// be read once nobody needs it. The queue's handler re-stamps the gate's lease
/// on [`Step::Reclaimed`], which is what keeps a long purge from unlocking
/// underneath itself.
#[derive(Debug, Clone)]
pub enum Step {
    /// The walk finished counting: this many stores, before any were removed.
    Found(u32),
    /// One store is gone, and this is what it was holding.
    Reclaimed { store: String, bytes: u64 },
    /// One store could not be removed, and this is why.
    Failed(String),
    /// The principal was removed.
    PrincipalRemoved,
}

/// **Empty an account.** The one implementation.
///
/// Returns `Err` only when the purge could not be *attempted* — an
/// `expect_empty` refusal, or a listing that could not be read. A walk that ran
/// and hit unremovable stores returns `Ok` with [`PurgeReport::failures`]
/// populated, and it is the caller that turns a non-empty `failures` into a
/// FAILED job. The split is deliberate: a refusal means nothing was destroyed,
/// and a partial means some of it was, and a caller that could not tell those
/// apart would either retry a refusal for ever or report a half-emptied account
/// as untouched.
pub fn purge_account(
    stores: &ServerStores,
    request: PurgeRequest,
    report: &mut dyn FnMut(Step),
) -> Result<PurgeReport> {
    let account = request.account.trim().to_owned();
    anyhow::ensure!(
        !account.is_empty(),
        "an account purge needs an account name"
    );

    // ── the census, all four classes, before anything is removed ───────────
    //
    // Counted first and in full so `stores_found` is a number the walk can be
    // measured against. A count that grew as the walk discovered things would
    // make "purged 5 of 5" true of a purge that missed half the account.
    let namespaces = account_namespaces(stores, &account)?;
    let mut targets: Vec<Target> = Vec::new();

    for namespace in &namespaces {
        for repo in stores
            .catalog()
            .list(Some(namespace))
            .with_context(|| format!("listing the repositories in {namespace}"))?
        {
            targets.push(Target {
                path: repo.path(),
                kind: Kind::Live,
            });
        }
    }
    for deleted in stores
        .tombstones()
        .context("listing the soft-deleted repositories")?
    {
        if covers(&namespaces, deleted.path.namespace()) {
            targets.push(Target {
                path: deleted.path,
                kind: Kind::Tombstoned,
            });
        }
    }
    for orphan in stores.orphans().context("listing the orphan stores")? {
        if covers(&namespaces, orphan.path.namespace()) {
            targets.push(Target {
                path: orphan.path,
                kind: Kind::Orphan,
            });
        }
    }
    // A directory can be BOTH catalogued and tombstoned only in the broken
    // states the reclaim rules describe, and the three listings are otherwise disjoint —
    // but a purge that removed a directory twice would count its bytes twice
    // and report a reclaim that never happened. Deduplicated by name, cheaply,
    // rather than trusted.
    targets.sort_by_key(|a| a.path.key());
    targets.dedup_by(|a, b| a.path.key() == b.path.key());

    if request.expect_empty {
        let live: Vec<String> = targets
            .iter()
            .filter(|t| t.kind == Kind::Live)
            .map(|t| t.path.to_string())
            .collect();
        anyhow::ensure!(
            live.is_empty(),
            "{account} still holds {} live repositor{}: {}. `expect_empty` refuses rather than \
             destroys, so nothing was touched; run the purge without it to reclaim them.",
            live.len(),
            if live.len() == 1 { "y" } else { "ies" },
            live.join(", ")
        );
    }

    let mut out = PurgeReport {
        stores_found: targets.len() as u32,
        ..PurgeReport::default()
    };
    report(Step::Found(out.stores_found));

    // ── the walk ──────────────────────────────────────────────────────────
    let mut purged_keys: Vec<String> = Vec::new();
    for target in targets {
        let name = target.path.to_string();
        // The catalog row first, so a store that is reclaimed but whose row
        // survives cannot be served afterwards. `delete` on a name with no row
        // — a tombstone or an orphan — is expected and is not a failure.
        if target.kind == Kind::Live {
            if let Err(err) = stores.catalog().delete(&target.path) {
                out.failures.push(format!(
                    "{name}: the catalog row could not be removed: {err}"
                ));
                report(Step::Failed(
                    out.failures.last().cloned().unwrap_or_default(),
                ));
                continue;
            }
        }
        match stores.purge_repo(&target.path) {
            Ok((before, after)) => {
                let bytes = before.saturating_sub(after);
                purged_keys.push(target.path.key());
                out.stores_purged = out.stores_purged.saturating_add(1);
                out.bytes_reclaimed = out.bytes_reclaimed.saturating_add(bytes);
                tracing::info!(
                    target: "gunnar::store",
                    event = "account.store_purged",
                    account = %account,
                    store = %name,
                    kind = target.kind.as_str(),
                    bytes,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "a store was reclaimed by an account purge"
                );
                report(Step::Reclaimed { store: name, bytes });
            }
            Err(err) => {
                // `purge_repo` leaves a ReclaimFailed tombstone of its own, so
                // what is left here is visible to `Repos.ListOrphans` and is
                // not lost when this report is pruned.
                let note = format!("{name}: {err:#}");
                out.failures.push(note.clone());
                report(Step::Failed(note));
            }
        }
    }

    // ── the log ───────────────────────────────────────────────────────────
    //
    // The stores are gone from the disk, and every pack and LFS object they ever
    // took is still in the transaction log: on this box until retention trims
    // it, and on the twin for ever, because the twin's log IS the copy (owner
    // ruling g-nonpay 2026-09-29: "deleted on main AND twin"). So an `Erase`
    // frame goes into the log — the twin erases its own frames when it stores
    // it, a restore when it replays it — and this box erases its own now. Only
    // the stores that WERE purged: a store the walk could not remove still has
    // its data on the disk, and a restore must be able to rebuild it.
    if !purged_keys.is_empty() && !stores.txlog().is_follower() {
        match erase_from_log(stores, &account, &purged_keys, &request.reason) {
            Ok(erased) => {
                out.log_frames_erased = erased.frames;
                out.log_bytes_erased = erased.bytes;
                tracing::info!(
                    target: "gunnar::store",
                    event = "account.log_erased",
                    account = %account,
                    frames = erased.frames,
                    bytes = erased.bytes,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "an account purge erased the account's frames from the transaction log"
                );
            }
            Err(err) => out.failures.push(format!(
                "the account's frames could not be erased from the transaction log: {err:#}"
            )),
        }
    }

    // ── the namespace rows, deepest first ─────────────────────────────────
    //
    // Deepest first because `delete_namespace` refuses a namespace that still
    // holds repositories, and `alice` holding `alice/team`'s rows would make
    // the outer delete fail on an account that is otherwise empty. Sorting by
    // length descending is enough: a nested name is strictly longer than the
    // one it nests under.
    let mut ordered = namespaces.clone();
    ordered.sort_by_key(|n| std::cmp::Reverse(n.len()));
    for namespace in ordered {
        match stores.catalog().delete_namespace(&namespace, false) {
            Ok(_) => {}
            // Nothing there is the normal case for an account whose namespace
            // row never existed, and it is not a failure of the purge.
            Err(gunnar_core::Error::NoSuchNamespace(_)) => {}
            Err(err) => out.failures.push(format!(
                "the namespace {namespace} could not be removed: {err}"
            )),
        }
    }

    // ── the principal, last ───────────────────────────────────────────────
    //
    // Last, and only if asked. Last because it takes the account's gate with
    // it (`AuthTable::remove_principal`), and a gate removed while stores were
    // still being reclaimed would unlock an account mid-walk — the exact race
    // the lockout exists to prevent. By the time this runs there is nothing
    // left to protect.
    if request.remove_principal {
        match stores.auth().remove_principal(&account) {
            Ok(_) => {
                out.principal_removed = true;
                tracing::info!(
                    target: "gunnar::audit",
                    event = "account.principal_removed",
                    account = %account,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "an account purge removed the principal, its keys, grants and memberships"
                );
                report(Step::PrincipalRemoved);
            }
            // A principal that is not there is the outcome that was asked for.
            Err(gunnar_core::Error::NoSuchPrincipal(_)) => out.principal_removed = true,
            Err(err) => out.failures.push(format!(
                "the principal {account} could not be removed: {err}"
            )),
        }
    }

    Ok(out)
}

/// Frame the erase, then erase this box's own frames of `repos` before it.
fn erase_from_log(
    stores: &ServerStores,
    account: &str,
    repos: &[String],
    reason: &str,
) -> Result<gunnar_core::txlog::Erased> {
    use gunnar_core::txlog::{Kind, Meta};
    let log = stores.txlog();
    let seq = {
        let _framed = log.mutation();
        log.append(
            Kind::Erase,
            &Meta::Erase {
                account: account.to_owned(),
                repos: repos.to_vec(),
                reason: reason.to_owned(),
            },
            &[],
        )
        .context("framing the erase")?
    };
    log.erase(seq, repos)
        .context("erasing the account's frames")
}

/// What a target is, for the log line and for `expect_empty`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
    Live,
    Tombstoned,
    Orphan,
}

impl Kind {
    fn as_str(self) -> &'static str {
        match self {
            Kind::Live => "live",
            Kind::Tombstoned => "tombstoned",
            Kind::Orphan => "orphan",
        }
    }
}

struct Target {
    path: RepoRef,
    kind: Kind,
}

/// **Every namespace that belongs to `account`**: the account itself, and every
/// namespace nested under it.
///
/// The account's own name is always in the list even when no namespace row
/// exists for it — an orphan store can stand under a name the catalog never
/// knew, which is what an orphan IS, and a purge that keyed off the namespace
/// rows would skip exactly the class it most needs to reach.
fn account_namespaces(stores: &ServerStores, account: &str) -> Result<Vec<String>> {
    let mut out = vec![account.to_owned()];
    let prefix = format!("{account}/");
    for namespace in stores
        .catalog()
        .namespaces()
        .context("listing the namespaces")?
    {
        if namespace.name.starts_with(&prefix) {
            out.push(namespace.name);
        }
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Is `namespace` one of the account's, or nested under one of them?
///
/// On PATH COMPONENTS. `alicehacks` starts with `alice` and is a different
/// account; a purge that caught it by string prefix would take a stranger's
/// repositories, which is the one mistake this whole module must not make.
fn covers(namespaces: &[String], namespace: &str) -> bool {
    namespaces
        .iter()
        .any(|owned| namespace == owned || namespace.starts_with(&format!("{owned}/")))
}

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

    /// The prefix rule, on its own, because it is the difference between
    /// emptying one account and emptying two.
    #[test]
    fn covers_matches_path_components_and_not_characters() {
        let owned = vec!["alice".to_owned()];
        assert!(covers(&owned, "alice"));
        assert!(covers(&owned, "alice/team"));
        assert!(covers(&owned, "alice/team/deep"));
        assert!(
            !covers(&owned, "alicehacks"),
            "`alicehacks` is a different account and a purge of `alice` must not reach it"
        );
        assert!(!covers(&owned, "bob"));
        assert!(
            !covers(&owned, "notalice"),
            "a suffix match would be worse still"
        );
    }
}