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}