1use std::path::Path;
12
13use serde::Serialize;
14use serde_json::json;
15
16use crate::api::{api_err, json_resp};
17use crate::error::ForgeError;
18use crate::guard::ok_ref;
19use crate::request::{percent_decode, ForgeRequest, ForgeResponse};
20use crate::{gitrun, html, ForgeService};
21
22pub const SEARCH_QUERY_CAP: usize = 256;
24pub const SEARCH_HIT_CAP: usize = 100;
26pub const SEARCH_PATH_CAP: usize = 100;
28pub const SEARCH_PER_FILE_CAP: usize = 5;
30pub const SEARCH_EXCERPT_CAP: usize = 200;
32const GREP_OUTPUT_CAP: usize = 8 * 1024 * 1024;
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct SearchMatch {
38 pub path: String,
40 pub line: u64,
42 pub text: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct SearchResult {
49 pub q: String,
51 #[serde(rename = "ref")]
53 pub rev: String,
54 pub paths: Vec<String>,
56 pub matches: Vec<SearchMatch>,
58 pub truncated: bool,
60}
61
62#[must_use]
76pub fn parse_grep(raw: &str) -> (Vec<SearchMatch>, bool) {
77 let mut out = Vec::new();
78 for line in raw.split('\n') {
79 if line.is_empty() {
80 continue;
81 }
82 let mut parts = line.split('\0');
83 let tree_path = parts.next().unwrap_or("");
84 let Some(line_no) = parts.next() else {
85 continue;
86 };
87 if out.len() >= SEARCH_HIT_CAP {
88 return (out, true);
89 }
90 let text: Vec<&str> = parts.collect();
91 let text = text.join("\0");
92 let path = tree_path
93 .split_once(':')
94 .map_or(tree_path, |(_, p)| p)
95 .to_string();
96 out.push(SearchMatch {
97 path,
98 line: line_no.parse().unwrap_or(0),
99 text: text.chars().take(SEARCH_EXCERPT_CAP).collect(),
100 });
101 }
102 (out, false)
103}
104
105pub async fn search_data(dir: &Path, branch: &str, q: &str) -> Result<SearchResult, ForgeError> {
112 let mut res = SearchResult {
113 q: q.to_string(),
114 rev: branch.to_string(),
115 paths: Vec::new(),
116 matches: Vec::new(),
117 truncated: false,
118 };
119 if !ok_ref(branch) {
120 return Ok(res);
121 }
122 let needle = q.to_lowercase();
123 if let Ok(o) = gitrun::run(
124 dir,
125 &["ls-tree", "-r", "--name-only", "-z", branch],
126 32 * 1024 * 1024,
127 )
128 .await
129 {
130 if o.code == Some(0) {
131 for p in String::from_utf8_lossy(&o.stdout).split('\0') {
132 if p.is_empty() || !p.to_lowercase().contains(&needle) {
133 continue;
134 }
135 if res.paths.len() >= SEARCH_PATH_CAP {
136 res.truncated = true;
137 break;
138 }
139 res.paths.push(p.to_string());
140 }
141 }
142 }
143 let max = format!("--max-count={SEARCH_PER_FILE_CAP}");
144 let mut args = vec![
145 "grep",
146 "-I",
147 "-n",
148 "-i",
149 "-F",
150 "-z",
151 max.as_str(),
152 "-e",
153 q,
154 branch,
155 "--",
156 ];
157 let mut o = gitrun::run_heavy(dir, &args, GREP_OUTPUT_CAP).await?;
158 if o.code.is_some_and(|c| c >= 2) && o.stderr.contains("max-count") {
159 args.remove(6);
161 o = gitrun::run_heavy(dir, &args, GREP_OUTPUT_CAP).await?;
162 }
163 if o.code == Some(0) || o.truncated {
166 let (matches, cut) = parse_grep(&String::from_utf8_lossy(&o.stdout));
167 res.matches = matches;
168 res.truncated |= cut || o.truncated;
169 }
170 Ok(res)
171}
172
173fn clean_query(req: &ForgeRequest) -> String {
175 req.query_param("q")
176 .map(|v| percent_decode(&v).trim().to_string())
177 .unwrap_or_default()
178}
179
180impl ForgeService {
181 pub(crate) async fn api_search(
183 &self,
184 req: &ForgeRequest,
185 dir: &Path,
186 ) -> Result<ForgeResponse, ForgeError> {
187 let q = clean_query(req);
188 if q.is_empty() {
189 return Ok(api_err(422, "q required"));
190 }
191 if q.chars().count() > SEARCH_QUERY_CAP {
192 return Ok(api_err(
193 422,
194 &format!("q too long (max {SEARCH_QUERY_CAP} chars)"),
195 ));
196 }
197 if q.contains('\0') {
198 return Ok(api_err(422, "q contains an invalid character"));
199 }
200 let branch = crate::repo::browse::default_branch(dir).await;
201 let r = search_data(dir, &branch, &q).await?;
202 Ok(json_resp(200, &json!(r)))
203 }
204
205 pub(crate) async fn h_search_page(
207 &self,
208 req: &ForgeRequest,
209 owner: &str,
210 repo: &str,
211 ) -> Result<ForgeResponse, ForgeError> {
212 let dir = self.resolve_repo(owner, repo).await?;
213 let q: String = clean_query(req)
214 .chars()
215 .filter(|c| *c != '\0')
216 .take(SEARCH_QUERY_CAP)
217 .collect();
218 let branch = crate::repo::browse::default_branch(&dir).await;
219 let data = if q.is_empty() {
220 None
221 } else {
222 Some(search_data(&dir, &branch, &q).await?)
223 };
224 Ok(ForgeResponse::html(
225 200,
226 html::search_page(&self.prefix(), owner, repo, &branch, &q, data.as_ref()),
227 ))
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::api::tests::{fixture, get_json, rq};
235 use crate::ownership::ForgeAgent;
236
237 #[test]
238 fn grep_parser_caps_hits_and_excerpts() {
239 let long = "x".repeat(500);
240 let mut raw = String::new();
241 for i in 0..150 {
242 raw.push_str(&format!("main:f{i}.txt\u{0}{i}\u{0}{long}\n"));
243 }
244 let (m, truncated) = parse_grep(&raw);
245 assert_eq!(m.len(), SEARCH_HIT_CAP);
246 assert!(truncated);
247 assert_eq!(m[0].text.chars().count(), SEARCH_EXCERPT_CAP);
248 let uni = "é".repeat(300);
250 let (m, _) = parse_grep(&format!("main:f\u{0}1\u{0}{uni}\n"));
251 assert_eq!(m[0].text.chars().count(), SEARCH_EXCERPT_CAP);
252 }
253
254 #[tokio::test]
256 async fn api_search_finds_line_and_path() {
257 let (_td, svc, _) = fixture();
258 let (st, s) = get_json(
259 &svc,
260 "/forge/api/repos/casey/demo/search",
261 "q=subdir",
262 ForgeAgent::Anonymous,
263 )
264 .await;
265 assert_eq!(st, 200);
266 assert_eq!(s["q"], "subdir");
267 assert_eq!(s["ref"], "main");
268 assert_eq!(s["truncated"], false);
269 assert!(s["paths"].is_array());
270 let hit = s["matches"]
271 .as_array()
272 .unwrap()
273 .iter()
274 .find(|m| m["path"] == "docs/notes.txt")
275 .expect("known string found");
276 assert_eq!(hit["line"], 1);
277 assert!(hit["text"]
278 .as_str()
279 .unwrap()
280 .contains("notes live in a subdir"));
281 let (_, p) = get_json(
282 &svc,
283 "/forge/api/repos/casey/demo/search",
284 "q=notes",
285 ForgeAgent::Anonymous,
286 )
287 .await;
288 assert!(p["paths"]
289 .as_array()
290 .unwrap()
291 .iter()
292 .any(|x| x == "docs/notes.txt"));
293 let (st, _) = get_json(
294 &svc,
295 "/forge/api/repos/casey/demo/search",
296 "",
297 ForgeAgent::Anonymous,
298 )
299 .await;
300 assert_eq!(st, 422, "q is required on the api");
301 }
302
303 #[tokio::test]
305 async fn search_miss_is_empty_not_error() {
306 let (_td, svc, _) = fixture();
307 let (st, m) = get_json(
308 &svc,
309 "/forge/api/repos/casey/demo/search",
310 "q=zqxjkwv",
311 ForgeAgent::Anonymous,
312 )
313 .await;
314 assert_eq!(st, 200);
315 assert_eq!(m["paths"], json!([]));
316 assert_eq!(m["matches"], json!([]));
317 let r = svc
318 .handle(
319 rq("GET", "/forge/casey/demo/search", "q=zqxjkwv", ""),
320 ForgeAgent::Anonymous,
321 )
322 .await
323 .unwrap();
324 assert!(String::from_utf8_lossy(&r.body).contains("No results"));
325 }
326
327 #[tokio::test]
328 async fn query_is_literal_bounded_and_option_safe() {
329 let (_td, svc, _) = fixture();
330 let (_, m) = get_json(
332 &svc,
333 "/forge/api/repos/casey/demo/search",
334 "q=.*",
335 ForgeAgent::Anonymous,
336 )
337 .await;
338 assert_eq!(m["matches"], json!([]));
339 let (st, m) = get_json(
341 &svc,
342 "/forge/api/repos/casey/demo/search",
343 "q=--open-files-in-pager%3Ctouch%20x%3E",
344 ForgeAgent::Anonymous,
345 )
346 .await;
347 assert_eq!(st, 200);
348 assert_eq!(m["matches"], json!([]));
349 let (st, _) = get_json(
350 &svc,
351 "/forge/api/repos/casey/demo/search",
352 &format!("q={}", "a".repeat(257)),
353 ForgeAgent::Anonymous,
354 )
355 .await;
356 assert_eq!(st, 422);
357 let (st, _) = get_json(
358 &svc,
359 "/forge/api/repos/casey/demo/search",
360 "q=a%00b",
361 ForgeAgent::Anonymous,
362 )
363 .await;
364 assert_eq!(st, 422);
365 let (st, ok) = get_json(
366 &svc,
367 "/forge/api/repos/casey/demo/search",
368 &format!("q={}", "a".repeat(256)),
369 ForgeAgent::Anonymous,
370 )
371 .await;
372 assert_eq!(st, 200);
373 assert_eq!(ok["matches"], json!([]));
374 }
375
376 #[tokio::test]
378 async fn html_search_page_is_escaped_and_line_anchored() {
379 let (_td, svc, _) = fixture();
380 let page = |q: &str| {
381 let svc = svc.clone();
382 let q = q.to_string();
383 async move {
384 let r = svc
385 .handle(
386 rq("GET", "/forge/casey/demo/search", &format!("q={q}"), ""),
387 ForgeAgent::Anonymous,
388 )
389 .await
390 .unwrap();
391 String::from_utf8_lossy(&r.body).into_owned()
392 }
393 };
394 let h = page("subdir").await;
395 assert!(h.contains("docs/notes.txt"));
396 assert!(h.contains("#L1"));
397 assert!(h.contains("notes live in a subdir"));
398 let evil = page("%3Cscript%3Ealert(9)%3C%2Fscript%3E").await;
399 assert!(!evil.contains("<script>alert(9)"));
400 assert!(evil.contains("<script>alert(9)"));
401 let x = page("%3Cscript%3E").await;
402 assert!(
403 x.contains("<script>"),
404 "matching excerpt visible, escaped"
405 );
406 assert!(!x.contains("<script>alert"));
407 let empty = page("").await;
408 assert!(empty.contains("name=\"q\""));
409 }
410
411 #[tokio::test]
412 async fn empty_repo_search_is_empty() {
413 let td = tempfile::TempDir::new().unwrap();
414 let svc = ForgeService::new(crate::ForgeConfig::default(), td.path()).unwrap();
415 let bare = td.path().join("repos/casey/blank.git");
416 std::fs::create_dir_all(bare.parent().unwrap()).unwrap();
417 crate::gitrun::testutil::git(
418 td.path(),
419 &["init", "--bare", "--quiet", bare.to_str().unwrap()],
420 );
421 let (st, m) = get_json(
422 &svc,
423 "/forge/api/repos/casey/blank/search",
424 "q=x",
425 ForgeAgent::Anonymous,
426 )
427 .await;
428 assert_eq!(st, 200);
429 assert_eq!(m["matches"], json!([]));
430 }
431}