Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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);
    }
}