//! Git operation proxy: command parsing and subprocess management. //! //! When a git client connects via SSH (e.g., `git push git@ssh.makenot.work:max/repo.git`), //! the exec_request receives a command like `git-receive-pack 'max/repo.git'`. This module //! parses that command, and spawns the git subprocess with its stdin/stdout/stderr piped //! through the SSH channel. use russh::ChannelId; use russh::server::Handle; use tokio::io::AsyncReadExt; use tokio::process::{Child, Command}; /// Parse a git exec command line. /// /// The grammar lives in `git-command`, shared with the server's `git_ssh` door. /// Never parse it here as well: two hand-written parsers diverge on leading /// whitespace, unbalanced quotes, repeated leading slashes, and whether `..` is /// tested before or after the `.git` suffix comes off, and that last one lets /// `/max/repo..git` through as repo `repo.`. /// /// Returns `None` for anything that is not a well-formed, path-safe git /// request, which is every case this door refuses. pub(crate) fn parse_command(cmd: &str) -> Option> { git_command::parse(cmd).ok() } /// Spawn a git subprocess and wire its I/O through the SSH channel. /// /// Returns the child's stdin handle so the caller can forward SSH data() to it. /// Stdout/stderr forwarding and process cleanup run in background tasks. pub(crate) fn spawn_git_process( git_user: &str, operation: &str, repo_path: &str, channel: ChannelId, handle: Handle, ) -> anyhow::Result { // An empty `git_user` means no separate git account is configured, so run // the operation as whoever we already are. Production always names one // (`--git-user`, default `git`) and takes the sudo path; the direct path is // what lets the backpressure test below drive a real push without needing a // sudo grant in the test environment. let mut command = if git_user.is_empty() { let mut c = Command::new(operation); c.arg(repo_path); c } else { let mut c = Command::new("sudo"); c.args(["-u", git_user, operation, repo_path]); c }; let mut child: Child = command .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) .spawn()?; let stdin = child .stdin .take() .ok_or_else(|| anyhow::anyhow!("failed to capture child stdin"))?; let stdout = child .stdout .take() .ok_or_else(|| anyhow::anyhow!("failed to capture child stdout"))?; let stderr = child .stderr .take() .ok_or_else(|| anyhow::anyhow!("failed to capture child stderr"))?; // Forward stdout → SSH channel data let stdout_handle = handle.clone(); let stdout_task = tokio::spawn(async move { let mut reader = stdout; let mut buf = vec![0u8; 32768]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { let data = bytes::Bytes::copy_from_slice(&buf[..n]); if stdout_handle.data(channel, data).await.is_err() { break; } } Err(_) => break, } } }); // Forward stderr → SSH channel extended data (type 1 = stderr) let stderr_handle = handle.clone(); let stderr_task = tokio::spawn(async move { let mut reader = stderr; let mut buf = [0u8; 8192]; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { let data = bytes::Bytes::copy_from_slice(&buf[..n]); if stderr_handle.extended_data(channel, 1, data).await.is_err() { break; } } Err(_) => break, } } }); // Wait for subprocess to complete, then close the SSH channel tokio::spawn(async move { let _ = stdout_task.await; let _ = stderr_task.await; let exit_code = match child.wait().await { Ok(status) => status.code().unwrap_or(1) as u32, Err(_) => 1, }; let _ = handle.exit_status_request(channel, exit_code).await; let _ = handle.eof(channel).await; let _ = handle.close(channel).await; }); Ok(stdin) } /// Post-receive hook template. `__HMAC__` is replaced with a per-repo HMAC /// signature so the global `BUILD_TRIGGER_TOKEN` never lands on disk. Both /// endpoints verify `HMAC(token, owner:repo)`, not the raw token, so this has /// to match `server`'s `build_runner::POST_RECEIVE_HOOK_TEMPLATE`. const POST_RECEIVE_HOOK: &str = r#"#!/bin/bash while read oldrev newrev refname; do case "$refname" in refs/tags/v[0-9]*) TAG="${refname#refs/tags/}" REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" REPO_NAME="$(basename "$REPO_PATH" .git)" OWNER="$(basename "$(dirname "$REPO_PATH")")" curl -sf -X POST \ -H "Authorization: Bearer __HMAC__" \ -H "Content-Type: application/json" \ -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"tag\": \"$TAG\"}" \ "http://localhost:3000/api/internal/builds/trigger" \ >/dev/null 2>&1 & ;; refs/heads/*) BRANCH="${refname#refs/heads/}" REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" REPO_NAME="$(basename "$REPO_PATH" .git)" OWNER="$(basename "$(dirname "$REPO_PATH")")" curl -sf -X POST \ -H "Authorization: Bearer __HMAC__" \ -H "Content-Type: application/json" \ -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$BRANCH\", \"before\": \"$oldrev\", \"after\": \"$newrev\"}" \ "http://localhost:3000/api/internal/issues/process-push" \ >/dev/null 2>&1 & ;; refs/mnw/notes-inbox/*) # Not backgrounded, unlike the two above. A notes merge is the # answer to this push, and post-receive stdout is the only way back # to the person who made it. The timeouts stop a quiet server from # hanging a push. REPO_PATH="$(cd "$(dirname "$0")/.." && pwd)" REPO_NAME="$(basename "$REPO_PATH" .git)" OWNER="$(basename "$(dirname "$REPO_PATH")")" if curl -sf --connect-timeout 5 --max-time 30 -X POST \ -H "Authorization: Bearer __HMAC__" \ -H "Content-Type: application/json" \ -d "{\"repo_owner\": \"$OWNER\", \"repo_name\": \"$REPO_NAME\", \"ref_name\": \"$refname\"}" \ "http://localhost:3000/api/internal/notes/merge-inbox" >/dev/null 2>&1; then echo "notes: merged into refs/notes/${refname#refs/mnw/notes-inbox/}" else # The notes are in the inbox ref regardless, so nothing is lost # and the next push merges them. echo "notes: received, merge deferred (the server did not answer)" fi ;; esac done "#; /// Compute the per-repo HMAC the internal endpoints expect. Mirrors /// `build_runner::repo_hmac` on the server side; the two must agree. fn repo_hmac(token: &str, owner: &str, repo: &str) -> String { use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; let mut mac = >::new_from_slice(token.as_bytes()).expect("HMAC accepts any key length"); mac.update(format!("{owner}:{repo}").as_bytes()); hex::encode(mac.finalize().into_bytes()) } /// Split a bare-repo path into (owner, repo), matching what the hook derives /// at run time from its own location: repo = basename minus `.git`, owner = /// the containing directory's name. fn owner_and_repo(repo_path: &str) -> Option<(String, String)> { let path = std::path::Path::new(repo_path); let repo = path.file_name()?.to_str()?; let repo = repo.strip_suffix(".git").unwrap_or(repo); let owner = path.parent()?.file_name()?.to_str()?; Some((owner.to_string(), repo.to_string())) } /// Install the post-receive hook in a bare repository. pub(crate) async fn install_post_receive_hook( _git_user: &str, repo_path: &str, token: &str, ) -> anyhow::Result<()> { let (owner, repo) = owner_and_repo(repo_path) .ok_or_else(|| anyhow::anyhow!("cannot derive owner/repo from {repo_path}"))?; let hook_content = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo)); let hook_path = std::path::PathBuf::from(repo_path).join("hooks/post-receive"); // Write directly — mnw-cli is in the git group, setgid dir gives correct ownership tokio::fs::write(&hook_path, hook_content.as_bytes()).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; tokio::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755)).await?; } tracing::debug!(path = %hook_path.display(), "installed post-receive hook"); Ok(()) } #[cfg(test)] mod tests { use super::*; // The grammar's tests live in `git-command`, which owns the parser. What // this door owes is proof that it reaches it and refuses what it should. #[test] fn the_three_verbs_a_client_sends() { let r = parse_command("git-upload-pack '/max/repo.git'").unwrap(); assert_eq!(r.operation.command(), "git-upload-pack"); assert_eq!((r.owner, r.repo), ("max", "repo")); let r = parse_command("git-receive-pack max/repo.git").unwrap(); assert_eq!(r.operation.command(), "git-receive-pack"); let r = parse_command("git-upload-archive \"/max/repo.git\"").unwrap(); assert_eq!(r.operation.command(), "git-upload-archive"); } #[test] fn non_git_commands_are_refused() { assert!(parse_command("ls -la").is_none()); assert!(parse_command("scp -t /tmp/file").is_none()); } #[test] fn path_unsafe_requests_are_refused() { for cmd in [ "git-upload-pack ../evil/repo.git", "git-upload-pack max/../../etc.git", "git-upload-pack max/sub/repo.git", "git-upload-pack max/.git", "git-upload-pack max/", "git-upload-pack /", ] { assert!(parse_command(cmd).is_none(), "{cmd}"); } } /// Taking `.git` off before testing `..` parses this as repo `repo.`. #[test] fn dotdot_is_tested_before_the_git_suffix() { assert!(parse_command("git-upload-pack /max/repo..git").is_none()); } /// Locks the derivation to the same vector the server's `repo_hmac` /// produces (HMAC-SHA256 over `owner:repo`, hex). If this drifts, every /// hook mnw-cli installs starts failing auth silently. #[test] fn repo_hmac_matches_server_vector() { assert_eq!( repo_hmac("test-token", "max", "repo"), "198b4a4f542c0c27c4ca333030dfe09fe670aa44bae34d7a586481bb13fd9eb0" ); } #[test] fn owner_and_repo_from_bare_path() { let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap(); assert_eq!(owner, "max"); assert_eq!(repo, "repo"); // No .git suffix is still valid. let (owner, repo) = owner_and_repo("/srv/git/max/repo").unwrap(); assert_eq!(owner, "max"); assert_eq!(repo, "repo"); // A bare name has no owner directory to read. assert!(owner_and_repo("repo.git").is_none()); } /// The hook that lands on disk must carry the per-repo HMAC and never the /// global token itself. #[test] fn hook_body_carries_hmac_not_token() { let token = "super-secret-token"; let (owner, repo) = owner_and_repo("/srv/git/max/repo.git").unwrap(); let body = POST_RECEIVE_HOOK.replace("__HMAC__", &repo_hmac(token, &owner, &repo)); assert!(!body.contains(token)); assert!(!body.contains("__HMAC__")); assert!(body.contains(&repo_hmac(token, "max", "repo"))); } }