Skip to main content

solid_pod_rs_forge/
search.rs

1//! In-repo code search: a bounded, unindexed `git grep` over the default
2//! branch, plus path-name matches.
3//!
4//! Follows the JSS `forge` `searchData` / `searchPage` / `apiSearch`
5//! behaviour (cited by name only). The query is a literal, case-insensitive
6//! substring (`git grep -I -n -i -F -z -e <q>`): argv only, never a shell,
7//! never a regex, and the query always follows `-e` so it cannot be read as
8//! an option. Bounds: query 256 chars, 100 hits, 100 path matches, 5 hits per
9//! file, 200-char excerpts.
10
11use 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
22/// Maximum query length in characters.
23pub const SEARCH_QUERY_CAP: usize = 256;
24/// Maximum returned grep hits.
25pub const SEARCH_HIT_CAP: usize = 100;
26/// Maximum returned path matches.
27pub const SEARCH_PATH_CAP: usize = 100;
28/// `git grep --max-count` per file.
29pub const SEARCH_PER_FILE_CAP: usize = 5;
30/// Characters kept of each matching line.
31pub const SEARCH_EXCERPT_CAP: usize = 200;
32/// Ceiling on `git grep` output we are willing to buffer.
33const GREP_OUTPUT_CAP: usize = 8 * 1024 * 1024;
34
35/// One matching line.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37pub struct SearchMatch {
38    /// Repo-relative file path.
39    pub path: String,
40    /// 1-based line number.
41    pub line: u64,
42    /// The line, cut to [`SEARCH_EXCERPT_CAP`] characters.
43    pub text: String,
44}
45
46/// The result of one search.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
48pub struct SearchResult {
49    /// The query as searched.
50    pub q: String,
51    /// The branch searched.
52    #[serde(rename = "ref")]
53    pub rev: String,
54    /// File paths whose name contains the query.
55    pub paths: Vec<String>,
56    /// Matching lines.
57    pub matches: Vec<SearchMatch>,
58    /// `true` when a cap cut the result short.
59    pub truncated: bool,
60}
61
62/// Parse the NUL-framed `git grep -z -n` output (`<tree>:<path>\0<line>\0<text>\n`).
63///
64/// # Examples
65///
66/// ```
67/// use solid_pod_rs_forge::search::parse_grep;
68///
69/// let (m, truncated) = parse_grep("main:docs/a:b.txt\u{0}3\u{0}hello\nmain:x\u{0}1\u{0}y\n");
70/// assert_eq!(m[0].path, "docs/a:b.txt");
71/// assert_eq!(m[0].line, 3);
72/// assert_eq!(m[1].text, "y");
73/// assert!(!truncated);
74/// ```
75#[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
105/// Run a search over `branch` of the bare repo at `dir`.
106///
107/// # Errors
108///
109/// [`ForgeError::Io`] / [`ForgeError::Unsupported`] when git cannot be run.
110/// An empty repository or no match is an empty result, not an error.
111pub 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        // git older than 2.38: no per-file cap, the total cap still holds.
160        args.remove(6);
161        o = gitrun::run_heavy(dir, &args, GREP_OUTPUT_CAP).await?;
162    }
163    // Exit 1 is "no match": an empty result, not an error. Anything else
164    // (empty repo, unknown branch) is also an empty result.
165    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
173/// Normalise a raw query value: percent-decode, trim.
174fn 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    /// `GET /api/repos/<o>/<r>/search?q=` (JSS `apiSearch`).
182    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    /// `GET /<owner>/<repo>/search?q=` (JSS `searchPage`).
206    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        // multibyte text is cut on a char boundary, not a byte boundary
249        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    // JSS "in-repo search greps the default branch: path + line + excerpt, bounded shape"
255    #[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    // JSS "search misses cleanly"
304    #[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        // a regex metacharacter is a literal: `.*` matches nothing here
331        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        // an option-looking query is just text
340        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    // JSS HTML search page: line-anchored links, escaped query and excerpts
377    #[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("&lt;script&gt;alert(9)"));
401        let x = page("%3Cscript%3E").await;
402        assert!(
403            x.contains("&lt;script&gt;"),
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}