gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
//! The exec parser, table-driven (the ACL model).
//!
//! This is the most security-critical code in the crate, so the table is the
//! test rather than a handful of hand-written cases, and it has an
//! **acceptance** half as well as a rejection half. A parser that refuses
//! everything passes every rejection test ever written and serves nothing; that
//! is the identity-value trap, and `legal_forms_are_accepted` is what catches
//! it.
//!
//! `the_rejection_table_can_go_red` is the red half: it points the same
//! rejection table at a deliberately naive parser and asserts the table
//! *fails* against it. Without that, a table of inputs that a correct parser
//! rejects and a table of inputs that any parser rejects look identical.
//!
//! # What changed when the sandbox merged into `gunnar-core`
//!
//! The sandbox under this parser is now `gunnar_core::resolve_repo_path`, the
//! only one. Two rows of the table below had to change with it, and both are
//! marked in place:
//!
//! * `a/b/c.git` is **no longer refused**: nested namespaces are legal, and the
//!   deleted `gunnar_ssh::path` was the copy that disagreed. It is replaced by
//!   `a/b/c/d/e.git`, which is over `MAX_COMPONENTS`.
//! * `ns/repo.lock` is **newly refused**, merged in from the deleted copy.
//!
//! Everything about *quoting* stayed here, because git sends the quotes and the
//! sandbox must not know SSH exists (the one-sandbox rule).

use gunnar_core::PathReject;
use gunnar_ssh::command::{parse, CommandError, GitService, Operation, MAX_EXEC_LEN, SERVICES};
use gunnar_ssh::Access;

/// Every form a legitimate client sends. All of these MUST parse.
const LEGAL: &[(&str, GitService, &str, &str)] = &[
    // scp-style: `git clone git@host:nordisk/osm-katana.git`
    (
        "git-upload-pack 'nordisk/osm-katana.git'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // ssh:// URL: the leading slash is git's, not the client's idea of absolute
    (
        "git-upload-pack '/nordisk/osm-katana.git'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // no `.git` suffix, GitHub-style, and what `git clone host:ns/repo` sends
    (
        "git-upload-pack 'nordisk/osm-katana'",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    // unquoted, which some clients and every hand-typed `ssh host <cmd>` send
    (
        "git-upload-pack nordisk/osm-katana.git",
        GitService::UploadPack,
        "nordisk",
        "osm-katana",
    ),
    (
        "git-receive-pack 'nordisk/osm-katana.git'",
        GitService::ReceivePack,
        "nordisk",
        "osm-katana",
    ),
    (
        "git-receive-pack '/nordisk/osm-katana.git'",
        GitService::ReceivePack,
        "nordisk",
        "osm-katana",
    ),
    // names with the punctuation real repositories use
    (
        "git-upload-pack 'team-1/my_repo.v2.git'",
        GitService::UploadPack,
        "team-1",
        "my_repo.v2",
    ),
    // **A nested namespace.** The sandbox this crate used to carry
    // refused this outright; `gunnar-core`'s (the one that survived) resolves
    // it, and it is the more permissive of the two. It is here in the
    // *acceptance* half so that reinstating the stricter rule cannot pass
    // unnoticed.
    (
        "git-upload-pack 'team/sub/repo.git'",
        GitService::UploadPack,
        "team/sub",
        "repo",
    ),
    (
        "git-receive-pack '/team/sub/deeper/repo.git'",
        GitService::ReceivePack,
        "team/sub/deeper",
        "repo",
    ),
    // ── git-lfs-transfer (LFS over SSH): the THREE-token, UNQUOTED shape ───
    //
    // MEASURED 2026-08-03 against git-lfs 3.7.1 on the reference desktop, by putting a script in
    // `GIT_SSH_COMMAND` and logging its argv. The exec line that arrives is
    // exactly:
    //
    //     git-lfs-transfer /repo.git upload
    //
    // No quotes anywhere, a leading slash from the `ssh://` URL, and a third
    // token. Every other verb in this table arrives as `git-upload-pack
    // '/ns/repo.git'`, i.e. two tokens with literal quote characters *inside*
    // the argv element. So this verb is the one whose parse is not the shape
    // everything else was written against, and until now nothing here exercised
    // it at all.
    (
        "git-lfs-transfer /nordisk/osm-katana.git download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
    (
        "git-lfs-transfer /nordisk/osm-katana.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "nordisk",
        "osm-katana",
    ),
    // Without the leading slash, which is what a `host:ns/repo.git` remote yields.
    (
        "git-lfs-transfer nordisk/osm-katana.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "nordisk",
        "osm-katana",
    ),
    // Quoted anyway. git-lfs does not send this, but `unquote` runs on the LFS
    // path by the same route as on a git path, and a client or a proxy that
    // quoted it must not be refused for it.
    (
        "git-lfs-transfer '/nordisk/osm-katana.git' download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
    // A nested namespace, i.e. a path that itself contains the separator the
    // operation is split on. `rsplit_once(' ')` is what makes this work and a
    // `split_once` would put `sub/repo.git download` in the path.
    (
        "git-lfs-transfer /team/sub/repo.git upload",
        GitService::LfsTransfer(Operation::Upload),
        "team/sub",
        "repo",
    ),
    // No `.git` suffix, which git-lfs sends whenever the remote URL has none.
    (
        "git-lfs-transfer /nordisk/osm-katana download",
        GitService::LfsTransfer(Operation::Download),
        "nordisk",
        "osm-katana",
    ),
];

#[test]
fn legal_forms_are_accepted() {
    for (raw, service, namespace, repo) in LEGAL {
        let parsed = parse(raw.as_bytes())
            .unwrap_or_else(|err| panic!("should have accepted {raw:?}, got {err}"));
        assert_eq!(parsed.service, *service, "{raw:?}");
        assert_eq!(parsed.repo.namespace(), *namespace, "{raw:?}");
        assert_eq!(parsed.repo.name(), *repo, "{raw:?}");
    }
}

#[test]
fn the_required_access_travels_with_the_command() {
    // Asserted on the parse output, not on the table, because the dispatch
    // reads it from the parse output. A table row that never reaches a
    // `GitCommand` would prove nothing.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .required,
        Access::Read
    );
    assert_eq!(
        parse(b"git-receive-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .required,
        Access::Write
    );
    // LFS over SSH: the LFS verb's level is **not a constant**, it comes from the third
    // token. Both directions are asserted, because a `Requirement::Fixed` on
    // this row would satisfy either one of them alone.
    assert_eq!(
        parse(b"git-lfs-transfer /nordisk/osm-katana.git download")
            .unwrap()
            .required,
        Access::Read
    );
    assert_eq!(
        parse(b"git-lfs-transfer /nordisk/osm-katana.git upload")
            .unwrap()
            .required,
        Access::Write
    );
}

/// **Which operation, not merely that one parsed.**
///
/// The failure this guards is silent and total: a parser that dropped the third
/// token, or that returned a fixed `Operation`, would still accept every legal
/// LFS form and still produce a `GitCommand`, and then every `upload` connection
/// would be served as a `download`, or worse, every `download` would be granted
/// Write. Both halves are asserted against the *same* path so nothing but the
/// operation differs between them.
#[test]
fn the_lfs_operation_and_the_grant_it_implies_both_come_from_the_exec_line() {
    let download = parse(b"git-lfs-transfer /nordisk/osm-katana.git download").unwrap();
    let upload = parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").unwrap();

    assert_eq!(
        download.service,
        GitService::LfsTransfer(Operation::Download)
    );
    assert_eq!(upload.service, GitService::LfsTransfer(Operation::Upload));
    assert_eq!(download.service.operation(), Some(Operation::Download));
    assert_eq!(upload.service.operation(), Some(Operation::Upload));

    // The two must differ. `assert_eq!` on each alone would pass against a
    // parser that returned the same value for both if the expectations were
    // ever "fixed" to match it.
    assert_ne!(download.service, upload.service);
    assert_ne!(download.required, upload.required);
    assert_eq!(download.required, Access::Read);
    assert_eq!(upload.required, Access::Write);

    // Same repository either way; the operation must not have been eaten out
    // of the path, nor the path out of the operation.
    assert_eq!(download.repo.key(), "nordisk/osm-katana");
    assert_eq!(upload.repo.key(), download.repo.key());

    // And the two git verbs carry no operation at all, so `operation()` is not
    // simply answering `Some(_)` for everything.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .unwrap()
            .service
            .operation(),
        None
    );
}

/// The malformed third token, refused by *name* rather than merely refused.
///
/// `MissingOperation` and `UnknownOperation` are different diagnoses and both
/// are reachable; a parser that collapsed them, or that folded either into
/// `MissingPath`, would still refuse the input and still pass
/// `hostile_forms_are_refused`.
#[test]
fn a_malformed_lfs_operation_is_refused_by_name() {
    // Two tokens: the whole argument is a path, there is no operation.
    assert!(
        matches!(
            parse(b"git-lfs-transfer /nordisk/osm-katana.git"),
            Err(CommandError::MissingOperation {
                verb: "git-lfs-transfer",
                ..
            })
        ),
        "got {:?}",
        parse(b"git-lfs-transfer /nordisk/osm-katana.git")
    );

    // A third token that is not an operation. Note the path is valid, so this
    // cannot be passing for a path reason.
    for bad in [
        &b"git-lfs-transfer /nordisk/osm-katana.git UPLOAD"[..],
        b"git-lfs-transfer /nordisk/osm-katana.git Download",
        b"git-lfs-transfer /nordisk/osm-katana.git lock",
        b"git-lfs-transfer /nordisk/osm-katana.git upload2",
        b"git-lfs-transfer /nordisk/osm-katana.git ''",
    ] {
        match parse(bad) {
            Err(CommandError::UnknownOperation { verb, .. }) => {
                assert_eq!(verb, "git-lfs-transfer")
            }
            other => panic!(
                "{:?} should have been UnknownOperation, got {other:?}",
                String::from_utf8_lossy(bad)
            ),
        }
    }

    // A trailing operation with nothing before it is a missing *path*, and it
    // must not be mistaken for a missing operation: `rsplit_once` finds the
    // space and hands the path half an empty string.
    assert!(
        matches!(
            parse(b"git-lfs-transfer  upload"),
            Err(CommandError::MissingPath("git-lfs-transfer"))
        ),
        "got {:?}",
        parse(b"git-lfs-transfer  upload")
    );

    // And the whole verb with nothing at all.
    assert!(matches!(
        parse(b"git-lfs-transfer"),
        Err(CommandError::MissingPath("git-lfs-transfer"))
    ));

    // The identity-value half: the well-formed forms these are variations of
    // must still be accepted, or every assertion above would pass against a
    // parser that refused the verb outright.
    assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git upload").is_ok());
    assert!(parse(b"git-lfs-transfer /nordisk/osm-katana.git download").is_ok());
}

/// Everything that must be refused, with why.
fn rejection_table() -> Vec<(String, &'static str)> {
    let mut cases: Vec<(String, &'static str)> = vec![
        // ── not a git command at all ───────────────────────────────────────
        ("bash".into(), "a shell"),
        ("bash -c 'id'".into(), "a shell with arguments"),
        ("sh".into(), "a shell"),
        ("/bin/sh -i".into(), "an absolute shell path"),
        ("scp -t /tmp".into(), "another exec-based protocol"),
        (
            "git-upload-archive 'ns/repo.git'".into(),
            "a git verb gunnar does not serve",
        ),
        (
            "git upload-pack 'ns/repo.git'".into(),
            "the spaced spelling, which is a different verb",
        ),
        ("GIT-UPLOAD-PACK 'ns/repo.git'".into(), "case is not folded"),
        (
            "git-upload-pack2 'ns/repo.git'".into(),
            "a verb with a suffix",
        ),
        (
            "xgit-upload-pack 'ns/repo.git'".into(),
            "a verb with a prefix",
        ),
        ("".into(), "an empty request"),
        // ── a legal verb, no usable path ───────────────────────────────────
        ("git-upload-pack".into(), "no path at all"),
        ("git-receive-pack".into(), "no path at all"),
        ("git-upload-pack ''".into(), "an empty quoted path"),
        ("git-upload-pack '/'".into(), "a bare root"),
        ("git-upload-pack '//'".into(), "a doubled root"),
        (
            "git-upload-pack 'repo.git'".into(),
            "no namespace (multi-tenancy)",
        ),
        // `a/b/c.git` used to be here and is now LEGAL, a nested
        // namespace. What is still refused is deeper than MAX_COMPONENTS.
        (
            "git-upload-pack 'a/b/c/d/e.git'".into(),
            "deeper than MAX_COMPONENTS",
        ),
        // Merged in from the deleted `gunnar_ssh::path`.
        (
            "git-upload-pack 'ns/repo.lock'".into(),
            "git's lock-file suffix",
        ),
        (
            "git-upload-pack 'ns.lock/repo.git'".into(),
            "the lock suffix on a namespace",
        ),
        // ── traversal ──────────────────────────────────────────────────────
        (
            "git-upload-pack '../../etc/passwd'".into(),
            "parent traversal",
        ),
        (
            "git-upload-pack 'ns/../../etc/shadow'".into(),
            "embedded traversal",
        ),
        ("git-upload-pack 'ns/..'".into(), "a `..` component"),
        (
            "git-upload-pack '.git/../..'".into(),
            "the spec's traversal case",
        ),
        ("git-upload-pack '~/ns/repo.git'".into(), "home-relative"),
        (
            "git-upload-pack '~root/ns/repo.git'".into(),
            "another user's home",
        ),
        // ── shell metacharacters, in case anything downstream ever forgets ─
        (
            "git-upload-pack 'ns/repo.git; id'".into(),
            "a command separator",
        ),
        (
            "git-upload-pack 'ns/repo.git && id'".into(),
            "a command chain",
        ),
        (
            "git-upload-pack 'ns/$(id).git'".into(),
            "command substitution",
        ),
        (
            "git-upload-pack 'ns/`id`.git'".into(),
            "backtick substitution",
        ),
        ("git-upload-pack 'ns/repo.git|nc host 1'".into(), "a pipe"),
        (
            "git-upload-pack 'ns/repo.git > /etc/cron.d/x'".into(),
            "a redirect",
        ),
        ("git-upload-pack 'ns/*'".into(), "a glob"),
        // ── quoting ────────────────────────────────────────────────────────
        (
            "git-upload-pack 'ns/repo.git".into(),
            "an unterminated quote",
        ),
        (
            "git-upload-pack 'ns/repo.git' extra".into(),
            "trailing bytes",
        ),
        (
            "git-upload-pack 'ns/repo.git''".into(),
            "a doubled closing quote",
        ),
        (
            "git-upload-pack \"ns/repo.git\"".into(),
            "double quotes, which git does not send",
        ),
        // ── unicode ────────────────────────────────────────────────────────
        (
            "git-upload-pack 'nordisk/\u{0441}sm-katana.git'".into(),
            "a Cyrillic homoglyph of an ASCII name",
        ),
        (
            "git-upload-pack 'nordisk/repo\u{202e}tig.'".into(),
            "a right-to-left override",
        ),
        (
            "git-upload-pack 'nordisk/re\u{00ad}po.git'".into(),
            "a soft hyphen",
        ),
    ];

    // ── NUL and control bytes ──────────────────────────────────────────────
    cases.push((
        String::from_utf8(b"git-upload-pack 'ns/repo\0.git'".to_vec()).unwrap(),
        "an embedded NUL",
    ));
    cases.push((
        String::from_utf8(b"git-upload-pack 'ns/repo.git'\0bash".to_vec()).unwrap(),
        "a NUL-hidden second command",
    ));
    cases.push((
        "git-upload-pack 'ns/re\npo.git'".into(),
        "an embedded newline",
    ));
    cases.push((
        "git-upload-pack\t'ns/repo.git'".into(),
        "a tab where the separator should be",
    ));

    // ── absurd length ──────────────────────────────────────────────────────
    cases.push((
        format!("git-upload-pack 'ns/{}.git'", "a".repeat(MAX_EXEC_LEN)),
        "over the exec length limit",
    ));
    cases.push((
        format!("git-upload-pack 'ns/{}.git'", "a".repeat(200)),
        "over the component length limit",
    ));
    cases.push((
        format!("{} 'ns/repo.git'", "x".repeat(MAX_EXEC_LEN)),
        "an absurd verb",
    ));

    // ── git-lfs-transfer (LFS over SSH) ────────────────────────────────────
    //
    // The verb whose argument is `<path> <operation>` rather than `<path>`.
    // Every rule the two git verbs are held to has to hold here too, and the
    // path half is the one at risk: it is split off by `rsplit_once(' ')` rather
    // than being the whole argument, so it reaches the sandbox by a different
    // route.
    for bad_operation in ["", "UPLOAD", "Upload", "up load", "upload extra", "lock"] {
        cases.push((
            format!("git-lfs-transfer /ns/repo.git {bad_operation}"),
            "not an lfs operation",
        ));
    }
    cases.push((
        "git-lfs-transfer /ns/repo.git".into(),
        "no operation at all",
    ));
    cases.push((
        "git-lfs-transfer upload".into(),
        "an operation with no path: the whole argument is one token",
    ));
    cases.push((
        "git-lfs-transfer  upload".into(),
        "an empty path before the operation",
    ));
    // The sandbox must run on the LFS path exactly as it does on a git path.
    // These are the same escapes the git verbs are tested with, sent through the
    // three-token form.
    for escape in [
        "/ns/../../etc/passwd",
        "~/ns/repo.git",
        "//ns/repo.git",
        "repo.git",
        "a/b/c/d/e.git",
        "ns/repo.lock",
        "ns/.hidden",
        "ns/-rf",
    ] {
        cases.push((
            format!("git-lfs-transfer {escape} download"),
            "a path the sandbox must refuse, through the lfs verb",
        ));
    }
    cases.push((
        "git-lfs-transfer /ns/re\npo.git upload".into(),
        "an embedded newline in an lfs path",
    ));
    cases.push((
        String::from_utf8(b"git-lfs-transfer /ns/repo.git\0 upload".to_vec()).unwrap(),
        "a NUL in an lfs path",
    ));
    cases.push((
        "git-lfs-transfer-x /ns/repo.git upload".into(),
        "a verb that merely starts with the lfs verb",
    ));

    cases
}

#[test]
fn hostile_forms_are_refused() {
    for (raw, why) in rejection_table() {
        assert!(
            parse(raw.as_bytes()).is_err(),
            "should have refused {raw:?} ({why})"
        );
    }
}

/// **Unquoting happens here, and the sandbox never sees a quote.**
///
/// git wraps the path in single quotes and gunnar has no shell to take them
/// off, so this crate does, and `gunnar-core` then refuses a quote as the
/// forbidden character it is. Both halves are asserted, because either one
/// alone would look fine: if the parser stopped unquoting, every quoted form
/// would be refused (caught by `legal_forms_are_accepted`); if the sandbox
/// started unquoting again, the double-quoted form below would be *accepted*.
#[test]
fn the_parser_unquotes_and_the_sandbox_refuses_what_it_is_handed() {
    // Single quotes come off here, so what reaches the sandbox is bare.
    assert_eq!(
        parse(b"git-upload-pack 'nordisk/osm-katana.git'")
            .expect("unquoted here")
            .repo
            .key(),
        "nordisk/osm-katana"
    );

    // Double quotes are NOT git's quoting, are passed through as literal
    // characters, and the sandbox refuses them. This is what a second, weaker
    // unquoter inside the sandbox would silently have accepted.
    assert_eq!(
        parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
            .expect_err("git does not send double quotes"),
        CommandError::Path {
            reason: Some(PathReject::ForbiddenCharacter),
            message: parse(b"git-upload-pack \"nordisk/osm-katana.git\"")
                .unwrap_err()
                .to_string(),
        }
    );
}

/// The sandbox's *reason* travels out through the parser, so a test can assert
/// which rule fired. A path refused for the wrong reason is one refactor away
/// from being accepted.
#[test]
fn the_sandbox_reason_reaches_the_command_error() {
    let cases: &[(&[u8], PathReject)] = &[
        (b"git-upload-pack 'ns/../../etc/passwd'", PathReject::DotDot),
        (
            b"git-upload-pack '~/ns/repo.git'",
            PathReject::HomeReference,
        ),
        (b"git-upload-pack '//ns/repo.git'", PathReject::Absolute),
        (b"git-upload-pack 'repo.git'", PathReject::TooFewComponents),
        (
            b"git-upload-pack 'a/b/c/d/e.git'",
            PathReject::TooManyComponents,
        ),
        (b"git-upload-pack 'ns/repo.lock'", PathReject::LockSuffix),
        // `.git` comes off the end before the components are validated, so
        // `ns/.git` is refused as `ns/`: an empty component, not a dot
        // component. Refused either way; pinned here so the *reason* cannot
        // drift unnoticed.
        (b"git-upload-pack 'ns/.git'", PathReject::EmptyComponent),
        (b"git-upload-pack 'ns/.hidden'", PathReject::DotComponent),
        (b"git-upload-pack 'ns/-rf'", PathReject::LeadingDash),
    ];
    for (raw, expected) in cases {
        match parse(raw) {
            Err(CommandError::Path { reason, .. }) => assert_eq!(
                reason,
                Some(*expected),
                "{:?} was refused for the wrong reason",
                String::from_utf8_lossy(raw)
            ),
            other => panic!(
                "{:?} should have been a sandbox rejection, got {other:?}",
                String::from_utf8_lossy(raw)
            ),
        }
    }
}

#[test]
fn a_shell_is_refused_as_an_unknown_command_not_as_a_bad_path() {
    // The distinction matters on the wire: an unknown command gets a channel
    // failure and no reply, because there is no git client on the other end to
    // read one.
    assert!(matches!(
        parse(b"bash"),
        Err(CommandError::UnknownCommand(verb)) if verb == "bash"
    ));
    assert!(matches!(
        parse(b"git-upload-pack"),
        Err(CommandError::MissingPath("git-upload-pack"))
    ));
    assert!(matches!(
        parse(b"git-upload-pack 'ns/repo\0'"),
        Err(CommandError::ControlByte { byte: 0, .. })
    ));
    assert!(matches!(
        parse(format!("git-upload-pack '{}'", "a".repeat(MAX_EXEC_LEN)).as_bytes()),
        Err(CommandError::TooLong { .. })
    ));
}

/// Measure, never assert: the rejection table must be seen to fail against a parser that is
/// wrong in the way real parsers are wrong.
#[test]
fn the_rejection_table_can_go_red() {
    /// What a naive implementation looks like: split on the first space, accept
    /// anything that starts with a known verb, strip the quotes. It is not a
    /// straw man; it is the shape the parser would have if the path rules and
    /// the byte rules were left to "the store will not find it anyway".
    fn naive(raw: &[u8]) -> Result<(), ()> {
        let text = String::from_utf8_lossy(raw);
        let (verb, rest) = text.split_once(' ').ok_or(())?;
        if !SERVICES.iter().any(|spec| spec.verb == verb) {
            return Err(());
        }
        let path = rest.trim_matches('\'');
        if path.is_empty() {
            return Err(());
        }
        Ok(())
    }

    let table = rejection_table();
    let survivors: Vec<&str> = table
        .iter()
        .filter(|(raw, _)| naive(raw.as_bytes()).is_ok())
        .map(|(raw, _)| raw.as_str())
        .collect();

    assert!(
        survivors.len() >= 20,
        "the rejection table barely tests anything: only {} of {} cases survive a naive parser",
        survivors.len(),
        table.len()
    );

    // And the acceptance half must be accepted by the naive parser too.
    // Otherwise the table would be distinguishing "correct" from "broken" by
    // accident of the legal cases rather than by the hostile ones.
    for (raw, _, _, _) in LEGAL {
        assert!(
            naive(raw.as_bytes()).is_ok(),
            "the legal case {raw:?} is not what separates the two parsers"
        );
    }
}

/// The line range of the `SERVICES` const in `src/command.rs`, 1-based and
/// inclusive.
///
/// Found by reading the source rather than written down, so it cannot go stale
/// the way a hardcoded row count does.
fn services_block() -> (String, usize, usize) {
    let path = format!("{}/src/command.rs", env!("CARGO_MANIFEST_DIR"));
    let text = std::fs::read_to_string(&path).expect("the dispatch module is readable");
    let lines: Vec<&str> = text.lines().collect();
    let first = lines
        .iter()
        .position(|line| line.starts_with("pub const SERVICES"))
        .expect("the dispatch table is declared as `pub const SERVICES`");
    let last = first
        + lines[first..]
            .iter()
            .position(|line| line.trim_end() == "];")
            .expect("the dispatch table is closed by `];` in column 0");
    (path, first + 1, last + 1)
}

/// Every `.rs` file under this crate's `src/`, as (path, contents).
fn crate_sources() -> Vec<(String, String)> {
    fn walk(dir: &std::path::Path, out: &mut Vec<(String, String)>) {
        for entry in std::fs::read_dir(dir).expect("the source tree is readable") {
            let path = entry.expect("a readable directory entry").path();
            if path.is_dir() {
                walk(&path, out);
            } else if path.extension().is_some_and(|ext| ext == "rs") {
                let text = std::fs::read_to_string(&path).expect("a readable source file");
                out.push((path.display().to_string(), text));
            }
        }
    }
    let mut out = Vec::new();
    walk(
        std::path::Path::new(&format!("{}/src", env!("CARGO_MANIFEST_DIR"))),
        &mut out,
    );
    assert!(
        out.len() > 5,
        "the source walk found only {} files, so it is not looking at the crate",
        out.len()
    );
    out
}

#[test]
fn the_dispatch_table_is_the_only_place_a_verb_is_named() {
    // `git-lfs-transfer` (LFS over SSH) landed, and it was one row, which is what this
    // test was written to make visible. It has to change when the table does,
    // and that is the point: a verb that appeared anywhere else would not show
    // up here at all.
    assert_eq!(SERVICES.len(), 3);
    assert_eq!(SERVICES[0].verb, "git-upload-pack");
    assert_eq!(SERVICES[1].verb, "git-receive-pack");
    // Spelled out rather than compared to `gunnar_lfs::TRANSFER_VERB`, which
    // the row is built from: comparing a constant to itself would pass however
    // it were spelled, and this verb is fixed by the real git-lfs client
    // (`GetLFSExeAndArgs`), not by us.
    assert_eq!(SERVICES[2].verb, "git-lfs-transfer");

    // The other direction. `verb()` goes through `row_for`, which finds a row
    // by *discriminant*, so both operations of the one LFS row must land on
    // that row rather than only the representative value the table holds.
    assert_eq!(GitService::UploadPack.verb(), "git-upload-pack");
    assert_eq!(GitService::ReceivePack.verb(), "git-receive-pack");
    assert_eq!(
        GitService::LfsTransfer(Operation::Download).verb(),
        "git-lfs-transfer"
    );
    assert_eq!(
        GitService::LfsTransfer(Operation::Upload).verb(),
        "git-lfs-transfer"
    );

    // **And the property the name claims**, which no row count can express: a
    // verb may be spelled as a string literal in the table and nowhere else in
    // the crate. A second spelling somewhere else is how a verb comes to be
    // dispatched without the grant level that travels with its row, which is
    // the failure this whole module is shaped to prevent. It would not show up
    // in any assertion above, because those only ever read the table.
    //
    // Doc comments name the verbs freely (the module docs quote the exec lines
    // git actually sends), so only code is searched.
    let (table_path, first, last) = services_block();
    for (path, text) in crate_sources() {
        for (index, line) in text.lines().enumerate() {
            let code = line.split("//").next().unwrap_or("");
            for spec in SERVICES {
                if !code.contains(&format!("{:?}", spec.verb)) {
                    continue;
                }
                let number = index + 1;
                let in_table = path == table_path && (first..=last).contains(&number);
                assert!(
                    in_table,
                    "`{}` is named at {path}:{number}, outside the dispatch table at \
                     {table_path}:{first}-{last}. A verb spelled twice is a verb that can be \
                     dispatched without the grant level its row carries.",
                    spec.verb
                );
            }
        }
    }
}