//! In-process repo reads via gix (see docs/m2-interfaces.md). //! //! Everything here is blocking; callers run it inside `spawn_blocking`. Repositories are opened //! isolated: the environment and user/system git configuration are ignored. use std::path::Path; use gix::bstr::ByteSlice; use gix::objs::tree::EntryKind as GixEntryKind; use crate::error::{Error, Result}; /// Bytes inspected for a NUL when deciding whether a blob is binary. const BINARY_PROBE_BYTES: usize = 8000; /// Maximum accepted length of a repo path, in bytes. const MAX_PATH_BYTES: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefKind { Branch, Tag, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RefInfo { pub name: String, pub full_name: String, pub target: String, // peeled commit oid pub kind: RefKind, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EntryKind { Blob, Executable, Symlink, Tree, Submodule, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TreeEntry { pub name: String, pub kind: EntryKind, pub oid: String, pub size: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlobData { pub oid: String, pub kind: EntryKind, pub size: u64, pub data: Vec, pub truncated: bool, pub is_binary: bool, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitInfo { pub oid: String, pub parents: Vec, pub author_name: String, pub author_email: String, pub author_time: i64, pub committer_name: String, pub committer_email: String, pub committer_time: i64, pub summary: String, pub message: String, } fn gerr(e: impl std::fmt::Display) -> Error { Error::Failed(e.to_string()) } fn open(repo: &Path) -> Result { gix::open_opts(repo, gix::open::Options::isolated()).map_err(gerr) } /// Git's ref-name rules (check-ref-format), plus rejection of anything that could be read as /// revision syntax. Only names passing this are ever handed to gix. fn valid_ref_name(name: &str) -> bool { if name.is_empty() || name.len() > 255 || name == "@" { return false; } if name.starts_with('-') || name.starts_with('/') || name.ends_with('/') || name.ends_with('.') { return false; } if name.contains("..") || name.contains("@{") || name.contains("//") { return false; } if name .chars() .any(|c| c.is_control() || " ~^:?*[\\".contains(c)) { return false; } name.split('/') .all(|seg| !seg.is_empty() && !seg.starts_with('.') && !seg.ends_with(".lock")) } fn valid_hex_prefix(s: &str) -> bool { (4..=64).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit()) } fn parse_oid(hex: &str) -> Result { gix::ObjectId::from_hex(hex.as_bytes()) .map_err(|_| Error::InvalidInput(format!("not a full object id: {hex}"))) } /// Commit id of `refs/heads/` or `refs/tags/` (tags peeled), if it exists and is a commit. /// The name must already be validated. fn ref_commit(repo: &gix::Repository, name: &str) -> Result> { for prefix in ["refs/heads/", "refs/tags/"] { let full = format!("{prefix}{name}"); let Some(mut reference) = repo.try_find_reference(full.as_str()).map_err(gerr)? else { continue; }; let id = reference.peel_to_id().map_err(gerr)?.detach(); if is_commit(repo, id)? { return Ok(Some(id)); } } Ok(None) } fn is_commit(repo: &gix::Repository, id: gix::ObjectId) -> Result { Ok(matches!( repo.try_find_header(id).map_err(gerr)?, Some(h) if h.kind() == gix::objs::Kind::Commit )) } /// Every branch and tag, with tags peeled to commits. Refs that do not peel to a commit are skipped. pub fn list_refs(repo: &Path) -> Result> { let repo = open(repo)?; let refs = repo.references().map_err(gerr)?; let mut out = Vec::new(); let groups = [ (RefKind::Branch, refs.local_branches().map_err(gerr)?), (RefKind::Tag, refs.tags().map_err(gerr)?), ]; for (kind, iter) in groups { for reference in iter { let mut reference = reference.map_err(gerr)?; let full_name = reference.name().as_bstr().to_str_lossy().into_owned(); let name = reference.name().shorten().to_str_lossy().into_owned(); let id = reference.peel_to_id().map_err(gerr)?.detach(); if !is_commit(&repo, id)? { continue; } out.push(RefInfo { name, full_name, target: id.to_hex().to_string(), kind, }); } } Ok(out) } /// Commit at the tip of `refs/heads/`, or None if the branch does not exist. pub fn default_branch_commit(repo: &Path, branch: &str) -> Result> { if !valid_ref_name(branch) { return Ok(None); } let repo = open(repo)?; let full = format!("refs/heads/{branch}"); let Some(mut reference) = repo.try_find_reference(full.as_str()).map_err(gerr)? else { return Ok(None); }; let id = reference.peel_to_id().map_err(gerr)?.detach(); Ok(is_commit(&repo, id)?.then(|| id.to_hex().to_string())) } /// Resolve user input to a full commit oid. Accepts a branch or tag name, or a 4-64 char hex prefix /// that matches exactly one commit. Anything else resolves to None. pub fn resolve_commit(repo: &Path, rev: &str) -> Result> { if valid_ref_name(rev) { let repo = open(repo)?; if let Some(id) = ref_commit(&repo, rev)? { return Ok(Some(id.to_hex().to_string())); } // A ref name that doesn't exist may still be a hex prefix below. if !valid_hex_prefix(rev) { return Ok(None); } return resolve_hex(&repo, rev); } if valid_hex_prefix(rev) { let repo = open(repo)?; return resolve_hex(&repo, rev); } Ok(None) } fn resolve_hex(repo: &gix::Repository, hex: &str) -> Result> { let lower = hex.to_ascii_lowercase(); let prefix = gix::hash::Prefix::from_hex(&lower).map_err(gerr)?; let outcome = repo.objects.lookup_prefix(prefix, None).map_err(gerr)?; match outcome { Some(Ok(id)) if is_commit(repo, id)? => Ok(Some(id.to_hex().to_string())), _ => Ok(None), // missing, ambiguous, or not a commit } } /// Split `"feature/x/src/main.rs"` into `("feature/x", commit, "src/main.rs")`. The longest existing /// branch or tag prefix wins. Otherwise a leading hex segment is taken as a commit prefix. pub fn split_ref_and_path(repo: &Path, rest: &str) -> Result> { let segments: Vec<&str> = rest.split('/').collect(); let repo = open(repo)?; for count in (1..=segments.len()).rev() { let candidate = segments[..count].join("/"); if !valid_ref_name(&candidate) { continue; } if let Some(id) = ref_commit(&repo, &candidate)? { let path = segments[count..].join("/"); if validate_repo_path(&path).is_err() { return Ok(None); } return Ok(Some((candidate, id.to_hex().to_string(), path))); } } let first = segments[0]; if valid_hex_prefix(first) && let Some(oid) = resolve_hex(&repo, first)? { let path = segments[1..].join("/"); if validate_repo_path(&path).is_err() { return Ok(None); } return Ok(Some((first.to_string(), oid, path))); } Ok(None) } fn map_kind(kind: GixEntryKind) -> EntryKind { match kind { GixEntryKind::Tree => EntryKind::Tree, GixEntryKind::Blob => EntryKind::Blob, GixEntryKind::BlobExecutable => EntryKind::Executable, GixEntryKind::Link => EntryKind::Symlink, GixEntryKind::Commit => EntryKind::Submodule, } } /// Tree object for `commit` at `path` ("" is the root). Ok(None) if the commit is missing or the /// path does not exist. A path that is a blob also yields None. fn tree_at<'r>( repo: &'r gix::Repository, commit: &str, path: &str, ) -> Result>> { let id = parse_oid(commit)?; if !is_commit(repo, id)? { return Ok(None); } let root = repo.find_commit(id).map_err(gerr)?.tree().map_err(gerr)?; if path.is_empty() { return Ok(Some(root)); } let mut root = root; let Some(entry) = root.peel_to_entry_by_path(path).map_err(gerr)? else { return Ok(None); }; if entry.mode().kind() != gix::objs::tree::EntryKind::Tree { return Ok(None); } let tree = entry .object() .map_err(gerr)? .try_into_tree() .map_err(gerr)?; Ok(Some(tree)) } /// Directory listing at `path` in `commit`: directories first, then names in bytewise order. pub fn read_tree(repo: &Path, commit: &str, path: &str) -> Result>> { validate_repo_path(path)?; let repo = open(repo)?; let Some(tree) = tree_at(&repo, commit, path)? else { return Ok(None); }; let mut entries = Vec::new(); for entry in tree.iter() { let entry = entry.map_err(gerr)?; let kind = map_kind(entry.mode().kind()); let oid = entry.object_id(); let size = match kind { EntryKind::Tree | EntryKind::Submodule => None, _ => Some(repo.find_header(oid).map_err(gerr)?.size()), }; entries.push(TreeEntry { name: entry.filename().to_str_lossy().into_owned(), kind, oid: oid.to_hex().to_string(), size, }); } entries.sort_by(|a, b| { (a.kind != EntryKind::Tree, a.name.as_bytes()) .cmp(&(b.kind != EntryKind::Tree, b.name.as_bytes())) }); Ok(Some(entries)) } /// Blob at `path` in `commit`, with at most `max_bytes` of data. Symlinks yield their target text. /// Submodules yield an empty entry (their target commit is not in this repo). Trees and missing /// paths yield None. /// /// Note: gix offers no public streaming read for packed objects, so the whole blob is loaded and /// then truncated to `max_bytes`. pub fn read_blob( repo: &Path, commit: &str, path: &str, max_bytes: usize, ) -> Result> { validate_repo_path(path)?; if path.is_empty() { return Ok(None); } let repo = open(repo)?; let id = parse_oid(commit)?; if !is_commit(&repo, id)? { return Ok(None); } let mut root = repo.find_commit(id).map_err(gerr)?.tree().map_err(gerr)?; let Some(entry) = root.peel_to_entry_by_path(path).map_err(gerr)? else { return Ok(None); }; let kind = map_kind(entry.mode().kind()); let oid = entry.object_id(); match kind { EntryKind::Tree => Ok(None), EntryKind::Submodule => Ok(Some(BlobData { oid: oid.to_hex().to_string(), kind, size: 0, data: Vec::new(), truncated: false, is_binary: false, })), EntryKind::Blob | EntryKind::Executable | EntryKind::Symlink => { let blob = repo.find_blob(oid).map_err(gerr)?; let full = &blob.data; let size = full.len() as u64; let is_binary = full[..full.len().min(BINARY_PROBE_BYTES)].contains(&0); let truncated = full.len() > max_bytes; let data = full[..full.len().min(max_bytes)].to_vec(); Ok(Some(BlobData { oid: oid.to_hex().to_string(), kind, size, data, truncated, is_binary, })) } } } /// The text of the regular file at `path` in `commit`. None when the path is missing, is not a /// regular file or symlink, is binary, is not valid UTF-8, or is larger than `max_bytes`. pub fn read_text_blob( repo: &Path, commit: &str, path: &str, max_bytes: usize, ) -> Result> { let Some(blob) = read_blob(repo, commit, path, max_bytes)? else { return Ok(None); }; if matches!(blob.kind, EntryKind::Tree | EntryKind::Submodule) || blob.truncated || blob.is_binary { return Ok(None); } Ok(String::from_utf8(blob.data).ok()) } fn lossy(bytes: &[u8]) -> String { String::from_utf8_lossy(bytes).into_owned() } /// Metadata and message of a commit. Ok(None) if the object is missing or not a commit. pub fn read_commit(repo: &Path, oid: &str) -> Result> { let repo = open(repo)?; let id = parse_oid(oid)?; if !is_commit(&repo, id)? { return Ok(None); } let commit = repo.find_commit(id).map_err(gerr)?; let decoded = commit.decode().map_err(gerr)?; let author = decoded.author().map_err(gerr)?; let committer = decoded.committer().map_err(gerr)?; let message = lossy(commit.message_raw_sloppy()); let summary = message.lines().next().unwrap_or_default().to_string(); Ok(Some(CommitInfo { oid: id.to_hex().to_string(), parents: decoded.parents().map(|p| p.to_hex().to_string()).collect(), author_name: lossy(author.name), author_email: lossy(author.email), author_time: author.time().map_err(gerr)?.seconds, committer_name: lossy(committer.name), committer_email: lossy(committer.email), committer_time: committer.time().map_err(gerr)?.seconds, summary, message, })) } /// Rules for `/`-separated repo paths: no empty, `.` or `..` segment, no NUL, at most 4096 bytes. /// The empty path (the root) is valid. pub fn validate_repo_path(path: &str) -> Result<()> { if path.len() > MAX_PATH_BYTES { return Err(Error::InvalidInput("path too long".into())); } if path.contains('\0') { return Err(Error::InvalidInput("path contains NUL".into())); } if path.is_empty() { return Ok(()); } for seg in path.split('/') { if seg.is_empty() || seg == "." || seg == ".." { return Err(Error::InvalidInput(format!( "invalid path segment in {path:?}" ))); } } Ok(()) }