Skip to main content

max / mnw-cli

6.7 KB · 219 lines History Blame Raw
1 //! Git operation proxy: command parsing and subprocess management.
2 //!
3 //! When a git client connects via SSH (e.g., `git push git@ssh.makenot.work:max/repo.git`),
4 //! the exec_request receives a command like `git-receive-pack 'max/repo.git'`. This module
5 //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped
6 //! through the SSH channel.
7
8 use russh::server::Handle;
9 use russh::ChannelId;
10 use tokio::io::AsyncReadExt;
11 use tokio::process::{Child, Command};
12
13 /// Parse a git exec command into (operation, raw_path).
14 ///
15 /// Git clients send commands like:
16 /// `git-upload-pack '/max/repo.git'`
17 /// `git-receive-pack 'max/repo.git'`
18 ///
19 /// Returns `None` for non-git commands.
20 pub fn parse_git_command(cmd: &str) -> Option<(&str, &str)> {
21 let (operation, rest) = cmd.split_once(' ')?;
22
23 match operation {
24 "git-upload-pack" | "git-receive-pack" | "git-upload-archive" => {}
25 _ => return None,
26 }
27
28 // Strip surrounding quotes (single or double)
29 let path = rest.trim();
30 let path = path
31 .strip_prefix('\'')
32 .and_then(|s| s.strip_suffix('\''))
33 .or_else(|| path.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
34 .unwrap_or(path);
35
36 Some((operation, path))
37 }
38
39 /// Parse a repo path like "max/repo.git" or "/max/repo" into (owner, repo_name).
40 ///
41 /// Strips leading `/` and trailing `.git`.
42 pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
43 let path = path.strip_prefix('/').unwrap_or(path);
44 let (owner, repo_name) = path.split_once('/')?;
45
46 if owner.is_empty() || owner.contains("..") {
47 return None;
48 }
49
50 // Strip trailing .git
51 let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name);
52
53 if repo_name.is_empty() || repo_name.contains("..") || repo_name.contains('/') {
54 return None;
55 }
56
57 Some((owner, repo_name))
58 }
59
60 /// Spawn a git subprocess and wire its I/O through the SSH channel.
61 ///
62 /// Returns the child's stdin handle so the caller can forward SSH data() to it.
63 /// Stdout/stderr forwarding and process cleanup run in background tasks.
64 pub async fn spawn_git_process(
65 git_user: &str,
66 operation: &str,
67 repo_path: &str,
68 channel: ChannelId,
69 handle: Handle,
70 ) -> anyhow::Result<tokio::process::ChildStdin> {
71 let mut child: Child = Command::new("sudo")
72 .args(["-u", git_user, operation, repo_path])
73 .stdin(std::process::Stdio::piped())
74 .stdout(std::process::Stdio::piped())
75 .stderr(std::process::Stdio::piped())
76 .kill_on_drop(true)
77 .spawn()?;
78
79 let stdin = child
80 .stdin
81 .take()
82 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdin"))?;
83 let stdout = child
84 .stdout
85 .take()
86 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdout"))?;
87 let stderr = child
88 .stderr
89 .take()
90 .ok_or_else(|| anyhow::anyhow!("failed to capture child stderr"))?;
91
92 // Forward stdout → SSH channel data
93 let stdout_handle = handle.clone();
94 let stdout_task = tokio::spawn(async move {
95 let mut reader = stdout;
96 let mut buf = [0u8; 32768];
97 loop {
98 match reader.read(&mut buf).await {
99 Ok(0) => break,
100 Ok(n) => {
101 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
102 if stdout_handle.data(channel, data).await.is_err() {
103 break;
104 }
105 }
106 Err(_) => break,
107 }
108 }
109 });
110
111 // Forward stderr → SSH channel extended data (type 1 = stderr)
112 let stderr_handle = handle.clone();
113 let stderr_task = tokio::spawn(async move {
114 let mut reader = stderr;
115 let mut buf = [0u8; 8192];
116 loop {
117 match reader.read(&mut buf).await {
118 Ok(0) => break,
119 Ok(n) => {
120 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
121 if stderr_handle.extended_data(channel, 1, data).await.is_err() {
122 break;
123 }
124 }
125 Err(_) => break,
126 }
127 }
128 });
129
130 // Wait for subprocess to complete, then close the SSH channel
131 tokio::spawn(async move {
132 let _ = stdout_task.await;
133 let _ = stderr_task.await;
134
135 let exit_code = match child.wait().await {
136 Ok(status) => status.code().unwrap_or(1) as u32,
137 Err(_) => 1,
138 };
139
140 let _ = handle.exit_status_request(channel, exit_code).await;
141 let _ = handle.eof(channel).await;
142 let _ = handle.close(channel).await;
143 });
144
145 Ok(stdin)
146 }
147
148 #[cfg(test)]
149 mod tests {
150 use super::*;
151
152 #[test]
153 fn parse_git_upload_pack() {
154 let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap();
155 assert_eq!(op, "git-upload-pack");
156 assert_eq!(path, "/max/repo.git");
157 }
158
159 #[test]
160 fn parse_git_receive_pack_no_quotes() {
161 let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap();
162 assert_eq!(op, "git-receive-pack");
163 assert_eq!(path, "max/repo.git");
164 }
165
166 #[test]
167 fn parse_git_upload_archive_double_quotes() {
168 let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap();
169 assert_eq!(op, "git-upload-archive");
170 assert_eq!(path, "/max/repo.git");
171 }
172
173 #[test]
174 fn parse_non_git_command() {
175 assert!(parse_git_command("ls -la").is_none());
176 assert!(parse_git_command("scp -t /tmp/file").is_none());
177 }
178
179 #[test]
180 fn parse_repo_path_basic() {
181 let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
182 assert_eq!(owner, "max");
183 assert_eq!(repo, "repo");
184 }
185
186 #[test]
187 fn parse_repo_path_with_leading_slash() {
188 let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap();
189 assert_eq!(owner, "max");
190 assert_eq!(repo, "myproject");
191 }
192
193 #[test]
194 fn parse_repo_path_no_git_suffix() {
195 let (owner, repo) = parse_repo_path("max/repo").unwrap();
196 assert_eq!(owner, "max");
197 assert_eq!(repo, "repo");
198 }
199
200 #[test]
201 fn parse_repo_path_rejects_traversal() {
202 assert!(parse_repo_path("../evil/repo.git").is_none());
203 assert!(parse_repo_path("max/../../etc.git").is_none());
204 }
205
206 #[test]
207 fn parse_repo_path_rejects_empty() {
208 assert!(parse_repo_path("").is_none());
209 assert!(parse_repo_path("/").is_none());
210 assert!(parse_repo_path("max/").is_none());
211 assert!(parse_repo_path("max/.git").is_none());
212 }
213
214 #[test]
215 fn parse_repo_path_rejects_nested() {
216 assert!(parse_repo_path("max/sub/repo.git").is_none());
217 }
218 }
219