use gunnar_core::{PathReject, RepoRef};
pub use gunnar_lfs::Operation;
use crate::access::Access;
pub const MAX_EXEC_LEN: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum GitService {
UploadPack,
ReceivePack,
LfsTransfer(Operation),
}
impl GitService {
pub fn verb(self) -> &'static str {
row_for(self).verb
}
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
}
}
}
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(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum Requirement {
Fixed(Access),
PerOperation(&'static [OperationSpec]),
}
#[derive(Debug, Clone, Copy)]
pub struct OperationSpec {
pub operation: Operation,
pub required: Access,
}
impl OperationSpec {
fn service(self) -> GitService {
GitService::LfsTransfer(self.operation)
}
}
#[derive(Debug, Clone, Copy)]
pub struct ServiceSpec {
pub verb: &'static str,
pub service: GitService,
pub required: Requirement,
}
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),
},
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,
},
]),
},
];
pub const NOT_SERVED: &[(&str, &str)] = &[(
"git-upload-archive",
"upload-archive is not served; clone and archive locally",
)];
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")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommand {
pub service: GitService,
pub required: Access,
pub repo: RepoRef,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CommandError {
#[error("empty exec request")]
Empty,
#[error("exec request is {len} bytes, over the {max}-byte limit")]
TooLong {
len: usize,
max: usize,
},
#[error("exec request contains a control byte (0x{byte:02x}) at offset {at}")]
ControlByte {
byte: u8,
at: usize,
},
#[error("exec request is not valid UTF-8")]
NotUtf8,
#[error("`{0}` is not a git service; gunnar has no shell")]
UnknownCommand(String),
#[error("`{verb}` is not served: {why}")]
NotServed {
verb: &'static str,
why: &'static str,
},
#[error("`{0}` was sent with no repository path")]
MissingPath(&'static str),
#[error("`{verb}` requires a trailing operation, one of {accepted}")]
MissingOperation {
verb: &'static str,
accepted: String,
},
#[error("`{verb}` does not accept the operation `{operation}`; it accepts {accepted}")]
UnknownOperation {
verb: &'static str,
operation: String,
accepted: String,
},
#[error("malformed quoting in exec request: {0}")]
BadQuoting(&'static str),
#[error("{message}")]
Path {
reason: Option<PathReject>,
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(),
}
}
}
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,
});
}
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));
}
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)
}
};
let path = unquote(path_text)?;
let repo = gunnar_core::resolve_repo_path(&path)?;
Ok(GitCommand {
service,
required,
repo,
})
}
pub(crate) fn unquote(arg: &str) -> Result<String, CommandError> {
if !arg.starts_with('\'') {
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",
));
}
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() {
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
);
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
);
}
#[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());
}
#[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() {
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());
}
}