crates/gunnar-lfs/src/operation.rs
//! Which direction the whole connection is for.
//!
//! # This is the detail the LFS design did not have, and it changes the dispatch
//!
//! The design said adding LFS is "one match arm". Measured against the real client
//! (`git-lfs/ssh/ssh.go`, `GetLFSExeAndArgs`), the exec line is
//!
//! ```text
//! git-lfs-transfer <path> <operation> operation ∈ { upload, download }
//! ```
//!
//! **Three tokens, and the path is not quoted.** The operation is chosen once,
//! at connection time, and it decides what the whole session may do: `upload`
//! admits `put-object` and `verify-object`, `download` admits `get-object`.
//!
//! So the grant level for `git-lfs-transfer` is **not a constant**, which is
//! what a `ServiceSpec { required: Access }` row can express, and that is why
//! `gunnar_ssh::command::Requirement` gained a second variant. The requirement
//! still travels in the table, one row listing both operations and the level
//! each needs, so no dispatch anywhere matches on which verb it got.
/// The `<operation>` argument of `git-lfs-transfer`.
///
/// Only these two exist. The specification says "other operations may be
/// implemented in the future", which on a server means *refuse the ones you do
/// not know*: a third operation accepted by accident would be a session whose
/// permitted commands nobody decided.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Operation {
/// `download`: the client is fetching objects. Needs Read.
Download,
/// `upload`: the client is pushing objects. Needs Write.
Upload,
}
impl Operation {
/// Every operation, in wire order. The dispatch table in `gunnar-ssh` is
/// asserted against this so the two lists cannot drift.
pub const ALL: &'static [Operation] = &[Operation::Download, Operation::Upload];
/// The wire token.
pub fn as_str(self) -> &'static str {
match self {
Operation::Download => "download",
Operation::Upload => "upload",
}
}
/// Parse the wire token, or `None`.
pub fn parse(text: &str) -> Option<Operation> {
Operation::ALL
.iter()
.copied()
.find(|op| op.as_str() == text)
}
/// May this session upload objects?
pub fn may_upload(self) -> bool {
matches!(self, Operation::Upload)
}
/// May this session download objects?
///
/// **`upload` does not imply `download`.** The specification lists
/// `get-object` under *Downloads* and says those commands "may be used if
/// the operation was `download`". An upload session that also served objects
/// would be a read grant handed out by a write one. Small, but exactly the
/// wrong direction.
pub fn may_download(self) -> bool {
matches!(self, Operation::Download)
}
}
impl std::fmt::Display for Operation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_two_operations_parse() {
assert_eq!(Operation::parse("upload"), Some(Operation::Upload));
assert_eq!(Operation::parse("download"), Some(Operation::Download));
for bad in ["", "Upload", "UPLOAD", "upload ", " upload", "lock", "quit"] {
assert_eq!(Operation::parse(bad), None, "{bad:?} must not parse");
}
}
#[test]
fn the_directions_do_not_imply_each_other() {
assert!(Operation::Upload.may_upload());
assert!(!Operation::Upload.may_download());
assert!(Operation::Download.may_download());
// The one that would be a silent read grant inside a write session.
assert!(!Operation::Download.may_upload());
}
#[test]
fn all_is_complete() {
for op in Operation::ALL {
assert_eq!(Operation::parse(op.as_str()), Some(*op));
}
assert_eq!(Operation::ALL.len(), 2);
}
}