git_internal/protocol/
ssh.rs

1//! SSH transport adapter for the Git smart protocol. Wraps the shared `GitProtocol` core with
2//! helpers for authenticating connections, parsing SSH commands, and serving upload/receive-pack
3//! requests over interactive channels.
4
5use super::{
6    core::{AuthenticationService, GitProtocol, RepositoryAccess},
7    types::{ProtocolError, ProtocolStream},
8};
9
10/// SSH Git protocol handler
11pub struct SshGitHandler<R: RepositoryAccess, A: AuthenticationService> {
12    protocol: GitProtocol<R, A>,
13}
14
15impl<R: RepositoryAccess, A: AuthenticationService> SshGitHandler<R, A> {
16    /// Create a new SSH Git handler
17    pub fn new(repo_access: R, auth_service: A) -> Self {
18        let mut protocol = GitProtocol::new(repo_access, auth_service);
19        protocol.set_transport(super::types::TransportProtocol::Ssh);
20        Self { protocol }
21    }
22
23    /// Authenticate SSH session using username and public key
24    /// Call this once after SSH handshake, before running Git commands
25    pub async fn authenticate_ssh(
26        &self,
27        username: &str,
28        public_key: &[u8],
29    ) -> Result<(), ProtocolError> {
30        self.protocol.authenticate_ssh(username, public_key).await
31    }
32
33    /// Handle git-upload-pack command (for clone/fetch)
34    pub async fn handle_upload_pack(
35        &mut self,
36        request_data: &[u8],
37    ) -> Result<ProtocolStream, ProtocolError> {
38        self.protocol.upload_pack(request_data).await
39    }
40
41    /// Handle git-receive-pack command (for push)
42    pub async fn handle_receive_pack(
43        &mut self,
44        request_stream: ProtocolStream,
45    ) -> Result<ProtocolStream, ProtocolError> {
46        self.protocol.receive_pack(request_stream).await
47    }
48
49    /// Handle info/refs request for SSH
50    pub async fn handle_info_refs(&mut self, service: &str) -> Result<Vec<u8>, ProtocolError> {
51        self.protocol.info_refs(service).await
52    }
53}
54
55/// SSH-specific utility functions
56/// Parse SSH command line into command and arguments
57pub fn parse_ssh_command(command_line: &str) -> Option<(String, Vec<String>)> {
58    let parts: Vec<&str> = command_line.split_whitespace().collect();
59    if parts.is_empty() {
60        return None;
61    }
62
63    let command = parts[0].to_string();
64    let args = parts[1..].iter().map(|s| s.to_string()).collect();
65
66    Some((command, args))
67}
68
69/// Check if command is a valid Git SSH command
70pub fn is_git_ssh_command(command: &str) -> bool {
71    matches!(command, "git-upload-pack" | "git-receive-pack")
72}
73
74/// Extract repository path from SSH command arguments
75pub fn extract_repo_path_from_args(args: &[String]) -> Option<&str> {
76    args.first().map(|s| s.as_str())
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    /// parse_ssh_command should split the command and arguments when present.
84    #[test]
85    fn parse_command_with_args() {
86        let input = "git-upload-pack /repos/demo.git";
87        let parsed = parse_ssh_command(input).expect("command should parse");
88        assert_eq!(parsed.0, "git-upload-pack");
89        assert_eq!(parsed.1, vec!["/repos/demo.git".to_string()]);
90    }
91
92    /// parse_ssh_command should return None for empty input.
93    #[test]
94    fn parse_command_empty_returns_none() {
95        assert!(parse_ssh_command("").is_none());
96        assert!(parse_ssh_command("   ").is_none());
97    }
98
99    /// is_git_ssh_command identifies upload-pack and receive-pack only.
100    #[test]
101    fn validate_git_ssh_commands() {
102        assert!(is_git_ssh_command("git-upload-pack"));
103        assert!(is_git_ssh_command("git-receive-pack"));
104        assert!(!is_git_ssh_command("git-upload-archive"));
105        assert!(!is_git_ssh_command("other"));
106    }
107
108    /// extract_repo_path_from_args returns the first argument if present.
109    #[test]
110    fn extract_repo_path_from_first_arg() {
111        let args = vec!["/repos/demo.git".to_string(), "--stateless-rpc".to_string()];
112        assert_eq!(extract_repo_path_from_args(&args), Some("/repos/demo.git"));
113        let empty: Vec<String> = vec![];
114        assert_eq!(extract_repo_path_from_args(&empty), None);
115    }
116}