Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-ssh/src/command.rs

//! 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());
    }
}