//! 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::server::Handle; use russh::ChannelId; use tokio::io::AsyncReadExt; use tokio::process::{Child, Command}; /// Parse a git exec command into (operation, raw_path). /// /// Git clients send commands like: /// `git-upload-pack '/max/repo.git'` /// `git-receive-pack 'max/repo.git'` /// /// Returns `None` for non-git commands. pub fn parse_git_command(cmd: &str) -> Option<(&str, &str)> { let (operation, rest) = cmd.split_once(' ')?; match operation { "git-upload-pack" | "git-receive-pack" | "git-upload-archive" => {} _ => return None, } // Strip surrounding quotes (single or double) let path = rest.trim(); let path = path .strip_prefix('\'') .and_then(|s| s.strip_suffix('\'')) .or_else(|| path.strip_prefix('"').and_then(|s| s.strip_suffix('"'))) .unwrap_or(path); Some((operation, path)) } /// Parse a repo path like "max/repo.git" or "/max/repo" into (owner, repo_name). /// /// Strips leading `/` and trailing `.git`. pub fn parse_repo_path(path: &str) -> Option<(&str, &str)> { let path = path.strip_prefix('/').unwrap_or(path); let (owner, repo_name) = path.split_once('/')?; if owner.is_empty() || owner.contains("..") { return None; } // Strip trailing .git let repo_name = repo_name.strip_suffix(".git").unwrap_or(repo_name); if repo_name.is_empty() || repo_name.contains("..") || repo_name.contains('/') { return None; } Some((owner, repo_name)) } /// 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 async fn spawn_git_process( git_user: &str, operation: &str, repo_path: &str, channel: ChannelId, handle: Handle, ) -> anyhow::Result { let mut child: Child = Command::new("sudo") .args(["-u", git_user, operation, repo_path]) .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 = [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. __TOKEN__ is replaced with the actual token. 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 __TOKEN__" \ -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 __TOKEN__" \ -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 & ;; esac done "#; /// Install the post-receive hook in a bare repository. pub async fn install_post_receive_hook( _git_user: &str, repo_path: &str, token: &str, ) -> anyhow::Result<()> { let hook_content = POST_RECEIVE_HOOK.replace("__TOKEN__", token); 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::*; #[test] fn parse_git_upload_pack() { let (op, path) = parse_git_command("git-upload-pack '/max/repo.git'").unwrap(); assert_eq!(op, "git-upload-pack"); assert_eq!(path, "/max/repo.git"); } #[test] fn parse_git_receive_pack_no_quotes() { let (op, path) = parse_git_command("git-receive-pack max/repo.git").unwrap(); assert_eq!(op, "git-receive-pack"); assert_eq!(path, "max/repo.git"); } #[test] fn parse_git_upload_archive_double_quotes() { let (op, path) = parse_git_command("git-upload-archive \"/max/repo.git\"").unwrap(); assert_eq!(op, "git-upload-archive"); assert_eq!(path, "/max/repo.git"); } #[test] fn parse_non_git_command() { assert!(parse_git_command("ls -la").is_none()); assert!(parse_git_command("scp -t /tmp/file").is_none()); } #[test] fn parse_repo_path_basic() { let (owner, repo) = parse_repo_path("max/repo.git").unwrap(); assert_eq!(owner, "max"); assert_eq!(repo, "repo"); } #[test] fn parse_repo_path_with_leading_slash() { let (owner, repo) = parse_repo_path("/max/myproject.git").unwrap(); assert_eq!(owner, "max"); assert_eq!(repo, "myproject"); } #[test] fn parse_repo_path_no_git_suffix() { let (owner, repo) = parse_repo_path("max/repo").unwrap(); assert_eq!(owner, "max"); assert_eq!(repo, "repo"); } #[test] fn parse_repo_path_rejects_traversal() { assert!(parse_repo_path("../evil/repo.git").is_none()); assert!(parse_repo_path("max/../../etc.git").is_none()); } #[test] fn parse_repo_path_rejects_empty() { assert!(parse_repo_path("").is_none()); assert!(parse_repo_path("/").is_none()); assert!(parse_repo_path("max/").is_none()); assert!(parse_repo_path("max/.git").is_none()); } #[test] fn parse_repo_path_rejects_nested() { assert!(parse_repo_path("max/sub/repo.git").is_none()); } }