Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-core/src/sandbox.rs

//! **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)),
        }
    }
}