Expand description
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
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
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:
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.
Structs§
- GitCommand
- A parsed, validated exec request.
- Operation
Spec - One accepted
<operation>of a verb that takes one. - Service
Spec - One row of the dispatch table.
Enums§
- Command
Error - Why an exec request was refused.
- GitService
- Which git service an exec request selects.
- Operation
- The
<operation>argument ofgit-lfs-transfer. - Requirement
- How a row’s grant level is arrived at.
Constants§
- MAX_
EXEC_ LEN - Longest exec request accepted, in bytes.
- NOT_
SERVED - 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
ERRpacket and a clean exit), not with a channel failure; a verb in neither table is thebashcase and gets the channel failure. - SERVICES
- The whole dispatch table. Adding a command is adding a row here.
Functions§
- parse
- Parse an exec request. See the module documentation.