Skip to main content

mcp_methods/server/
source.rs

1//! Source-file tooling: ``read_source`` / ``grep`` / ``list_source``.
2//!
3//! Operates on a *dynamic* source root provider — a closure returning
4//! the active list of allowed dirs at the moment of each tool call.
5//! GitHub-workspace mode wires this to the active repo's path; local-
6//! workspace mode wires it to the bound root (re-routed on each
7//! `set_root_dir` call); `--source-root` and `--watch` modes wire it
8//! to a fixed root. An empty list signals "no active source" and the
9//! tools return a friendly error.
10//!
11//! All path traversal protection is done by canonicalising the
12//! resolved path against the allowed dirs before any I/O happens.
13//!
14//! Design: stay close to the existing Python `mcp_methods` semantics
15//! (line numbers, header format, "showing N of M matches", etc.) so a
16//! manifest written for the legacy Python server returns visually
17//! similar output.
18
19#![allow(dead_code)]
20
21use std::fs;
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use grep_matcher::Matcher;
26use grep_regex::RegexMatcherBuilder;
27use grep_searcher::sinks::UTF8;
28use grep_searcher::SearcherBuilder;
29use ignore::overrides::OverrideBuilder;
30use ignore::WalkBuilder;
31use regex::Regex;
32
33/// Provider returning the current allowed source dirs.
34pub type SourceRootsProvider = Arc<dyn Fn() -> Vec<String> + Send + Sync>;
35
36// ---------------------------------------------------------------------------
37// read_source
38// ---------------------------------------------------------------------------
39
40#[derive(Debug, Default, Clone)]
41pub struct ReadOpts {
42    pub start_line: Option<usize>,
43    pub end_line: Option<usize>,
44    pub grep: Option<String>,
45    pub grep_context: Option<usize>,
46    pub max_matches: Option<usize>,
47    pub max_chars: Option<usize>,
48    /// When set, read the file's content at this git revision
49    /// (tag / branch / SHA) via `git show <rev>:<path>` instead of the
50    /// working tree. The slicing / grep / max_chars pipeline is applied
51    /// to the historical content unchanged.
52    pub rev: Option<String>,
53}
54
55/// Read a file from one of the allowed source dirs.
56///
57/// Returns a user-facing string. Path traversal attempts and missing
58/// files surface as ``Error: …`` strings rather than panics, mirroring
59/// the existing Python-server behaviour so the agent sees a clean
60/// error in tool output rather than an MCP error envelope.
61pub fn read_source(file_path: &str, allowed_dirs: &[String], opts: &ReadOpts) -> String {
62    if let Some(rev) = opts.rev.as_deref() {
63        return read_source_at_rev(file_path, rev, allowed_dirs, opts);
64    }
65    let resolved = match resolve_under_roots(file_path, allowed_dirs) {
66        Some(p) => p,
67        None => return format!("Error: file not found or access denied: {file_path}"),
68    };
69    let raw = match fs::read_to_string(&resolved) {
70        Ok(s) => s,
71        Err(e) => return format!("Error reading file: {e}"),
72    };
73    apply_read_options(file_path, &raw, opts)
74}
75
76/// Read `file_path` at git revision `rev` via `git show <rev>:<path>`,
77/// then run the same slicing / grep / max_chars pipeline as a live read.
78///
79/// The path is validated to be a safe repo-relative path *before* it
80/// reaches git (no absolute paths, no `..` escape), so a rev read can't
81/// be used to smuggle a traversal past the sandbox. The active source
82/// root must be a git work tree; otherwise a clear error is returned.
83fn read_source_at_rev(
84    file_path: &str,
85    rev: &str,
86    allowed_dirs: &[String],
87    opts: &ReadOpts,
88) -> String {
89    if !is_safe_relative_path(file_path) {
90        return format!("Error: file not found or access denied: {file_path}");
91    }
92    let root = match git_work_tree_root(allowed_dirs) {
93        Some(r) => r,
94        None => {
95            return "Error: rev-aware read requires a git repository as the active source root, \
96                    but the current root is not a git work tree."
97                .to_string()
98        }
99    };
100    // `<rev>:<path>` — git treats the path as relative to the repo root.
101    let spec = format!("{rev}:{file_path}");
102    let out = match std::process::Command::new("git")
103        .args(["-C", &root, "show", &spec])
104        .output()
105    {
106        Ok(o) => o,
107        Err(e) => return format!("Error: failed to run git: {e}"),
108    };
109    if !out.status.success() {
110        let stderr = String::from_utf8_lossy(&out.stderr);
111        let msg = stderr.trim();
112        let msg = msg.strip_prefix("fatal: ").unwrap_or(msg);
113        return format!("Error reading {file_path}@{rev}: {msg}");
114    }
115    let raw = String::from_utf8_lossy(&out.stdout);
116    apply_read_options(&format!("{file_path}@{rev}"), &raw, opts)
117}
118
119/// True if `p` is a repo-relative path that cannot escape the repo root:
120/// not absolute, and with no `..` / root / prefix components.
121fn is_safe_relative_path(p: &str) -> bool {
122    use std::path::Component;
123    let path = Path::new(p);
124    if path.is_absolute() {
125        return false;
126    }
127    path.components()
128        .all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
129}
130
131/// Return the first allowed dir that is a git work tree, if any.
132fn git_work_tree_root(allowed_dirs: &[String]) -> Option<String> {
133    for d in allowed_dirs {
134        if let Ok(out) = std::process::Command::new("git")
135            .args(["-C", d, "rev-parse", "--is-inside-work-tree"])
136            .output()
137        {
138            if out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true" {
139                return Some(d.clone());
140            }
141        }
142    }
143    None
144}
145
146fn apply_read_options(file_path: &str, raw: &str, opts: &ReadOpts) -> String {
147    let all_lines: Vec<&str> = raw.lines().collect();
148    let total = all_lines.len();
149
150    let (selected, start) = if opts.start_line.is_some() || opts.end_line.is_some() {
151        let s = opts.start_line.unwrap_or(1).max(1);
152        let e = opts.end_line.unwrap_or(total).min(total);
153        let sel: Vec<&str> = all_lines
154            .get(s.saturating_sub(1)..e.min(all_lines.len()))
155            .unwrap_or(&[])
156            .to_vec();
157        (sel, s)
158    } else {
159        (all_lines.clone(), 1usize)
160    };
161
162    if let Some(pattern) = opts.grep.as_deref() {
163        let re = match Regex::new(pattern) {
164            Ok(r) => r,
165            Err(e) => return format!("Error: invalid grep pattern: {e}"),
166        };
167        let ctx = opts.grep_context.unwrap_or(2);
168        let numbered: Vec<(usize, &str)> = selected
169            .iter()
170            .enumerate()
171            .map(|(i, line)| (start + i, *line))
172            .collect();
173        let gr = grep_lines(&numbered, &re, ctx, opts.max_matches);
174        let match_label = if gr.shown < gr.total {
175            format!("showing {} of {} matches", gr.shown, gr.total)
176        } else {
177            format!("{} matches", gr.total)
178        };
179        let header = format!("{file_path}  ({match_label} in {total} lines)");
180        if gr.lines.is_empty() {
181            return header;
182        }
183        let mut text = format!("{header}\n{}", gr.lines.join("\n"));
184        truncate_at_max_chars(&mut text, opts.max_chars, gr.total);
185        return text;
186    }
187
188    let body = selected.join("\n");
189    let mut text = if opts.start_line.is_some() || opts.end_line.is_some() {
190        let s = opts.start_line.unwrap_or(1).max(1);
191        let e = opts.end_line.unwrap_or(total).min(total);
192        format!("{file_path}  (lines {s}-{e} of {total})\n{body}")
193    } else {
194        format!("{file_path}  ({total} lines)\n{body}")
195    };
196    truncate_at_max_chars(&mut text, opts.max_chars, 0);
197    text
198}
199
200struct GrepResult {
201    total: usize,
202    shown: usize,
203    lines: Vec<String>,
204}
205
206/// In-memory grep over (line_number, line_text) pairs.
207fn grep_lines(
208    lines: &[(usize, &str)],
209    re: &Regex,
210    context: usize,
211    max_matches: Option<usize>,
212) -> GrepResult {
213    let mut match_idx: Vec<usize> = Vec::new();
214    for (i, (_, content)) in lines.iter().enumerate() {
215        if re.is_match(content) {
216            match_idx.push(i);
217        }
218    }
219    let total = match_idx.len();
220    let shown_idx = if let Some(cap) = max_matches {
221        match_idx.into_iter().take(cap).collect::<Vec<_>>()
222    } else {
223        match_idx
224    };
225    let shown = shown_idx.len();
226
227    if shown_idx.is_empty() {
228        return GrepResult {
229            total,
230            shown: 0,
231            lines: Vec::new(),
232        };
233    }
234
235    // Build inclusive (start, end) windows for each match, then merge overlapping.
236    let mut windows: Vec<(usize, usize)> = shown_idx
237        .iter()
238        .map(|&i| {
239            (
240                i.saturating_sub(context),
241                (i + context).min(lines.len() - 1),
242            )
243        })
244        .collect();
245    windows.sort_by_key(|w| w.0);
246
247    let mut merged: Vec<(usize, usize)> = Vec::new();
248    for w in windows {
249        if let Some(last) = merged.last_mut() {
250            if w.0 <= last.1 + 1 {
251                last.1 = last.1.max(w.1);
252                continue;
253            }
254        }
255        merged.push(w);
256    }
257
258    let mut out: Vec<String> = Vec::new();
259    for (k, (s, e)) in merged.iter().enumerate() {
260        if k > 0 {
261            out.push("--".to_string());
262        }
263        for &(lineno, text) in lines.iter().take(*e + 1).skip(*s) {
264            out.push(format!("{lineno:>6}: {text}"));
265        }
266    }
267
268    GrepResult {
269        total,
270        shown,
271        lines: out,
272    }
273}
274
275fn truncate_at_max_chars(text: &mut String, max_chars: Option<usize>, total_matches: usize) {
276    let Some(mc) = max_chars else { return };
277    if text.len() <= mc {
278        return;
279    }
280    let mut end = mc;
281    while end > 0 && !text.is_char_boundary(end) {
282        end -= 1;
283    }
284    text.truncate(end);
285    if total_matches > 0 {
286        text.push_str(&format!(
287            "\n\n[... truncated at {mc} chars — {total_matches} matches total]"
288        ));
289    } else {
290        text.push_str(&format!("\n\n[... truncated at {mc} chars]"));
291    }
292}
293
294// ---------------------------------------------------------------------------
295// grep — ripgrep across files
296// ---------------------------------------------------------------------------
297
298#[derive(Debug, Default, Clone)]
299pub struct GrepOpts {
300    pub glob: Option<String>,
301    pub context: usize,
302    pub max_results: Option<usize>,
303    pub case_insensitive: bool,
304}
305
306pub fn grep(allowed_dirs: &[String], pattern: &str, opts: &GrepOpts) -> String {
307    if allowed_dirs.is_empty() {
308        return "Error: no source roots configured.".to_string();
309    }
310    let matcher = match RegexMatcherBuilder::new()
311        .case_insensitive(opts.case_insensitive)
312        .build(pattern)
313    {
314        Ok(m) => m,
315        Err(e) => return format!("Error: invalid regex pattern: {e}"),
316    };
317
318    let primary = PathBuf::from(&allowed_dirs[0]);
319    let mut walker = WalkBuilder::new(&primary);
320    for d in allowed_dirs.iter().skip(1) {
321        walker.add(d);
322    }
323    walker
324        .standard_filters(true)
325        .git_ignore(true)
326        .git_global(true)
327        .git_exclude(true)
328        .hidden(true);
329
330    if let Some(g) = &opts.glob {
331        if !g.is_empty() && g != "*" {
332            let mut overrides = OverrideBuilder::new(&primary);
333            if let Err(e) = overrides.add(g) {
334                return format!("Error: invalid glob pattern '{g}': {e}");
335            }
336            match overrides.build() {
337                Ok(ov) => {
338                    walker.overrides(ov);
339                }
340                Err(e) => return format!("Error: failed to compile glob '{g}': {e}"),
341            }
342        }
343    }
344
345    let mut searcher = SearcherBuilder::new()
346        .before_context(opts.context)
347        .after_context(opts.context)
348        .build();
349
350    let mut output: Vec<String> = Vec::new();
351    let mut total_matches: usize = 0;
352    let cap = opts.max_results;
353
354    'walk: for result in walker.build() {
355        let entry = match result {
356            Ok(e) => e,
357            Err(_) => continue,
358        };
359        if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
360            continue;
361        }
362        let path = entry.path();
363        let mut path_matches: Vec<(u64, String, bool)> = Vec::new();
364        let sink_result = searcher.search_path(
365            &matcher,
366            path,
367            UTF8(|lnum, line| {
368                let hit = matcher.find(line.as_bytes()).ok().flatten().is_some();
369                path_matches.push((lnum, line.trim_end().to_string(), hit));
370                Ok(true)
371            }),
372        );
373        if sink_result.is_err() {
374            continue;
375        }
376        if path_matches.is_empty() {
377            continue;
378        }
379        let rel = path.strip_prefix(&primary).unwrap_or(path);
380        let prefix = rel.display().to_string();
381        for (lnum, content, is_match) in path_matches {
382            let sep = if is_match { ":" } else { "-" };
383            if is_match {
384                total_matches += 1;
385            }
386            output.push(format!("{prefix}{sep}{lnum}{sep}{content}"));
387            if let Some(c) = cap {
388                if total_matches >= c {
389                    break 'walk;
390                }
391            }
392        }
393    }
394
395    if output.is_empty() {
396        return format!("No matches for pattern '{pattern}'.");
397    }
398    let mut text = output.join("\n");
399    if let Some(c) = cap {
400        if total_matches >= c {
401            text.push_str(&format!(
402                "\n\n(showing first {c} matches — pass max_results=None for all)"
403            ));
404        }
405    }
406    text
407}
408
409// ---------------------------------------------------------------------------
410// list_source — directory listing
411// ---------------------------------------------------------------------------
412
413#[derive(Debug, Default, Clone)]
414pub struct ListOpts {
415    pub depth: usize,
416    pub glob: Option<String>,
417    pub dirs_only: bool,
418}
419
420pub fn list_source(target: &Path, primary_root: &Path, opts: &ListOpts) -> String {
421    if !target.exists() {
422        return format!("Error: path '{}' does not exist.", target.display());
423    }
424    if !target.is_dir() {
425        return format!("Error: path '{}' is not a directory.", target.display());
426    }
427
428    let depth = if opts.depth == 0 { 1 } else { opts.depth };
429    let glob_re = opts
430        .glob
431        .as_deref()
432        .map(glob_to_regex)
433        .transpose()
434        .unwrap_or_else(|e| {
435            tracing::warn!("ignoring invalid glob: {e}");
436            None
437        });
438
439    let mut entries: Vec<String> = Vec::new();
440    walk_listing(
441        target,
442        primary_root,
443        opts,
444        glob_re.as_ref(),
445        0,
446        depth,
447        &mut entries,
448    );
449
450    if entries.is_empty() {
451        return format!("No entries in '{}'.", target.display());
452    }
453    entries.join("\n")
454}
455
456fn walk_listing(
457    dir: &Path,
458    primary_root: &Path,
459    opts: &ListOpts,
460    glob_re: Option<&Regex>,
461    current_depth: usize,
462    max_depth: usize,
463    out: &mut Vec<String>,
464) {
465    let read = match fs::read_dir(dir) {
466        Ok(r) => r,
467        Err(_) => return,
468    };
469    let mut children: Vec<_> = read.filter_map(|e| e.ok()).collect();
470    children.sort_by_key(|e| e.file_name());
471
472    for entry in children {
473        let path = entry.path();
474        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
475        if opts.dirs_only && !is_dir {
476            continue;
477        }
478        if let Some(re) = glob_re {
479            let name = entry.file_name().to_string_lossy().into_owned();
480            if !is_dir && !re.is_match(&name) {
481                continue;
482            }
483        }
484        let rel = path
485            .strip_prefix(primary_root)
486            .unwrap_or(&path)
487            .display()
488            .to_string();
489        let indent = "  ".repeat(current_depth);
490        let suffix = if is_dir { "/" } else { "" };
491        out.push(format!("{indent}{rel}{suffix}"));
492        if is_dir && current_depth + 1 < max_depth {
493            walk_listing(
494                &path,
495                primary_root,
496                opts,
497                glob_re,
498                current_depth + 1,
499                max_depth,
500                out,
501            );
502        }
503    }
504}
505
506/// Translate a shell glob to a regex anchored at start/end.
507fn glob_to_regex(glob: &str) -> Result<Regex, regex::Error> {
508    let mut out = String::with_capacity(glob.len() * 2 + 4);
509    out.push('^');
510    let mut chars = glob.chars().peekable();
511    for c in &mut chars {
512        match c {
513            '*' => out.push_str(".*"),
514            '?' => out.push('.'),
515            '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' => {
516                out.push('\\');
517                out.push(c);
518            }
519            other => out.push(other),
520        }
521    }
522    out.push('$');
523    Regex::new(&out)
524}
525
526// ---------------------------------------------------------------------------
527// Path resolution
528// ---------------------------------------------------------------------------
529
530/// Resolve ``file_path`` against the allowed dirs and verify the canonical
531/// path lives under at least one of them. Returns ``None`` when the file
532/// is missing or the path traversal lands outside the sandbox.
533pub fn resolve_under_roots(file_path: &str, allowed_dirs: &[String]) -> Option<PathBuf> {
534    if allowed_dirs.is_empty() {
535        return None;
536    }
537    let canon_dirs: Vec<PathBuf> = allowed_dirs
538        .iter()
539        .filter_map(|d| PathBuf::from(d).canonicalize().ok())
540        .collect();
541
542    for (i, d) in allowed_dirs.iter().enumerate() {
543        let candidate = PathBuf::from(d).join(file_path);
544        if let Ok(canon) = candidate.canonicalize() {
545            if let Some(dir_canon) = canon_dirs.get(i) {
546                if canon.starts_with(dir_canon) && canon.exists() {
547                    return Some(canon);
548                }
549            }
550        }
551    }
552
553    let abs = PathBuf::from(file_path);
554    if let Ok(canon) = abs.canonicalize() {
555        for dir_canon in &canon_dirs {
556            if canon.starts_with(dir_canon) && canon.exists() {
557                return Some(canon);
558            }
559        }
560    }
561    None
562}
563
564/// Resolve a path under the first allowed dir for directory listing.
565/// Differs from [`resolve_under_roots`] in that it accepts directories,
566/// non-existent paths included only after canonicalisation succeeds.
567pub fn resolve_dir_under_roots(path: &str, allowed_dirs: &[String]) -> Option<PathBuf> {
568    if allowed_dirs.is_empty() {
569        return None;
570    }
571    let primary = PathBuf::from(&allowed_dirs[0]);
572    let canon_primary = primary.canonicalize().ok()?;
573    let candidate = if path == "." {
574        canon_primary.clone()
575    } else {
576        primary.join(path).canonicalize().ok()?
577    };
578    let canon_dirs: Vec<PathBuf> = allowed_dirs
579        .iter()
580        .filter_map(|d| PathBuf::from(d).canonicalize().ok())
581        .collect();
582    for d in &canon_dirs {
583        if candidate.starts_with(d) {
584            return Some(candidate);
585        }
586    }
587    None
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    fn make_tree() -> tempfile::TempDir {
595        let dir = tempfile::tempdir().unwrap();
596        std::fs::write(
597            dir.path().join("hello.txt"),
598            "line one\nline two with marker\nline three\n",
599        )
600        .unwrap();
601        std::fs::write(dir.path().join("data.json"), "{\"name\": \"Alice\"}\n").unwrap();
602        std::fs::create_dir_all(dir.path().join("sub")).unwrap();
603        std::fs::write(dir.path().join("sub").join("nested.txt"), "nested file\n").unwrap();
604        dir
605    }
606
607    #[test]
608    fn read_source_full_file() {
609        let dir = make_tree();
610        let roots = vec![dir.path().to_string_lossy().into_owned()];
611        let out = read_source("hello.txt", &roots, &ReadOpts::default());
612        assert!(out.contains("line one"));
613        assert!(out.contains("line three"));
614    }
615
616    #[test]
617    fn read_source_grep_filter() {
618        let dir = make_tree();
619        let roots = vec![dir.path().to_string_lossy().into_owned()];
620        let opts = ReadOpts {
621            grep: Some("marker".to_string()),
622            ..Default::default()
623        };
624        let out = read_source("hello.txt", &roots, &opts);
625        assert!(out.contains("marker"));
626        assert!(out.contains("matches"));
627    }
628
629    #[test]
630    fn read_source_blocks_traversal() {
631        let dir = make_tree();
632        let roots = vec![dir.path().to_string_lossy().into_owned()];
633        let out = read_source("../escape.txt", &roots, &ReadOpts::default());
634        assert!(out.starts_with("Error:"));
635    }
636
637    #[test]
638    fn read_source_line_range() {
639        let dir = make_tree();
640        let roots = vec![dir.path().to_string_lossy().into_owned()];
641        let opts = ReadOpts {
642            start_line: Some(2),
643            end_line: Some(2),
644            ..Default::default()
645        };
646        let out = read_source("hello.txt", &roots, &opts);
647        assert!(out.contains("line two with marker"));
648        assert!(!out.contains("line one"));
649        assert!(!out.contains("line three"));
650    }
651
652    #[test]
653    fn grep_finds_pattern() {
654        let dir = make_tree();
655        let roots = vec![dir.path().to_string_lossy().into_owned()];
656        let out = grep(&roots, "Alice", &GrepOpts::default());
657        assert!(out.contains("data.json"));
658    }
659
660    #[test]
661    fn grep_glob_filter() {
662        let dir = make_tree();
663        std::fs::write(dir.path().join("extra.json"), "marker in json\n").unwrap();
664        let roots = vec![dir.path().to_string_lossy().into_owned()];
665        let opts = GrepOpts {
666            glob: Some("*.txt".to_string()),
667            ..Default::default()
668        };
669        let out = grep(&roots, "marker", &opts);
670        assert!(out.contains("hello.txt"));
671        assert!(!out.contains("extra.json"));
672    }
673
674    #[test]
675    fn grep_no_matches() {
676        let dir = make_tree();
677        let roots = vec![dir.path().to_string_lossy().into_owned()];
678        let out = grep(&roots, "xyznotfound", &GrepOpts::default());
679        assert!(out.contains("No matches"));
680    }
681
682    #[test]
683    fn list_source_root() {
684        let dir = make_tree();
685        let primary = dir.path();
686        let out = list_source(primary, primary, &ListOpts::default());
687        assert!(out.contains("hello.txt"));
688        assert!(out.contains("data.json"));
689    }
690
691    #[test]
692    fn list_source_dirs_only() {
693        let dir = make_tree();
694        let primary = dir.path();
695        let opts = ListOpts {
696            dirs_only: true,
697            depth: 1,
698            ..Default::default()
699        };
700        let out = list_source(primary, primary, &opts);
701        assert!(out.contains("sub"));
702        assert!(!out.contains("hello.txt"));
703    }
704
705    #[test]
706    fn list_source_subdir() {
707        let dir = make_tree();
708        let target = dir.path().join("sub");
709        let out = list_source(&target, dir.path(), &ListOpts::default());
710        assert!(out.contains("nested.txt"));
711    }
712
713    #[test]
714    fn glob_translation() {
715        let re = glob_to_regex("*.py").unwrap();
716        assert!(re.is_match("foo.py"));
717        assert!(!re.is_match("foo.rs"));
718        let re = glob_to_regex("test_*").unwrap();
719        assert!(re.is_match("test_x"));
720        assert!(!re.is_match("xtest"));
721    }
722
723    #[test]
724    fn resolve_blocks_escape() {
725        let dir = make_tree();
726        let outside = tempfile::tempdir().unwrap();
727        std::fs::write(outside.path().join("secret.txt"), "x").unwrap();
728        let roots = vec![dir.path().to_string_lossy().into_owned()];
729        let escape = format!(
730            "../{}/secret.txt",
731            outside.path().file_name().unwrap().to_string_lossy()
732        );
733        assert!(resolve_under_roots(&escape, &roots).is_none());
734    }
735
736    // --- rev-aware read_source ---------------------------------------------
737
738    fn git(dir: &Path, args: &[&str]) {
739        let out = std::process::Command::new("git")
740            .args(args)
741            .current_dir(dir)
742            .env("GIT_CONFIG_GLOBAL", "/dev/null")
743            .env("GIT_CONFIG_SYSTEM", "/dev/null")
744            .env("GIT_AUTHOR_NAME", "t")
745            .env("GIT_AUTHOR_EMAIL", "t@t")
746            .env("GIT_COMMITTER_NAME", "t")
747            .env("GIT_COMMITTER_EMAIL", "t@t")
748            .output()
749            .expect("git spawn");
750        assert!(
751            out.status.success(),
752            "git {:?} failed: {}",
753            args,
754            String::from_utf8_lossy(&out.stderr)
755        );
756    }
757
758    /// Init a repo with two commits of `file.txt`, tagging the first as
759    /// `v1`. Returns the tempdir.
760    fn make_git_repo() -> tempfile::TempDir {
761        let dir = tempfile::tempdir().unwrap();
762        let p = dir.path();
763        git(p, &["init", "-q"]);
764        std::fs::write(p.join("file.txt"), "old content v1\n").unwrap();
765        git(p, &["add", "file.txt"]);
766        git(p, &["commit", "-q", "-m", "v1"]);
767        git(p, &["tag", "v1"]);
768        std::fs::write(p.join("file.txt"), "new content v2\n").unwrap();
769        git(p, &["add", "file.txt"]);
770        git(p, &["commit", "-q", "-m", "v2"]);
771        dir
772    }
773
774    #[test]
775    fn read_source_rev_reads_historical_content() {
776        let dir = make_git_repo();
777        let roots = vec![dir.path().to_string_lossy().into_owned()];
778        // Working tree has v2; rev=v1 must surface the old content.
779        let opts = ReadOpts {
780            rev: Some("v1".to_string()),
781            ..Default::default()
782        };
783        let out = read_source("file.txt", &roots, &opts);
784        assert!(out.contains("old content v1"), "got: {out}");
785        assert!(!out.contains("new content v2"), "got: {out}");
786        assert!(out.contains("file.txt@v1"), "header missing rev: {out}");
787
788        // Without rev, the working tree (v2) is read.
789        let live = read_source("file.txt", &roots, &ReadOpts::default());
790        assert!(live.contains("new content v2"), "got: {live}");
791    }
792
793    #[test]
794    fn read_source_rev_pipeline_applies() {
795        let dir = make_git_repo();
796        let roots = vec![dir.path().to_string_lossy().into_owned()];
797        let opts = ReadOpts {
798            rev: Some("v1".to_string()),
799            grep: Some("old".to_string()),
800            ..Default::default()
801        };
802        let out = read_source("file.txt", &roots, &opts);
803        assert!(out.contains("old content v1"), "got: {out}");
804        assert!(out.contains("matches"), "grep header missing: {out}");
805    }
806
807    #[test]
808    fn read_source_rev_bad_rev_errors() {
809        let dir = make_git_repo();
810        let roots = vec![dir.path().to_string_lossy().into_owned()];
811        let opts = ReadOpts {
812            rev: Some("no-such-rev".to_string()),
813            ..Default::default()
814        };
815        let out = read_source("file.txt", &roots, &opts);
816        assert!(out.starts_with("Error"), "got: {out}");
817    }
818
819    #[test]
820    fn read_source_rev_bad_path_errors() {
821        let dir = make_git_repo();
822        let roots = vec![dir.path().to_string_lossy().into_owned()];
823        let opts = ReadOpts {
824            rev: Some("v1".to_string()),
825            ..Default::default()
826        };
827        let out = read_source("does_not_exist.txt", &roots, &opts);
828        assert!(out.starts_with("Error"), "got: {out}");
829    }
830
831    #[test]
832    fn read_source_rev_non_git_root_errors() {
833        // A plain (non-git) tempdir must be rejected with a clear message.
834        let dir = make_tree();
835        let roots = vec![dir.path().to_string_lossy().into_owned()];
836        let opts = ReadOpts {
837            rev: Some("v1".to_string()),
838            ..Default::default()
839        };
840        let out = read_source("hello.txt", &roots, &opts);
841        assert!(out.starts_with("Error"), "got: {out}");
842        assert!(out.contains("git"), "should mention git requirement: {out}");
843    }
844
845    #[test]
846    fn read_source_rev_blocks_traversal() {
847        let dir = make_git_repo();
848        let roots = vec![dir.path().to_string_lossy().into_owned()];
849        let opts = ReadOpts {
850            rev: Some("v1".to_string()),
851            ..Default::default()
852        };
853        let out = read_source("../escape.txt", &roots, &opts);
854        assert!(out.starts_with("Error"), "got: {out}");
855        // Must be rejected by our lexical guard, not handed to git.
856        assert!(
857            !out.contains("@v1"),
858            "traversal path reached git show: {out}"
859        );
860    }
861
862    #[test]
863    fn is_safe_relative_path_guards() {
864        assert!(is_safe_relative_path("a/b.txt"));
865        assert!(is_safe_relative_path("./a.txt"));
866        assert!(!is_safe_relative_path("../a.txt"));
867        assert!(!is_safe_relative_path("a/../../b"));
868        assert!(!is_safe_relative_path("/etc/passwd"));
869    }
870}