use anyhow::{Context, Result};
use gunnar_store::git_storage_trait::{GitServe, ObjType};
use gunnar_store::object::{self, EntryKind, TreeIter};
use crate::stores::ServerStores;
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_file_bytes: u64,
pub max_matches: usize,
}
pub const DEFAULT_MAX_FILE_BYTES: u64 = 1 << 20;
pub const DEFAULT_MAX_MATCHES: usize = 1000;
impl Default for Limits {
fn default() -> Self {
Limits {
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
max_matches: DEFAULT_MAX_MATCHES,
}
}
}
#[derive(Debug, Clone)]
pub struct Match {
pub path: String,
pub line_number: usize,
pub line: Vec<u8>,
}
fn root_tree(
git: &dyn GitServe,
hash: gix_hash::Kind,
commit: gix_hash::ObjectId,
) -> Result<gix_hash::ObjectId> {
let (kind, bytes) = git
.read(commit.as_bytes())?
.with_context(|| format!("commit {commit} is not in this repository"))?;
anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
Ok(object::commit(&bytes, hash)
.with_context(|| format!("decoding commit {commit}"))?
.tree)
}
pub fn search_tree(
git: &dyn GitServe,
hash: gix_hash::Kind,
commit: gix_hash::ObjectId,
needle: &[u8],
limits: Limits,
) -> Result<Vec<Match>> {
anyhow::ensure!(!needle.is_empty(), "the search pattern is empty");
let mut out = Vec::new();
let root = root_tree(git, hash, commit)?;
walk(git, hash, root, "", needle, &limits, &mut out)?;
Ok(out)
}
fn walk(
git: &dyn GitServe,
hash: gix_hash::Kind,
tree: gix_hash::ObjectId,
prefix: &str,
needle: &[u8],
limits: &Limits,
out: &mut Vec<Match>,
) -> Result<()> {
if out.len() >= limits.max_matches {
return Ok(());
}
let (kind, bytes) = match git.read(tree.as_bytes())? {
Some(pair) => pair,
None => return Ok(()),
};
if kind != ObjType::Tree {
return Ok(());
}
for entry in TreeIter::from_bytes(&bytes, hash).named() {
let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
let name = String::from_utf8_lossy(entry.name);
let path = if prefix.is_empty() {
name.into_owned()
} else {
format!("{prefix}/{name}")
};
match entry.kind {
EntryKind::Tree => walk(git, hash, entry.oid, &path, needle, limits, out)?,
EntryKind::Blob | EntryKind::BlobExecutable => {
scan_blob(git, entry.oid, &path, needle, limits, out)?
}
EntryKind::Link | EntryKind::Commit => {}
}
if out.len() >= limits.max_matches {
break;
}
}
Ok(())
}
fn scan_blob(
git: &dyn GitServe,
oid: gix_hash::ObjectId,
path: &str,
needle: &[u8],
limits: &Limits,
out: &mut Vec<Match>,
) -> Result<()> {
if let Some((_, size)) = git.header(oid.as_bytes())? {
if size > limits.max_file_bytes {
return Ok(());
}
}
let Some((ObjType::Blob, bytes)) = git.read(oid.as_bytes())? else {
return Ok(());
};
if bytes.len() as u64 > limits.max_file_bytes {
return Ok(());
}
if crate::diff::is_binary(&bytes) {
return Ok(());
}
for (i, line) in crate::diff::split_lines(&bytes).into_iter().enumerate() {
if contains(line, needle) {
out.push(Match {
path: path.to_owned(),
line_number: i + 1,
line: line.to_vec(),
});
if out.len() >= limits.max_matches {
break;
}
}
}
Ok(())
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
pub fn search(
stores: &ServerStores,
plane: &crate::control_plane::CoreControlPlane,
repo: &gunnar_proto::RepoPath,
rev: &str,
pattern: &str,
limits: Limits,
) -> Result<Vec<Match>> {
use gunnar_grpc::ControlPlane as _;
let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
.context("the repository path is not a legal one")?;
let entry = stores
.catalog()
.get(&repo_ref)?
.with_context(|| format!("no such repository: {repo_ref}"))?;
let store = stores.repo_store(&entry)?;
let git = store.serve();
let hash = store.hash_kind();
let commits = plane
.log(repo, rev, 1, "")
.map_err(|err| anyhow::anyhow!("{err}"))?;
let head = commits
.first()
.with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
.map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
search_tree(git, hash, tip, pattern.as_bytes(), limits)
}
pub fn search_at(
data_root: &std::path::Path,
repo: &gunnar_proto::RepoPath,
rev: &str,
pattern: &str,
limits: Limits,
) -> Result<Vec<Match>> {
let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
search(&stores, &plane, repo, rev, pattern, limits)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testgit::{Repo, TREE_MODE_BLOB, TREE_MODE_TREE};
#[test]
fn a_present_string_is_found_at_its_line_and_an_absent_one_is_not() {
let repo = Repo::new();
let readme = repo.blob(b"hello world\nthe needle is here\ngoodbye\n");
let other = repo.blob(b"nothing to see\nmove along\n");
let src = repo.tree(&[(TREE_MODE_BLOB, "main.rs", other)]);
let root = repo.tree(&[
(TREE_MODE_BLOB, "README.md", readme),
(TREE_MODE_TREE, "src", src),
]);
let commit = repo.commit(root, &[], "Ada", "seed");
let hits = search_tree(
repo.serve(),
repo.hash_kind(),
commit,
b"needle",
Limits::default(),
)
.expect("search");
assert_eq!(hits.len(), 1, "the needle is on exactly one line of one file");
assert_eq!(hits[0].path, "README.md");
assert_eq!(hits[0].line_number, 2);
assert_eq!(hits[0].line, b"the needle is here");
let none = search_tree(
repo.serve(),
repo.hash_kind(),
commit,
b"haystackk",
Limits::default(),
)
.expect("search for an absent string is not an error");
assert!(
none.is_empty(),
"an absent string must yield zero hits, got {none:?}"
);
}
#[test]
fn the_ceilings_bind() {
let repo = Repo::new();
let big = {
let mut body = vec![b'x'; 64];
body.extend_from_slice(b"\nneedle\n");
repo.blob(&body)
};
let small = repo.blob(b"needle\nneedle\nneedle\n");
let root = repo.tree(&[
(TREE_MODE_BLOB, "big.txt", big),
(TREE_MODE_BLOB, "small.txt", small),
]);
let commit = repo.commit(root, &[], "Ada", "seed");
let tight = Limits {
max_file_bytes: 8,
max_matches: DEFAULT_MAX_MATCHES,
};
let hits = search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", tight)
.expect("search");
assert!(
hits.iter().all(|m| m.path == "small.txt"),
"the over-ceiling big.txt must be skipped, got {hits:?}"
);
let capped = Limits {
max_file_bytes: DEFAULT_MAX_FILE_BYTES,
max_matches: 2,
};
let two = search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", capped)
.expect("search");
assert_eq!(two.len(), 2, "the match ceiling caps the result at two");
}
}