1use anyhow::{Context, Result};
39use gunnar_store::git_storage_trait::{GitServe, ObjType};
40use gunnar_store::object::{self, EntryKind, TreeIter};
41
42use crate::stores::ServerStores;
43
44#[derive(Debug, Clone, Copy)]
48pub struct Limits {
49 pub max_file_bytes: u64,
51 pub max_matches: usize,
53}
54
55pub const DEFAULT_MAX_FILE_BYTES: u64 = 1 << 20;
57pub const DEFAULT_MAX_MATCHES: usize = 1000;
59
60impl Default for Limits {
61 fn default() -> Self {
62 Limits {
63 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
64 max_matches: DEFAULT_MAX_MATCHES,
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct Match {
72 pub path: String,
74 pub line_number: usize,
76 pub line: Vec<u8>,
78}
79
80fn root_tree(
82 git: &dyn GitServe,
83 hash: gix_hash::Kind,
84 commit: gix_hash::ObjectId,
85) -> Result<gix_hash::ObjectId> {
86 let (kind, bytes) = git
87 .read(commit.as_bytes())?
88 .with_context(|| format!("commit {commit} is not in this repository"))?;
89 anyhow::ensure!(kind == ObjType::Commit, "{commit} is not a commit");
90 Ok(object::commit(&bytes, hash)
91 .with_context(|| format!("decoding commit {commit}"))?
92 .tree)
93}
94
95pub fn search_tree(
102 git: &dyn GitServe,
103 hash: gix_hash::Kind,
104 commit: gix_hash::ObjectId,
105 needle: &[u8],
106 limits: Limits,
107) -> Result<Vec<Match>> {
108 anyhow::ensure!(!needle.is_empty(), "the search pattern is empty");
111
112 let mut out = Vec::new();
113 let root = root_tree(git, hash, commit)?;
114 walk(git, hash, root, "", needle, &limits, &mut out)?;
115 Ok(out)
116}
117
118fn walk(
120 git: &dyn GitServe,
121 hash: gix_hash::Kind,
122 tree: gix_hash::ObjectId,
123 prefix: &str,
124 needle: &[u8],
125 limits: &Limits,
126 out: &mut Vec<Match>,
127) -> Result<()> {
128 if out.len() >= limits.max_matches {
129 return Ok(());
130 }
131 let (kind, bytes) = match git.read(tree.as_bytes())? {
132 Some(pair) => pair,
133 None => return Ok(()),
134 };
135 if kind != ObjType::Tree {
136 return Ok(());
137 }
138 for entry in TreeIter::from_bytes(&bytes, hash).named() {
139 let entry = entry.with_context(|| format!("decoding tree {tree}"))?;
140 let name = String::from_utf8_lossy(entry.name);
141 let path = if prefix.is_empty() {
142 name.into_owned()
143 } else {
144 format!("{prefix}/{name}")
145 };
146 match entry.kind {
147 EntryKind::Tree => walk(git, hash, entry.oid, &path, needle, limits, out)?,
148 EntryKind::Blob | EntryKind::BlobExecutable => {
149 scan_blob(git, entry.oid, &path, needle, limits, out)?
150 }
151 EntryKind::Link | EntryKind::Commit => {}
155 }
156 if out.len() >= limits.max_matches {
157 break;
158 }
159 }
160 Ok(())
161}
162
163fn scan_blob(
165 git: &dyn GitServe,
166 oid: gix_hash::ObjectId,
167 path: &str,
168 needle: &[u8],
169 limits: &Limits,
170 out: &mut Vec<Match>,
171) -> Result<()> {
172 if let Some((_, size)) = git.header(oid.as_bytes())? {
176 if size > limits.max_file_bytes {
177 return Ok(());
178 }
179 }
180 let Some((ObjType::Blob, bytes)) = git.read(oid.as_bytes())? else {
181 return Ok(());
182 };
183 if bytes.len() as u64 > limits.max_file_bytes {
186 return Ok(());
187 }
188 if crate::diff::is_binary(&bytes) {
189 return Ok(());
190 }
191 for (i, line) in crate::diff::split_lines(&bytes).into_iter().enumerate() {
192 if contains(line, needle) {
193 out.push(Match {
194 path: path.to_owned(),
195 line_number: i + 1,
196 line: line.to_vec(),
197 });
198 if out.len() >= limits.max_matches {
199 break;
200 }
201 }
202 }
203 Ok(())
204}
205
206fn contains(haystack: &[u8], needle: &[u8]) -> bool {
208 if needle.is_empty() || needle.len() > haystack.len() {
209 return false;
210 }
211 haystack
212 .windows(needle.len())
213 .any(|window| window == needle)
214}
215
216pub fn search(
219 stores: &ServerStores,
220 plane: &crate::control_plane::CoreControlPlane,
221 repo: &gunnar_proto::RepoPath,
222 rev: &str,
223 pattern: &str,
224 limits: Limits,
225) -> Result<Vec<Match>> {
226 use gunnar_grpc::ControlPlane as _;
227
228 let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
229 .context("the repository path is not a legal one")?;
230 let entry = stores
231 .catalog()
232 .get(&repo_ref)?
233 .with_context(|| format!("no such repository: {repo_ref}"))?;
234 let store = stores.repo_store(&entry)?;
235 let git = store.serve();
236 let hash = store.hash_kind();
237
238 let commits = plane
239 .log(repo, rev, 1, "")
240 .map_err(|err| anyhow::anyhow!("{err}"))?;
241 let head = commits
242 .first()
243 .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
244 let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
245 .map_err(gix_error::Error::from)
246 .with_context(|| format!("{} is not an object id", head.oid))?;
247
248 search_tree(git, hash, tip, pattern.as_bytes(), limits)
249}
250
251pub fn search_at(
253 data_root: &std::path::Path,
254 repo: &gunnar_proto::RepoPath,
255 rev: &str,
256 pattern: &str,
257 limits: Limits,
258) -> Result<Vec<Match>> {
259 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
260 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
261 search(&stores, &plane, repo, rev, pattern, limits)
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::testgit::{Repo, TREE_MODE_BLOB, TREE_MODE_TREE};
268
269 #[test]
279 fn a_present_string_is_found_at_its_line_and_an_absent_one_is_not() {
280 let repo = Repo::new();
281 let readme = repo.blob(b"hello world\nthe needle is here\ngoodbye\n");
282 let other = repo.blob(b"nothing to see\nmove along\n");
283 let src = repo.tree(&[(TREE_MODE_BLOB, "main.rs", other)]);
284 let root = repo.tree(&[
285 (TREE_MODE_BLOB, "README.md", readme),
286 (TREE_MODE_TREE, "src", src),
287 ]);
288 let commit = repo.commit(root, &[], "Ada", "seed");
289
290 let hits = search_tree(
291 repo.serve(),
292 repo.hash_kind(),
293 commit,
294 b"needle",
295 Limits::default(),
296 )
297 .expect("search");
298 assert_eq!(
299 hits.len(),
300 1,
301 "the needle is on exactly one line of one file"
302 );
303 assert_eq!(hits[0].path, "README.md");
304 assert_eq!(hits[0].line_number, 2);
305 assert_eq!(hits[0].line, b"the needle is here");
306
307 let none = search_tree(
310 repo.serve(),
311 repo.hash_kind(),
312 commit,
313 b"haystackk",
314 Limits::default(),
315 )
316 .expect("search for an absent string is not an error");
317 assert!(
318 none.is_empty(),
319 "an absent string must yield zero hits, got {none:?}"
320 );
321 }
322
323 #[test]
327 fn the_ceilings_bind() {
328 let repo = Repo::new();
329 let big = {
331 let mut body = vec![b'x'; 64];
332 body.extend_from_slice(b"\nneedle\n");
333 repo.blob(&body)
334 };
335 let small = repo.blob(b"needle\nneedle\nneedle\n");
336 let root = repo.tree(&[
337 (TREE_MODE_BLOB, "big.txt", big),
338 (TREE_MODE_BLOB, "small.txt", small),
339 ]);
340 let commit = repo.commit(root, &[], "Ada", "seed");
341
342 let tight = Limits {
343 max_file_bytes: 8,
344 max_matches: DEFAULT_MAX_MATCHES,
345 };
346 let hits =
347 search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", tight).expect("search");
348 assert!(
349 hits.iter().all(|m| m.path == "small.txt"),
350 "the over-ceiling big.txt must be skipped, got {hits:?}"
351 );
352
353 let capped = Limits {
355 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
356 max_matches: 2,
357 };
358 let two =
359 search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", capped).expect("search");
360 assert_eq!(two.len(), 2, "the match ceiling caps the result at two");
361 }
362}