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).with_context(|| format!("{} is not an object id", head.oid))?;
246
247 search_tree(git, hash, tip, pattern.as_bytes(), limits)
248}
249
250pub fn search_at(
252 data_root: &std::path::Path,
253 repo: &gunnar_proto::RepoPath,
254 rev: &str,
255 pattern: &str,
256 limits: Limits,
257) -> Result<Vec<Match>> {
258 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
259 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
260 search(&stores, &plane, repo, rev, pattern, limits)
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::testgit::{Repo, TREE_MODE_BLOB, TREE_MODE_TREE};
267
268 #[test]
278 fn a_present_string_is_found_at_its_line_and_an_absent_one_is_not() {
279 let repo = Repo::new();
280 let readme = repo.blob(b"hello world\nthe needle is here\ngoodbye\n");
281 let other = repo.blob(b"nothing to see\nmove along\n");
282 let src = repo.tree(&[(TREE_MODE_BLOB, "main.rs", other)]);
283 let root = repo.tree(&[
284 (TREE_MODE_BLOB, "README.md", readme),
285 (TREE_MODE_TREE, "src", src),
286 ]);
287 let commit = repo.commit(root, &[], "Ada", "seed");
288
289 let hits = search_tree(
290 repo.serve(),
291 repo.hash_kind(),
292 commit,
293 b"needle",
294 Limits::default(),
295 )
296 .expect("search");
297 assert_eq!(hits.len(), 1, "the needle is on exactly one line of one file");
298 assert_eq!(hits[0].path, "README.md");
299 assert_eq!(hits[0].line_number, 2);
300 assert_eq!(hits[0].line, b"the needle is here");
301
302 let none = search_tree(
305 repo.serve(),
306 repo.hash_kind(),
307 commit,
308 b"haystackk",
309 Limits::default(),
310 )
311 .expect("search for an absent string is not an error");
312 assert!(
313 none.is_empty(),
314 "an absent string must yield zero hits, got {none:?}"
315 );
316 }
317
318 #[test]
322 fn the_ceilings_bind() {
323 let repo = Repo::new();
324 let big = {
326 let mut body = vec![b'x'; 64];
327 body.extend_from_slice(b"\nneedle\n");
328 repo.blob(&body)
329 };
330 let small = repo.blob(b"needle\nneedle\nneedle\n");
331 let root = repo.tree(&[
332 (TREE_MODE_BLOB, "big.txt", big),
333 (TREE_MODE_BLOB, "small.txt", small),
334 ]);
335 let commit = repo.commit(root, &[], "Ada", "seed");
336
337 let tight = Limits {
338 max_file_bytes: 8,
339 max_matches: DEFAULT_MAX_MATCHES,
340 };
341 let hits = search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", tight)
342 .expect("search");
343 assert!(
344 hits.iter().all(|m| m.path == "small.txt"),
345 "the over-ceiling big.txt must be skipped, got {hits:?}"
346 );
347
348 let capped = Limits {
350 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
351 max_matches: 2,
352 };
353 let two = search_tree(repo.serve(), repo.hash_kind(), commit, b"needle", capped)
354 .expect("search");
355 assert_eq!(two.len(), 2, "the match ceiling caps the result at two");
356 }
357}