Skip to main content

max / makenotwork

8.9 KB · 273 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 /// Post-receive hook template. __TOKEN__ is replaced with the actual token.
149 const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
150 while read oldrev newrev refname; do
151 case "$refname" in
152 refs/tags/v[0-9]*)
153 TAG="${refname#refs/tags/}"
154 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
155 REPO_NAME="$(basename "$REPO_PATH" .git)"
156 OWNER="$(basename "$(dirname "$REPO_PATH")")"
157 curl -sf -X POST \
158 -H "Authorization: Bearer __TOKEN__" \
159 -H "Content-Type: application/json" \
160 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
161 "http://localhost:3000/api/internal/builds/trigger" \
162 >/dev/null 2>&1 &
163 ;;
164 refs/heads/*)
165 BRANCH="${refname#refs/heads/}"
166 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
167 REPO_NAME="$(basename "$REPO_PATH" .git)"
168 OWNER="$(basename "$(dirname "$REPO_PATH")")"
169 curl -sf -X POST \
170 -H "Authorization: Bearer __TOKEN__" \
171 -H "Content-Type: application/json" \
172 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
173 "http://localhost:3000/api/internal/issues/process-push" \
174 >/dev/null 2>&1 &
175 ;;
176 esac
177 done
178 "#;
179
180 /// Install the post-receive hook in a bare repository.
181 pub async fn install_post_receive_hook(
182 _git_user: &str,
183 repo_path: &str,
184 token: &str,
185 ) -> anyhow::Result<()> {
186 let hook_content = POST_RECEIVE_HOOK.replace("__TOKEN__", token);
187 let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
188
189 // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
190 tokio::fs::write(&hook_path, hook_content.as_bytes()).await?;
191
192 #[cfg(unix)]
193 {
194 use std::os::unix::fs::PermissionsExt;
195 tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).await?;
196 }
197
198 tracing::debug!(path = %hook_path.display(), "installed post-receive hook");
199 Ok(())
200 }
201
202 #[cfg(test)]
203 mod tests {
204 use super::*;
205
206 #[test]
207 fn parse_git_upload_pack() {
208 let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap();
209 assert_eq!(op, "git-upload-pack");
210 assert_eq!(path, "/max/repo.git");
211 }
212
213 #[test]
214 fn parse_git_receive_pack_no_quotes() {
215 let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap();
216 assert_eq!(op, "git-receive-pack");
217 assert_eq!(path, "max/repo.git");
218 }
219
220 #[test]
221 fn parse_git_upload_archive_double_quotes() {
222 let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap();
223 assert_eq!(op, "git-upload-archive");
224 assert_eq!(path, "/max/repo.git");
225 }
226
227 #[test]
228 fn parse_non_git_command() {
229 assert!(parse_git_command("ls -la").is_none());
230 assert!(parse_git_command("scp -t /tmp/file").is_none());
231 }
232
233 #[test]
234 fn parse_repo_path_basic() {
235 let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
236 assert_eq!(owner, "max");
237 assert_eq!(repo, "repo");
238 }
239
240 #[test]
241 fn parse_repo_path_with_leading_slash() {
242 let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap();
243 assert_eq!(owner, "max");
244 assert_eq!(repo, "myproject");
245 }
246
247 #[test]
248 fn parse_repo_path_no_git_suffix() {
249 let (owner, repo) = parse_repo_path("max/repo").unwrap();
250 assert_eq!(owner, "max");
251 assert_eq!(repo, "repo");
252 }
253
254 #[test]
255 fn parse_repo_path_rejects_traversal() {
256 assert!(parse_repo_path("../evil/repo.git").is_none());
257 assert!(parse_repo_path("max/../../etc.git").is_none());
258 }
259
260 #[test]
261 fn parse_repo_path_rejects_empty() {
262 assert!(parse_repo_path("").is_none());
263 assert!(parse_repo_path("/").is_none());
264 assert!(parse_repo_path("max/").is_none());
265 assert!(parse_repo_path("max/.git").is_none());
266 }
267
268 #[test]
269 fn parse_repo_path_rejects_nested() {
270 assert!(parse_repo_path("max/sub/repo.git").is_none());
271 }
272 }
273