Skip to main content

max / makenotwork

11.7 KB · 339 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 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 = vec![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. `__HMAC__` is replaced with a per-repo HMAC
149 /// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both
150 /// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has
151 /// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`.
152 const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash
153 while read oldrev newrev refname; do
154 case "$refname" in
155 refs/tags/v[0-9]*)
156 TAG="${refname#refs/tags/}"
157 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
158 REPO_NAME="$(basename "$REPO_PATH" .git)"
159 OWNER="$(basename "$(dirname "$REPO_PATH")")"
160 curl -sf -X POST \
161 -H "Authorization: Bearer __HMAC__" \
162 -H "Content-Type: application/json" \
163 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \
164 "http://localhost:3000/api/internal/builds/trigger" \
165 >/dev/null 2>&1 &
166 ;;
167 refs/heads/*)
168 BRANCH="${refname#refs/heads/}"
169 REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)"
170 REPO_NAME="$(basename "$REPO_PATH" .git)"
171 OWNER="$(basename "$(dirname "$REPO_PATH")")"
172 curl -sf -X POST \
173 -H "Authorization: Bearer __HMAC__" \
174 -H "Content-Type: application/json" \
175 -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \
176 "http://localhost:3000/api/internal/issues/process-push" \
177 >/dev/null 2>&1 &
178 ;;
179 esac
180 done
181 "#;
182
183 /// Compute the per-repo HMAC the internal endpoints expect. Mirrors
184 /// `build_runner::repo_hmac` on the server side; the two must agree.
185 fn repo_hmac(token: &str, owner: &str, repo: &str) -> String {
186 use hmac::{Hmac, KeyInit, Mac};
187 use sha2::Sha256;
188 let mut mac =
189 <Hmac<Sha256>>::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length");
190 mac.update(format!("{owner}:{repo}").as_bytes());
191 hex::encode(mac.finalize().into_bytes())
192 }
193
194 /// Split a bare-repo path into (owner, repo), matching what the hook derives
195 /// at run time from its own location: repo = basename minus `.git`, owner =
196 /// the containing directory's name.
197 fn owner_and_repo(repo_path: &str) -> Option<(String, String)> {
198 let path = std::path::Path::new(repo_path);
199 let repo = path.file_name()?.to_str()?;
200 let repo = repo.strip_suffix(".git").unwrap_or(repo);
201 let owner = path.parent()?.file_name()?.to_str()?;
202 Some((owner.to_string(), repo.to_string()))
203 }
204
205 /// Install the post-receive hook in a bare repository.
206 pub(crate) async fn install_post_receive_hook(
207 _git_user: &str,
208 repo_path: &str,
209 token: &str,
210 ) -> anyhow::Result<()> {
211 let (owner, repo) = owner_and_repo(repo_path)
212 .ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?;
213 let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
214 let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive");
215
216 // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership
217 tokio::fs::write(&hook_path, hook_content.as_bytes()).await?;
218
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::PermissionsExt;
222 tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).await?;
223 }
224
225 tracing::debug!(path = %hook_path.display(), "installed post-receive hook");
226 Ok(())
227 }
228
229 #[cfg(test)]
230 mod tests {
231 use super::*;
232
233 #[test]
234 fn parse_git_upload_pack() {
235 let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap();
236 assert_eq!(op, "git-upload-pack");
237 assert_eq!(path, "/max/repo.git");
238 }
239
240 #[test]
241 fn parse_git_receive_pack_no_quotes() {
242 let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap();
243 assert_eq!(op, "git-receive-pack");
244 assert_eq!(path, "max/repo.git");
245 }
246
247 #[test]
248 fn parse_git_upload_archive_double_quotes() {
249 let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap();
250 assert_eq!(op, "git-upload-archive");
251 assert_eq!(path, "/max/repo.git");
252 }
253
254 #[test]
255 fn parse_non_git_command() {
256 assert!(parse_git_command("ls -la").is_none());
257 assert!(parse_git_command("scp -t /tmp/file").is_none());
258 }
259
260 /// Locks the derivation to the same vector the server's `repo_hmac`
261 /// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every
262 /// hook mnw-cli installs starts failing auth silently.
263 #[test]
264 fn repo_hmac_matches_server_vector() {
265 assert_eq!(
266 repo_hmac("test-token", "max", "repo"),
267 "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0"
268 );
269 }
270
271 #[test]
272 fn owner_and_repo_from_bare_path() {
273 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
274 assert_eq!(owner, "max");
275 assert_eq!(repo, "repo");
276
277 // No .git suffix is still valid.
278 let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap();
279 assert_eq!(owner, "max");
280 assert_eq!(repo, "repo");
281
282 // A bare name has no owner directory to read.
283 assert!(owner_and_repo("repo.git").is_none());
284 }
285
286 /// The hook that lands on disk must carry the per-repo HMAC and never the
287 /// global token itself.
288 #[test]
289 fn hook_body_carries_hmac_not_token() {
290 let token = "super-secret-token";
291 let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap();
292 let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo));
293
294 assert!(!body.contains(token));
295 assert!(!body.contains("__HMAC__"));
296 assert!(body.contains(&repo_hmac(token, "max", "repo")));
297 }
298
299 #[test]
300 fn parse_repo_path_basic() {
301 let (owner, repo) = parse_repo_path("max/repo.git").unwrap();
302 assert_eq!(owner, "max");
303 assert_eq!(repo, "repo");
304 }
305
306 #[test]
307 fn parse_repo_path_with_leading_slash() {
308 let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap();
309 assert_eq!(owner, "max");
310 assert_eq!(repo, "myproject");
311 }
312
313 #[test]
314 fn parse_repo_path_no_git_suffix() {
315 let (owner, repo) = parse_repo_path("max/repo").unwrap();
316 assert_eq!(owner, "max");
317 assert_eq!(repo, "repo");
318 }
319
320 #[test]
321 fn parse_repo_path_rejects_traversal() {
322 assert!(parse_repo_path("../evil/repo.git").is_none());
323 assert!(parse_repo_path("max/../../etc.git").is_none());
324 }
325
326 #[test]
327 fn parse_repo_path_rejects_empty() {
328 assert!(parse_repo_path("").is_none());
329 assert!(parse_repo_path("/").is_none());
330 assert!(parse_repo_path("max/").is_none());
331 assert!(parse_repo_path("max/.git").is_none());
332 }
333
334 #[test]
335 fn parse_repo_path_rejects_nested() {
336 assert!(parse_repo_path("max/sub/repo.git").is_none());
337 }
338 }
339