Skip to main content

gunnar_ssh/
command.rs

1//! The exec parser (the ACL model), the most security-critical code in this crate.
2//!
3//! An SSH `exec` request carries an arbitrary byte string that a normal server
4//! hands to a shell. gunnar hands it to [`parse`], which either produces one of
5//! the verbs in [`SERVICES`] with a validated [`RepoPath`], or refuses. There is
6//! no fallthrough, no shell, and no process.
7//!
8//! # The third command landed, and it was one table row (LFS over SSH)
9//!
10//! git-lfs 3.0's pure-SSH transfer is here. The dispatch was written so adding
11//! it would be a row in [`SERVICES`] and a variant of [`GitService`] rather than
12//! a refactor, and that held: nothing in this module or in [`crate::server`]
13//! matches on the *set* of services, the required grant level travels in the
14//! table, and the dispatch compares levels rather than matching on which verb it
15//! got.
16//!
17//! **One thing did have to change, and it is the verification the LFS-over-SSH design asked
18//! for.** Measured against the real git-lfs client on 2026-08-03
19//! (`git-lfs/ssh/ssh.go`, `GetLFSExeAndArgs`), the exec line is
20//!
21//! ```text
22//! args = append(args, fmt.Sprintf("%s %s %s", command, meta.Path, operation))
23//! ```
24//!
25//! i.e. `git-lfs-transfer <path> <operation>`, **three tokens, and the path is not
26//! quoted at all**. `<operation>` is `upload` or `download`, it is chosen once
27//! per connection, and it decides the grant: upload needs Write, download needs
28//! Read. A `ServiceSpec { required: Access }` cannot say that, so the field
29//! became [`Requirement`], which is either a fixed level or a list of operations
30//! with a level each. The requirement still travels in the table. There is still
31//! no second authorisation path.
32//!
33//! # What is accepted
34//!
35//! ```text
36//! git-upload-pack  '<path>'                needs Read
37//! git-receive-pack '<path>'                needs Write
38//! git-lfs-transfer <path> download         needs Read
39//! git-lfs-transfer <path> upload           needs Write
40//! ```
41//!
42//! `<path>` may be single-quoted in git's `sq_quote` style (including the
43//! `'\''` escape for an embedded quote), or bare. Everything else is refused:
44//! any other verb, a missing path, an unknown operation, a control byte or NUL
45//! anywhere, an unterminated quote, trailing bytes after the closing quote, and
46//! anything over [`MAX_EXEC_LEN`].
47//!
48//! # Unquoting lives HERE, and the sandbox lives in `gunnar-core`
49//!
50//! git sends **one argv element with the quotes inside it**. There is no shell
51//! on gunnar's side to take them off. Measured against git 2.53.0 on 2026-08-03
52//! with a `GIT_SSH_COMMAND` that echoes its arguments:
53//!
54//! ```text
55//! ssh://git@host/ns/repo.git   →   git-upload-pack '/ns/repo.git'
56//! git@host:ns/repo.git         →   git-upload-pack 'ns/repo.git'
57//! ```
58//!
59//! Stripping that quoting is a **transport** concern, so [`unquote`] does it
60//! here and [`gunnar_core::resolve_repo_path`], the one path sandbox, is
61//! handed an already-unquoted string and does not know SSH exists. It refuses a
62//! quote as the forbidden character it is, which is also the red half of this
63//! module: if [`unquote`] ever stopped working, paths would be refused rather
64//! than silently resolving somewhere.
65//!
66//! This crate used to carry its own sandbox, `gunnar_ssh::path::RepoPath`, and
67//! it **disagreed** with `gunnar-core`'s: it demanded exactly two components
68//! where the other permits nested namespaces. It is deleted. Nested
69//! namespaces are legal (`team/sub/repo.git` is namespace `team/sub`), and the
70//! escalation that would otherwise imply is closed by grants not inheriting.
71
72use gunnar_core::{PathReject, RepoRef};
73pub use gunnar_lfs::Operation;
74
75use crate::access::Access;
76
77/// Longest exec request accepted, in bytes.
78///
79/// The longest legal request is a verb plus a quoted [`crate::path::MAX_PATH_LEN`]
80/// path plus quoting, comfortably under this. Anything larger is refused before
81/// it is looked at, so a client cannot make the parser do work by sending a
82/// megabyte.
83pub const MAX_EXEC_LEN: usize = 512;
84
85/// Which git service an exec request selects.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
87pub enum GitService {
88    /// Serving fetch and clone: protocol v2 with a v0 fallback (the git wire protocol).
89    UploadPack,
90    /// Serving push (the git wire protocol).
91    ReceivePack,
92    /// Serving git-lfs 3.0's pure-SSH transfer (LFS over SSH).
93    ///
94    /// The [`Operation`] is part of the exec line, not of a later handshake, so
95    /// it is part of the identity of the service the client asked for, and
96    /// therefore of the grant level it needs.
97    LfsTransfer(Operation),
98}
99
100impl GitService {
101    /// The wire verb, e.g. `git-upload-pack`.
102    pub fn verb(self) -> &'static str {
103        row_for(self).verb
104    }
105
106    /// The grant level this service requires.
107    pub fn required_access(self) -> Access {
108        let row = row_for(self);
109        match row.required {
110            Requirement::Fixed(level) => level,
111            Requirement::PerOperation(ops) => {
112                ops.iter()
113                    .find(|spec| spec.service() == self)
114                    .expect("every operation has a row in its service's table")
115                    .required
116            }
117        }
118    }
119
120    /// The `<operation>` this service was asked for, if its verb takes one.
121    pub fn operation(self) -> Option<Operation> {
122        match self {
123            GitService::LfsTransfer(operation) => Some(operation),
124            _ => None,
125        }
126    }
127}
128
129impl std::fmt::Display for GitService {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.write_str(self.verb())?;
132        if let Some(operation) = self.operation() {
133            write!(f, " {operation}")?;
134        }
135        Ok(())
136    }
137}
138
139/// How a row's grant level is arrived at.
140///
141/// Two variants because the git-lfs exec line carries an `<operation>` that
142/// stock git's two verbs do not. Folding LFS into [`Requirement::Fixed`] would
143/// mean picking one level for both directions: `Read` would let a reader upload,
144/// `Write` would stop a reader fetching. Neither is the answer, and neither
145/// failure would show up in a test that only checked *some* level was demanded.
146#[derive(Debug, Clone, Copy)]
147pub enum Requirement {
148    /// The whole argument is the path, and the level does not vary.
149    Fixed(Access),
150    /// The argument is `<path> <operation>`, and each accepted operation names
151    /// its own level. Anything not in this list is [`CommandError::UnknownOperation`].
152    PerOperation(&'static [OperationSpec]),
153}
154
155/// One accepted `<operation>` of a verb that takes one.
156#[derive(Debug, Clone, Copy)]
157pub struct OperationSpec {
158    /// The operation itself. Its wire token is [`Operation::as_str`], so the
159    /// table cannot spell it differently from the protocol that answers it.
160    pub operation: Operation,
161    /// The grant level this operation requires.
162    pub required: Access,
163}
164
165impl OperationSpec {
166    /// The service value this operation selects.
167    fn service(self) -> GitService {
168        GitService::LfsTransfer(self.operation)
169    }
170}
171
172/// One row of the dispatch table.
173#[derive(Debug, Clone, Copy)]
174pub struct ServiceSpec {
175    /// The exact verb a client must send.
176    pub verb: &'static str,
177    /// The service it selects. For a [`Requirement::PerOperation`] row this is a
178    /// representative value: rows are identified by variant, and the operation
179    /// comes from the exec line.
180    pub service: GitService,
181    /// The grant level required before the stream is handed over, and how it is
182    /// arrived at.
183    pub required: Requirement,
184}
185
186/// **The whole dispatch table.** Adding a command is adding a row here.
187pub const SERVICES: &[ServiceSpec] = &[
188    ServiceSpec {
189        verb: "git-upload-pack",
190        service: GitService::UploadPack,
191        required: Requirement::Fixed(Access::Read),
192    },
193    ServiceSpec {
194        verb: "git-receive-pack",
195        service: GitService::ReceivePack,
196        required: Requirement::Fixed(Access::Write),
197    },
198    // LFS over SSH, verified against the git-lfs specification and against the real
199    // client on 2026-08-03. The verb is `gunnar_lfs::TRANSFER_VERB` rather than
200    // a literal so this row and the protocol that answers it cannot drift.
201    ServiceSpec {
202        verb: gunnar_lfs::TRANSFER_VERB,
203        service: GitService::LfsTransfer(Operation::Download),
204        required: Requirement::PerOperation(&[
205            OperationSpec {
206                operation: Operation::Download,
207                required: Access::Read,
208            },
209            OperationSpec {
210                operation: Operation::Upload,
211                required: Access::Write,
212            },
213        ]),
214    },
215];
216
217/// Git verbs gunnar recognises and **does not serve**, each with the sentence
218/// the client is told. A verb here is answered in git's own protocol (an `ERR`
219/// packet and a clean exit), not with a channel failure; a verb in neither
220/// table is the `bash` case and gets the channel failure.
221pub const NOT_SERVED: &[(&str, &str)] = &[(
222    "git-upload-archive",
223    "upload-archive is not served; clone and archive locally",
224)];
225
226/// The table row for a service, found by **variant** rather than by value.
227///
228/// `GitService::LfsTransfer` carries its operation, so comparing values would
229/// need one row per operation and would put the same verb in the table twice.
230fn row_for(service: GitService) -> &'static ServiceSpec {
231    SERVICES
232        .iter()
233        .find(|spec| std::mem::discriminant(&spec.service) == std::mem::discriminant(&service))
234        .expect("every GitService variant has a row in SERVICES")
235}
236
237/// A parsed, validated exec request.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct GitCommand {
240    /// Which service was asked for.
241    pub service: GitService,
242    /// The grant level it requires, carried so the dispatch never has to
243    /// re-derive it.
244    pub required: Access,
245    /// The repository, as a validated namespace and name, never a filesystem
246    /// path. See [`gunnar_core::sandbox`].
247    pub repo: RepoRef,
248}
249
250/// Why an exec request was refused.
251///
252/// Every variant means the same thing on the wire (`channel_failure`, no
253/// shell), but they are distinguished so the server log says which one happened.
254#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
255pub enum CommandError {
256    /// Nothing was sent.
257    #[error("empty exec request")]
258    Empty,
259    /// Over [`MAX_EXEC_LEN`].
260    #[error("exec request is {len} bytes, over the {max}-byte limit")]
261    TooLong {
262        /// What arrived.
263        len: usize,
264        /// What is allowed.
265        max: usize,
266    },
267    /// A NUL or other control byte was present.
268    #[error("exec request contains a control byte (0x{byte:02x}) at offset {at}")]
269    ControlByte {
270        /// The offending byte.
271        byte: u8,
272        /// Where it was.
273        at: usize,
274    },
275    /// Not UTF-8.
276    #[error("exec request is not valid UTF-8")]
277    NotUtf8,
278    /// The verb is not one of [`SERVICES`]. **This is the `bash` case.**
279    #[error("`{0}` is not a git service; gunnar has no shell")]
280    UnknownCommand(String),
281    /// A git verb gunnar knows the name of and does not serve, **refused by
282    /// name** so the client prints a sentence rather than `exec request
283    /// failed on channel 0`. Today that is `git-upload-archive`
284    /// (`git archive --remote=`): it is not a transfer, the archive is a
285    /// local operation on a clone, and answering it would be a second read
286    /// path beside upload-pack. The site test measured the bare channel
287    /// failure on 2026-08-30 and asked for the sentence.
288    #[error("`{verb}` is not served: {why}")]
289    NotServed {
290        /// Which verb.
291        verb: &'static str,
292        /// The sentence the client is owed.
293        why: &'static str,
294    },
295    /// A known verb with no argument.
296    #[error("`{0}` was sent with no repository path")]
297    MissingPath(&'static str),
298    /// A verb that takes an `<operation>` was sent without one.
299    #[error("`{verb}` requires a trailing operation, one of {accepted}")]
300    MissingOperation {
301        /// Which verb.
302        verb: &'static str,
303        /// What it would have accepted, comma-separated.
304        accepted: String,
305    },
306    /// A verb that takes an `<operation>` was sent one it does not accept.
307    ///
308    /// **The offending token is carried escaped**, because it came off the wire
309    /// and goes into a log line.
310    #[error("`{verb}` does not accept the operation `{operation}`; it accepts {accepted}")]
311    UnknownOperation {
312        /// Which verb.
313        verb: &'static str,
314        /// What arrived, with control bytes already refused upstream.
315        operation: String,
316        /// What it would have accepted, comma-separated.
317        accepted: String,
318    },
319    /// The quoting was malformed: unterminated, or trailing bytes after the
320    /// closing quote.
321    #[error("malformed quoting in exec request: {0}")]
322    BadQuoting(&'static str),
323    /// The path did not survive the sandbox in
324    /// [`gunnar_core::resolve_repo_path`].
325    ///
326    /// The rendered refusal is carried rather than the `gunnar_core::Error`
327    /// itself, which is neither `Clone` nor `PartialEq`, and `reason` is kept
328    /// alongside it so a test can assert **which rule fired** rather than merely
329    /// that something failed. A path refused for the wrong reason is one
330    /// refactor away from being accepted.
331    #[error("{message}")]
332    Path {
333        /// Which sandbox rule fired, when the sandbox named one.
334        reason: Option<PathReject>,
335        /// The rendered refusal, with the offending input already escaped for
336        /// safe logging by `gunnar-core`.
337        message: String,
338    },
339}
340
341impl From<gunnar_core::Error> for CommandError {
342    fn from(err: gunnar_core::Error) -> Self {
343        CommandError::Path {
344            reason: err.path_reject(),
345            message: err.to_string(),
346        }
347    }
348}
349
350/// Parse an exec request. See the module documentation.
351pub fn parse(raw: &[u8]) -> Result<GitCommand, CommandError> {
352    if raw.is_empty() {
353        return Err(CommandError::Empty);
354    }
355    if raw.len() > MAX_EXEC_LEN {
356        return Err(CommandError::TooLong {
357            len: raw.len(),
358            max: MAX_EXEC_LEN,
359        });
360    }
361    // Before anything is interpreted: no NUL, no control bytes, no DEL. A NUL
362    // that survived into a later comparison is how a check and the thing it
363    // checks stop looking at the same string.
364    if let Some((at, &byte)) = raw
365        .iter()
366        .enumerate()
367        .find(|(_, b)| **b < 0x20 || **b == 0x7f)
368    {
369        return Err(CommandError::ControlByte { byte, at });
370    }
371    let text = std::str::from_utf8(raw).map_err(|_| CommandError::NotUtf8)?;
372
373    let (verb, rest) = match text.split_once(' ') {
374        Some((verb, rest)) => (verb, rest),
375        None => (text, ""),
376    };
377
378    let Some(spec) = SERVICES.iter().find(|spec| spec.verb == verb) else {
379        if let Some(row) = NOT_SERVED.iter().find(|row| row.0 == verb) {
380            return Err(CommandError::NotServed {
381                verb: row.0,
382                why: row.1,
383            });
384        }
385        return Err(CommandError::UnknownCommand(verb.to_string()));
386    };
387    if rest.is_empty() {
388        return Err(CommandError::MissingPath(spec.verb));
389    }
390
391    // Where the argument stops being a path. For the two git verbs it never
392    // does; for `git-lfs-transfer` the LAST space separates the path from the
393    // operation, and it is the last rather than the first because the operation
394    // is a fixed token at the end while the path is whatever came before it.
395    let (path_text, service, required) = match spec.required {
396        Requirement::Fixed(level) => (rest, spec.service, level),
397        Requirement::PerOperation(ops) => {
398            let accepted = || {
399                ops.iter()
400                    .map(|op| op.operation.as_str())
401                    .collect::<Vec<_>>()
402                    .join(", ")
403            };
404            let Some((path, operation)) = rest.rsplit_once(' ') else {
405                return Err(CommandError::MissingOperation {
406                    verb: spec.verb,
407                    accepted: accepted(),
408                });
409            };
410            let Some(matched) = Operation::parse(operation)
411                .and_then(|parsed| ops.iter().find(|op| op.operation == parsed))
412            else {
413                return Err(CommandError::UnknownOperation {
414                    verb: spec.verb,
415                    operation: operation.to_string(),
416                    accepted: accepted(),
417                });
418            };
419            if path.is_empty() {
420                return Err(CommandError::MissingPath(spec.verb));
421            }
422            (path, matched.service(), matched.required)
423        }
424    };
425
426    // Unquote here, sandbox there. The sandbox is handed a bare string and has
427    // no opinion about SSH; see the module docs. git-lfs sends the path
428    // UNQUOTED, which this handles by the same route a bare git path takes:
429    // `unquote` passes a token with no leading quote straight through, and the
430    // sandbox's character allowlist is what refuses anything odd in it.
431    let path = unquote(path_text)?;
432    let repo = gunnar_core::resolve_repo_path(&path)?;
433    Ok(GitCommand {
434        service,
435        required,
436        repo,
437    })
438}
439
440/// Undo git's `sq_quote`, or pass a bare token through.
441///
442/// git quotes the path with single quotes and escapes an embedded `'` as
443/// `'\''`: closing the quote, emitting an escaped quote, reopening. Anything
444/// else inside the quotes is literal.
445///
446/// This is the **only** unquoter in gunnar. `gunnar_core::resolve_repo_path`
447/// used to strip a surrounding quote pair as well, which was a second and weaker
448/// copy of this rule sitting inside the security boundary; it no longer does
449///
450pub(crate) fn unquote(arg: &str) -> Result<String, CommandError> {
451    if !arg.starts_with('\'') {
452        // Bare form. No unquoting to do; the character allowlist in the sandbox
453        // is what refuses whitespace and metacharacters, so there is no second,
454        // weaker copy of that rule here (one implementation). Note that this means a
455        // *double*-quoted path is passed through with its quotes, and the
456        // sandbox then refuses `"` as a forbidden character, which is correct:
457        // git never sends double quotes.
458        return Ok(arg.to_string());
459    }
460
461    let bytes = arg.as_bytes();
462    let mut out = String::with_capacity(arg.len());
463    let mut i = 1;
464    loop {
465        if i >= bytes.len() {
466            return Err(CommandError::BadQuoting("unterminated single quote"));
467        }
468        if bytes[i] == b'\'' {
469            if i + 1 == bytes.len() {
470                return Ok(out);
471            }
472            if arg[i..].starts_with("'\\''") {
473                out.push('\'');
474                i += 4;
475                continue;
476            }
477            return Err(CommandError::BadQuoting(
478                "bytes after the closing single quote",
479            ));
480        }
481        // `arg` is valid UTF-8 and we only ever index at a byte we matched as
482        // ASCII, so the remainder always starts on a character boundary.
483        let ch = arg[i..]
484            .chars()
485            .next()
486            .ok_or(CommandError::BadQuoting("unterminated single quote"))?;
487        out.push(ch);
488        i += ch.len_utf8();
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn every_service_variant_has_exactly_one_row() {
498        // `row_for` panics if a variant is missing a row, and a duplicated verb
499        // would make the table's meaning depend on ordering. Both would be
500        // invisible in any parse test.
501        for spec in SERVICES {
502            assert_eq!(row_for(spec.service).verb, spec.verb);
503            assert_eq!(
504                SERVICES.iter().filter(|s| s.verb == spec.verb).count(),
505                1,
506                "duplicate verb {:?}",
507                spec.verb
508            );
509            // And every per-operation row resolves a level for each of its
510            // operations. Without this, an `OperationSpec` list that had gained
511            // an entry `required_access` could not find would panic at runtime
512            // on a real connection rather than here.
513            if let Requirement::PerOperation(ops) = spec.required {
514                for op in ops {
515                    let _ = op.service().required_access();
516                }
517            }
518        }
519        assert_eq!(GitService::UploadPack.required_access(), Access::Read);
520        assert_eq!(GitService::ReceivePack.required_access(), Access::Write);
521        assert_eq!(
522            GitService::LfsTransfer(Operation::Download).required_access(),
523            Access::Read
524        );
525        assert_eq!(
526            GitService::LfsTransfer(Operation::Upload).required_access(),
527            Access::Write
528        );
529    }
530
531    /// The two lists that could drift: `gunnar-lfs` knows which operations the
532    /// protocol has, this table knows which ones the dispatch admits. An
533    /// operation added to one and not the other is a session gunnar would open
534    /// and then not know how to serve, or one it would refuse for no reason.
535    #[test]
536    fn the_table_admits_exactly_the_operations_the_protocol_defines() {
537        let row = SERVICES
538            .iter()
539            .find(|spec| spec.verb == gunnar_lfs::TRANSFER_VERB)
540            .expect("the lfs row");
541        let Requirement::PerOperation(ops) = row.required else {
542            panic!("the lfs row must be per-operation; its grant is not a constant");
543        };
544        let admitted: Vec<Operation> = ops.iter().map(|op| op.operation).collect();
545        assert_eq!(admitted, Operation::ALL.to_vec());
546    }
547
548    /// The whole point of the shape: nothing outside the table decides
549    /// what a level is. A row whose level was hard-coded somewhere else would
550    /// pass every parse test and be wrong exactly once.
551    #[test]
552    fn the_required_level_comes_from_the_table_and_nowhere_else() {
553        for spec in SERVICES {
554            match spec.required {
555                Requirement::Fixed(level) => {
556                    assert_eq!(spec.service.required_access(), level);
557                }
558                Requirement::PerOperation(ops) => {
559                    for op in ops {
560                        assert_eq!(op.service().required_access(), op.required);
561                    }
562                }
563            }
564        }
565    }
566
567    #[test]
568    fn sq_quote_escape_round_trips() {
569        // git's own escape. Not reachable through `parse` because `'` is not an
570        // allowed path character, but the unquoter must still get it right;
571        // otherwise it would silently mis-split some future argument.
572        assert_eq!(unquote("'a'\\''b'").unwrap(), "a'b");
573        assert_eq!(unquote("'plain'").unwrap(), "plain");
574        assert!(unquote("'unterminated").is_err());
575        assert!(unquote("'closed' trailing").is_err());
576    }
577}