Skip to main content

gunnar_core/
sandbox.rs

1//! **The path sandbox: the security core of the server.**
2//!
3//! An SSH client sends `git-upload-pack 'ns/repo.git'`. Everything after the
4//! command name is attacker-controlled, and [`resolve_repo_path`] is the single
5//! function that turns it into something the rest of gunnar is allowed to act
6//! on. A path escape here is the whole security model (the ACL model).
7//!
8//! # Two properties, and the second is the one that gets forgotten
9//!
10//! 1. Every escape is rejected: `..`, absolute paths, NUL bytes, control
11//!    characters, unicode normalisation and homoglyph tricks, `.git/../..`,
12//!    empty components, over-long names.
13//! 2. **Legitimate nested names are accepted.** A sandbox that rejects
14//!    everything passes every escape test and is useless: the identity-value
15//!    identity-value trap in its purest form. `tests/sandbox.rs` asserts both halves
16//!    from the same table, and `red::broken_resolve_repo_path` proves the escape
17//!    half can actually go red.
18//!
19//! # Fix by construction: the output is not a path
20//!
21//! [`RepoRef`] is two (or more) validated components, not a string that later
22//! gets joined onto a root. Catalog lookups are by `<namespace>/<repo>` **key**,
23//! so on the appliance, where there is no filesystem at all (the no-OS server rule), a
24//! traversal has nothing to traverse. The only place a `RepoRef` becomes a
25//! filesystem path is [`RepoRef::path_under`], which is where the symlink check
26//! lives, because a symlink escape is a property of the filesystem and cannot be
27//! detected by looking at a string.
28//!
29//! # This is the ONLY path sandbox
30//!
31//! `gunnar-ssh` grew a second one, `gunnar_ssh::path::RepoPath`, while this
32//! crate was being written, and **the two disagreed**: that one enforced exactly
33//! two components, this one permits nested namespaces. Two security checks that
34//! disagree are worse than either alone, because the strict one gives false
35//! confidence about the permissive one. The other copy is deleted; the rules it
36//! had and this one did not (the `.lock` suffix) were merged in here, and its
37//! rejection table was merged into [`red::ESCAPES`].
38//!
39//! The permissive shape is the surviving one. It is a superset, and the
40//! escalation risk it would otherwise carry is closed elsewhere, by construction:
41//! **grants do not inherit**, so `team` does not imply `team/sub`
42//! ([`crate::model::Scope::Namespace`] matches exactly, pinned by
43//! `tests/acl.rs`).
44//!
45//! # Unquoting is NOT done here
46//!
47//! git sends one argv element with the quotes inside it (`git-upload-pack
48//! '/ns/repo.git'`), and gunnar has no shell to strip them. Taking them off is a
49//! *transport* concern and lives in `gunnar_ssh::command`, which understands
50//! git's `sq_quote` escaping (including `'\''`). This function receives an
51//! already-unquoted string, does not know SSH exists, and treats a quote as what
52//! it is here: a character that is not allowed in a repository name.
53//!
54//! # Deviations from the brief, recorded honestly
55//!
56//! * **A single leading `/` is stripped, not rejected.** `ssh://host/ns/repo.git`
57//!   makes a stock git client send `git-upload-pack '/ns/repo.git'`; verified
58//!   against the real `git` binary, see `tests/sandbox.rs`. Rejecting a leading
59//!   slash would reject every `ssh://` URL. `//anything` is still rejected, and
60//!   `/etc/passwd` resolves to the *catalog key* `etc/passwd`, which is a lookup
61//!   miss and not a file.
62//! * **Non-ASCII is rejected outright.** That is a blunt answer to unicode
63//!   normalisation and homoglyph attacks and it does cost legitimate non-ASCII
64//!   repository names. It is the one that cannot be got subtly wrong.
65
66use std::fmt;
67use std::path::{Path, PathBuf};
68
69use crate::error::{Error, Result};
70
71/// Longest whole path gunnar will look at, in bytes.
72pub const MAX_PATH_BYTES: usize = 255;
73
74/// Longest single component (one namespace level, or the repository name).
75pub const MAX_COMPONENT_BYTES: usize = 64;
76
77/// Most components a repository path may have: up to three namespace levels
78/// plus the repository name.
79pub const MAX_COMPONENTS: usize = 4;
80
81/// Why a repository path was refused.
82///
83/// Carried in [`Error::InvalidPath`] so tests can assert *which* rule fired
84/// rather than merely that something failed. A sandbox that rejects the right
85/// input for the wrong reason is one refactor away from accepting it.
86#[derive(Clone, Copy, PartialEq, Eq, Debug)]
87#[non_exhaustive]
88pub enum PathReject {
89    /// Nothing, or only quotes.
90    Empty,
91    /// Longer than [`MAX_PATH_BYTES`].
92    TooLong,
93    /// A component longer than [`MAX_COMPONENT_BYTES`].
94    ComponentTooLong,
95    /// A NUL byte: the classic truncation trick against a C consumer.
96    Nul,
97    /// A control character or a space.
98    Control,
99    /// A byte outside ASCII. See the module docs: this is deliberate.
100    NonAscii,
101    /// A character that is legal in ASCII but not in a gunnar name.
102    ForbiddenCharacter,
103    /// Still absolute after the one permitted leading `/` was taken off.
104    Absolute,
105    /// A `~` home-directory reference. The server has no home directories.
106    HomeReference,
107    /// A `..` anywhere.
108    DotDot,
109    /// A component that is exactly `.`, or starts with one.
110    DotComponent,
111    /// A component ending in `.`.
112    TrailingDot,
113    /// A component ending in `.lock`.
114    ///
115    /// git's own lock-file suffix. Merged in from the `gunnar-ssh` sandbox this
116    /// one replaced: a repository or namespace called `x.lock` would
117    /// collide with a lock file on the `fs` backend, and a ref named after it
118    /// would collide in `gix-ref`.
119    LockSuffix,
120    /// A component starting with `-`, which reads as a command-line flag.
121    LeadingDash,
122    /// An empty component (a leading, trailing or doubled `/`).
123    EmptyComponent,
124    /// Fewer than two components: no namespace. Multi-tenancy is not optional
125    /// (multi-tenancy).
126    TooFewComponents,
127    /// More than [`MAX_COMPONENTS`] components.
128    TooManyComponents,
129}
130
131impl fmt::Display for PathReject {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        let s = match self {
134            PathReject::Empty => "empty",
135            PathReject::TooLong => "longer than 255 bytes",
136            PathReject::ComponentTooLong => "a component is longer than 64 bytes",
137            PathReject::Nul => "contains a NUL byte",
138            PathReject::Control => "contains a control character or a space",
139            PathReject::NonAscii => "contains a non-ASCII byte",
140            PathReject::ForbiddenCharacter => "contains a character that is not [A-Za-z0-9._-]",
141            PathReject::Absolute => "absolute",
142            PathReject::HomeReference => "a home-directory reference",
143            PathReject::DotDot => "contains `..`",
144            PathReject::DotComponent => "a component starts with `.`",
145            PathReject::TrailingDot => "a component ends with `.`",
146            PathReject::LockSuffix => "a component ends with `.lock`",
147            PathReject::LeadingDash => "a component starts with `-`",
148            PathReject::EmptyComponent => "an empty path component",
149            PathReject::TooFewComponents => "no namespace: expected `<namespace>/<repo>`",
150            PathReject::TooManyComponents => "too many path components",
151        };
152        f.write_str(s)
153    }
154}
155
156/// A validated repository reference: a namespace and a name, and nothing else.
157///
158/// The only ways to obtain one are [`resolve_repo_path`] and [`RepoRef::new`],
159/// both of which validate. There is no constructor that takes a string
160/// unchecked outside of [`red`], which exists to prove the checks bite.
161#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
162pub struct RepoRef {
163    namespace: String,
164    name: String,
165}
166
167impl RepoRef {
168    /// Validate a namespace and a repository name and pair them up.
169    ///
170    /// The namespace may itself be nested (`team/sub`); every level is validated
171    /// by the same rules as the repository name.
172    pub fn new(namespace: &str, name: &str) -> Result<Self> {
173        let input = format!("{namespace}/{name}");
174        if namespace.is_empty() {
175            return Err(reject(&input, PathReject::TooFewComponents));
176        }
177        let mut levels = 0usize;
178        for level in namespace.split('/') {
179            check_component(&input, level)?;
180            levels += 1;
181        }
182        check_component(&input, name)?;
183        if levels + 1 > MAX_COMPONENTS {
184            return Err(reject(&input, PathReject::TooManyComponents));
185        }
186        if input.len() > MAX_PATH_BYTES {
187            return Err(reject(&input, PathReject::TooLong));
188        }
189        Ok(RepoRef {
190            namespace: namespace.to_owned(),
191            name: name.to_owned(),
192        })
193    }
194
195    /// Pair two components up **without validating them**.
196    ///
197    /// Crate-private, and used by exactly one caller: the deliberately broken
198    /// resolver in [`red`], whose entire job is to be the thing that must fail.
199    /// It does not exist at all in a server build.
200    #[cfg(any(test, feature = "conformance"))]
201    pub(crate) fn unchecked(namespace: &str, name: &str) -> Self {
202        RepoRef {
203            namespace: namespace.to_owned(),
204            name: name.to_owned(),
205        }
206    }
207
208    /// The namespace, possibly nested (`team/sub`).
209    pub fn namespace(&self) -> &str {
210        &self.namespace
211    }
212
213    /// The repository name, without any `.git` suffix.
214    pub fn name(&self) -> &str {
215        &self.name
216    }
217
218    /// The catalog key, `<namespace>/<repo>`. Stable, and the only identifier
219    /// the catalog stores.
220    pub fn key(&self) -> String {
221        format!("{}/{}", self.namespace, self.name)
222    }
223
224    /// The path as a client would write it after the colon:
225    /// `<namespace>/<repo>.git`.
226    pub fn to_ssh_path(&self) -> String {
227        format!("{}/{}.git", self.namespace, self.name)
228    }
229
230    /// Where this repository's directory lives under `root`, **checked against
231    /// symlink escapes**.
232    ///
233    /// The string checks in [`resolve_repo_path`] cannot see a symlink: `ns` may
234    /// be a perfectly ordinary-looking name that happens to be a link to `/etc`.
235    /// So the deepest existing ancestor of the target is canonicalised and
236    /// asserted to still be inside the canonicalised `root`.
237    ///
238    /// This is for the `fs` backend only. The appliance has no filesystem and
239    /// never calls it.
240    pub fn path_under(&self, root: &Path) -> Result<PathBuf> {
241        let root_real = root.canonicalize().map_err(|err| Error::PathEscape {
242            path: root.to_path_buf(),
243            reason: format!("the repository root does not resolve: {err}"),
244        })?;
245        let target = root_real
246            .join(&self.namespace)
247            .join(format!("{}.git", self.name));
248
249        // Walk up to the deepest ancestor that exists; that is the deepest point
250        // a symlink could have redirected. Everything below it is not created
251        // yet and therefore cannot redirect anywhere.
252        let mut probe = target.clone();
253        loop {
254            match probe.canonicalize() {
255                Ok(real) => {
256                    if real != root_real && !real.starts_with(&root_real) {
257                        return Err(Error::PathEscape {
258                            path: probe,
259                            reason: format!(
260                                "resolves to {}, which is outside {}",
261                                real.display(),
262                                root_real.display()
263                            ),
264                        });
265                    }
266                    return Ok(target);
267                }
268                Err(err) if err.kind() == std::io::ErrorKind::NotFound => match probe.parent() {
269                    Some(parent) if parent.starts_with(&root_real) => {
270                        probe = parent.to_path_buf();
271                    }
272                    _ => {
273                        return Err(Error::PathEscape {
274                            path: target,
275                            reason: "no ancestor of the target lies under the repository root"
276                                .to_owned(),
277                        })
278                    }
279                },
280                Err(err) => {
281                    return Err(Error::PathEscape {
282                        path: probe,
283                        reason: format!("could not be resolved: {err}"),
284                    })
285                }
286            }
287        }
288    }
289}
290
291impl fmt::Display for RepoRef {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(f, "{}/{}", self.namespace, self.name)
294    }
295}
296
297impl fmt::Debug for RepoRef {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        write!(f, "RepoRef({}/{})", self.namespace, self.name)
300    }
301}
302
303impl std::str::FromStr for RepoRef {
304    type Err = Error;
305    fn from_str(s: &str) -> Result<Self> {
306        resolve_repo_path(s)
307    }
308}
309
310fn reject(input: &str, reason: PathReject) -> Error {
311    // The offending input is echoed back escaped, never raw: it reaches logs,
312    // and a raw NUL or an ANSI escape in a log line is its own small attack.
313    Error::InvalidPath {
314        input: input.escape_debug().to_string(),
315        reason,
316    }
317}
318
319/// The rules every single component obeys, in one place so a namespace level, a
320/// repository name and a principal id cannot drift apart (one implementation).
321pub(crate) fn check_component(input: &str, component: &str) -> Result<()> {
322    if component.is_empty() {
323        return Err(reject(input, PathReject::EmptyComponent));
324    }
325    if component.len() > MAX_COMPONENT_BYTES {
326        return Err(reject(input, PathReject::ComponentTooLong));
327    }
328    if component.contains("..") {
329        return Err(reject(input, PathReject::DotDot));
330    }
331    if component.starts_with('.') {
332        return Err(reject(input, PathReject::DotComponent));
333    }
334    if component.ends_with('.') {
335        return Err(reject(input, PathReject::TrailingDot));
336    }
337    if component.ends_with(".lock") {
338        return Err(reject(input, PathReject::LockSuffix));
339    }
340    if component.starts_with('-') {
341        return Err(reject(input, PathReject::LeadingDash));
342    }
343    for byte in component.bytes() {
344        match byte {
345            b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'.' | b'_' | b'-' => {}
346            0 => return Err(reject(input, PathReject::Nul)),
347            0x80.. => return Err(reject(input, PathReject::NonAscii)),
348            0x01..=0x20 | 0x7f => return Err(reject(input, PathReject::Control)),
349            _ => return Err(reject(input, PathReject::ForbiddenCharacter)),
350        }
351    }
352    Ok(())
353}
354
355/// Turn an SSH command argument into a validated [`RepoRef`].
356///
357/// Accepts what a stock git client actually sends:
358///
359/// | client URL | argument | resolves to |
360/// |---|---|---|
361/// | `git@host:ns/repo.git` | `ns/repo.git` | `ns` / `repo` |
362/// | `ssh://git@host/ns/repo.git` | `/ns/repo.git` | `ns` / `repo` |
363/// | `git@host:team/sub/repo.git` | `team/sub/repo.git` | `team/sub` / `repo` |
364///
365/// **The argument must already be unquoted.** git wraps it in single quotes on
366/// the wire and `gunnar_ssh::command` takes them off; a quote reaching here is a
367/// forbidden character, not a quote. See the module docs.
368///
369/// Everything else is refused; the reasons are [`PathReject`].
370pub fn resolve_repo_path(input: &str) -> Result<RepoRef> {
371    // NUL first, before anything can be truncated at it.
372    if input.contains('\0') {
373        return Err(reject(input, PathReject::Nul));
374    }
375    if input.len() > MAX_PATH_BYTES {
376        return Err(reject(input, PathReject::TooLong));
377    }
378
379    let mut rest = input;
380    if rest.is_empty() {
381        return Err(reject(input, PathReject::Empty));
382    }
383
384    if rest.starts_with('~') {
385        return Err(reject(input, PathReject::HomeReference));
386    }
387    // Exactly one leading slash, for the `ssh://` URL form. See the module docs.
388    if let Some(stripped) = rest.strip_prefix('/') {
389        if stripped.starts_with('/') {
390            return Err(reject(input, PathReject::Absolute));
391        }
392        rest = stripped;
393    }
394    if rest.is_empty() {
395        return Err(reject(input, PathReject::Empty));
396    }
397
398    // `.git` is a suffix on the *name*, never a component of its own, so it
399    // comes off before the components are validated. Otherwise a repository
400    // called `x` would be reachable as `ns/x.git` but a namespace called
401    // `.git` would look like a legal component.
402    let rest = rest.strip_suffix(".git").unwrap_or(rest);
403    if rest.is_empty() {
404        return Err(reject(input, PathReject::Empty));
405    }
406
407    let components: Vec<&str> = rest.split('/').collect();
408    if components.len() < 2 {
409        return Err(reject(input, PathReject::TooFewComponents));
410    }
411    // Content before count, so that a traversal is reported as a traversal
412    // rather than as "too many components". The reason ends up in a log line
413    // an operator has to act on, and `..` is the one worth seeing.
414    for component in &components {
415        check_component(input, component)?;
416    }
417    if components.len() > MAX_COMPONENTS {
418        return Err(reject(input, PathReject::TooManyComponents));
419    }
420
421    let (name, namespace) = components
422        .split_last()
423        .expect("at least two components were just checked");
424    Ok(RepoRef {
425        namespace: namespace.join("/"),
426        name: (*name).to_owned(),
427    })
428}
429
430/// Red-first test support: the shared escape table, and a resolver that is
431/// **deliberately broken**.
432///
433/// Never accept a green you have not seen red. A table of escape
434/// strings that a working sandbox rejects proves nothing on its own: the same
435/// table passes against a sandbox that rejects *everything*, and it passes
436/// vacuously if a row is not actually an escape. So both halves are asserted
437/// from here:
438///
439/// * every row of [`ESCAPES`] is accepted by [`broken_resolve_repo_path`] and
440///   rejected by [`resolve_repo_path`], so each row has teeth and the guard is
441///   seen to be able to fail;
442/// * every row of [`LEGITIMATE`] is accepted by [`resolve_repo_path`], so the
443///   sandbox is not the identity-value trap.
444///
445/// Behind the `conformance` feature, which is off for any server build.
446#[cfg(any(test, feature = "conformance"))]
447pub mod red {
448    use super::{RepoRef, Result};
449
450    /// Inputs that must never resolve, with the reason each one exists.
451    ///
452    /// Every entry is accepted by [`broken_resolve_repo_path`], which is what
453    /// makes the table a real test rather than a list of strings that happen to
454    /// be rejected.
455    ///
456    /// This is the **union** of this crate's table and the one `gunnar-ssh`
457    /// carried before its sandbox was deleted. Rows marked `[ssh]` came
458    /// from there.
459    pub const ESCAPES: &[(&str, &str)] = &[
460        ("../etc/passwd", "the plain traversal"),
461        (
462            "ns/../../etc/passwd",
463            "traversal from inside a legal namespace",
464        ),
465        ("ns/repo/../../../root", "traversal past the root"),
466        ("ns/.git/../..", "the `.git/../..` form from the brief"),
467        ("..", "nothing but a traversal"),
468        ("ns/..", "a trailing traversal"),
469        ("a..b/repo", "`..` embedded in a component"),
470        ("//etc/passwd", "double-slash absolute"),
471        (
472            "/../etc/passwd",
473            "traversal behind the tolerated leading slash",
474        ),
475        ("~/repo", "home-directory reference"),
476        ("~root/repo.git", "another user's home directory"),
477        ("ns/re\0po", "NUL byte truncation"),
478        ("\0ns/repo", "leading NUL"),
479        (
480            "ns/re\npo",
481            "newline, a second command to anything line-oriented",
482        ),
483        ("ns/re\tpo", "tab"),
484        ("ns/repo\u{7f}", "DEL"),
485        ("ns/re po", "space"),
486        ("ns/repo\u{1b}[31m", "ANSI escape aimed at the log reader"),
487        ("café/repo", "non-ASCII"),
488        ("ns/repo\u{0301}", "NFD: `o` plus a combining acute"),
489        ("ns/repo", "fullwidth homoglyph of `s`"),
490        ("nѕ/repo", "Cyrillic homoglyph of `s`"),
491        ("ns\u{2044}repo/x", "fraction slash: a `/` that is not `/`"),
492        ("ns/repo\u{200b}x", "zero-width space"),
493        ("ns\\repo", "backslash separator"),
494        ("ns/..\\..\\etc", "Windows-flavoured traversal"),
495        ("", "empty"),
496        ("''", "empty inside quotes"),
497        ("repo", "no namespace at all"),
498        ("repo.git", "no namespace, with the suffix"),
499        ("/repo.git", "no namespace, ssh:// form"),
500        ("ns/", "trailing slash, empty name"),
501        ("/ns/", "trailing slash, ssh:// form"),
502        ("ns//repo", "empty component in the middle"),
503        ("a/b/c/d/e", "deeper than MAX_COMPONENTS"),
504        ("ns/.hidden", "component starting with a dot"),
505        ("ns/.git", "the git directory itself"),
506        (".git/repo", "the git directory as a namespace"),
507        ("ns/repo.", "component ending in a dot"),
508        ("ns/-rf", "component starting with a dash"),
509        ("-ns/repo", "namespace starting with a dash"),
510        ("ns/repo:extra", "colon, the scp-syntax separator"),
511        ("ns/$(id)", "command substitution"),
512        ("ns/`id`", "backtick substitution"),
513        ("ns/repo;id", "shell separator"),
514        ("ns/re*po", "glob"),
515        ("ns/re'po", "a quote that is not a surrounding quote"),
516        (
517            "ns/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
518            "component over 64 bytes",
519        ),
520        // ── merged in from the deleted `gunnar_ssh::path` table ─────────────
521        ("ns/repo.lock", "[ssh] git's lock-file suffix"),
522        ("ns.lock/repo", "[ssh] the lock suffix on a namespace too"),
523        ("../..", "[ssh] nothing but traversals"),
524        ("ns/repo/../other", "[ssh] traversal back down a sibling"),
525        ("~alice/ns/repo", "[ssh] a named user's home directory"),
526        ("ns/re\\po", "[ssh] a backslash inside a component"),
527        ("ns/repo|nc", "[ssh] a pipe to another process"),
528        ("ns/repo > /etc/cron.d/x", "[ssh] a redirect"),
529        ("ns/repo && id", "[ssh] a command chain"),
530        (
531            "ns/\u{0441}ore",
532            "[ssh] Cyrillic `с`, a homoglyph of ASCII `c`",
533        ),
534        ("ns/repo\u{202e}", "[ssh] a right-to-left override"),
535        (
536            "ns/re\u{00ad}po",
537            "[ssh] a soft hyphen, invisible in a log line",
538        ),
539        // ── quoting: the sandbox does NOT unquote ───────────────────────────
540        //
541        // These two used to be in `LEGITIMATE`, because this function stripped a
542        // surrounding quote pair. It no longer does: git's `sq_quote` is undone
543        // in `gunnar_ssh::command`, which knows the `'\''` escape, and a second
544        // weaker unquoter here was exactly the twinning the merge exists to end. A
545        // quote arriving here is a character that is not allowed in a name.
546        (
547            "'ns/repo.git'",
548            "single-quoted; unquoting belongs to the SSH command parser",
549        ),
550        (
551            "\"ns/repo.git\"",
552            "double-quoted, and git never sends double quotes at all",
553        ),
554    ];
555
556    /// Inputs that must resolve, and to exactly `(namespace, name)`.
557    ///
558    /// This half is not decoration. A sandbox that rejects every one of the
559    /// [`ESCAPES`] *and* every one of these is a sandbox that rejects
560    /// everything, which is the identity-value trap and passes any test that
561    /// only looks for rejections.
562    pub const LEGITIMATE: &[(&str, &str, &str)] = &[
563        ("ns/repo", "ns", "repo"),
564        ("ns/repo.git", "ns", "repo"),
565        ("/ns/repo.git", "ns", "repo"),
566        ("team/sub/repo.git", "team/sub", "repo"),
567        ("team/sub/deeper/repo.git", "team/sub/deeper", "repo"),
568        ("/team/sub/repo.git", "team/sub", "repo"),
569        (
570            "My-Team_1/some.repo-name_2.git",
571            "My-Team_1",
572            "some.repo-name_2",
573        ),
574        ("ns/repo.git.git", "ns", "repo.git"),
575        ("ns/a", "ns", "a"),
576        ("0/9", "0", "9"),
577        // ── merged in from the deleted `gunnar_ssh::path` table ─────────────
578        ("nordisk/osm-katana.git", "nordisk", "osm-katana"),
579        ("/nordisk/osm-katana.git", "nordisk", "osm-katana"),
580        ("a/b", "a", "b"),
581        ("team-1/repo_2.name", "team-1", "repo_2.name"),
582        ("team-1/my_repo.v2.git", "team-1", "my_repo.v2"),
583        // `.git` comes off the END, once. A namespace that is *called* `x.git`
584        // is a namespace called `x.git`.
585        ("x.git/repo", "x.git", "repo"),
586        // **The documented deviation, pinned.** `/etc/passwd` is a legal
587        // *string*: it resolves to the catalog key `etc/passwd`, which nothing
588        // has ever registered, so `RepoCatalog::open` misses and the exec is
589        // refused one layer up. Rejecting it syntactically would mean a
590        // blocklist of filesystem names gunnar never uses, and would forbid a
591        // legitimate namespace called `etc`. The property worth having is the
592        // stronger one: no string a client sends becomes a filename at all.
593        ("/etc/passwd", "etc", "passwd"),
594        (
595            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/b",
596            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
597            "b",
598        ),
599    ];
600
601    /// **A DELIBERATELY BROKEN RESOLVER. NEVER CALL THIS FROM SERVER CODE.**
602    ///
603    /// This is the naive implementation (strip `.git`, split on `/`, take the
604    /// first component as the namespace) with every check removed. It exists
605    /// so the escape table can be seen to go red: if `ESCAPES` did not contain
606    /// real escapes, this function would reject them too, and the red test
607    /// would fail.
608    pub fn broken_resolve_repo_path(input: &str) -> Result<RepoRef> {
609        let rest = input.trim_matches(['\'', '"']);
610        let rest = rest.strip_prefix('/').unwrap_or(rest);
611        let rest = rest.strip_suffix(".git").unwrap_or(rest);
612        match rest.split_once('/') {
613            Some((namespace, name)) => Ok(RepoRef::unchecked(namespace, name)),
614            None => Ok(RepoRef::unchecked("", rest)),
615        }
616    }
617}