gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
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
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
//! **The path sandbox: the security core of the server.**
//!
//! An SSH client sends `git-upload-pack 'ns/repo.git'`. Everything after the
//! command name is attacker-controlled, and [`resolve_repo_path`] is the single
//! function that turns it into something the rest of gunnar is allowed to act
//! on. A path escape here is the whole security model (the ACL model).
//!
//! # Two properties, and the second is the one that gets forgotten
//!
//! 1. Every escape is rejected: `..`, absolute paths, NUL bytes, control
//!    characters, unicode normalisation and homoglyph tricks, `.git/../..`,
//!    empty components, over-long names.
//! 2. **Legitimate nested names are accepted.** A sandbox that rejects
//!    everything passes every escape test and is useless: the identity-value
//!    identity-value trap in its purest form. `tests/sandbox.rs` asserts both halves
//!    from the same table, and `red::broken_resolve_repo_path` proves the escape
//!    half can actually go red.
//!
//! # Fix by construction: the output is not a path
//!
//! [`RepoRef`] is two (or more) validated components, not a string that later
//! gets joined onto a root. Catalog lookups are by `<namespace>/<repo>` **key**,
//! so on the appliance, where there is no filesystem at all (the no-OS server rule), a
//! traversal has nothing to traverse. The only place a `RepoRef` becomes a
//! filesystem path is [`RepoRef::path_under`], which is where the symlink check
//! lives, because a symlink escape is a property of the filesystem and cannot be
//! detected by looking at a string.
//!
//! # This is the ONLY path sandbox
//!
//! `gunnar-ssh` grew a second one, `gunnar_ssh::path::RepoPath`, while this
//! crate was being written, and **the two disagreed**: that one enforced exactly
//! two components, this one permits nested namespaces. Two security checks that
//! disagree are worse than either alone, because the strict one gives false
//! confidence about the permissive one. The other copy is deleted; the rules it
//! had and this one did not (the `.lock` suffix) were merged in here, and its
//! rejection table was merged into [`red::ESCAPES`].
//!
//! The permissive shape is the surviving one. It is a superset, and the
//! escalation risk it would otherwise carry is closed elsewhere, by construction:
//! **grants do not inherit**, so `team` does not imply `team/sub`
//! ([`crate::model::Scope::Namespace`] matches exactly, pinned by
//! `tests/acl.rs`).
//!
//! # Unquoting is NOT done here
//!
//! git sends one argv element with the quotes inside it (`git-upload-pack
//! '/ns/repo.git'`), and gunnar has no shell to strip them. Taking them off is a
//! *transport* concern and lives in `gunnar_ssh::command`, which understands
//! git's `sq_quote` escaping (including `'\''`). This function receives an
//! already-unquoted string, does not know SSH exists, and treats a quote as what
//! it is here: a character that is not allowed in a repository name.
//!
//! # Deviations from the brief, recorded honestly
//!
//! * **A single leading `/` is stripped, not rejected.** `ssh://host/ns/repo.git`
//!   makes a stock git client send `git-upload-pack '/ns/repo.git'`; verified
//!   against the real `git` binary, see `tests/sandbox.rs`. Rejecting a leading
//!   slash would reject every `ssh://` URL. `//anything` is still rejected, and
//!   `/etc/passwd` resolves to the *catalog key* `etc/passwd`, which is a lookup
//!   miss and not a file.
//! * **Non-ASCII is rejected outright.** That is a blunt answer to unicode
//!   normalisation and homoglyph attacks and it does cost legitimate non-ASCII
//!   repository names. It is the one that cannot be got subtly wrong.

use std::fmt;
use std::path::{Path, PathBuf};

use crate::error::{Error, Result};

/// Longest whole path gunnar will look at, in bytes.
pub const MAX_PATH_BYTES: usize = 255;

/// Longest single component (one namespace level, or the repository name).
pub const MAX_COMPONENT_BYTES: usize = 64;

/// Most components a repository path may have: up to three namespace levels
/// plus the repository name.
pub const MAX_COMPONENTS: usize = 4;

/// Why a repository path was refused.
///
/// Carried in [`Error::InvalidPath`] so tests can assert *which* rule fired
/// rather than merely that something failed. A sandbox that rejects the right
/// input for the wrong reason is one refactor away from accepting it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[non_exhaustive]
pub enum PathReject {
    /// Nothing, or only quotes.
    Empty,
    /// Longer than [`MAX_PATH_BYTES`].
    TooLong,
    /// A component longer than [`MAX_COMPONENT_BYTES`].
    ComponentTooLong,
    /// A NUL byte: the classic truncation trick against a C consumer.
    Nul,
    /// A control character or a space.
    Control,
    /// A byte outside ASCII. See the module docs: this is deliberate.
    NonAscii,
    /// A character that is legal in ASCII but not in a gunnar name.
    ForbiddenCharacter,
    /// Still absolute after the one permitted leading `/` was taken off.
    Absolute,
    /// A `~` home-directory reference. The server has no home directories.
    HomeReference,
    /// A `..` anywhere.
    DotDot,
    /// A component that is exactly `.`, or starts with one.
    DotComponent,
    /// A component ending in `.`.
    TrailingDot,
    /// A component ending in `.lock`.
    ///
    /// git's own lock-file suffix. Merged in from the `gunnar-ssh` sandbox this
    /// one replaced: a repository or namespace called `x.lock` would
    /// collide with a lock file on the `fs` backend, and a ref named after it
    /// would collide in `gix-ref`.
    LockSuffix,
    /// A component starting with `-`, which reads as a command-line flag.
    LeadingDash,
    /// An empty component (a leading, trailing or doubled `/`).
    EmptyComponent,
    /// Fewer than two components: no namespace. Multi-tenancy is not optional
    /// (multi-tenancy).
    TooFewComponents,
    /// More than [`MAX_COMPONENTS`] components.
    TooManyComponents,
}

impl fmt::Display for PathReject {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let s = match self {
            PathReject::Empty => "empty",
            PathReject::TooLong => "longer than 255 bytes",
            PathReject::ComponentTooLong => "a component is longer than 64 bytes",
            PathReject::Nul => "contains a NUL byte",
            PathReject::Control => "contains a control character or a space",
            PathReject::NonAscii => "contains a non-ASCII byte",
            PathReject::ForbiddenCharacter => "contains a character that is not [A-Za-z0-9._-]",
            PathReject::Absolute => "absolute",
            PathReject::HomeReference => "a home-directory reference",
            PathReject::DotDot => "contains `..`",
            PathReject::DotComponent => "a component starts with `.`",
            PathReject::TrailingDot => "a component ends with `.`",
            PathReject::LockSuffix => "a component ends with `.lock`",
            PathReject::LeadingDash => "a component starts with `-`",
            PathReject::EmptyComponent => "an empty path component",
            PathReject::TooFewComponents => "no namespace: expected `<namespace>/<repo>`",
            PathReject::TooManyComponents => "too many path components",
        };
        f.write_str(s)
    }
}

/// A validated repository reference: a namespace and a name, and nothing else.
///
/// The only ways to obtain one are [`resolve_repo_path`] and [`RepoRef::new`],
/// both of which validate. There is no constructor that takes a string
/// unchecked outside of [`red`], which exists to prove the checks bite.
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
pub struct RepoRef {
    namespace: String,
    name: String,
}

impl RepoRef {
    /// Validate a namespace and a repository name and pair them up.
    ///
    /// The namespace may itself be nested (`team/sub`); every level is validated
    /// by the same rules as the repository name.
    pub fn new(namespace: &str, name: &str) -> Result<Self> {
        let input = format!("{namespace}/{name}");
        if namespace.is_empty() {
            return Err(reject(&input, PathReject::TooFewComponents));
        }
        let mut levels = 0usize;
        for level in namespace.split('/') {
            check_component(&input, level)?;
            levels += 1;
        }
        check_component(&input, name)?;
        if levels + 1 > MAX_COMPONENTS {
            return Err(reject(&input, PathReject::TooManyComponents));
        }
        if input.len() > MAX_PATH_BYTES {
            return Err(reject(&input, PathReject::TooLong));
        }
        Ok(RepoRef {
            namespace: namespace.to_owned(),
            name: name.to_owned(),
        })
    }

    /// Pair two components up **without validating them**.
    ///
    /// Crate-private, and used by exactly one caller: the deliberately broken
    /// resolver in [`red`], whose entire job is to be the thing that must fail.
    /// It does not exist at all in a server build.
    #[cfg(any(test, feature = "conformance"))]
    pub(crate) fn unchecked(namespace: &str, name: &str) -> Self {
        RepoRef {
            namespace: namespace.to_owned(),
            name: name.to_owned(),
        }
    }

    /// The namespace, possibly nested (`team/sub`).
    pub fn namespace(&self) -> &str {
        &self.namespace
    }

    /// The repository name, without any `.git` suffix.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// The catalog key, `<namespace>/<repo>`. Stable, and the only identifier
    /// the catalog stores.
    pub fn key(&self) -> String {
        format!("{}/{}", self.namespace, self.name)
    }

    /// The path as a client would write it after the colon:
    /// `<namespace>/<repo>.git`.
    pub fn to_ssh_path(&self) -> String {
        format!("{}/{}.git", self.namespace, self.name)
    }

    /// Where this repository's directory lives under `root`, **checked against
    /// symlink escapes**.
    ///
    /// The string checks in [`resolve_repo_path`] cannot see a symlink: `ns` may
    /// be a perfectly ordinary-looking name that happens to be a link to `/etc`.
    /// So the deepest existing ancestor of the target is canonicalised and
    /// asserted to still be inside the canonicalised `root`.
    ///
    /// This is for the `fs` backend only. The appliance has no filesystem and
    /// never calls it.
    pub fn path_under(&self, root: &Path) -> Result<PathBuf> {
        let root_real = root.canonicalize().map_err(|err| Error::PathEscape {
            path: root.to_path_buf(),
            reason: format!("the repository root does not resolve: {err}"),
        })?;
        let target = root_real
            .join(&self.namespace)
            .join(format!("{}.git", self.name));

        // Walk up to the deepest ancestor that exists; that is the deepest point
        // a symlink could have redirected. Everything below it is not created
        // yet and therefore cannot redirect anywhere.
        let mut probe = target.clone();
        loop {
            match probe.canonicalize() {
                Ok(real) => {
                    if real != root_real && !real.starts_with(&root_real) {
                        return Err(Error::PathEscape {
                            path: probe,
                            reason: format!(
                                "resolves to {}, which is outside {}",
                                real.display(),
                                root_real.display()
                            ),
                        });
                    }
                    return Ok(target);
                }
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => match probe.parent() {
                    Some(parent) if parent.starts_with(&root_real) => {
                        probe = parent.to_path_buf();
                    }
                    _ => {
                        return Err(Error::PathEscape {
                            path: target,
                            reason: "no ancestor of the target lies under the repository root"
                                .to_owned(),
                        })
                    }
                },
                Err(err) => {
                    return Err(Error::PathEscape {
                        path: probe,
                        reason: format!("could not be resolved: {err}"),
                    })
                }
            }
        }
    }
}

impl fmt::Display for RepoRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.namespace, self.name)
    }
}

impl fmt::Debug for RepoRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "RepoRef({}/{})", self.namespace, self.name)
    }
}

impl std::str::FromStr for RepoRef {
    type Err = Error;
    fn from_str(s: &str) -> Result<Self> {
        resolve_repo_path(s)
    }
}

fn reject(input: &str, reason: PathReject) -> Error {
    // The offending input is echoed back escaped, never raw: it reaches logs,
    // and a raw NUL or an ANSI escape in a log line is its own small attack.
    Error::InvalidPath {
        input: input.escape_debug().to_string(),
        reason,
    }
}

/// The rules every single component obeys, in one place so a namespace level, a
/// repository name and a principal id cannot drift apart (one implementation).
pub(crate) fn check_component(input: &str, component: &str) -> Result<()> {
    if component.is_empty() {
        return Err(reject(input, PathReject::EmptyComponent));
    }
    if component.len() > MAX_COMPONENT_BYTES {
        return Err(reject(input, PathReject::ComponentTooLong));
    }
    if component.contains("..") {
        return Err(reject(input, PathReject::DotDot));
    }
    if component.starts_with('.') {
        return Err(reject(input, PathReject::DotComponent));
    }
    if component.ends_with('.') {
        return Err(reject(input, PathReject::TrailingDot));
    }
    if component.ends_with(".lock") {
        return Err(reject(input, PathReject::LockSuffix));
    }
    if component.starts_with('-') {
        return Err(reject(input, PathReject::LeadingDash));
    }
    for byte in component.bytes() {
        match byte {
            b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'_' | b'-' => {}
            0 => return Err(reject(input, PathReject::Nul)),
            0x80.. => return Err(reject(input, PathReject::NonAscii)),
            0x01..=0x20 | 0x7f => return Err(reject(input, PathReject::Control)),
            _ => return Err(reject(input, PathReject::ForbiddenCharacter)),
        }
    }
    Ok(())
}

/// Turn an SSH command argument into a validated [`RepoRef`].
///
/// Accepts what a stock git client actually sends:
///
/// | client URL | argument | resolves to |
/// |---|---|---|
/// | `git@host:ns/repo.git` | `ns/repo.git` | `ns` / `repo` |
/// | `ssh://git@host/ns/repo.git` | `/ns/repo.git` | `ns` / `repo` |
/// | `git@host:team/sub/repo.git` | `team/sub/repo.git` | `team/sub` / `repo` |
///
/// **The argument must already be unquoted.** git wraps it in single quotes on
/// the wire and `gunnar_ssh::command` takes them off; a quote reaching here is a
/// forbidden character, not a quote. See the module docs.
///
/// Everything else is refused; the reasons are [`PathReject`].
pub fn resolve_repo_path(input: &str) -> Result<RepoRef> {
    // NUL first, before anything can be truncated at it.
    if input.contains('\0') {
        return Err(reject(input, PathReject::Nul));
    }
    if input.len() > MAX_PATH_BYTES {
        return Err(reject(input, PathReject::TooLong));
    }

    let mut rest = input;
    if rest.is_empty() {
        return Err(reject(input, PathReject::Empty));
    }

    if rest.starts_with('~') {
        return Err(reject(input, PathReject::HomeReference));
    }
    // Exactly one leading slash, for the `ssh://` URL form. See the module docs.
    if let Some(stripped) = rest.strip_prefix('/') {
        if stripped.starts_with('/') {
            return Err(reject(input, PathReject::Absolute));
        }
        rest = stripped;
    }
    if rest.is_empty() {
        return Err(reject(input, PathReject::Empty));
    }

    // `.git` is a suffix on the *name*, never a component of its own, so it
    // comes off before the components are validated. Otherwise a repository
    // called `x` would be reachable as `ns/x.git` but a namespace called
    // `.git` would look like a legal component.
    let rest = rest.strip_suffix(".git").unwrap_or(rest);
    if rest.is_empty() {
        return Err(reject(input, PathReject::Empty));
    }

    let components: Vec<&str> = rest.split('/').collect();
    if components.len() < 2 {
        return Err(reject(input, PathReject::TooFewComponents));
    }
    // Content before count, so that a traversal is reported as a traversal
    // rather than as "too many components". The reason ends up in a log line
    // an operator has to act on, and `..` is the one worth seeing.
    for component in &components {
        check_component(input, component)?;
    }
    if components.len() > MAX_COMPONENTS {
        return Err(reject(input, PathReject::TooManyComponents));
    }

    let (name, namespace) = components
        .split_last()
        .expect("at least two components were just checked");
    Ok(RepoRef {
        namespace: namespace.join("/"),
        name: (*name).to_owned(),
    })
}

/// Red-first test support: the shared escape table, and a resolver that is
/// **deliberately broken**.
///
/// Never accept a green you have not seen red. A table of escape
/// strings that a working sandbox rejects proves nothing on its own: the same
/// table passes against a sandbox that rejects *everything*, and it passes
/// vacuously if a row is not actually an escape. So both halves are asserted
/// from here:
///
/// * every row of [`ESCAPES`] is accepted by [`broken_resolve_repo_path`] and
///   rejected by [`resolve_repo_path`], so each row has teeth and the guard is
///   seen to be able to fail;
/// * every row of [`LEGITIMATE`] is accepted by [`resolve_repo_path`], so the
///   sandbox is not the identity-value trap.
///
/// Behind the `conformance` feature, which is off for any server build.
#[cfg(any(test, feature = "conformance"))]
pub mod red {
    use super::{RepoRef, Result};

    /// Inputs that must never resolve, with the reason each one exists.
    ///
    /// Every entry is accepted by [`broken_resolve_repo_path`], which is what
    /// makes the table a real test rather than a list of strings that happen to
    /// be rejected.
    ///
    /// This is the **union** of this crate's table and the one `gunnar-ssh`
    /// carried before its sandbox was deleted. Rows marked `[ssh]` came
    /// from there.
    pub const ESCAPES: &[(&str, &str)] = &[
        ("../etc/passwd", "the plain traversal"),
        (
            "ns/../../etc/passwd",
            "traversal from inside a legal namespace",
        ),
        ("ns/repo/../../../root", "traversal past the root"),
        ("ns/.git/../..", "the `.git/../..` form from the brief"),
        ("..", "nothing but a traversal"),
        ("ns/..", "a trailing traversal"),
        ("a..b/repo", "`..` embedded in a component"),
        ("//etc/passwd", "double-slash absolute"),
        (
            "/../etc/passwd",
            "traversal behind the tolerated leading slash",
        ),
        ("~/repo", "home-directory reference"),
        ("~root/repo.git", "another user's home directory"),
        ("ns/re\0po", "NUL byte truncation"),
        ("\0ns/repo", "leading NUL"),
        (
            "ns/re\npo",
            "newline, a second command to anything line-oriented",
        ),
        ("ns/re\tpo", "tab"),
        ("ns/repo\u{7f}", "DEL"),
        ("ns/re po", "space"),
        ("ns/repo\u{1b}[31m", "ANSI escape aimed at the log reader"),
        ("café/repo", "non-ASCII"),
        ("ns/repo\u{0301}", "NFD: `o` plus a combining acute"),
        ("ns/repo", "fullwidth homoglyph of `s`"),
        ("nѕ/repo", "Cyrillic homoglyph of `s`"),
        ("ns\u{2044}repo/x", "fraction slash: a `/` that is not `/`"),
        ("ns/repo\u{200b}x", "zero-width space"),
        ("ns\\repo", "backslash separator"),
        ("ns/..\\..\\etc", "Windows-flavoured traversal"),
        ("", "empty"),
        ("''", "empty inside quotes"),
        ("repo", "no namespace at all"),
        ("repo.git", "no namespace, with the suffix"),
        ("/repo.git", "no namespace, ssh:// form"),
        ("ns/", "trailing slash, empty name"),
        ("/ns/", "trailing slash, ssh:// form"),
        ("ns//repo", "empty component in the middle"),
        ("a/b/c/d/e", "deeper than MAX_COMPONENTS"),
        ("ns/.hidden", "component starting with a dot"),
        ("ns/.git", "the git directory itself"),
        (".git/repo", "the git directory as a namespace"),
        ("ns/repo.", "component ending in a dot"),
        ("ns/-rf", "component starting with a dash"),
        ("-ns/repo", "namespace starting with a dash"),
        ("ns/repo:extra", "colon, the scp-syntax separator"),
        ("ns/$(id)", "command substitution"),
        ("ns/`id`", "backtick substitution"),
        ("ns/repo;id", "shell separator"),
        ("ns/re*po", "glob"),
        ("ns/re'po", "a quote that is not a surrounding quote"),
        (
            "ns/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            "component over 64 bytes",
        ),
        // ── merged in from the deleted `gunnar_ssh::path` table ─────────────
        ("ns/repo.lock", "[ssh] git's lock-file suffix"),
        ("ns.lock/repo", "[ssh] the lock suffix on a namespace too"),
        ("../..", "[ssh] nothing but traversals"),
        ("ns/repo/../other", "[ssh] traversal back down a sibling"),
        ("~alice/ns/repo", "[ssh] a named user's home directory"),
        ("ns/re\\po", "[ssh] a backslash inside a component"),
        ("ns/repo|nc", "[ssh] a pipe to another process"),
        ("ns/repo > /etc/cron.d/x", "[ssh] a redirect"),
        ("ns/repo && id", "[ssh] a command chain"),
        (
            "ns/\u{0441}ore",
            "[ssh] Cyrillic `с`, a homoglyph of ASCII `c`",
        ),
        ("ns/repo\u{202e}", "[ssh] a right-to-left override"),
        (
            "ns/re\u{00ad}po",
            "[ssh] a soft hyphen, invisible in a log line",
        ),
        // ── quoting: the sandbox does NOT unquote ───────────────────────────
        //
        // These two used to be in `LEGITIMATE`, because this function stripped a
        // surrounding quote pair. It no longer does: git's `sq_quote` is undone
        // in `gunnar_ssh::command`, which knows the `'\''` escape, and a second
        // weaker unquoter here was exactly the twinning the merge exists to end. A
        // quote arriving here is a character that is not allowed in a name.
        (
            "'ns/repo.git'",
            "single-quoted; unquoting belongs to the SSH command parser",
        ),
        (
            "\"ns/repo.git\"",
            "double-quoted, and git never sends double quotes at all",
        ),
    ];

    /// Inputs that must resolve, and to exactly `(namespace, name)`.
    ///
    /// This half is not decoration. A sandbox that rejects every one of the
    /// [`ESCAPES`] *and* every one of these is a sandbox that rejects
    /// everything, which is the identity-value trap and passes any test that
    /// only looks for rejections.
    pub const LEGITIMATE: &[(&str, &str, &str)] = &[
        ("ns/repo", "ns", "repo"),
        ("ns/repo.git", "ns", "repo"),
        ("/ns/repo.git", "ns", "repo"),
        ("team/sub/repo.git", "team/sub", "repo"),
        ("team/sub/deeper/repo.git", "team/sub/deeper", "repo"),
        ("/team/sub/repo.git", "team/sub", "repo"),
        (
            "My-Team_1/some.repo-name_2.git",
            "My-Team_1",
            "some.repo-name_2",
        ),
        ("ns/repo.git.git", "ns", "repo.git"),
        ("ns/a", "ns", "a"),
        ("0/9", "0", "9"),
        // ── merged in from the deleted `gunnar_ssh::path` table ─────────────
        ("nordisk/osm-katana.git", "nordisk", "osm-katana"),
        ("/nordisk/osm-katana.git", "nordisk", "osm-katana"),
        ("a/b", "a", "b"),
        ("team-1/repo_2.name", "team-1", "repo_2.name"),
        ("team-1/my_repo.v2.git", "team-1", "my_repo.v2"),
        // `.git` comes off the END, once. A namespace that is *called* `x.git`
        // is a namespace called `x.git`.
        ("x.git/repo", "x.git", "repo"),
        // **The documented deviation, pinned.** `/etc/passwd` is a legal
        // *string*: it resolves to the catalog key `etc/passwd`, which nothing
        // has ever registered, so `RepoCatalog::open` misses and the exec is
        // refused one layer up. Rejecting it syntactically would mean a
        // blocklist of filesystem names gunnar never uses, and would forbid a
        // legitimate namespace called `etc`. The property worth having is the
        // stronger one: no string a client sends becomes a filename at all.
        ("/etc/passwd", "etc", "passwd"),
        (
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/b",
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
            "b",
        ),
    ];

    /// **A DELIBERATELY BROKEN RESOLVER. NEVER CALL THIS FROM SERVER CODE.**
    ///
    /// This is the naive implementation (strip `.git`, split on `/`, take the
    /// first component as the namespace) with every check removed. It exists
    /// so the escape table can be seen to go red: if `ESCAPES` did not contain
    /// real escapes, this function would reject them too, and the red test
    /// would fail.
    pub fn broken_resolve_repo_path(input: &str) -> Result<RepoRef> {
        let rest = input.trim_matches(['\'', '"']);
        let rest = rest.strip_prefix('/').unwrap_or(rest);
        let rest = rest.strip_suffix(".git").unwrap_or(rest);
        match rest.split_once('/') {
            Some((namespace, name)) => Ok(RepoRef::unchecked(namespace, name)),
            None => Ok(RepoRef::unchecked("", rest)),
        }
    }
}