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
//! End to end: a real SSH connection, a real exec request, and bytes that
//! actually cross the seam (the ACL model, the two-listener split).
//!
//! What this proves that a unit test cannot: the parsed command and the
//! resolved repository reach [`gunnar_ssh::seam::GitTransport`] *and* the
//! channel's byte stream is live in both directions afterwards. The transport
//! double writes a line naming what it was asked for, and the client reads that
//! line back over SSH: applied output, not a flag (measured, not asserted).

mod common;

use gunnar_ssh::seam::BannerTransport;
use gunnar_ssh::Access;

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

#[tokio::test]
async fn upload_pack_reaches_the_wire_seam_with_the_right_request() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert_eq!(
                stdout, "git-upload-pack nordisk/osm-katana principal-1\n",
                "the transport's own bytes must arrive over the SSH channel"
            );
            assert_eq!(exit_status, Some(0));
        }
        ExecOutcome::Refused => panic!("a read grant must serve git-upload-pack"),
    }

    // And the seam saw the same thing the client did.
    assert_eq!(
        fixture.transport.served(),
        vec!["git-upload-pack nordisk/osm-katana principal-1".to_string()]
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn receive_pack_reaches_the_wire_seam_when_the_grant_allows_it() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Write).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack '/nordisk/osm-katana.git'")
        .await
        .expect("exec");
    assert!(
        matches!(&outcome, ExecOutcome::Accepted { stdout, .. }
            if stdout == "git-receive-pack nordisk/osm-katana principal-1\n"),
        "{outcome:?}"
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// **The exit-status ordering guard, and the only version of it that can go
/// red.**
///
/// A git service that writes a correct answer and then loses its exit status is
/// worse than one that fails outright: the server did the work, the client
/// reports failure, and the two disagree. That is exactly what happened while
/// `exec_request` handed the transport a `Channel::into_stream()`. russh's
/// `ChannelCloseOnDrop` spawned a `Close` when the transport let the stream go,
/// and the `Close` beat the `exit-status` to the client. Measured 2026-08-03
/// over stock OpenSSH: a complete 244-byte advertisement, exit **255**.
///
/// The tests above assert `exit_status == Some(0)` too, and they were **green
/// with the bug present**: on a current-thread runtime the server's own send
/// takes a free mpsc permit without yielding, so the spawned close never got to
/// run first. This one uses [`BannerTransport::settling_after_it_releases_the_stream`],
/// which drops the stream and then yields, so a close-on-drop has every
/// opportunity to arrive first, and a server that closes nothing on drop is
/// unaffected by the wait.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_exit_status_survives_the_transport_letting_the_stream_go() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // Both, and in this order: a missing status only matters because the
            // work behind it succeeded, and a test asserting the status alone
            // could be satisfied by a server that answered nothing at all.
            assert_eq!(
                stdout, "git-upload-pack nordisk/osm-katana principal-1\n",
                "the transport's bytes must still arrive"
            );
            assert_eq!(
                exit_status,
                Some(0),
                "gunnar served {} bytes and then lost the exit status — every successful push \
                 over this server would report failure",
                stdout.len()
            );
        }
        ExecOutcome::Refused => panic!("a read grant must serve git-upload-pack"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// The same guard, the other way round: a **non-zero** status has to survive the
/// release too.
///
/// Without this, everything above would pass equally well against a server that
/// hard-coded zero, and a failed transfer would be reported to the client as a
/// success. This one goes through the same path (the transport is handed the
/// stream, lets it go, and *then* the status is sent), so it is red under the
/// same bug.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_failed_transfer_reports_a_non_zero_exit_status() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream().that_then_fails(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted { exit_status, .. } => assert_eq!(
            exit_status,
            Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED),
            "a transfer that failed must say so in its exit status"
        ),
        ExecOutcome::Refused => panic!("the exec itself was authorised; only the transfer failed"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// A push the grant check refuses reports [`gunnar_ssh::server::EXIT_DENIED`].
///
/// **This one is NOT a guard for the ordering bug** and is not claimed to be:
/// the denial is answered before any stream exists, so nothing here can race a
/// close-on-drop. It guards the other half of "non-zero on a refused push":
/// that the refusal path reports a status at all, and reports one that is not
/// zero.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_refused_push_reports_a_non_zero_exit_status() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted { exit_status, .. } => {
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
            assert_ne!(exit_status, Some(0), "a refusal must not report success");
        }
        ExecOutcome::Refused => panic!("a denial is answered in git's own protocol, not refused"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// A transfer refused **after** authorisation, before a byte was written, must
/// reach the client as words and not only as an exit status.
///
/// The defect this guards, found on 2026-08-05: `exec_request`'s spawned task
/// logged the `TransportError` and sent `exit-status 1` and nothing else. So
/// every post-authorisation refusal — most concretely a `gunnar gc` holding the
/// repository's `O_EXCL` store lock, which is the *only* thing that makes a
/// repack and a transfer mutually exclusive — arrived at the user as
/// `fatal: the remote end hung up unexpectedly`, with the entire reason sitting
/// in the server log. The same class as `report_error`'s zero call sites.
///
/// Two gates in that same function already answered in git's own protocol, which
/// is why this was invisible: the shape of a correct refusal was right there, one
/// screen up.
///
/// # Seen red
///
/// With the `if let Some(message) = told` block removed from
/// `Handler::exec_request`, `stdout` is `""` and the first assertion fails
/// naming the subject. With `TransportError::client_message` returning `None`
/// for `Refused`, likewise. The exit-status assertion alone is **not** enough
/// and is deliberately kept second: `a_failed_transfer_reports_a_non_zero_exit_status`
/// was already green over this bug for a whole day.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_transfer_refused_before_its_first_byte_tells_the_client_why() {
    // The sentence is distinctive on purpose: an assertion on `"ERR "` alone
    // would be satisfied by the *authorisation* denial, which is a different
    // code path answered by a different gate, and this test would then pass
    // while proving nothing about the one under it.
    const WHY: &str = "the repository is locked by maintenance on the server";

    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::refusing(WHY),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert!(
                stdout.contains(WHY),
                "a transfer refused before its first byte must tell the client why; the client \
                 got {stdout:?}"
            );
            // And in git's own framing, not as bare prose: a pkt-line is four hex
            // length bytes and then `ERR <message>`. Prose on the data band is
            // not an error message to a git client, it is a protocol violation.
            assert!(
                stdout.len() > 4 && stdout[4..].starts_with("ERR "),
                "the reason must be an ERR pkt-line, got {stdout:?}"
            );
            let declared = usize::from_str_radix(&stdout[..4], 16).expect("pkt-line length");
            assert_eq!(
                declared,
                stdout.len(),
                "the pkt-line length must cover exactly what was written, got {stdout:?}"
            );
            assert_eq!(
                exit_status,
                Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED),
                "and it still has to be a failure"
            );
        }
        ExecOutcome::Refused => {
            panic!("the exec was authorised; only the transfer was refused")
        }
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

/// The other direction, and the reason [`TransportError::client_message`] is not
/// simply "always tell them".
///
/// A transport that has already written its banner and *then* fails must get
/// **no** pkt-line appended. The client is mid-stream; four hex digits and `ERR`
/// spliced after an advertisement is corruption that a git client reads as a
/// malformed packet, which is a worse answer than silence. `report_error`'s
/// sideband is the only correct channel once a transfer is under way, and it
/// belongs to `gunnar-wire`, not to the session.
///
/// # Seen red
///
/// Making `client_message()` return `Some` for `TransportError::Protocol` puts an
/// `ERR` packet after the banner and this fails, naming the banner it was
/// appended to.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_transfer_that_already_wrote_gets_no_pkt_line_spliced_after_it() {
    let key = mint_key();
    let fixture = start_with_transport(
        plane_with_key(&key, Access::Read).control,
        None,
        BannerTransport::settling_after_it_releases_the_stream().that_then_fails(),
    )
    .await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // The banner did arrive: this is a control on the control. Without it
            // an empty `stdout` would satisfy `!contains("ERR ")` and the test
            // would be green against a transport that never ran at all.
            assert!(
                stdout.contains("git-upload-pack nordisk/osm-katana"),
                "the banner has to have been served, or this test proves nothing: {stdout:?}"
            );
            assert!(
                !stdout.contains("ERR "),
                "a failure after the first byte must NOT splice a pkt-line into the stream: \
                 {stdout:?}"
            );
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_TRANSPORT_FAILED));
        }
        ExecOutcome::Refused => panic!("the exec was authorised"),
    }

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_read_only_key_cannot_push_and_is_told_why_in_git_s_own_protocol() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let outcome = exec(&session, "git-receive-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");

    match outcome {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            // A pkt-line: four hex length bytes, then `ERR <message>`. Stock git
            // prints this instead of "Could not read from remote repository".
            assert!(
                stdout.starts_with("0060ERR ") || stdout[4..].starts_with("ERR "),
                "expected an ERR pkt-line, got {stdout:?}"
            );
            assert!(
                stdout.contains(
                    "repository not found, or you do not have access: nordisk/osm-katana"
                ),
                "{stdout:?}"
            );
            assert_eq!(exit_status, Some(gunnar_ssh::server::EXIT_DENIED));
        }
        ExecOutcome::Refused => panic!(
            "a git client asking for a repository it may not have should be answered in its own \
             protocol, not with an opaque channel failure"
        ),
    }

    // And nothing reached the wire.
    assert!(fixture.transport.served().is_empty());

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn no_shell_ever() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    for command in [
        "bash",
        "sh -c id",
        "/bin/sh",
        "scp -t /tmp",
        "git-upload-pack",
        "git-upload-pack '../../etc/passwd'",
        "git upload-pack 'nordisk/osm-katana.git'",
    ] {
        let outcome = exec(&session, command).await.expect("exec");
        assert_eq!(
            outcome,
            ExecOutcome::Refused,
            "{command:?} must be refused outright"
        );
    }

    // `git-upload-archive` is in `command::NOT_SERVED` since 2026-08-30
    // (2026-08-30): it is refused BY NAME, in git's own protocol, so the user
    // reads a sentence instead of `exec request failed on channel 0`. Still not
    // served — the transport assertion below covers every command in this test
    // — but the channel opens far enough to carry the ERR line, which is the
    // whole point of naming it.
    let archive = exec(&session, "git-upload-archive 'nordisk/osm-katana.git'")
        .await
        .expect("exec");
    match archive {
        ExecOutcome::Accepted {
            stdout,
            exit_status,
        } => {
            assert!(
                stdout.contains("upload-archive is not served"),
                "the refusal must say why: {stdout:?}"
            );
            assert_eq!(exit_status, Some(128), "a denial, not a busy signal");
        }
        other => panic!("git-upload-archive must be refused by NAME, not silently: {other:?}"),
    }

    // Not one of them reached the wire seam.
    assert!(
        fixture.transport.served().is_empty(),
        "something got through: {:?}",
        fixture.transport.served()
    );

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_shell_request_is_refused_rather_than_left_hanging() {
    // russh's default for `shell_request` answers nothing at all, which leaves a
    // client waiting forever. A hung client and a refused one look the same in
    // a test that only checks "no shell ran".
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let mut channel = session.channel_open_session().await.expect("open");
    channel.request_shell(true).await.expect("send request");

    let answer = tokio::time::timeout(std::time::Duration::from_secs(5), async {
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => return Some(true),
                russh::ChannelMsg::Failure => return Some(false),
                _ => {}
            }
        }
        None
    })
    .await
    .expect("the server must answer a shell request, not ignore it");
    assert_eq!(answer, Some(false), "a shell request must be FAILED");

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn a_subsystem_request_is_refused() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Admin).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let mut channel = session.channel_open_session().await.expect("open");
    channel
        .request_subsystem(true, "sftp")
        .await
        .expect("send request");

    let answer = tokio::time::timeout(std::time::Duration::from_secs(5), async {
        while let Some(msg) = channel.wait().await {
            match msg {
                russh::ChannelMsg::Success => return Some(true),
                russh::ChannelMsg::Failure => return Some(false),
                _ => {}
            }
        }
        None
    })
    .await
    .expect("the server must answer a subsystem request");
    assert_eq!(answer, Some(false), "sftp must be FAILED");

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}

#[tokio::test]
async fn an_unknown_repository_is_refused_even_for_an_admin() {
    let key = mint_key();
    // A registered key with a server-wide **admin** grant, and an empty
    // catalog. Authorisation is not the thing standing in the way here; the
    // repository simply does not exist.
    let plane = Plane::empty()
        .with_principal("principal-1")
        .with_key("principal-1", &key)
        .with_namespace_grant("principal-1", "nordisk", gunnar_core::model::Level::Admin);
    let fixture = start(plane.control, None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");
    let outcome = exec(&session, "git-upload-pack 'nordisk/osm-katana.git'")
        .await
        .expect("exec");
    assert!(
        matches!(&outcome, ExecOutcome::Accepted { stdout, .. } if stdout.contains("ERR ")),
        "{outcome:?}"
    );
    assert!(fixture.transport.served().is_empty());

    fixture
        .server
        .shutdown("test over")
        .await
        .expect("shutdown");
}