Skip to main content

gunnar_lfs/
operation.rs

1//! Which direction the whole connection is for.
2//!
3//! # This is the detail the LFS design did not have, and it changes the dispatch
4//!
5//! The design said adding LFS is "one match arm". Measured against the real client
6//! (`git-lfs/ssh/ssh.go`, `GetLFSExeAndArgs`), the exec line is
7//!
8//! ```text
9//! git-lfs-transfer <path> <operation>        operation ∈ { upload, download }
10//! ```
11//!
12//! **Three tokens, and the path is not quoted.** The operation is chosen once,
13//! at connection time, and it decides what the whole session may do: `upload`
14//! admits `put-object` and `verify-object`, `download` admits `get-object`.
15//!
16//! So the grant level for `git-lfs-transfer` is **not a constant**, which is
17//! what a `ServiceSpec { required: Access }` row can express, and that is why
18//! `gunnar_ssh::command::Requirement` gained a second variant. The requirement
19//! still travels in the table, one row listing both operations and the level
20//! each needs, so no dispatch anywhere matches on which verb it got.
21
22/// The `<operation>` argument of `git-lfs-transfer`.
23///
24/// Only these two exist. The specification says "other operations may be
25/// implemented in the future", which on a server means *refuse the ones you do
26/// not know*: a third operation accepted by accident would be a session whose
27/// permitted commands nobody decided.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum Operation {
30    /// `download`: the client is fetching objects. Needs Read.
31    Download,
32    /// `upload`: the client is pushing objects. Needs Write.
33    Upload,
34}
35
36impl Operation {
37    /// Every operation, in wire order. The dispatch table in `gunnar-ssh` is
38    /// asserted against this so the two lists cannot drift.
39    pub const ALL: &'static [Operation] = &[Operation::Download, Operation::Upload];
40
41    /// The wire token.
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Operation::Download => "download",
45            Operation::Upload => "upload",
46        }
47    }
48
49    /// Parse the wire token, or `None`.
50    pub fn parse(text: &str) -> Option<Operation> {
51        Operation::ALL
52            .iter()
53            .copied()
54            .find(|op| op.as_str() == text)
55    }
56
57    /// May this session upload objects?
58    pub fn may_upload(self) -> bool {
59        matches!(self, Operation::Upload)
60    }
61
62    /// May this session download objects?
63    ///
64    /// **`upload` does not imply `download`.** The specification lists
65    /// `get-object` under *Downloads* and says those commands "may be used if
66    /// the operation was `download`". An upload session that also served objects
67    /// would be a read grant handed out by a write one. Small, but exactly the
68    /// wrong direction.
69    pub fn may_download(self) -> bool {
70        matches!(self, Operation::Download)
71    }
72}
73
74impl std::fmt::Display for Operation {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn only_the_two_operations_parse() {
86        assert_eq!(Operation::parse("upload"), Some(Operation::Upload));
87        assert_eq!(Operation::parse("download"), Some(Operation::Download));
88        for bad in ["", "Upload", "UPLOAD", "upload ", " upload", "lock", "quit"] {
89            assert_eq!(Operation::parse(bad), None, "{bad:?} must not parse");
90        }
91    }
92
93    #[test]
94    fn the_directions_do_not_imply_each_other() {
95        assert!(Operation::Upload.may_upload());
96        assert!(!Operation::Upload.may_download());
97        assert!(Operation::Download.may_download());
98        // The one that would be a silent read grant inside a write session.
99        assert!(!Operation::Download.may_upload());
100    }
101
102    #[test]
103    fn all_is_complete() {
104        for op in Operation::ALL {
105            assert_eq!(Operation::parse(op.as_str()), Some(*op));
106        }
107        assert_eq!(Operation::ALL.len(), 2);
108    }
109}