Skip to main content

max / makenotwork

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