Skip to main content

max / makenotwork

24.4 KB · 660 lines History Blame Raw
1 //! The git-over-SSH command grammar, parsed once for every door that serves it.
2 //!
3 //! A git client asks for a repository by sending one line:
4 //!
5 //! ```text
6 //! git-upload-pack '/max/shop.git'
7 //! ```
8 //!
9 //! Two hosts receive that line. `mnw-cli`'s russh server is what
10 //! `ssh.makenot.work` runs, and `mnw-admin git-auth` is what sshd's `command=`
11 //! prefix invokes. Both parse it here, so the grammar has one implementation.
12 //! A second parser is a source of divergence on leading whitespace, unbalanced
13 //! quotes, repeated leading slashes, and the order of the `.git` strip against
14 //! the `..` check: the same push accepted by one host and refused by the
15 //! other.
16 //!
17 //! ## What this crate promises, and what it leaves to the caller
18 //!
19 //! It promises **path safety**: an accepted [`Request`] has an owner and a repo
20 //! that are each a single, non-empty, non-traversing path segment, so
21 //! [`Request::repo_dir`] cannot leave the root it is given. That is the property
22 //! the fuzz target asserts, and it is the one every caller depends on before it
23 //! joins anything.
24 //!
25 //! It does not decide **identity policy**. Whether `max` is a real user, whether
26 //! a username may contain a hyphen, whether the caller may push here: all of
27 //! that is the server's, and `Username::new` runs on top of this. The split is
28 //! deliberate: path safety is a property of the string and belongs where the
29 //! string is parsed, while identity is a property of the deployment.
30 //!
31 //! <!-- wiki: astra-soak-overview -->
32
33 use std::path::{Path, PathBuf};
34
35 /// The three verbs a git client may ask for.
36 ///
37 /// Nothing else is served. The management verbs (`repo list`, `key rm`) moved to
38 /// `mnw-cli`'s own command surface in 2026-07 and never travelled through this
39 /// grammar.
40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41 pub enum Operation {
42 /// `git fetch` / `git clone`, the read side.
43 UploadPack,
44 /// `git push`, the write side.
45 ReceivePack,
46 /// `git archive --remote`, read-only and rarely used.
47 UploadArchive,
48 }
49
50 impl Operation {
51 /// The wire spelling, which is also the binary name both doors exec.
52 #[must_use]
53 pub fn command(self) -> &'static str {
54 match self {
55 Self::UploadPack => "git-upload-pack",
56 Self::ReceivePack => "git-receive-pack",
57 Self::UploadArchive => "git-upload-archive",
58 }
59 }
60
61 fn from_wire(s: &str) -> Option<Self> {
62 match s {
63 "git-upload-pack" => Some(Self::UploadPack),
64 "git-receive-pack" => Some(Self::ReceivePack),
65 "git-upload-archive" => Some(Self::UploadArchive),
66 _ => None,
67 }
68 }
69 }
70
71 /// Why a command line was refused.
72 ///
73 /// Callers map every variant to the same client-visible answer — a git client
74 /// learns "repository not found" and nothing more, so a probe cannot tell a
75 /// malformed name from a private repository. The distinction exists for logs.
76 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
77 pub enum ParseError {
78 /// No space, so there is no path argument.
79 NoArgument,
80 /// The verb is not one of the three.
81 UnsupportedOperation,
82 /// The path carries no `/`, so it names no owner.
83 MissingOwner,
84 /// A segment was empty, over-long, traversing, or outside the charset.
85 InvalidSegment,
86 }
87
88 impl std::fmt::Display for ParseError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 let s = match self {
91 Self::NoArgument => "no repository argument",
92 Self::UnsupportedOperation => "unsupported git operation",
93 Self::MissingOwner => "repository path names no owner",
94 Self::InvalidSegment => "invalid owner or repository name",
95 };
96 f.write_str(s)
97 }
98 }
99
100 impl std::error::Error for ParseError {}
101
102 /// A parsed, path-safe request.
103 ///
104 /// Borrowed from the command line rather than owned: both callers have the line
105 /// in hand for the whole operation, and copying two short segments per
106 /// connection buys nothing.
107 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
108 pub struct Request<'a> {
109 /// Which verb was asked for.
110 pub operation: Operation,
111 /// The namespace owner. A single validated path segment.
112 pub owner: &'a str,
113 /// The repository, with any `.git` suffix already removed. A single
114 /// validated path segment.
115 pub repo: &'a str,
116 }
117
118 impl Request<'_> {
119 /// The bare repository's directory under `root`.
120 ///
121 /// The join lives here so there is one place where an owner and a repo
122 /// become a path, and so the fuzz target can assert about the thing callers
123 /// actually use rather than about the segments in isolation. Both segments
124 /// passed [`valid_segment`], so the result is always `root/owner/repo.git`
125 /// with no way out of `root`.
126 #[must_use]
127 pub fn repo_dir(&self, root: impl AsRef<Path>) -> PathBuf {
128 root.as_ref()
129 .join(self.owner)
130 .join(format!("{}.git", self.repo))
131 }
132
133 /// The command to hand `git-shell -c`, rebuilt from validated parts.
134 ///
135 /// Rebuilt rather than forwarded: the original line is attacker-controlled
136 /// and forwarding it is how argument injection gets in. Every byte here came
137 /// through [`valid_segment`].
138 #[must_use]
139 pub fn shell_command(&self) -> String {
140 format!(
141 "{} '/{}/{}.git'",
142 self.operation.command(),
143 self.owner,
144 self.repo
145 )
146 }
147 }
148
149 /// Longest a segment may be. Matches the server's `validate_segment` and
150 /// `validate_git_repo_name`, both of which cap at 64.
151 const SEGMENT_MAX: usize = 64;
152
153 /// Is this string safe to use as one path component?
154 ///
155 /// The whole safety argument of this crate reduces to this function, so it is
156 /// deliberately a whitelist and deliberately boring. Rejects: empty, over-long,
157 /// anything outside `[A-Za-z0-9._-]`, any `..` anywhere, and a dot at either
158 /// end.
159 ///
160 /// The dot rules do more than block `.` and `..`:
161 ///
162 /// - A **leading** dot is hidden on disk, and `.git` as a repository name would
163 /// put a repository's own metadata directory name into the namespace.
164 /// - A **trailing** dot is what the fuzz target found on its first run
165 /// (`max/v1.2.`). It passes every other rule, and then [`Request::repo_dir`]
166 /// and [`Request::shell_command`] append `.git` and produce `v1.2..git` — a
167 /// name this parser itself refuses, because the `..` test in [`parse`] fires
168 /// on it. A grammar that will not re-read its own output is one where two
169 /// callers can disagree about what a name means, which is the class of defect
170 /// this crate exists to remove. Trailing dots are also unrepresentable on
171 /// Windows and collide with the undotted name on some filesystems, so nothing
172 /// is lost by refusing them.
173 ///
174 /// Excluding whole classes is cheaper to defend than enumerating bad members,
175 /// and the charset rule already excludes `/` so no segment can silently become
176 /// two.
177 #[must_use]
178 pub fn valid_segment(s: &str) -> bool {
179 if s.is_empty() || s.len() > SEGMENT_MAX {
180 return false;
181 }
182 if s.starts_with('.') || s.ends_with('.') || s.contains("..") {
183 return false;
184 }
185 s.bytes()
186 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
187 }
188
189 /// Parse one `SSH_ORIGINAL_COMMAND` / russh `exec` line.
190 ///
191 /// # The four reconciled divergences
192 ///
193 /// Each was a real difference between the two former parsers, and each is
194 /// settled here in the direction named:
195 ///
196 /// 1. **Surrounding whitespace is trimmed.** `mnw-cli` trimmed, the server did
197 /// not. Trimming is the live door's behaviour and costs nothing.
198 /// 2. **Quotes must balance, and only one pair is stripped.** The server used
199 /// `trim_matches`, which strips greedily and does not care whether the quotes
200 /// pair up, so `'/max/shop.git` (unterminated) parsed. Requiring a pair is
201 /// `mnw-cli`'s rule and the stricter one.
202 /// 3. **At most one leading slash is stripped.** The server stripped every
203 /// leading slash, so `//max/shop.git` parsed. Git sends at most one.
204 /// 4. **`..` cannot survive the `.git` strip.** The two parsers tested for `..`
205 /// on opposite sides of the suffix strip, and this was the only class of the
206 /// four with teeth: `mnw-cli` stripped first, so `/max/shop..git` became repo
207 /// `shop.` and was accepted, safe only because a charset guard further in
208 /// happened to catch what was left.
209 ///
210 /// There is no ordering here to get wrong, because there is one check and it
211 /// runs last. [`valid_segment`] refuses `..` anywhere and a dot at either
212 /// end, and those two rules together close the strip entirely: if the raw
213 /// remainder contains `..` and the stripped name does not, the `..` must
214 /// straddle the boundary, so the remainder ends `..git` and the stripped name
215 /// ends with a dot. `.git` carries no `..` of its own, so there is no other
216 /// way across.
217 ///
218 /// There is deliberately no separate pre-strip guard: every input one would
219 /// reject is already rejected below, and two overlapping guards is the same
220 /// defect as two parsers, one level down.
221 pub fn parse(command_line: &str) -> Result<Request<'_>, ParseError> {
222 let (verb, rest) = command_line.split_once(' ').ok_or(ParseError::NoArgument)?;
223 let operation = Operation::from_wire(verb).ok_or(ParseError::UnsupportedOperation)?;
224
225 // (1) Whitespace, then (2) one balanced quote pair.
226 let path = unquote(rest.trim());
227
228 // (3) One leading slash. `git` sends the path as the client wrote it after
229 // the colon, so both `max/shop.git` and `/max/shop.git` are ordinary.
230 let path = path.strip_prefix('/').unwrap_or(path);
231
232 let (owner, rest) = path.split_once('/').ok_or(ParseError::MissingOwner)?;
233
234 // `rest` may still contain `/` here; `valid_segment` is what refuses it, so
235 // a nested path never becomes a repo name.
236 let repo = rest.strip_suffix(".git").unwrap_or(rest);
237
238 // (4) The single gate. Both segments, after the strip.
239 if !valid_segment(owner) || !valid_segment(repo) {
240 return Err(ParseError::InvalidSegment);
241 }
242
243 Ok(Request {
244 operation,
245 owner,
246 repo,
247 })
248 }
249
250 /// Strip one balanced pair of surrounding quotes, single or double.
251 ///
252 /// Returns the input unchanged when the quotes do not pair, which is what makes
253 /// an unterminated quote a parse failure downstream rather than a silent strip.
254 fn unquote(s: &str) -> &str {
255 for q in ['\'', '"'] {
256 if let Some(inner) = s.strip_prefix(q)
257 && let Some(inner) = inner.strip_suffix(q)
258 {
259 return inner;
260 }
261 }
262 s
263 }
264
265 pub mod oracle {
266 //! The crate's contract, written as an executable assertion.
267 //!
268 //! This is a normal public module rather than something behind a `fuzzing`
269 //! feature, because two callers need it and neither is the fuzzer: the
270 //! committed regression replay in `tests/regressions.rs` runs it on stable,
271 //! and the libFuzzer target runs it on nightly. A property asserted in one
272 //! of those and not the other is a property that drifts.
273 //!
274 //! Everything here is about a *parsed* request. Whether a given line ought
275 //! to parse is the grammar's business and is covered by unit tests; what
276 //! this says is that nothing which does parse can hurt a caller.
277
278 use super::{Request, valid_segment};
279 use std::path::{Component, Path};
280
281 /// Panics if an accepted request violates anything this crate promises.
282 ///
283 /// # Panics
284 ///
285 /// By design. It is an oracle, and a panic is how it reports.
286 pub fn check(request: &Request<'_>) {
287 // 1. Both segments are segments. Everything below rests on this.
288 assert!(
289 valid_segment(request.owner),
290 "accepted an invalid owner: {:?}",
291 request.owner
292 );
293 assert!(
294 valid_segment(request.repo),
295 "accepted an invalid repo: {:?}",
296 request.repo
297 );
298
299 // 2. The join cannot leave the root. Checked structurally rather than
300 // by touching the filesystem: a fuzz target must not depend on what
301 // happens to exist on the box, and `canonicalize` would.
302 let root = Path::new("/srv/git");
303 let dir = request.repo_dir(root);
304 assert!(
305 dir.starts_with(root),
306 "repo_dir escaped the root: {}",
307 dir.display()
308 );
309 let extra: Vec<_> = dir
310 .strip_prefix(root)
311 .expect("starts_with just held")
312 .components()
313 .collect();
314 assert_eq!(
315 extra.len(),
316 2,
317 "repo_dir added {} components, not owner + repo: {}",
318 extra.len(),
319 dir.display()
320 );
321 for c in extra {
322 assert!(
323 matches!(c, Component::Normal(_)),
324 "repo_dir grew a non-normal component: {}",
325 dir.display()
326 );
327 }
328
329 // 3. The git-shell argument round-trips. This is the injection oracle:
330 // if a name could break out of the single quotes, or introduce a
331 // space, or otherwise re-parse as something else, the request that
332 // comes back would differ from the one that went in.
333 let rebuilt = request.shell_command();
334 match super::parse(&rebuilt) {
335 Ok(again) => assert_eq!(
336 &again, request,
337 "shell_command did not round-trip: {rebuilt:?}"
338 ),
339 Err(e) => panic!("shell_command produced an unparsable line {rebuilt:?}: {e}"),
340 }
341
342 // 4. One quoted argument, no more. `git-shell -c` splits on whitespace
343 // outside quotes, so a second quote pair or a stray space would be a
344 // second argument.
345 let arg = rebuilt
346 .strip_prefix(request.operation.command())
347 .expect("rebuilt starts with the verb");
348 assert_eq!(
349 arg.matches('\'').count(),
350 2,
351 "shell argument is not one quoted word: {rebuilt:?}"
352 );
353 assert!(
354 arg.starts_with(" '") && arg.ends_with('\''),
355 "shell argument is not one quoted word: {rebuilt:?}"
356 );
357 }
358
359 /// Parse `line` and, if it is accepted, hold it to [`check`].
360 ///
361 /// The entry point both the fuzz target and the regression replay call, so
362 /// "what the fuzzer checks" has exactly one definition.
363 ///
364 /// Returns whether a request was actually checked. The fuzz target ignores
365 /// that and should: a line the grammar refuses is a fine thing to feed it.
366 /// It exists because without a return value nothing can observe this
367 /// function running at all: `parse` only ever yields requests the oracle
368 /// accepts, so an empty body passes every test. A silently empty oracle is
369 /// the one failure this whole arrangement cannot afford.
370 pub fn check_line(line: &str) -> bool {
371 match super::parse(line) {
372 Ok(request) => {
373 check(&request);
374 true
375 }
376 Err(_) => false,
377 }
378 }
379 }
380
381 #[cfg(test)]
382 mod tests {
383 use super::*;
384
385 fn ok(cmd: &str) -> Request<'_> {
386 parse(cmd).expect("should parse")
387 }
388
389 // ── The shapes a real git client sends ──
390
391 #[test]
392 fn the_three_verbs() {
393 assert_eq!(
394 ok("git-upload-pack '/max/shop.git'").operation,
395 Operation::UploadPack
396 );
397 assert_eq!(
398 ok("git-receive-pack '/max/shop.git'").operation,
399 Operation::ReceivePack
400 );
401 assert_eq!(
402 ok("git-upload-archive '/max/shop.git'").operation,
403 Operation::UploadArchive
404 );
405 }
406
407 /// Hyphens and underscores are in the charset. Without this, flipping an
408 /// operator in `valid_segment` so hyphens are refused passes every other
409 /// test here.
410 #[test]
411 fn the_full_charset_is_accepted() {
412 let r = ok("git-upload-pack my_user-1/my-repo_v2.0.git");
413 assert_eq!((r.owner, r.repo), ("my_user-1", "my-repo_v2.0"));
414 }
415
416 #[test]
417 fn every_parse_error_says_which_one() {
418 // The `Display` impl reaches a git client through `anyhow`, so an
419 // impl that returned an empty string would degrade every refusal on
420 // the sshd door into a blank message.
421 for (line, want) in [
422 ("git-upload-pack", ParseError::NoArgument),
423 ("git-foo /max/shop.git", ParseError::UnsupportedOperation),
424 ("git-upload-pack shop.git", ParseError::MissingOwner),
425 ("git-upload-pack /max/../etc", ParseError::InvalidSegment),
426 ] {
427 let err = parse(line).expect_err("should refuse");
428 assert_eq!(err, want, "{line}");
429 assert!(!err.to_string().is_empty(), "{want:?} displays as nothing");
430 }
431 // And the four are distinguishable, not one message four times.
432 let shown = [
433 ParseError::NoArgument,
434 ParseError::UnsupportedOperation,
435 ParseError::MissingOwner,
436 ParseError::InvalidSegment,
437 ]
438 .map(|e| e.to_string());
439 let mut uniq = shown.to_vec();
440 uniq.sort();
441 uniq.dedup();
442 assert_eq!(uniq.len(), 4, "ParseError messages collide: {shown:?}");
443 }
444
445 #[test]
446 fn quoting_and_slashes_a_client_may_send() {
447 for cmd in [
448 "git-upload-pack '/max/shop.git'",
449 "git-upload-pack \"/max/shop.git\"",
450 "git-upload-pack /max/shop.git",
451 "git-upload-pack max/shop.git",
452 "git-upload-pack max/shop",
453 ] {
454 let r = ok(cmd);
455 assert_eq!((r.owner, r.repo), ("max", "shop"), "{cmd}");
456 }
457 }
458
459 #[test]
460 fn a_repo_may_contain_a_dot_that_is_not_at_either_end() {
461 assert_eq!(ok("git-upload-pack max/v1.2.git").repo, "v1.2");
462 assert_eq!(ok("git-upload-pack max/v1.2.3").repo, "v1.2.3");
463 }
464
465 /// `v1.2.` passes every other rule in `valid_segment`, and then `repo_dir`
466 /// and `shell_command` append `.git` to give `v1.2..git`, which this parser
467 /// refuses. The crate has to re-read its own output.
468 #[test]
469 fn a_trailing_dot_is_refused_because_the_git_suffix_would_make_it_dotdot() {
470 assert!(parse("git-upload-pack max/v1.2.").is_err());
471 assert!(parse("git-upload-pack max./shop").is_err());
472 // The round-trip the failure showed up as.
473 let r = ok("git-upload-pack max/v1.2.3");
474 assert_eq!(parse(&r.shell_command()), Ok(r));
475 }
476
477 // ── The four reconciled divergences, one test each ──
478
479 #[test]
480 fn divergence_1_surrounding_whitespace_is_trimmed() {
481 // Accepted by mnw-cli, refused by the server.
482 assert_eq!(ok("git-upload-pack max/shop.git ").repo, "shop");
483 }
484
485 #[test]
486 fn divergence_2_unbalanced_quotes_are_refused() {
487 // Accepted by the server's greedy `trim_matches`, refused by mnw-cli.
488 assert!(parse("git-upload-pack '/max/shop.git").is_err());
489 assert!(parse("git-upload-pack /max/shop.git'").is_err());
490 // And only one pair comes off, so a doubled quote is now a bad segment
491 // rather than a silently stripped one.
492 assert!(parse("git-upload-pack ''/max/shop.git''").is_err());
493 }
494
495 #[test]
496 fn divergence_3_only_one_leading_slash_is_stripped() {
497 // Accepted by the server, refused by mnw-cli. The second slash makes the
498 // owner empty, which `valid_segment` rejects.
499 assert!(parse("git-upload-pack //max/shop.git").is_err());
500 assert!(parse("git-upload-pack ///max/shop.git").is_err());
501 }
502
503 #[test]
504 fn divergence_4_dotdot_is_tested_before_the_git_suffix_comes_off() {
505 // The one with teeth. mnw-cli stripped `.git` first, saw `shop.`, and
506 // accepted it; the charset guard downstream was the only thing between
507 // that and a name nobody meant to allow.
508 assert!(parse("git-upload-pack /max/shop..git").is_err());
509 assert!(parse("git-upload-pack /max/..git").is_err());
510 }
511
512 // ── Path safety ──
513
514 #[test]
515 fn traversal_in_either_segment() {
516 for cmd in [
517 "git-upload-pack /../etc/passwd",
518 "git-upload-pack /max/../../etc",
519 "git-upload-pack ../max/shop.git",
520 "git-upload-pack /max/..",
521 ] {
522 assert!(parse(cmd).is_err(), "{cmd} should not parse");
523 }
524 }
525
526 #[test]
527 fn a_nested_path_is_not_a_repo_name() {
528 // `rest` keeps its `/` through the `..` check; `valid_segment` is what
529 // refuses it, and this test is what would notice if that stopped being
530 // true.
531 assert!(parse("git-upload-pack /max/a/b.git").is_err());
532 }
533
534 #[test]
535 fn leading_dot_segments() {
536 assert!(parse("git-upload-pack /max/.hidden.git").is_err());
537 assert!(parse("git-upload-pack /.max/shop.git").is_err());
538 assert!(parse("git-upload-pack /max/.git").is_err());
539 }
540
541 #[test]
542 fn empty_segments() {
543 assert!(parse("git-upload-pack /max/").is_err());
544 assert!(parse("git-upload-pack //shop.git").is_err());
545 assert!(parse("git-upload-pack /max").is_err());
546 }
547
548 #[test]
549 fn charset_is_a_whitelist() {
550 for bad in [
551 "max/sh op",
552 "max/sh;op",
553 "max/sh\0op",
554 "max/shüp",
555 "ma x/shop",
556 ] {
557 assert!(
558 parse(&format!("git-upload-pack {bad}")).is_err(),
559 "{bad} should not parse"
560 );
561 }
562 }
563
564 #[test]
565 fn segment_length_is_capped() {
566 let long = "a".repeat(SEGMENT_MAX + 1);
567 assert!(parse(&format!("git-upload-pack max/{long}.git")).is_err());
568 let at_limit = "a".repeat(SEGMENT_MAX);
569 assert!(parse(&format!("git-upload-pack max/{at_limit}")).is_ok());
570 }
571
572 // ── Malformed lines ──
573
574 #[test]
575 fn a_line_with_no_argument() {
576 assert_eq!(parse("git-upload-pack"), Err(ParseError::NoArgument));
577 assert_eq!(parse(""), Err(ParseError::NoArgument));
578 }
579
580 #[test]
581 fn a_verb_that_is_not_served() {
582 assert_eq!(
583 parse("git-foo /max/shop.git"),
584 Err(ParseError::UnsupportedOperation)
585 );
586 // The management verbs are not this grammar's, and never were.
587 assert_eq!(parse("repo list"), Err(ParseError::UnsupportedOperation));
588 // Nor is anything a shell would enjoy.
589 assert_eq!(parse("rm -rf /"), Err(ParseError::UnsupportedOperation));
590 }
591
592 #[test]
593 fn a_path_with_no_owner() {
594 assert_eq!(
595 parse("git-upload-pack shop.git"),
596 Err(ParseError::MissingOwner)
597 );
598 }
599
600 // ── What callers do with the result ──
601
602 #[test]
603 fn repo_dir_stays_under_the_root() {
604 let r = ok("git-receive-pack '/max/shop.git'");
605 assert_eq!(
606 r.repo_dir("/var/lib/mnw/git"),
607 PathBuf::from("/var/lib/mnw/git/max/shop.git")
608 );
609 }
610
611 #[test]
612 fn shell_command_is_rebuilt_not_forwarded() {
613 let r = ok("git-upload-pack \"max/shop\"");
614 assert_eq!(r.shell_command(), "git-upload-pack '/max/shop.git'");
615 }
616
617 // ── The oracle, which nothing else can prove is running ──
618 //
619 // `parse` only ever yields requests the oracle accepts, so every test above
620 // passes just as well against an oracle whose body is `()`. These build the
621 // bad requests `parse` will not, and assert the oracle rejects them.
622
623 fn req(owner: &'static str, repo: &'static str) -> Request<'static> {
624 Request {
625 operation: Operation::UploadPack,
626 owner,
627 repo,
628 }
629 }
630
631 #[test]
632 fn the_oracle_accepts_a_good_request() {
633 oracle::check(&req("max", "shop"));
634 }
635
636 #[test]
637 fn the_oracle_rejects_a_traversing_owner() {
638 assert!(std::panic::catch_unwind(|| oracle::check(&req("..", "shop"))).is_err());
639 }
640
641 #[test]
642 fn the_oracle_rejects_a_separator_in_a_segment() {
643 // The shape that would put a second path component under the root.
644 assert!(std::panic::catch_unwind(|| oracle::check(&req("max", "a/b"))).is_err());
645 }
646
647 #[test]
648 fn the_oracle_rejects_a_name_that_would_break_out_of_the_quotes() {
649 // The injection shape. `shell_command` would emit
650 // `git-upload-pack '/max/x'; rm -rf /.git'`, which is two words.
651 assert!(std::panic::catch_unwind(|| oracle::check(&req("max", "x'; rm -rf /"))).is_err());
652 }
653
654 #[test]
655 fn check_line_reports_whether_it_checked() {
656 assert!(oracle::check_line("git-upload-pack /max/shop.git"));
657 assert!(!oracle::check_line("not a git command"));
658 }
659 }
660