//! Files, trees, history and diffs without a clone. use crate::{App, AppError, repo_name}; use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use knotless::git::Git; use serde::Deserialize; use serde_json::{Value, json}; use std::sync::Arc; #[derive(Deserialize, Default)] pub struct Params { rev: Option, path: Option, limit: Option, base: Option, head: Option, } type Repo = Path<(String, String)>; async fn open(app: &App, (authority, repo): (String, String)) -> Result { app.mirror(&authority, repo_name(&repo)).await } /// A revision from the query, refusing anything git could read as an option. fn rev(value: Option<&str>) -> Result { let rev = value.unwrap_or("HEAD"); if rev.is_empty() || rev.starts_with('-') || rev.contains(['\0', '\n', ' ']) { return Err(AppError( StatusCode::BAD_REQUEST, format!("bad revision {rev:?}"), )); } Ok(rev.to_string()) } fn path(value: Option<&str>) -> String { value.unwrap_or_default().trim_matches('/').to_string() } async fn git(git: &Git, args: &[&str]) -> Result, AppError> { git.run(args, None) .await .map_err(|e| AppError(StatusCode::NOT_FOUND, format!("{e:#}"))) } /// Caching for a response about `rev`: forever when it names a commit by /// its hash, briefly when it names a ref that can move. fn cache_for(rev: &str) -> &'static str { if rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()) { "public, max-age=31536000, immutable" } else { "no-cache" } } pub async fn refs(State(app): State>, Path(repo): Repo) -> Result, AppError> { let g = open(&app, repo).await?; let refs: Vec = g .refs() .await .map_err(AppError::internal)? .into_iter() .map(|(name, target)| json!({"name": name, "target": target})) .collect(); let head = g.text(&["symbolic-ref", "-q", "HEAD"], None).await.ok(); Ok(Json(json!({"refs": refs, "head": head}))) } pub async fn tree( State(app): State>, Path(repo): Repo, Query(q): Query, ) -> Result { let g = open(&app, repo).await?; let rev = rev(q.rev.as_deref())?; let path = path(q.path.as_deref()); let out = git(&g, &["ls-tree", "-l", "-z", &format!("{rev}:{path}")]).await?; let entries: Vec = String::from_utf8_lossy(&out) .split('\0') .filter_map(|line| { let (meta, name) = line.split_once('\t')?; let mut f = meta.split_whitespace(); let (mode, kind, sha, size) = (f.next()?, f.next()?, f.next()?, f.next()?); Some(json!({"name": name, "mode": mode, "type": kind, "sha": sha, "size": size.parse::().ok()})) }) .collect(); Ok(( [(header::CACHE_CONTROL, cache_for(&rev))], Json(json!({"rev": rev, "path": path, "entries": entries})), ) .into_response()) } pub async fn blob( State(app): State>, Path(repo): Repo, Query(q): Query, ) -> Result { let g = open(&app, repo).await?; let rev = rev(q.rev.as_deref())?; let spec = format!("{rev}:{}", path(q.path.as_deref())); let sha = String::from_utf8_lossy(&git(&g, &["rev-parse", "--verify", &spec]).await?) .trim() .to_string(); let bytes = git(&g, &["cat-file", "blob", &sha]).await?; Ok(( [ (header::CONTENT_TYPE, "application/octet-stream".to_string()), (header::ETAG, format!("\"{sha}\"")), (header::CACHE_CONTROL, cache_for(&rev).to_string()), ], bytes, ) .into_response()) } pub async fn log( State(app): State>, Path(repo): Repo, Query(q): Query, ) -> Result, AppError> { let g = open(&app, repo).await?; let rev = rev(q.rev.as_deref())?; let limit = q.limit.unwrap_or(50).min(1000).to_string(); let mut args = vec![ "log", "-z", "--format=%H%x1f%P%x1f%an%x1f%ae%x1f%aI%x1f%B", "-n", &limit, &rev, "--", ]; let path = path(q.path.as_deref()); if !path.is_empty() { args.push(&path); } let out = git(&g, &args).await?; let commits: Vec = String::from_utf8_lossy(&out) .split('\0') .filter(|s| !s.is_empty()) .map(|record| { let mut f = record.splitn(6, '\x1f'); let mut field = || f.next().unwrap_or_default(); let (sha, parents, name, email, date, message) = (field(), field(), field(), field(), field(), field()); json!({ "sha": sha, "parents": parents.split_whitespace().collect::>(), "author": {"name": name, "email": email, "date": date}, "message": message.trim_end(), }) }) .collect(); Ok(Json(json!({"rev": rev, "commits": commits}))) } pub async fn diff( State(app): State>, Path(repo): Repo, Query(q): Query, ) -> Result { let g = open(&app, repo).await?; let base = rev(q.base.as_deref())?; let head = rev(q.head.as_deref())?; let out = git( &g, &[ "diff", "--no-color", "--no-ext-diff", &format!("{base}...{head}"), "--", ], ) .await?; Ok(([(header::CONTENT_TYPE, "text/x-diff; charset=utf-8")], out).into_response()) } pub async fn object( State(app): State>, Path((authority, repo, sha)): Path<(String, String, String)>, ) -> Result { if !(sha.len() == 40 || sha.len() == 64) || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { return Err(AppError( StatusCode::BAD_REQUEST, format!("not an object id: {sha}"), )); } let g = open(&app, (authority, repo)).await?; let kind = String::from_utf8_lossy(&git(&g, &["cat-file", "-t", &sha]).await?) .trim() .to_string(); let bytes = git(&g, &["cat-file", &kind, &sha]).await?; Ok(( [ (header::CONTENT_TYPE, "application/octet-stream".to_string()), (header::HeaderName::from_static("x-git-type"), kind), (header::ETAG, format!("\"{sha}\"")), ( header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string(), ), ], bytes, ) .into_response()) } #[cfg(test)] mod tests { use super::*; #[test] fn revisions_and_caching() { assert_eq!(rev(None).unwrap(), "HEAD"); assert!(rev(Some("--output=/x")).is_err()); assert!(rev(Some("")).is_err()); assert!(rev(Some("a b")).is_err()); assert_eq!(path(Some("/src/")), "src"); assert_eq!( cache_for(&"a".repeat(40)), "public, max-age=31536000, immutable" ); assert_eq!(cache_for("main"), "no-cache"); let _ = Params::default(); } }