1#![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
33pub type SourceRootsProvider = Arc<dyn Fn() -> Vec<String> + Send + Sync>;
35
36#[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 pub rev: Option<String>,
53}
54
55pub 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
76fn 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 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
119fn 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
131fn 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
206fn 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 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#[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#[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
506fn 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
526pub 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
564pub 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 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 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 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 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 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 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}