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
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
//! **Adversarial authentication and authorisation over SSH**, the second of
//! the two surfaces both 2026-08-05 sweeps named as never swept.
//!
//! `tests/auth.rs` covers the two states an identity is in *before* a session
//! starts: a key the table knows and a key it does not. Everything here is
//! about the states it can be in **after** — a principal deleted, a key
//! revoked, a grant withdrawn, a repository dropped, all while a connection is
//! already authenticated and holding a channel open. That window is not
//! hypothetical: `auth_publickey` caches the `Principal` on the session
//! (`Handler::principal`), so what is re-read at exec time and what is not is a
//! real question with a real answer, and it had never been asked. It is asked
//! here — and since 2026-09-11 the answer includes the FINGERPRINT, which was
//! the one input on the list that `exec_request` used to take from the cache.
//!
//! # The bar every case here has to clear
//!
//! **A refusal must reach the client, in words.** Not a style point:
//! `report_error` was found on 2026-08-05 with zero call sites, so the server
//! refused pushes and the client saw only `the remote end hung up
//! unexpectedly` while the log held the whole reason. So each case asserts the
//! bytes on the data band, not the exit status — an `ExitStatus(1)` and a
//! silent hang-up look identical to the person holding the terminal.
//!
//! The shape that must arrive is git's own: an `ERR ` pkt-line, which is what
//! `git` prints verbatim after `remote: `. `git_says` below is the reader.
//!
//! # And the difference the wire deliberately hides
//!
//! "No such repository" and "you have no grant" are **one string** to the
//! client, on purpose, so a caller cannot enumerate private repositories by
//! the difference. That is a written position, and it makes the assertions
//! here two-sided: the client-visible message must be the *same* in both
//! cases, and the server log must still tell them apart.
//!
//! # Result, plainly: what was measured, and what was not
//!
//! **The surface came up clean.** Ten adversarial cases, no wrong answer in any
//! of them. What the sweep produced is therefore not a bug list, and saying so
//! is the honest headline:
//!
//! * **one fix, by construction, not a defect**: `DispatchError::client_message`
//!   spelled the "not found or no access" sentence **twice**, once per arm, for
//!   a property whose whole content is that the two are equal. Folded into one
//!   arm (one implementation). Nothing was wrong with the strings; they were one edit away
//!   from being wrong in the direction that leaks which private repositories
//!   exist.
//! * **one documented position, since REVERSED (2026-09-11, GUN-015)**: a
//!   revoked key used to keep the session it had already opened. The note that
//!   recorded the position also recorded its cost — an operator revoking a
//!   compromised key had no verb that would drop the connections it held — and
//!   that cost turned out to be the finding rather than a footnote to it. See
//!   `a_revoked_key_loses_the_session_it_already_opened`.
//! * **everything else measured and clean**, including the two the sweep
//!   named by hand: a principal deleted mid-session, and a namespace that
//!   vanishes between the check and the use.
//!
//! # What holds each of them up, since none of it is in this crate
//!
//! Worth stating, because "it was fine" is not a result and the *reason* it is
//! fine is the thing a future change can break:
//!
//! | case | what refuses it |
//! |---|---|
//! | principal deleted mid-session | `AuthTable::may`'s `principals.contains_key` early return — **load-bearing**, because account ownership is intrinsic and has no row to revoke |
//! | grant downgraded mid-session | `authorise_service` calling `control.access_for` per exec, not per session |
//! | repository or namespace deleted | `authorise_service` calling `control.resolve` per exec |
//! | expired key | `principal_by_fingerprint` delegating to `..._at(now_ms())` |
//! | a borrowed username | `auth_publickey` resolving by fingerprint and never by `user` |
//!
//! Every one of those five was driven **red** by removing exactly that line,
//! and each time exactly one test here failed, naming its own subject. The
//! probe outputs are in the commit message.

mod common;

use gunnar_core::{Level, RepoRef};
use gunnar_ssh::Access;

use common::{authenticate, exec, mint_key, plane_with_key, start, the_repo, ExecOutcome, Plane};

/// The message a client can actually see, or `None` if the server said nothing.
///
/// Reads the `ERR ` pkt-line out of the data band the way `git` does: a 4-digit
/// hex length, then `ERR `, then the sentence. Anything else — an empty band, a
/// bare exit status, a length header with no payload — is `None`, and `None` is
/// what every assertion here is guarding against.
///
/// # This reader is itself checked
///
/// `the_reader_can_tell_silence_from_a_message` drives both directions through
/// it, because a `git_says` that answered `Some("")` to everything would make
/// every case below pass while the client heard nothing.
fn git_says(outcome: &ExecOutcome) -> Option<String> {
    let ExecOutcome::Accepted { stdout, .. } = outcome else {
        return None;
    };
    let bytes = stdout.as_bytes();
    if bytes.len() < 8 {
        return None;
    }
    let len = usize::from_str_radix(std::str::from_utf8(&bytes[..4]).ok()?, 16).ok()?;
    if len < 8 || len > bytes.len() {
        return None;
    }
    let body = &bytes[4..len];
    let text = std::str::from_utf8(body).ok()?;
    let message = text.strip_prefix("ERR ")?.trim_end().to_owned();
    (!message.is_empty()).then_some(message)
}

/// The reader is not blind, in both directions.
///
/// A `git_says` that answered `Some(..)` to a silent channel would make every
/// refusal below pass over the exact defect they exist to catch, and one that
/// answered `None` to everything would make them all fail for the wrong reason.
#[test]
fn the_reader_can_tell_silence_from_a_message() {
    assert_eq!(git_says(&ExecOutcome::Refused), None);
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: String::new(),
            exit_status: Some(1)
        }),
        None,
        "a channel that carried no bytes must read as silence, or every case here passes \
         over a server that says nothing"
    );
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: "0000".into(),
            exit_status: Some(1)
        }),
        None,
        "a flush packet is not a message"
    );
    // A real `ERR` line: `0016` = 22 bytes total, `ERR ` + 14 characters.
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: "0016ERR no such thing\n".into(),
            exit_status: Some(1)
        })
        .as_deref(),
        Some("no such thing"),
    );
}

/// `git-upload-pack` on [`the_repo`], quoted the way `git` quotes it.
fn upload(path: &RepoRef) -> String {
    format!("git-upload-pack '{path}'")
}

/// `git-receive-pack`, which needs `Write` where the above needs `Read`.
fn receive(path: &RepoRef) -> String {
    format!("git-receive-pack '{path}'")
}

// ─────────────────────────────────────────────────────────────────────────────
// 1. A valid key, the wrong repository
// ─────────────────────────────────────────────────────────────────────────────

/// **A key that authenticates perfectly and holds nothing on the repository it
/// asks for**, refused in git's own words.
///
/// The most ordinary adversarial case there is, and the one the wire's
/// deliberate ambiguity is built around: this refusal and
/// `a_repository_that_does_not_exist_refuses_in_the_same_words` must be
/// **indistinguishable to the client**, so a caller cannot walk a namespace by
/// reading the difference. Both halves are asserted, in one test each, and the
/// third test below compares them.
///
/// # Seen red
///
/// With `session.data(channel, err_packet(&message)?)` removed from
/// `exec_request`'s denial arm, this fails on `git_says` being `None` — which
/// is the exact production shape that lane found this morning, reproduced.
#[tokio::test]
async fn a_key_with_no_grant_on_this_repository_is_refused_in_words() {
    let key = mint_key();
    // A real principal with a real, registered key — and a grant on a
    // *different* repository, so the refusal is about this repository and not
    // about an identity the server does not know.
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("stranger")
        .with_key("stranger", &key);
    let other = RepoRef::new("nordisk", "somewhere-else").expect("a legal path");
    plane
        .catalog()
        .create(gunnar_core::NewRepo::new(other.clone()))
        .expect("a second repository");
    plane
        .auth()
        .grant(gunnar_core::Grant::repo(
            "stranger",
            other.clone(),
            Level::Admin,
        ))
        .expect("Admin, but on the other repository");
    let control = plane.control.clone();
    let fixture = start(control, None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("the key is registered, so authentication must succeed");

    // The control: the same key on the repository it DOES hold Admin on. Without
    // it, a server that refused everything would pass the refusal below.
    let allowed = exec(&session, &upload(&other)).await.expect("exec");
    assert!(
        matches!(allowed, ExecOutcome::Accepted { .. }) && git_says(&allowed).is_none(),
        "the key was refused on the repository it holds Admin on, so the refusal below is \
         not about the grant: {allowed:?}"
    );

    let refused = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&refused).unwrap_or_else(|| {
        panic!(
            "a client refused for want of a grant heard NOTHING. It will print `the remote \
             end hung up unexpectedly` and the reason will be in a log it cannot read: \
             {refused:?}"
        )
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository that was refused: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A repository that does not exist refuses in exactly the same words.**
///
/// The other half of the ambiguity, and the reason the two are separate tests
/// with a third comparing them: an assertion that merely said "both are
/// refused" would be satisfied by two different messages, which is the leak.
#[tokio::test]
async fn a_repository_that_does_not_exist_refuses_in_the_same_words() {
    let key = mint_key();
    let fixture = start(plane_with_key(&key, Access::Admin).control, None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let ghost = RepoRef::new("nordisk", "never-created").expect("a legal path");
    let refused = exec(&session, &upload(&ghost)).await.expect("exec");
    let said = git_says(&refused).unwrap_or_else(|| {
        panic!("a client asking for a missing repository heard nothing: {refused:?}")
    });
    assert!(
        said.contains(&ghost.to_string()),
        "the refusal does not name what was asked for: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **The two refusals above are the same sentence with the path swapped.**
///
/// This is the assertion the other two cannot make on their own, and it is the
/// one that actually pins the position: a client must not be able to tell "this
/// repository does not exist" from "you may not have it", because the
/// difference is a directory listing of every private repository on the server.
///
/// # Seen red
///
/// Change `DispatchError::NoSuchRepo`'s client message to anything that is not
/// the denial's, and this fails naming both strings, while both tests above
/// stay green.
#[tokio::test]
async fn a_missing_repository_and_a_forbidden_one_are_one_string_to_the_client() {
    let key = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("stranger")
        .with_key("stranger", &key);
    let fixture = start(plane.control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    // Exists, no grant.
    let forbidden = exec(&session, &upload(&the_repo())).await.expect("exec");
    // Does not exist. Same namespace, so even the namespace is not a tell.
    let missing_path = RepoRef::new("nordisk", "not-a-repository").expect("a legal path");
    let missing = exec(&session, &upload(&missing_path)).await.expect("exec");

    let forbidden_said = git_says(&forbidden).expect("the forbidden repository answered");
    let missing_said = git_says(&missing).expect("the missing repository answered");

    // Normalised by swapping the path out, because the path is the one thing
    // that legitimately differs — the client asked for it and already knows it.
    let a = forbidden_said.replace(&the_repo().to_string(), "<path>");
    let b = missing_said.replace(&missing_path.to_string(), "<path>");
    assert_eq!(
        a, b,
        "a client can tell a repository it may not have from one that does not exist:\n  \
         forbidden: {forbidden_said}\n  missing:   {missing_said}\n\
         That difference is a directory listing of every private repository on the server."
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 2. The identity moving underneath a live session
// ─────────────────────────────────────────────────────────────────────────────

/// **A principal deleted mid-session cannot exec again.**
///
/// `auth_publickey` caches the `Principal` on the session and `exec_request`
/// authorises from that cached value, so nothing in the SSH layer notices the
/// deletion. What saves it is one line in `AuthTable::may`, which checks
/// `doc.principals.contains_key` before it consults ownership or any grant —
/// and that check is load-bearing precisely because **account ownership is
/// intrinsic**: `AuthTable::owns_account` is a string comparison between a
/// principal id and a namespace, with no row anywhere to revoke. Without the
/// existence check, a deleted principal would keep `Admin` on its own
/// namespace forever, out of a rule that has nothing to look up.
///
/// So this is a guard on `gunnar-core`'s invariant, asserted from the far side
/// of a real SSH session, which is the only place the caching matters.
///
/// # What changed on 2026-09-11, and why the assertion moved rather than went
///
/// `exec_request` re-reads the fingerprint now (GUN-015), and
/// `remove_principal` takes the principal's keys with it — so a deleted
/// principal is now stopped one step EARLIER, by its credential no longer
/// resolving, and the client is told that instead of being told about the
/// repository. Better, and it costs this test its subject: the wire no longer
/// reaches `AuthTable::may` at all in this scenario, so an assertion about what
/// the wire says would silently stop being about the `contains_key` early
/// return.
///
/// So the test asserts BOTH, at the level each one lives on: the session is
/// stopped (over a real handshake, in words the client can read), and `may`
/// itself still refuses the deleted owner on its own namespace (called
/// directly, where the intrinsic-ownership rule is). Neither half can be
/// removed without the other noticing.
///
/// # Seen red
///
/// Remove the `contains_key` early return from `AuthTable::may` and the second
/// half fails: the deleted principal still holding `Admin` on its own
/// namespace, out of a rule that has nothing to look up.
#[tokio::test]
async fn a_principal_deleted_mid_session_cannot_exec_again() {
    let key = mint_key();
    // The principal OWNS its namespace, so the intrinsic rule is the one under
    // test. A grant-based fixture would be saved by the grant going away with
    // the principal, which is a different and weaker guarantee.
    let owned = RepoRef::new("alice", "diary").expect("a legal path");
    let plane = Plane::empty()
        .with_principal("alice")
        .with_key("alice", &key);
    plane
        .catalog()
        .create_namespace("alice")
        .expect("namespace");
    plane
        .catalog()
        .create(
            gunnar_core::NewRepo::new(owned.clone()).visibility(gunnar_core::Visibility::Private),
        )
        .expect("repository");
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    // Before: intrinsic ownership serves it, with no grant anywhere.
    let before = exec(&session, &receive(&owned)).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the owner could not reach its own account repository, so the deletion below \
         proves nothing: {before:?}"
    );
    assert!(
        control
            .auth()
            .grants(Some("alice"))
            .expect("grants")
            .is_empty(),
        "the fixture handed out a grant, so this test is about a grant and not about \
         intrinsic ownership"
    );

    // The session stays open. Only the table changes.
    control
        .auth()
        .remove_principal("alice")
        .expect("remove the principal");

    let after = exec(&session, &receive(&owned)).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!(
            "a deleted principal was still served on its own namespace, or was refused in \
             silence: {after:?}"
        )
    });
    assert_eq!(
        said,
        gunnar_ssh::server::REVOKED_MESSAGE,
        "the deleted principal was stopped, but not by its credential ceasing to resolve. \
         Something else caught it, and the second half below is then the only thing \
         standing between a deleted owner and its namespace: {said:?}"
    );

    // The other half, at the level it lives on. Ownership is intrinsic —
    // `owns_account` is a string comparison with no row anywhere to revoke — so
    // without `may`'s existence check a deleted principal keeps `Admin` on its
    // own namespace forever. The wire cannot see this any more; the table can.
    let repo = control
        .catalog()
        .get(&owned)
        .expect("the catalog is readable")
        .expect("the repository is still there");
    for level in [Level::Read, Level::Write, Level::Admin] {
        assert!(
            !control
                .auth()
                .may("alice", &repo, level)
                .expect("the identity table is readable"),
            "a deleted principal still holds {level:?} on its own namespace"
        );
    }

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A grant revoked mid-session stops the next exec.**
///
/// The grant is re-read at every exec, not cached with the principal: that is
/// what `Dispatch::authorise_service` calling `control.access_for` does, and it
/// is worth pinning because the principal beside it *is* cached and the two
/// look alike at the call site.
///
/// The downgrade is asserted in the sharp direction — `Write` withdrawn to
/// `Read` — rather than by removing access entirely, because a session that
/// re-read nothing and a session that re-read only "is there any grant at all"
/// answer the removal case identically.
#[tokio::test]
async fn a_grant_downgraded_mid_session_stops_the_next_push() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Write);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &receive(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "a Write grant could not push, so the downgrade below proves nothing: {before:?}"
    );

    // Withdraw Write, leave Read. The session is untouched.
    assert!(
        control
            .auth()
            .revoke_grant("principal-1", &gunnar_core::Scope::Repo(the_repo()))
            .expect("revoke"),
        "the fixture's grant was not where this test revoked from"
    );
    control
        .auth()
        .grant(gunnar_core::Grant::repo(
            "principal-1",
            the_repo(),
            Level::Read,
        ))
        .expect("re-grant at Read");

    // The push must now be refused…
    let push = exec(&session, &receive(&the_repo())).await.expect("exec");
    let said = git_says(&push).unwrap_or_else(|| {
        panic!("a downgraded session pushed, or was refused in silence: {push:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository: {said:?}"
    );

    // …and the fetch must not be, or this is measuring the connection dying
    // rather than the grant being re-read.
    let fetch = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(fetch, ExecOutcome::Accepted { .. }) && git_says(&fetch).is_none(),
        "the Read grant that survived the downgrade cannot fetch either, so the refusal \
         above is the session breaking and not the ACL answering: {fetch:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **The repository deleted mid-session, between the check and the use.**
///
/// The catalog is re-resolved at every exec — `authorise_service` calls
/// `control.resolve` before it asks the ACL anything — so a repository that
/// vanished answers as one that never existed, in the same words, which is the
/// right answer and is asserted here rather than assumed.
#[tokio::test]
async fn a_repository_deleted_mid_session_refuses_like_one_that_never_existed() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Admin);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "an Admin grant could not fetch, so the deletion below proves nothing: {before:?}"
    );

    control
        .catalog()
        .delete(&the_repo())
        .expect("delete the repository");

    let after = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!("a session holding Admin on a deleted repository heard nothing: {after:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A whole namespace deleted mid-session**, which is the same seam one level
/// up and the one the sweep named by hand.
///
/// `delete_namespace(.., true)` takes the repositories with it, so the exec
/// that follows resolves nothing. Separate from the repository case because the
/// two go through different catalog writes and only one of them was reachable
/// from the test suite.
#[tokio::test]
async fn a_namespace_deleted_mid_session_takes_its_repositories_with_it() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Admin);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the fixture cannot fetch before the namespace goes, so nothing below is measured: \
         {before:?}"
    );

    let taken = control
        .catalog()
        .delete_namespace(the_repo().namespace(), true)
        .expect("delete the namespace and its repositories");
    assert_eq!(
        taken.len(),
        1,
        "the namespace delete took {} repositories with it, so the fixture is not the \
         shape this test describes",
        taken.len()
    );

    let after = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!("a session whose namespace was deleted under it heard nothing: {after:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name what was asked for: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. The key itself
// ─────────────────────────────────────────────────────────────────────────────

/// **A key revoked mid-session loses the session it already had.**
///
/// This was the opposite position until 2026-09-11, written down here at
/// length and honestly: `auth_publickey` runs once per *connection*, cached the
/// `Principal` on the session, and every later `exec_request` authorised out of
/// that cache, so revoking a key did not stop the channels it had opened. The
/// note ended by naming exactly what it cost — *"revoking a key does not evict
/// a session that key opened. An operator revoking a compromised key must also
/// drop the connections — gunnar has no verb for that today"* — and that is the
/// gap, not a nuance of it. git over SSH holds one connection open for as long
/// as a client wants to keep pushing, and an operator who revokes a leaked key
/// and watches it stop working on their own next connection has every reason to
/// believe it stopped working everywhere.
///
/// The appeal to OpenSSH does not carry the weight it looks like it does.
/// Removing a line from `authorized_keys` indeed disconnects nobody — but
/// OpenSSH hands the session to a shell and has nothing left to re-check at,
/// while gunnar authorises **every exec** against a live control plane and was
/// already re-reading the grant, the repository and the namespace there. The
/// fingerprint was the one input on that list that it chose to remember.
/// `DocRef::read` caches nothing, so the table was current the whole time;
/// nothing asked it.
///
/// Both halves are still asserted, for the reason the old note gave and which
/// did not change with the position:
///
/// * the exec BEFORE the revocation is accepted — so a refusal afterwards is
///   caused by the revocation and not by a fixture that never worked, and it is
///   the same `live_principal` path answering yes to a live key;
/// * a **new** connection with the revoked key is refused — so the revocation
///   is real rather than something the first half could be mistaking for one.
#[tokio::test]
async fn a_revoked_key_loses_the_session_it_already_opened() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Write);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let addr = fixture.server.local_addr();

    let session = authenticate(addr, &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");
    let before = exec(&session, &receive(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the fixture cannot push before the revocation: {before:?}"
    );

    let fingerprint = common::fingerprint_of(&key);
    assert!(
        control
            .auth()
            .revoke_key(&fingerprint)
            .expect("revoke")
            .is_some(),
        "the fingerprint this test revoked is not the one the table holds, so nothing was \
         revoked and both halves below would be vacuous"
    );

    // Half one: the open session stops. THE POSITION.
    //
    // Asserted as the sentence the client can SEE, not as the exit status: an
    // `ExitStatus(1)` and a silent hang-up look identical to the person holding
    // the terminal, which is the bar every case in this file clears.
    let after = exec(&session, &receive(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!(
            "an exec on a session whose key was revoked was answered without a word the \
             client can read: {after:?}"
        )
    });
    assert_eq!(
        said,
        gunnar_ssh::server::REVOKED_MESSAGE,
        "the refusal reached the client, but not as the one sentence the server has for \
         this — so what a user is told is decided at the refusal site and not in one place"
    );

    // Half two: a new connection is refused. Without this the first half is
    // just a revocation that does nothing.
    assert!(
        authenticate(addr, &key, "git")
            .await
            .expect("the connection itself must succeed")
            .is_none(),
        "a revoked key opened a NEW session. The revocation is a no-op and the position \
         above is a hole"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **An expired key does not open an SSH session**, over a real handshake.
///
/// `gunnar-core`'s own tests cover `is_valid_at`; this covers the path that
/// uses it, and the reason it is worth a separate test is that the same gap was
/// **real on the neighbouring path this morning**: `key_directory` built the
/// ssh-sig gate's key material from `AuthTable::keys()`, which filters nothing,
/// so one credential had two lifetimes and the longer one was written down
/// nowhere. `Control::principal_for_key` goes through
/// `principal_by_fingerprint`, which delegates to `..._at(now_ms())` and checks
/// the window — so this path was already right, and this test is what says so
/// rather than assuming it.
///
/// Both directions, because a server that refused every key would satisfy the
/// expired arm alone.
#[tokio::test]
async fn an_expired_key_does_not_open_a_session() {
    let live = mint_key();
    let dead = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_key("principal-1", &live)
        .with_grant("principal-1", Access::Write);
    // Expired an hour ago. Registered through the same door an operator uses,
    // so nothing here reaches past the API into the document.
    let an_hour_ago = gunnar_core::now_ms() - 3_600_000;
    plane
        .auth()
        .register_key(
            "principal-1",
            &common::authorized_keys_line(&dead),
            Some(an_hour_ago),
        )
        .expect("register an already-expired key");
    let fixture = start(plane.control.clone(), None).await;
    let addr = fixture.server.local_addr();

    assert!(
        authenticate(addr, &dead, "git")
            .await
            .expect("the connection itself must succeed")
            .is_none(),
        "an expired key opened an SSH session. Its principal, its grant and its \
         registration are all intact; the only thing wrong with it is the clock"
    );
    // The control. Same principal, same grant, a key that has not expired.
    assert!(
        authenticate(addr, &live, "git")
            .await
            .expect("connect")
            .is_some(),
        "the unexpired key was refused too, so the refusal above is not about expiry"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A key that belongs to another principal authenticates as that other
/// principal, never as the one holding the grant.**
///
/// The SSH username is decorative (`the_ssh_username_is_decorative` in
/// `tests/auth.rs` pins that from the other side); this pins the consequence.
/// Two principals, one with the grant and one without, and the key of the one
/// without: the session must be refused however the username is spelled,
/// **including when it is spelled as the principal that does hold the grant**.
///
/// That last spelling is the whole point. A server that let the username select
/// the identity would serve this, and every other test in the suite would stay
/// green because they all connect as `git`.
///
/// # Seen red
///
/// Make `auth_publickey` resolve the principal by the offered username rather
/// than by the fingerprint and this fails on the `holder` arm being served.
#[tokio::test]
async fn a_key_cannot_borrow_the_identity_its_username_names() {
    let holder_key = mint_key();
    let stranger_key = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("holder")
        .with_key("holder", &holder_key)
        .with_grant("holder", Access::Admin)
        .with_principal("stranger")
        .with_key("stranger", &stranger_key);
    let fixture = start(plane.control.clone(), None).await;
    let addr = fixture.server.local_addr();

    // The control: the holder's own key works, so the grant is real.
    let holder = authenticate(addr, &holder_key, "git")
        .await
        .expect("connect")
        .expect("the holder authenticates");
    let served = exec(&holder, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(served, ExecOutcome::Accepted { .. }) && git_says(&served).is_none(),
        "the holder cannot reach the repository it holds Admin on: {served:?}"
    );

    // Every username a stranger might try, including the holder's id.
    for user in ["git", "holder", "root", "stranger"] {
        let session = authenticate(addr, &stranger_key, user)
            .await
            .expect("connect")
            .unwrap_or_else(|| {
                panic!("the stranger's key is registered; it must authenticate as {user:?}")
            });
        let outcome = exec(&session, &upload(&the_repo())).await.expect("exec");
        let said = git_says(&outcome).unwrap_or_else(|| {
            panic!(
                "connecting as {user:?} with the stranger's key was SERVED, or refused in \
                 silence. The SSH username must not reach an authorisation decision: \
                 {outcome:?}"
            )
        });
        assert!(
            said.contains("stranger") || said.contains(&the_repo().to_string()),
            "the refusal for user {user:?} names neither the principal nor the repository: \
             {said:?}"
        );
        assert!(
            !said.contains("holds admin"),
            "the refusal for user {user:?} reports the HOLDER's level, so the username \
             selected the identity: {said:?}"
        );
    }

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. The connection itself
// ─────────────────────────────────────────────────────────────────────────────

/// **A connection may not offer keys without end.**
///
/// `SshConfig::max_auth_attempts` was set, handed to russh's `Config`, and read
/// by nobody: russh 0.61.2 declares the field, defaults it to 10 and
/// `Debug`-prints it, and the three occurrences in that crate's source are
/// exactly those three. So the number in gunnar's config was documentation. One
/// TCP connection could walk a dictionary of public keys past the identity
/// table at `auth_rejection_time` apiece, without ever re-establishing
/// anything — which is also the cheapest way to learn WHICH keys a server
/// knows, since an accepted offer is distinguishable from a refused one by
/// definition.
///
/// Three things are asserted, and the third is what makes the first two mean
/// something:
///
/// * the server stops answering at the limit — the connection is dropped, not
///   merely refused, because a refusal leaves the next offer available;
/// * a client that stays UNDER the limit is answered every time — so the limit
///   is a limit and not a server that hangs up on strangers;
/// * a good key inside the budget still authenticates — so nothing here made
///   the server refuse the traffic it exists for.
///
/// `auth_rejection_time` is zeroed because the fixture would otherwise pay
/// russh's real constant-time delay per offer, and this test says nothing about
/// that delay.
#[tokio::test]
async fn a_connection_may_not_offer_keys_without_end() {
    const LIMIT: usize = 3;

    let good = common::mint_key();
    let plane = plane_with_key(&good, Access::Write);
    let mut config = gunnar_ssh::SshConfig::ephemeral_loopback();
    config.max_auth_attempts = LIMIT;
    config.auth_rejection_time = std::time::Duration::ZERO;
    let fixture = common::start_with_config(
        plane.control,
        config,
        gunnar_ssh::seam::BannerTransport::new(),
    )
    .await;
    let addr = fixture.server.local_addr();

    // Over the limit: strangers, one connection.
    let strangers: Vec<_> = (0..LIMIT + 4).map(|_| common::mint_key()).collect();
    let (answered, accepted, error) = common::offer_keys(addr, &strangers, "git").await;
    assert!(!accepted, "a key nobody registered authenticated");
    assert!(
        error.is_some(),
        "the server answered all {answered} offers and kept the connection open. \
         `max_auth_attempts` is {LIMIT} and nothing is enforcing it"
    );
    assert!(
        answered <= LIMIT,
        "the server answered {answered} offers before dropping the connection, and the \
         limit is {LIMIT}"
    );

    // Under the limit: the same strangers, fewer of them, all answered.
    let (answered, accepted, error) =
        common::offer_keys(addr, &strangers[..LIMIT - 1], "git").await;
    assert_eq!(
        (answered, accepted, error.is_none()),
        (LIMIT - 1, false, true),
        "a client that stayed inside the budget was cut off anyway, so the limit above is \
         a server that hangs up on strangers rather than a limit"
    );

    // And the traffic the server exists for still works, with refusals ahead of
    // it in the same connection.
    let mut mixed = strangers[..LIMIT - 1].to_vec();
    mixed.push(good);
    let (answered, accepted, error) = common::offer_keys(addr, &mixed, "git").await;
    assert!(
        accepted && error.is_none(),
        "a registered key offered as attempt {answered} of {LIMIT} was not accepted: \
         {error:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}