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