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}