//! Git client for the file manager. Runs the git CLI without a shell; file //! names always follow `--`, refs and URLs are validated. use crate::api::{ApiError, ApiResult}; use crate::app::App; use crate::files::checked_path; use crate::store::User; use axum::extract::State; use axum::http::StatusCode; use axum::{Extension, Json}; use serde::Deserialize; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tokio::process::Command; type AppState = Arc; /// Nearest directory containing `.git`. pub fn repo_root(dir: &Path) -> Option { let mut d = dir.to_path_buf(); loop { if d.join(".git").exists() { return Some(d); } if !d.pop() { return None; } } } struct Out { ok: bool, stdout: String, stderr: String, } /// Options that keep repository content from running code (hooks, fsmonitor). const SAFE_OPTS: &[&str] = &["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.ext.allow=never"]; tokio::task_local! { /// Sandbox of the requesting user for all git calls of this request. static SANDBOX: Option; } async fn git(dir: &Path, args: &[&str], timeout: u64) -> Result { let mut all: Vec<&str> = SAFE_OPTS.to_vec(); all.extend_from_slice(args); let spec = SANDBOX.try_with(|s| s.clone()).ok().flatten(); let mut cmd = match &spec { Some(spec) => { if !crate::sandbox::available() { return Err(ApiError(StatusCode::SERVICE_UNAVAILABLE, "git is disabled: the sandbox is not available".into())); } crate::sandbox::command(spec, dir, "git", &all) } None => { let mut c = Command::new("git"); c.args(&all).current_dir(dir).env("GIT_TERMINAL_PROMPT", "0").env("GIT_PAGER", "cat").env("LC_ALL", "C"); c } }; let fut = cmd.kill_on_drop(true).output(); let out = tokio::time::timeout(Duration::from_secs(timeout), fut) .await .map_err(|_| ApiError(StatusCode::GATEWAY_TIMEOUT, "git timed out".into()))? .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot run git: {e}")))?; Ok(Out { ok: out.status.success(), stdout: String::from_utf8_lossy(&out.stdout).into_owned(), stderr: String::from_utf8_lossy(&out.stderr).into_owned(), }) } fn fail(o: Out) -> ApiError { let msg = if o.stderr.trim().is_empty() { o.stdout } else { o.stderr }; ApiError(StatusCode::BAD_REQUEST, msg.trim().to_string()) } async fn git_ok(dir: &Path, args: &[&str], timeout: u64) -> Result { let o = git(dir, args, timeout).await?; if o.ok { Ok(o.stdout) } else { Err(fail(o)) } } fn valid_ref(name: &str) -> bool { !name.is_empty() && !name.starts_with('-') && !name.contains("..") && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')) } #[derive(Deserialize)] pub struct GitBody { /// Repository directory, or any path inside it. path: String, action: String, #[serde(default)] files: Vec, #[serde(default)] staged: bool, message: Option, #[serde(default)] amend: bool, branch: Option, #[serde(default)] create: bool, rev: Option, url: Option, limit: Option, } /// Repo-relative paths for the given absolute file paths (all checked). fn rel_files(app: &App, user: &User, repo: &Path, files: &[String]) -> Result, ApiError> { files .iter() .map(|f| { let p = if Path::new(f).is_absolute() { PathBuf::from(f) } else { repo.join(f) }; let p = checked_path(app, user, &p.to_string_lossy(), false)?; p.strip_prefix(repo) .map(|r| r.to_string_lossy().into_owned()) .map_err(|_| ApiError(StatusCode::BAD_REQUEST, "file is outside the repository".into())) }) .collect() } fn parse_status(raw: &str) -> Value { let mut branch = Value::Null; let mut upstream = Value::Null; let (mut ahead, mut behind) = (0, 0); let mut files = Vec::new(); let mut entries = raw.split('\0').filter(|s| !s.is_empty()); while let Some(e) = entries.next() { if let Some(h) = e.strip_prefix("## ") { // "main...origin/main [ahead 1, behind 2]" or "No commits yet on main" let (names, info) = h.split_once(" [").map(|(a, b)| (a, b.trim_end_matches(']'))).unwrap_or((h, "")); let names = names.trim_start_matches("No commits yet on "); let (b, u) = names.split_once("...").map(|(a, b)| (a, Some(b))).unwrap_or((names, None)); branch = json!(b); upstream = json!(u); for part in info.split(", ") { if let Some(n) = part.strip_prefix("ahead ") { ahead = n.parse().unwrap_or(0); } if let Some(n) = part.strip_prefix("behind ") { behind = n.parse().unwrap_or(0); } } continue; } if e.len() < 4 { continue; } let (x, y) = (&e[0..1], &e[1..2]); let path = &e[3..]; let mut item = json!({"path": path, "index": x, "worktree": y}); // Renames/copies carry the original path as the next entry. if x == "R" || x == "C" { item["from"] = json!(entries.next()); } files.push(item); } json!({"branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind, "files": files}) } pub async fn handle(Extension(user): Extension, State(app): State, Json(b): Json) -> ApiResult { let spec = crate::sandbox::spec_for(&app, Some(&user.id)); SANDBOX.scope(spec, handle_inner(user, app, b)).await } async fn handle_inner(user: User, app: AppState, b: GitBody) -> ApiResult { let target = checked_path(&app, &user, &b.path, b.action != "clone")?; // Actions that create a repository. match b.action.as_str() { "init" => { if !target.is_dir() { return Err(ApiError(StatusCode::BAD_REQUEST, "not a directory".into())); } let out = git_ok(&target, &["init", "-q"], 30).await?; return Ok(Json(json!({"ok": true, "output": out}))); } "clone" => { let url = b.url.as_deref().unwrap_or("").trim().to_string(); let allowed = url.starts_with("https://") || url.starts_with("http://") || url.starts_with("ssh://") || url.starts_with("git@"); if !allowed || url.contains(char::is_whitespace) { return Err(ApiError(StatusCode::BAD_REQUEST, "unsupported repository URL".into())); } if target.exists() { return Err(ApiError(StatusCode::CONFLICT, "target folder already exists".into())); } let parent = target.parent().unwrap_or(Path::new("/")).to_path_buf(); let name = target.file_name().and_then(|n| n.to_str()).unwrap_or("repo").to_string(); let o = git(&parent, &["clone", "--", &url, &name], 600).await?; if !o.ok { return Err(fail(o)); } return Ok(Json(json!({"ok": true, "path": target, "output": o.stderr}))); } _ => {} } let start = if target.is_dir() { target.clone() } else { target.parent().unwrap_or(&target).to_path_buf() }; let repo = repo_root(&start).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "not a git repository".into()))?; // The repository itself must be inside the user's folders. checked_path(&app, &user, &repo.to_string_lossy(), true)?; let result = match b.action.as_str() { "status" => { let raw = git_ok(&repo, &["status", "--porcelain=v1", "-b", "-z", "--untracked-files=all"], 60).await?; let mut v = parse_status(&raw); v["repo"] = json!(repo); v } "diff" => { let files = rel_files(&app, &user, &repo, &b.files)?; let mut args = vec!["diff", "--no-color", "--no-ext-diff"]; if b.staged { args.push("--cached"); } args.push("--"); args.extend(files.iter().map(String::as_str)); let mut out = git_ok(&repo, &args, 60).await?; // Untracked files have no diff: show them as new. if out.is_empty() && !b.staged && files.len() == 1 { let o = git(&repo, &["diff", "--no-color", "--no-index", "--", "/dev/null", &files[0]], 60).await?; out = o.stdout; } out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len())); json!({"diff": out}) } "stage" | "unstage" | "discard" => { let files = rel_files(&app, &user, &repo, &b.files)?; if files.is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "no files".into())); } let mut args: Vec<&str> = match b.action.as_str() { "stage" => vec!["add", "-A"], "unstage" => vec!["restore", "--staged"], _ => vec!["restore", "--worktree"], }; args.push("--"); args.extend(files.iter().map(String::as_str)); git_ok(&repo, &args, 60).await?; json!({"ok": true}) } "commit" => { let msg = b.message.as_deref().unwrap_or("").trim().to_string(); if msg.is_empty() && !b.amend { return Err(ApiError(StatusCode::BAD_REQUEST, "commit message is empty".into())); } // Use the repository's identity if configured, otherwise the CodeCraft user. let has_identity = git(&repo, &["config", "user.email"], 10).await.map(|o| o.ok && !o.stdout.trim().is_empty()).unwrap_or(false); let name = format!("user.name={}", if user.display_name.is_empty() { &user.username } else { &user.display_name }); let email = format!("user.email={}@users.codecraft.local", user.username); let mut args: Vec<&str> = Vec::new(); if !has_identity { args.extend(["-c", &name, "-c", &email]); } args.push("commit"); if b.amend { args.push("--amend"); if msg.is_empty() { args.push("--no-edit"); } } if !msg.is_empty() { args.extend(["-m", &msg]); } let out = git_ok(&repo, &args, 60).await?; json!({"ok": true, "output": out}) } "log" => { let n = b.limit.unwrap_or(50).min(500).to_string(); let raw = git(&repo, &["log", "-n", &n, "--pretty=format:%H%x1f%an%x1f%at%x1f%s%x1f%D%x1e"], 60).await?; let commits: Vec = raw .stdout .split('\u{1e}') .filter(|s| !s.trim().is_empty()) .map(|c| { let f: Vec<&str> = c.trim_start_matches('\n').split('\u{1f}').collect(); json!({ "hash": f.first(), "author": f.get(1), "time": f.get(2).and_then(|t| t.parse::().ok()).map(|t| t * 1000), "subject": f.get(3), "refs": f.get(4), }) }) .collect(); json!({"commits": commits}) } "show" => { let rev = b.rev.as_deref().unwrap_or(""); if rev.len() < 4 || !rev.chars().all(|c| c.is_ascii_hexdigit()) { return Err(ApiError(StatusCode::BAD_REQUEST, "invalid commit".into())); } let mut out = git_ok(&repo, &["show", "--no-color", "--stat", "--patch", rev], 60).await?; out.truncate(out.char_indices().nth(600_000).map(|(i, _)| i).unwrap_or(out.len())); json!({"diff": out}) } "branches" => { let raw = git_ok(&repo, &["branch", "-a", "--format=%(refname:short)%1f%(HEAD)%1f%(upstream:short)"], 30).await?; let list: Vec = raw .lines() .filter(|l| !l.trim().is_empty()) .map(|l| { let f: Vec<&str> = l.split('\u{1f}').collect(); json!({"name": f.first(), "current": f.get(1) == Some(&"*"), "upstream": f.get(2).filter(|u| !u.is_empty())}) }) .collect(); json!({"branches": list}) } "checkout" => { let branch = b.branch.as_deref().unwrap_or(""); if !valid_ref(branch) { return Err(ApiError(StatusCode::BAD_REQUEST, "invalid branch name".into())); } let args: Vec<&str> = if b.create { vec!["switch", "-c", branch] } else { vec!["switch", branch] }; let o = git(&repo, &args, 60).await?; if !o.ok { return Err(fail(o)); } json!({"ok": true, "output": o.stderr}) } "fetch" | "pull" | "push" => { let args: Vec<&str> = match b.action.as_str() { "fetch" => vec!["fetch", "--prune"], "pull" => vec!["pull", "--ff-only"], _ => { let has_upstream = git(&repo, &["rev-parse", "--abbrev-ref", "@{u}"], 10).await.map(|o| o.ok).unwrap_or(false); if has_upstream { vec!["push"] } else { vec!["push", "-u", "origin", "HEAD"] } } }; let o = git(&repo, &args, 300).await?; if b.action == "push" { app.audit(Some(&user), "git.push", format!("{} ({})", repo.display(), if o.ok { "ok" } else { "failed" }), ""); } if !o.ok { return Err(fail(o)); } json!({"ok": true, "output": format!("{}{}", o.stdout, o.stderr)}) } other => return Err(ApiError(StatusCode::BAD_REQUEST, format!("unknown action '{other}'"))), }; Ok(Json(result)) } #[cfg(test)] mod tests { use super::*; #[test] fn parses_porcelain_status() { let raw = "## main...origin/main [ahead 2, behind 1]\0 M src/a.rs\0A new.txt\0R b.rs\0old.rs\0?? x y.txt\0"; let v = parse_status(raw); assert_eq!(v["branch"], "main"); assert_eq!(v["upstream"], "origin/main"); assert_eq!(v["ahead"], 2); assert_eq!(v["behind"], 1); let files = v["files"].as_array().unwrap(); assert_eq!(files.len(), 4); assert_eq!(files[2]["from"], "old.rs"); assert_eq!(files[3]["path"], "x y.txt"); assert_eq!(parse_status("## No commits yet on main\0")["branch"], "main"); } #[test] fn refs_are_validated() { assert!(valid_ref("feature/login-2")); assert!(!valid_ref("--upload-pack=x")); assert!(!valid_ref("a..b")); assert!(!valid_ref("a b")); } }