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
//! The path sandbox at the level it actually protects: the dispatch (the ACL model).
//!
//! `tests/command_parsing.rs` covers the syntax and carries this crate's half of
//! the merged rejection table. This file covers the decision (parse, resolve,
//! check the grant), because that is where "reject an escape" and "accept a
//! legitimate nested path" have to both be true at once.
//!
//! Since the merge the sandbox under all of this is `gunnar_core::resolve_repo_path`,
//! the only one, and the lookups are `gunnar-core`'s real
//! `AuthTable`/`RepoCatalog` rather than test doubles.

mod common;

use gunnar_core::model::Level;
use gunnar_ssh::server::{Dispatch, DispatchError};
use gunnar_ssh::{Access, GitService, Principal, RepoRef};

use common::{plane_with_key, principal, Plane};

/// A dispatch where `principal-1` holds `level` on `nordisk/osm-katana`.
fn dispatch(level: Access) -> (Dispatch, Principal) {
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_grant("principal-1", level);
    (Dispatch::new(plane.control), principal("principal-1"))
}

#[test]
fn a_legitimate_nested_path_is_served() {
    // The identity-value guard for the whole file. A sandbox that refuses
    // everything satisfies every escape test below and serves nobody.
    let (dispatch, who) = dispatch(Access::Read);
    let request = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("a registered repository with a read grant must be served");
    assert_eq!(request.service, GitService::UploadPack);
    assert_eq!(request.repo.key(), "nordisk/osm-katana");
    assert_eq!(request.principal.id, "principal-1");

    // And the ssh:// spelling of the same repository reaches the same place.
    let via_url = dispatch
        .authorise(&who, None, b"git-upload-pack '/nordisk/osm-katana'")
        .expect("the ssh:// spelling names the same repository");
    assert_eq!(via_url.repo, request.repo);
}

/// **The behaviour that changed when the sandbox merged, pinned.**
///
/// This crate's deleted sandbox enforced exactly two path components, so
/// `team/sub/repo.git` was a *parse error*. `gunnar-core`'s permits nested
/// namespaces, and that is the one that survived. Without this test the merge
/// could silently be undone by reinstating the stricter rule, and every
/// rejection test in the crate would stay green.
#[test]
fn a_nested_namespace_resolves_and_is_served() {
    let plane = Plane::empty().with_principal("principal-1");
    let deep = RepoRef::new("team/sub", "project").expect("nested namespaces are legal");
    plane
        .catalog()
        .create_namespace("team/sub")
        .expect("create the nested namespace");
    plane
        .catalog()
        .create(gunnar_core::NewRepo::new(deep.clone()))
        .expect("create");
    plane
        .auth()
        .grant(gunnar_core::model::Grant::repo(
            "principal-1",
            deep,
            Level::Read,
        ))
        .expect("grant");

    let dispatch = Dispatch::new(plane.control);
    let request = dispatch
        .authorise(
            &principal("principal-1"),
            None,
            b"git-upload-pack 'team/sub/project.git'",
        )
        .expect("a nested namespace is legal");
    assert_eq!(request.repo.namespace, "team/sub");
    assert_eq!(request.repo.name, "project");
}

/// **The rule that makes the permissive sandbox safe.**
///
/// Nested namespaces being legal would be a privilege escalation if a grant on
/// `team` reached `team/sub`. It does not, and that is asserted here at the
/// dispatch. `gunnar-core`'s `tests/acl.rs` asserts it at the ACL, but the
/// escalation would be *exploited* through this path, so it is pinned on this
/// side too.
#[test]
fn a_grant_on_the_parent_namespace_does_not_reach_a_nested_one() {
    let plane = Plane::empty().with_principal("principal-1");
    for (namespace, name) in [("team", "shallow"), ("team/sub", "deep")] {
        plane
            .catalog()
            .create_namespace(namespace)
            .expect("create namespace");
        plane
            .catalog()
            .create(gunnar_core::NewRepo::new(
                RepoRef::new(namespace, name).expect("legal"),
            ))
            .expect("create");
    }
    // Admin on `team`, and nothing at all on `team/sub`.
    let plane = plane.with_namespace_grant("principal-1", "team", Level::Admin);
    let dispatch = Dispatch::new(plane.control);
    let who = principal("principal-1");

    // The red half first: the grant really does work where it applies. Without
    // this, a grant table that did nothing at all would pass the assertion
    // below.
    dispatch
        .authorise(&who, None, b"git-receive-pack 'team/shallow.git'")
        .expect("an admin grant on `team` must serve a repository in `team`");

    let err = dispatch
        .authorise(&who, None, b"git-upload-pack 'team/sub/deep.git'")
        .expect_err("a grant on `team` must NOT reach `team/sub`");
    assert!(
        matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
        "nested namespaces must not inherit grants: {err}"
    );
}

#[test]
fn escapes_never_reach_a_repository() {
    let (dispatch, who) = dispatch(Access::Admin);
    for raw in [
        &b"git-upload-pack '../../etc/passwd'"[..],
        b"git-upload-pack 'nordisk/../../etc/passwd'",
        b"git-upload-pack '/nordisk/../osm-katana'",
        b"git-upload-pack '.git/../..'",
        b"git-upload-pack '~/nordisk/osm-katana.git'",
        b"git-upload-pack 'nordisk/osm-katana.git\0'",
        // Merged in with the sandbox: git's lock-file suffix, which this
        // crate's deleted copy refused and `gunnar-core`'s did not until now.
        b"git-upload-pack 'nordisk/osm-katana.lock'",
    ] {
        let err = dispatch.authorise(&who, None, raw).expect_err(&format!(
            "should have refused {:?}",
            String::from_utf8_lossy(raw)
        ));
        assert!(
            matches!(err, DispatchError::Command(_)),
            "{:?} should have failed at the parser, not later: {err}",
            String::from_utf8_lossy(raw)
        );
    }
}

#[test]
fn an_absolute_system_path_is_refused_because_it_names_nothing() {
    // The ACL model lists `/etc/passwd` as a sandbox case. It is refused, but
    // one layer later than the spec implies, and for a better reason. See the
    // module docs of `gunnar_core::sandbox`: it parses as namespace `etc`,
    // repository `passwd`, which is not in the catalog, and there is never a
    // filename anywhere in the pipeline for it to have escaped into.
    let (dispatch, who) = dispatch(Access::Admin);
    let err = dispatch
        .authorise(&who, None, b"git-upload-pack '/etc/passwd'")
        .expect_err("must be refused");
    match err {
        DispatchError::NoSuchRepo(path) => {
            assert_eq!(path, RepoRef::new("etc", "passwd").unwrap());
        }
        other => panic!("expected NoSuchRepo, got {other}"),
    }
}

#[test]
fn upload_pack_needs_read_and_receive_pack_needs_write() {
    let (read_only, who) = dispatch(Access::Read);
    read_only
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("read is enough to fetch");
    let err = read_only
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect_err("read must NOT be enough to push");
    assert!(
        matches!(
            &err,
            DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
        ),
        "{err}"
    );

    let (writable, who) = dispatch(Access::Write);
    writable
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect("write is enough to push");
    writable
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("write implies read");
}

#[test]
fn a_principal_with_no_grant_at_all_is_denied() {
    let plane = Plane::empty().with_the_repo().with_principal("stranger");
    let dispatch = Dispatch::new(plane.control);
    let err = dispatch
        .authorise(
            &principal("stranger"),
            None,
            b"git-upload-pack 'nordisk/osm-katana.git'",
        )
        .expect_err("no grant means no access");
    assert!(
        matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
        "{err}"
    );
}

#[test]
fn not_found_and_not_allowed_are_indistinguishable_to_the_client() {
    // Two different server-side facts, one client-visible string. If these ever
    // diverge, a client can enumerate private repositories one guess at a time.
    let (dispatch, who) = dispatch(Access::Read);
    let missing = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/does-not-exist.git'")
        .expect_err("no such repo");
    let denied = dispatch
        .authorise(&who, None, b"git-receive-pack 'nordisk/osm-katana.git'")
        .expect_err("no write grant");

    assert!(matches!(missing, DispatchError::NoSuchRepo(_)));
    assert!(matches!(denied, DispatchError::Denied { .. }));
    // Different internally…
    assert_ne!(missing.to_string(), denied.to_string());
    // …and both refer to the repository the client named, with the same wording.
    let missing_msg = missing.client_message().expect("a git client is listening");
    let denied_msg = denied.client_message().expect("a git client is listening");
    assert!(missing_msg.starts_with("repository not found, or you do not have access:"));
    assert!(denied_msg.starts_with("repository not found, or you do not have access:"));

    // The one place they may legitimately differ is the path the client itself
    // sent back to it, so compare the wording with the paths removed.
    let strip = |m: &str| m.split(':').next().unwrap().to_string();
    assert_eq!(strip(&missing_msg), strip(&denied_msg));
}

/// **A storage fault is not a permission denial (the ACL model).**
///
/// The seam this dispatch replaced returned `Option`, so an unreadable catalog
/// and an empty one were the same answer: "no such repository". `gunnar-core`
/// returns `Result` precisely so they are not, and this is the guard that the
/// `Result` is still carried rather than swallowed somewhere in between.
///
/// The red half is built in: the same dispatch, the same command, served
/// successfully first, so a green here cannot come from the request being
/// refused for some other reason.
#[test]
fn an_unreadable_catalog_is_a_fault_and_not_a_denial() {
    let plane = plane_with_key(&common::mint_key(), Access::Read);
    let dispatch = Dispatch::new(plane.control.clone());
    let who = principal("principal-1");

    dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("served while the catalog is readable");

    plane.corrupt_the_catalog();

    let err = dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect_err("an unreadable catalog must not be answered at all");
    assert!(
        matches!(err, DispatchError::Store(_)),
        "a storage fault reported as {err:?}; it must not be a Denied or a NoSuchRepo, \
         or an outage becomes a silent permission denial"
    );
    // And the client is told the truth, not that it lacks access.
    let message = err.client_message().expect("say something");
    assert!(
        !message.contains("do not have access"),
        "a storage fault must not be reported to the client as a permission problem: {message}"
    );
}

/// The same, for the identity table rather than the catalog.
#[test]
fn an_unreadable_identity_table_is_a_fault_and_not_a_denial() {
    let plane = plane_with_key(&common::mint_key(), Access::Read);
    let dispatch = Dispatch::new(plane.control.clone());
    let who = principal("principal-1");

    dispatch
        .authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'")
        .expect("served while the table is readable");

    plane.corrupt_the_auth_table();

    assert!(
        matches!(
            dispatch.authorise(&who, None, b"git-upload-pack 'nordisk/osm-katana.git'"),
            Err(DispatchError::Store(_))
        ),
        "an unreadable identity table must be a fault, not `you have no grant`"
    );
}

#[test]
fn a_shell_gets_no_message_at_all() {
    // `None` means channel_failure and silence: there is no git client on the
    // other end that would understand a pkt-line.
    let (dispatch, who) = dispatch(Access::Admin);
    let err = dispatch
        .authorise(&who, None, b"bash")
        .expect_err("no shell, ever");
    assert!(err.client_message().is_none(), "{err}");
}

/// **The LFS verb through the real ACL.**
///
/// `tests/command_parsing.rs` proves the exec line `git-lfs-transfer <path>
/// upload` *parses* to `Access::Write`. That is a statement about the parser
/// and nothing else: it never builds an `AuthTable`, never holds a grant, and
/// would stay green if the dispatch dropped the parsed requirement on the floor
/// and handed every authenticated caller a writable LFS stream.
///
/// This is the same matrix `upload_pack_needs_read_and_receive_pack_needs_write`
/// asserts for the two git verbs, for the third one, which had no coverage at
/// this level at all. The `download` half is what makes it discriminating: a
/// dispatch that refused all LFS would satisfy the upload assertion alone.
#[test]
fn lfs_download_needs_read_and_lfs_upload_needs_write() {
    const DOWNLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git download";
    const UPLOAD: &[u8] = b"git-lfs-transfer nordisk/osm-katana.git upload";

    let (read_only, who) = dispatch(Access::Read);
    read_only
        .authorise(&who, None, DOWNLOAD)
        .expect("read is enough to download an LFS object");
    let err = read_only
        .authorise(&who, None, UPLOAD)
        .expect_err("read must NOT be enough to upload an LFS object");
    assert!(
        matches!(
            &err,
            DispatchError::Denied { needed: Access::Write, held, .. } if held == "read"
        ),
        "an LFS upload refused a read-only principal for the wrong reason: {err}"
    );

    let (writable, who) = dispatch(Access::Write);
    writable
        .authorise(&who, None, UPLOAD)
        .expect("write is enough to upload an LFS object");
    writable
        .authorise(&who, None, DOWNLOAD)
        .expect("write implies read for LFS too");

    // And a principal with no grant reaches neither, so the read half above is
    // a grant being honoured rather than LFS being ungated.
    let plane = Plane::empty().with_the_repo().with_principal("stranger");
    let ungranted = Dispatch::new(plane.control);
    for line in [DOWNLOAD, UPLOAD] {
        let err = ungranted
            .authorise(&principal("stranger"), None, line)
            .expect_err("no grant must reach no LFS operation");
        assert!(
            matches!(&err, DispatchError::Denied { held, .. } if held == "none"),
            "{err}"
        );
    }
}

/// The LFS operation the *caller* names is the one that is checked.
///
/// The failure this excludes: a dispatch that read the requirement from the
/// service's first `OperationSpec` (or from any fixed field) rather than from
/// the operation on the exec line would grant `upload` at `Read`, because
/// `Download` is the row that comes first in `SERVICES`.
#[test]
fn the_lfs_operation_named_on_the_exec_line_is_the_one_that_is_checked() {
    let (read_only, who) = dispatch(Access::Read);

    let allowed = read_only
        .authorise(
            &who,
            None,
            b"git-lfs-transfer nordisk/osm-katana.git download",
        )
        .expect("download is a read");
    assert!(
        matches!(
            allowed.service,
            GitService::LfsTransfer(gunnar_lfs::Operation::Download)
        ),
        "the dispatch carried the wrong operation forward: {:?}",
        allowed.service
    );

    // Same principal, same repository, same verb — only the operation differs.
    read_only
        .authorise(
            &who,
            None,
            b"git-lfs-transfer nordisk/osm-katana.git upload",
        )
        .expect_err("upload must be judged as a write even though download was allowed");
}