gunnar-ssh 2.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
//! The exec parser (the ACL model), the most security-critical code in this crate.
//!
//! An SSH `exec` request carries an arbitrary byte string that a normal server
//! hands to a shell. gunnar hands it to [`parse`], which either produces one of
//! the verbs in [`SERVICES`] with a validated [`RepoPath`], or refuses. There is
//! no fallthrough, no shell, and no process.
//!
//! # The third command landed, and it was one table row (LFS over SSH)
//!
//! git-lfs 3.0's pure-SSH transfer is here. The dispatch was written so adding
//! it would be a row in [`SERVICES`] and a variant of [`GitService`] rather than
//! a refactor, and that held: nothing in this module or in [`crate::server`]
//! matches on the *set* of services, the required grant level travels in the
//! table, and the dispatch compares levels rather than matching on which verb it
//! got.
//!
//! **One thing did have to change, and it is the verification the LFS-over-SSH design asked
//! for.** Measured against the real git-lfs client on 2026-08-03
//! (`git-lfs/ssh/ssh.go`, `GetLFSExeAndArgs`), the exec line is
//!
//! ```text
//! args = append(args, fmt.Sprintf("%s %s %s", command, meta.Path, operation))
//! ```
//!
//! i.e. `git-lfs-transfer <path> <operation>`, **three tokens, and the path is not
//! quoted at all**. `<operation>` is `upload` or `download`, it is chosen once
//! per connection, and it decides the grant: upload needs Write, download needs
//! Read. A `ServiceSpec { required: Access }` cannot say that, so the field
//! became [`Requirement`], which is either a fixed level or a list of operations
//! with a level each. The requirement still travels in the table. There is still
//! no second authorisation path.
//!
//! # What is accepted
//!
//! ```text
//! git-upload-pack  '<path>'                needs Read
//! git-receive-pack '<path>'                needs Write
//! git-lfs-transfer <path> download         needs Read
//! git-lfs-transfer <path> upload           needs Write
//! ```
//!
//! `<path>` may be single-quoted in git's `sq_quote` style (including the
//! `'\''` escape for an embedded quote), or bare. Everything else is refused:
//! any other verb, a missing path, an unknown operation, a control byte or NUL
//! anywhere, an unterminated quote, trailing bytes after the closing quote, and
//! anything over [`MAX_EXEC_LEN`].
//!
//! # Unquoting lives HERE, and the sandbox lives in `gunnar-core`
//!
//! git sends **one argv element with the quotes inside it**. There is no shell
//! on gunnar's side to take them off. Measured against git 2.53.0 on 2026-08-03
//! with a `GIT_SSH_COMMAND` that echoes its arguments:
//!
//! ```text
//! ssh://git@host/ns/repo.git   →   git-upload-pack '/ns/repo.git'
//! git@host:ns/repo.git         →   git-upload-pack 'ns/repo.git'
//! ```
//!
//! Stripping that quoting is a **transport** concern, so [`unquote`] does it
//! here and [`gunnar_core::resolve_repo_path`], the one path sandbox, is
//! handed an already-unquoted string and does not know SSH exists. It refuses a
//! quote as the forbidden character it is, which is also the red half of this
//! module: if [`unquote`] ever stopped working, paths would be refused rather
//! than silently resolving somewhere.
//!
//! This crate used to carry its own sandbox, `gunnar_ssh::path::RepoPath`, and
//! it **disagreed** with `gunnar-core`'s: it demanded exactly two components
//! where the other permits nested namespaces. It is deleted. Nested
//! namespaces are legal (`team/sub/repo.git` is namespace `team/sub`), and the
//! escalation that would otherwise imply is closed by grants not inheriting.

use gunnar_core::{PathReject, RepoRef};
pub use gunnar_lfs::Operation;

use crate::access::Access;

/// Longest exec request accepted, in bytes.
///
/// The longest legal request is a verb plus a quoted [`crate::path::MAX_PATH_LEN`]
/// path plus quoting, comfortably under this. Anything larger is refused before
/// it is looked at, so a client cannot make the parser do work by sending a
/// megabyte.
pub const MAX_EXEC_LEN: usize = 512;

/// Which git service an exec request selects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum GitService {
    /// Serving fetch and clone: protocol v2 with a v0 fallback (the git wire protocol).
    UploadPack,
    /// Serving push (the git wire protocol).
    ReceivePack,
    /// Serving git-lfs 3.0's pure-SSH transfer (LFS over SSH).
    ///
    /// The [`Operation`] is part of the exec line, not of a later handshake, so
    /// it is part of the identity of the service the client asked for, and
    /// therefore of the grant level it needs.
    LfsTransfer(Operation),
}

impl GitService {
    /// The wire verb, e.g. `git-upload-pack`.
    pub fn verb(self) -> &'static str {
        row_for(self).verb
    }

    /// The grant level this service requires.
    pub fn required_access(self) -> Access {
        let row = row_for(self);
        match row.required {
            Requirement::Fixed(level) => level,
            Requirement::PerOperation(ops) => {
                ops.iter()
                    .find(|spec| spec.service() == self)
                    .expect("every operation has a row in its service's table")
                    .required
            }
        }
    }

    /// The `<operation>` this service was asked for, if its verb takes one.
    pub fn operation(self) -> Option<Operation> {
        match self {
            GitService::LfsTransfer(operation) => Some(operation),
            _ => None,
        }
    }
}

impl std::fmt::Display for GitService {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.verb())?;
        if let Some(operation) = self.operation() {
            write!(f, " {operation}")?;
        }
        Ok(())
    }
}

/// How a row's grant level is arrived at.
///
/// Two variants because the git-lfs exec line carries an `<operation>` that
/// stock git's two verbs do not. Folding LFS into [`Requirement::Fixed`] would
/// mean picking one level for both directions: `Read` would let a reader upload,
/// `Write` would stop a reader fetching. Neither is the answer, and neither
/// failure would show up in a test that only checked *some* level was demanded.
#[derive(Debug, Clone, Copy)]
pub enum Requirement {
    /// The whole argument is the path, and the level does not vary.
    Fixed(Access),
    /// The argument is `<path> <operation>`, and each accepted operation names
    /// its own level. Anything not in this list is [`CommandError::UnknownOperation`].
    PerOperation(&'static [OperationSpec]),
}

/// One accepted `<operation>` of a verb that takes one.
#[derive(Debug, Clone, Copy)]
pub struct OperationSpec {
    /// The operation itself. Its wire token is [`Operation::as_str`], so the
    /// table cannot spell it differently from the protocol that answers it.
    pub operation: Operation,
    /// The grant level this operation requires.
    pub required: Access,
}

impl OperationSpec {
    /// The service value this operation selects.
    fn service(self) -> GitService {
        GitService::LfsTransfer(self.operation)
    }
}

/// One row of the dispatch table.
#[derive(Debug, Clone, Copy)]
pub struct ServiceSpec {
    /// The exact verb a client must send.
    pub verb: &'static str,
    /// The service it selects. For a [`Requirement::PerOperation`] row this is a
    /// representative value: rows are identified by variant, and the operation
    /// comes from the exec line.
    pub service: GitService,
    /// The grant level required before the stream is handed over, and how it is
    /// arrived at.
    pub required: Requirement,
}

/// **The whole dispatch table.** Adding a command is adding a row here.
pub const SERVICES: &[ServiceSpec] = &[
    ServiceSpec {
        verb: "git-upload-pack",
        service: GitService::UploadPack,
        required: Requirement::Fixed(Access::Read),
    },
    ServiceSpec {
        verb: "git-receive-pack",
        service: GitService::ReceivePack,
        required: Requirement::Fixed(Access::Write),
    },
    // LFS over SSH, verified against the git-lfs specification and against the real
    // client on 2026-08-03. The verb is `gunnar_lfs::TRANSFER_VERB` rather than
    // a literal so this row and the protocol that answers it cannot drift.
    ServiceSpec {
        verb: gunnar_lfs::TRANSFER_VERB,
        service: GitService::LfsTransfer(Operation::Download),
        required: Requirement::PerOperation(&[
            OperationSpec {
                operation: Operation::Download,
                required: Access::Read,
            },
            OperationSpec {
                operation: Operation::Upload,
                required: Access::Write,
            },
        ]),
    },
];

/// Git verbs gunnar recognises and **does not serve**, each with the sentence
/// the client is told. A verb here is answered in git's own protocol (an `ERR`
/// packet and a clean exit), not with a channel failure; a verb in neither
/// table is the `bash` case and gets the channel failure.
pub const NOT_SERVED: &[(&str, &str)] = &[(
    "git-upload-archive",
    "upload-archive is not served; clone and archive locally",
)];

/// The table row for a service, found by **variant** rather than by value.
///
/// `GitService::LfsTransfer` carries its operation, so comparing values would
/// need one row per operation and would put the same verb in the table twice.
fn row_for(service: GitService) -> &'static ServiceSpec {
    SERVICES
        .iter()
        .find(|spec| std::mem::discriminant(&spec.service) == std::mem::discriminant(&service))
        .expect("every GitService variant has a row in SERVICES")
}

/// A parsed, validated exec request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommand {
    /// Which service was asked for.
    pub service: GitService,
    /// The grant level it requires, carried so the dispatch never has to
    /// re-derive it.
    pub required: Access,
    /// The repository, as a validated namespace and name, never a filesystem
    /// path. See [`gunnar_core::sandbox`].
    pub repo: RepoRef,
}

/// Why an exec request was refused.
///
/// Every variant means the same thing on the wire (`channel_failure`, no
/// shell), but they are distinguished so the server log says which one happened.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CommandError {
    /// Nothing was sent.
    #[error("empty exec request")]
    Empty,
    /// Over [`MAX_EXEC_LEN`].
    #[error("exec request is {len} bytes, over the {max}-byte limit")]
    TooLong {
        /// What arrived.
        len: usize,
        /// What is allowed.
        max: usize,
    },
    /// A NUL or other control byte was present.
    #[error("exec request contains a control byte (0x{byte:02x}) at offset {at}")]
    ControlByte {
        /// The offending byte.
        byte: u8,
        /// Where it was.
        at: usize,
    },
    /// Not UTF-8.
    #[error("exec request is not valid UTF-8")]
    NotUtf8,
    /// The verb is not one of [`SERVICES`]. **This is the `bash` case.**
    #[error("`{0}` is not a git service; gunnar has no shell")]
    UnknownCommand(String),
    /// A git verb gunnar knows the name of and does not serve, **refused by
    /// name** so the client prints a sentence rather than `exec request
    /// failed on channel 0`. Today that is `git-upload-archive`
    /// (`git archive --remote=`): it is not a transfer, the archive is a
    /// local operation on a clone, and answering it would be a second read
    /// path beside upload-pack. The site test measured the bare channel
    /// failure on 2026-08-30 and asked for the sentence.
    #[error("`{verb}` is not served: {why}")]
    NotServed {
        /// Which verb.
        verb: &'static str,
        /// The sentence the client is owed.
        why: &'static str,
    },
    /// A known verb with no argument.
    #[error("`{0}` was sent with no repository path")]
    MissingPath(&'static str),
    /// A verb that takes an `<operation>` was sent without one.
    #[error("`{verb}` requires a trailing operation, one of {accepted}")]
    MissingOperation {
        /// Which verb.
        verb: &'static str,
        /// What it would have accepted, comma-separated.
        accepted: String,
    },
    /// A verb that takes an `<operation>` was sent one it does not accept.
    ///
    /// **The offending token is carried escaped**, because it came off the wire
    /// and goes into a log line.
    #[error("`{verb}` does not accept the operation `{operation}`; it accepts {accepted}")]
    UnknownOperation {
        /// Which verb.
        verb: &'static str,
        /// What arrived, with control bytes already refused upstream.
        operation: String,
        /// What it would have accepted, comma-separated.
        accepted: String,
    },
    /// The quoting was malformed: unterminated, or trailing bytes after the
    /// closing quote.
    #[error("malformed quoting in exec request: {0}")]
    BadQuoting(&'static str),
    /// The path did not survive the sandbox in
    /// [`gunnar_core::resolve_repo_path`].
    ///
    /// The rendered refusal is carried rather than the `gunnar_core::Error`
    /// itself, which is neither `Clone` nor `PartialEq`, and `reason` is kept
    /// alongside it so a test can assert **which rule fired** rather than merely
    /// that something failed. A path refused for the wrong reason is one
    /// refactor away from being accepted.
    #[error("{message}")]
    Path {
        /// Which sandbox rule fired, when the sandbox named one.
        reason: Option<PathReject>,
        /// The rendered refusal, with the offending input already escaped for
        /// safe logging by `gunnar-core`.
        message: String,
    },
}

impl From<gunnar_core::Error> for CommandError {
    fn from(err: gunnar_core::Error) -> Self {
        CommandError::Path {
            reason: err.path_reject(),
            message: err.to_string(),
        }
    }
}

/// Parse an exec request. See the module documentation.
pub fn parse(raw: &[u8]) -> Result<GitCommand, CommandError> {
    if raw.is_empty() {
        return Err(CommandError::Empty);
    }
    if raw.len() > MAX_EXEC_LEN {
        return Err(CommandError::TooLong {
            len: raw.len(),
            max: MAX_EXEC_LEN,
        });
    }
    // Before anything is interpreted: no NUL, no control bytes, no DEL. A NUL
    // that survived into a later comparison is how a check and the thing it
    // checks stop looking at the same string.
    if let Some((at, &byte)) = raw
        .iter()
        .enumerate()
        .find(|(_, b)| **b < 0x20 || **b == 0x7f)
    {
        return Err(CommandError::ControlByte { byte, at });
    }
    let text = std::str::from_utf8(raw).map_err(|_| CommandError::NotUtf8)?;

    let (verb, rest) = match text.split_once(' ') {
        Some((verb, rest)) => (verb, rest),
        None => (text, ""),
    };

    let Some(spec) = SERVICES.iter().find(|spec| spec.verb == verb) else {
        if let Some(row) = NOT_SERVED.iter().find(|row| row.0 == verb) {
            return Err(CommandError::NotServed {
                verb: row.0,
                why: row.1,
            });
        }
        return Err(CommandError::UnknownCommand(verb.to_string()));
    };
    if rest.is_empty() {
        return Err(CommandError::MissingPath(spec.verb));
    }

    // Where the argument stops being a path. For the two git verbs it never
    // does; for `git-lfs-transfer` the LAST space separates the path from the
    // operation, and it is the last rather than the first because the operation
    // is a fixed token at the end while the path is whatever came before it.
    let (path_text, service, required) = match spec.required {
        Requirement::Fixed(level) => (rest, spec.service, level),
        Requirement::PerOperation(ops) => {
            let accepted = || {
                ops.iter()
                    .map(|op| op.operation.as_str())
                    .collect::<Vec<_>>()
                    .join(", ")
            };
            let Some((path, operation)) = rest.rsplit_once(' ') else {
                return Err(CommandError::MissingOperation {
                    verb: spec.verb,
                    accepted: accepted(),
                });
            };
            let Some(matched) = Operation::parse(operation)
                .and_then(|parsed| ops.iter().find(|op| op.operation == parsed))
            else {
                return Err(CommandError::UnknownOperation {
                    verb: spec.verb,
                    operation: operation.to_string(),
                    accepted: accepted(),
                });
            };
            if path.is_empty() {
                return Err(CommandError::MissingPath(spec.verb));
            }
            (path, matched.service(), matched.required)
        }
    };

    // Unquote here, sandbox there. The sandbox is handed a bare string and has
    // no opinion about SSH; see the module docs. git-lfs sends the path
    // UNQUOTED, which this handles by the same route a bare git path takes:
    // `unquote` passes a token with no leading quote straight through, and the
    // sandbox's character allowlist is what refuses anything odd in it.
    let path = unquote(path_text)?;
    let repo = gunnar_core::resolve_repo_path(&path)?;
    Ok(GitCommand {
        service,
        required,
        repo,
    })
}

/// Undo git's `sq_quote`, or pass a bare token through.
///
/// git quotes the path with single quotes and escapes an embedded `'` as
/// `'\''`: closing the quote, emitting an escaped quote, reopening. Anything
/// else inside the quotes is literal.
///
/// This is the **only** unquoter in gunnar. `gunnar_core::resolve_repo_path`
/// used to strip a surrounding quote pair as well, which was a second and weaker
/// copy of this rule sitting inside the security boundary; it no longer does
///
pub(crate) fn unquote(arg: &str) -> Result<String, CommandError> {
    if !arg.starts_with('\'') {
        // Bare form. No unquoting to do; the character allowlist in the sandbox
        // is what refuses whitespace and metacharacters, so there is no second,
        // weaker copy of that rule here (one implementation). Note that this means a
        // *double*-quoted path is passed through with its quotes, and the
        // sandbox then refuses `"` as a forbidden character, which is correct:
        // git never sends double quotes.
        return Ok(arg.to_string());
    }

    let bytes = arg.as_bytes();
    let mut out = String::with_capacity(arg.len());
    let mut i = 1;
    loop {
        if i >= bytes.len() {
            return Err(CommandError::BadQuoting("unterminated single quote"));
        }
        if bytes[i] == b'\'' {
            if i + 1 == bytes.len() {
                return Ok(out);
            }
            if arg[i..].starts_with("'\\''") {
                out.push('\'');
                i += 4;
                continue;
            }
            return Err(CommandError::BadQuoting(
                "bytes after the closing single quote",
            ));
        }
        // `arg` is valid UTF-8 and we only ever index at a byte we matched as
        // ASCII, so the remainder always starts on a character boundary.
        let ch = arg[i..]
            .chars()
            .next()
            .ok_or(CommandError::BadQuoting("unterminated single quote"))?;
        out.push(ch);
        i += ch.len_utf8();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn every_service_variant_has_exactly_one_row() {
        // `row_for` panics if a variant is missing a row, and a duplicated verb
        // would make the table's meaning depend on ordering. Both would be
        // invisible in any parse test.
        for spec in SERVICES {
            assert_eq!(row_for(spec.service).verb, spec.verb);
            assert_eq!(
                SERVICES.iter().filter(|s| s.verb == spec.verb).count(),
                1,
                "duplicate verb {:?}",
                spec.verb
            );
            // And every per-operation row resolves a level for each of its
            // operations. Without this, an `OperationSpec` list that had gained
            // an entry `required_access` could not find would panic at runtime
            // on a real connection rather than here.
            if let Requirement::PerOperation(ops) = spec.required {
                for op in ops {
                    let _ = op.service().required_access();
                }
            }
        }
        assert_eq!(GitService::UploadPack.required_access(), Access::Read);
        assert_eq!(GitService::ReceivePack.required_access(), Access::Write);
        assert_eq!(
            GitService::LfsTransfer(Operation::Download).required_access(),
            Access::Read
        );
        assert_eq!(
            GitService::LfsTransfer(Operation::Upload).required_access(),
            Access::Write
        );
    }

    /// The two lists that could drift: `gunnar-lfs` knows which operations the
    /// protocol has, this table knows which ones the dispatch admits. An
    /// operation added to one and not the other is a session gunnar would open
    /// and then not know how to serve, or one it would refuse for no reason.
    #[test]
    fn the_table_admits_exactly_the_operations_the_protocol_defines() {
        let row = SERVICES
            .iter()
            .find(|spec| spec.verb == gunnar_lfs::TRANSFER_VERB)
            .expect("the lfs row");
        let Requirement::PerOperation(ops) = row.required else {
            panic!("the lfs row must be per-operation; its grant is not a constant");
        };
        let admitted: Vec<Operation> = ops.iter().map(|op| op.operation).collect();
        assert_eq!(admitted, Operation::ALL.to_vec());
    }

    /// The whole point of the shape: nothing outside the table decides
    /// what a level is. A row whose level was hard-coded somewhere else would
    /// pass every parse test and be wrong exactly once.
    #[test]
    fn the_required_level_comes_from_the_table_and_nowhere_else() {
        for spec in SERVICES {
            match spec.required {
                Requirement::Fixed(level) => {
                    assert_eq!(spec.service.required_access(), level);
                }
                Requirement::PerOperation(ops) => {
                    for op in ops {
                        assert_eq!(op.service().required_access(), op.required);
                    }
                }
            }
        }
    }

    #[test]
    fn sq_quote_escape_round_trips() {
        // git's own escape. Not reachable through `parse` because `'` is not an
        // allowed path character, but the unquoter must still get it right;
        // otherwise it would silently mis-split some future argument.
        assert_eq!(unquote("'a'\\''b'").unwrap(), "a'b");
        assert_eq!(unquote("'plain'").unwrap(), "plain");
        assert!(unquote("'unterminated").is_err());
        assert!(unquote("'closed' trailing").is_err());
    }
}