Skip to main content

max / makenotwork

13.4 KB · 373 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::ChannelId;
9 use russh::server::Handle;
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(crate) 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(crate) 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(crate) 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 // An empty `git_user` means no separate git account is configured, so run
72 // the operation as whoever we already are. Production always names one
73 // (`--git-user`, default `git`) and takes the sudo path; the direct path is
74 // what lets the backpressure test below drive a real push without needing a
75 // sudo grant in the test environment.
76 let mut command = if git_user.is_empty() {
77 let mut c = Command::new(operation);
78 c.arg(repo_path);
79 c
80 } else {
81 let mut c = Command::new("sudo");
82 c.args(["-u", git_user, operation, repo_path]);
83 c
84 };
85
86 let mut child: Child = command
87 .stdin(std::process::Stdio::piped())
88 .stdout(std::process::Stdio::piped())
89 .stderr(std::process::Stdio::piped())
90 .kill_on_drop(true)
91 .spawn()?;
92
93 let stdin = child
94 .stdin
95 .take()
96 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdin"))?;
97 let stdout = child
98 .stdout
99 .take()
100 .ok_or_else(|| anyhow::anyhow!("failed to capture child stdout"))?;
101 let stderr = child
102 .stderr
103 .take()
104 .ok_or_else(|| anyhow::anyhow!("failed to capture child stderr"))?;
105
106 // Forward stdout → SSH channel data
107 let stdout_handle = handle.clone();
108 let stdout_task = tokio::spawn(async move {
109 let mut reader = stdout;
110 let mut buf = vec![0u8; 32768];
111 loop {
112 match reader.read(&mut buf).await {
113 Ok(0) => break,
114 Ok(n) => {
115 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
116 if stdout_handle.data(channel, data).await.is_err() {
117 break;
118 }
119 }
120 Err(_) => break,
121 }
122 }
123 });
124
125 // Forward stderr → SSH channel extended data (type 1 = stderr)
126 let stderr_handle = handle.clone();
127 let stderr_task = tokio::spawn(async move {
128 let mut reader = stderr;
129 let mut buf = [0u8; 8192];
130 loop {
131 match reader.read(&mut buf).await {
132 Ok(0) => break,
133 Ok(n) => {
134 let data = bytes::Bytes::copy_from_slice(&buf[..n]);
135 if stderr_handle.extended_data(channel, 1, data).await.is_err() {
136 break;
137 }
138 }
139 Err(_) => break,
140 }
141 }
142 });
143
144 // Wait for subprocess to complete, then close the SSH channel
145 tokio::spawn(async move {
146 let _ = stdout_task.await;
147 let _ = stderr_task.await;
148
149 let exit_code = match child.wait().await {
150 Ok(status) => status.code().unwrap_or(1) as u32,
151 Err(_) => 1,
152 };
153
154 let _ = handle.exit_status_request(channel, exit_code).await;
155 let _ = handle.eof(channel).await;
156 let _ = handle.close(channel).await;
157 });
158
159 Ok(stdin)
160 }
161
162 /// Post-receive hook template. `__HMAC__` is replaced with a per-repo HMAC
163 /// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both
164 /// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has
165 /// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`.
166 const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
167 while read oldrev newrev refname; do
168 case "$refname" in
169 refs/tags/v[0-9]*)
170 TAG="${refname#refs/tags/}"
171 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
172 REPO_NAME="$(basename "$REPO_PATH" .git)"
173 OWNER="$(basename "$(dirname "$REPO_PATH")")"
174 curl -sf -X POST \
175 -H "Authorization: Bearer __HMAC__" \
176 -H "Content-Type: application/json" \
177 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
178 "http://localhost:3000/api/internal/builds/trigger" \
179 >/dev/null 2>&1 &
180 ;;
181 refs/heads/*)
182 BRANCH="${refname#refs/heads/}"
183 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
184 REPO_NAME="$(basename "$REPO_PATH" .git)"
185 OWNER="$(basename "$(dirname "$REPO_PATH")")"
186 curl -sf -X POST \
187 -H "Authorization: Bearer __HMAC__" \
188 -H "Content-Type: application/json" \
189 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
190 "http://localhost:3000/api/internal/issues/process-push" \
191 >/dev/null 2>&1 &
192 ;;
193 refs/mnw/notes-inbox/*)
194 # Not backgrounded, unlike the two above. A notes merge is the
195 # answer to this push, and post-receive stdout is the only way back
196 # to the person who made it. The timeouts stop a quiet server from
197 # hanging a push.
198 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
199 REPO_NAME="$(basename "$REPO_PATH" .git)"
200 OWNER="$(basename "$(dirname "$REPO_PATH")")"
201 if curl -sf --connect-timeout 5 --max-time 30 -X POST \
202 -H "Authorization: Bearer __HMAC__" \
203 -H "Content-Type: application/json" \
204 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \
205 "http://localhost:3000/api/internal/notes/merge-inbox" >/dev/null 2>&1; then
206 echo "notes: merged into refs/notes/${refname#refs/mnw/notes-inbox/}"
207 else
208 # The notes are in the inbox ref regardless, so nothing is lost
209 # and the next push merges them.
210 echo "notes: received, merge deferred (the server did not answer)"
211 fi
212 ;;
213 esac
214 done
215 "#;
216
217 /// Compute the per-repo HMAC the internal endpoints expect. Mirrors
218 /// `build_runner::repo_hmac` on the server side; the two must agree.
219 fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
220 use hmac::{Hmac, KeyInit, Mac};
221 use sha2::Sha256;
222 let mut mac =
223 <Hmac<Sha256>>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
224 mac.update(format!("{owner}:{repo}").as_bytes());
225 hex::encode(mac.finalize().into_bytes())
226 }
227
228 /// Split a bare-repo path into (owner, repo), matching what the hook derives
229 /// at run time from its own location: repo = basename minus `.git`, owner =
230 /// the containing directory's name.
231 fn owner_and_repo(repo_path: &str) -> Option<(String, String)> {
232 let path = std::path::Path::new(repo_path);
233 let repo = path.file_name()?.to_str()?;
234 let repo = repo.strip_suffix(".git").unwrap_or(repo);
235 let owner = path.parent()?.file_name()?.to_str()?;
236 Some((owner.to_string(), repo.to_string()))
237 }
238
239 /// Install the post-receive hook in a bare repository.
240 pub(crate) async fn install_post_receive_hook(
241 _git_user: &str,
242 repo_path: &str,
243 token: &str,
244 ) -> anyhow::Result<()> {
245 let (owner, repo) = owner_and_repo(repo_path)
246 .ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?;
247 let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
248 let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
249
250 // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
251 tokio::fs::write(&hook_path, hook_content.as_bytes()).await?;
252
253 #[cfg(unix)]
254 {
255 use std::os::unix::fs::PermissionsExt;
256 tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).await?;
257 }
258
259 tracing::debug!(path = %hook_path.display(), "installed post-receive hook");
260 Ok(())
261 }
262
263 #[cfg(test)]
264 mod tests {
265 use super::*;
266
267 #[test]
268 fn parse_git_upload_pack() {
269 let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap();
270 assert_eq!(op, "git-upload-pack");
271 assert_eq!(path, "/max/repo.git");
272 }
273
274 #[test]
275 fn parse_git_receive_pack_no_quotes() {
276 let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap();
277 assert_eq!(op, "git-receive-pack");
278 assert_eq!(path, "max/repo.git");
279 }
280
281 #[test]
282 fn parse_git_upload_archive_double_quotes() {
283 let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap();
284 assert_eq!(op, "git-upload-archive");
285 assert_eq!(path, "/max/repo.git");
286 }
287
288 #[test]
289 fn parse_non_git_command() {
290 assert!(parse_git_command("ls -la").is_none());
291 assert!(parse_git_command("scp -t /tmp/file").is_none());
292 }
293
294 /// Locks the derivation to the same vector the server's `repo_hmac`
295 /// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every
296 /// hook mnw-cli installs starts failing auth silently.
297 #[test]
298 fn repo_hmac_matches_server_vector() {
299 assert_eq!(
300 repo_hmac("test-token", "max", "repo"),
301 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
302 );
303 }
304
305 #[test]
306 fn owner_and_repo_from_bare_path() {
307 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
308 assert_eq!(owner, "max");
309 assert_eq!(repo, "repo");
310
311 // No .git suffix is still valid.
312 let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap();
313 assert_eq!(owner, "max");
314 assert_eq!(repo, "repo");
315
316 // A bare name has no owner directory to read.
317 assert!(owner_and_repo("repo.git").is_none());
318 }
319
320 /// The hook that lands on disk must carry the per-repo HMAC and never the
321 /// global token itself.
322 #[test]
323 fn hook_body_carries_hmac_not_token() {
324 let token = "super-secret-token";
325 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
326 let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
327
328 assert!(!body.contains(token));
329 assert!(!body.contains("__HMAC__"));
330 assert!(body.contains(&repo_hmac(token, "max", "repo")));
331 }
332
333 #[test]
334 fn parse_repo_path_basic() {
335 let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
336 assert_eq!(owner, "max");
337 assert_eq!(repo, "repo");
338 }
339
340 #[test]
341 fn parse_repo_path_with_leading_slash() {
342 let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap();
343 assert_eq!(owner, "max");
344 assert_eq!(repo, "myproject");
345 }
346
347 #[test]
348 fn parse_repo_path_no_git_suffix() {
349 let (owner, repo) = parse_repo_path("max/repo").unwrap();
350 assert_eq!(owner, "max");
351 assert_eq!(repo, "repo");
352 }
353
354 #[test]
355 fn parse_repo_path_rejects_traversal() {
356 assert!(parse_repo_path("../evil/repo.git").is_none());
357 assert!(parse_repo_path("max/../../etc.git").is_none());
358 }
359
360 #[test]
361 fn parse_repo_path_rejects_empty() {
362 assert!(parse_repo_path("").is_none());
363 assert!(parse_repo_path("/").is_none());
364 assert!(parse_repo_path("max/").is_none());
365 assert!(parse_repo_path("max/.git").is_none());
366 }
367
368 #[test]
369 fn parse_repo_path_rejects_nested() {
370 assert!(parse_repo_path("max/sub/repo.git").is_none());
371 }
372 }
373