1use crate::core::shell_allowlist::{Separator, segments_with_separators, strip_all_heredoc_bodies};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ShellReadKind {
30 Full,
32 Partial,
34}
35
36#[derive(Debug, Default, Clone, PartialEq, Eq)]
39pub struct ShellFileEffects {
40 pub reads: Vec<(String, ShellReadKind)>,
41 pub edits: Vec<String>,
42}
43
44const MAX_NESTED_SHELLS: usize = 16;
46
47#[must_use]
51pub fn analyze(command: &str) -> ShellFileEffects {
52 if command.len() > crate::core::shell_allowlist::MAX_VALIDATED_COMMAND_BYTES {
53 return ShellFileEffects::default();
54 }
55 analyze_nested(command, 0)
56}
57
58fn analyze_nested(command: &str, depth: usize) -> ShellFileEffects {
59 let mut effects = ShellFileEffects::default();
60 for path in apply_patch_targets(command) {
63 push_unique(&mut effects.edits, path);
64 }
65
66 let stripped = strip_all_heredoc_bodies(command);
67 let segments = segments_with_separators(&stripped);
68 let mut cwd: Option<String> = None;
69 for (segment, sep) in &segments {
70 let piped_out = *sep == Some(Separator::Pipe);
73
74 let words = lex(segment);
75 let mut argv: Vec<&Word> = Vec::new();
76 let mut redirected_out = false;
77 let mut iter = words.iter().peekable();
78 while let Some(word) = iter.next() {
79 match word {
80 Word::Redirect { output, dup } => {
81 let target = iter.next();
82 if !*output {
83 continue;
84 }
85 redirected_out = true;
86 if *dup {
87 continue;
88 }
89 if let Some(Word::Text { text, plain_path }) = target
90 && *plain_path
91 && is_edit_target(text)
92 {
93 push_unique(&mut effects.edits, join(cwd.as_deref(), text));
94 }
95 }
96 Word::Text { .. } => argv.push(word),
97 }
98 }
99
100 let argv: Vec<&str> = argv
102 .iter()
103 .filter_map(|w| match w {
104 Word::Text { text, .. } => Some(text.as_str()),
105 Word::Redirect { .. } => None,
106 })
107 .skip_while(|w| is_env_assignment(w))
108 .collect();
109 let Some((&bin, args)) = argv.split_first() else {
110 continue;
111 };
112 let bin = bin.rsplit('/').next().unwrap_or(bin);
113
114 match bin {
115 "lean-ctx" | "sh" | "bash" | "zsh" | "dash" => {
117 if let [flag, inner, ..] = args
118 && *flag == "-c"
119 && depth < MAX_NESTED_SHELLS
120 {
121 let nested = analyze_nested(inner, depth + 1);
122 for (path, kind) in nested.reads {
123 push_read(&mut effects.reads, join(cwd.as_deref(), &path), kind);
124 }
125 for path in nested.edits {
126 push_unique(&mut effects.edits, join(cwd.as_deref(), &path));
127 }
128 }
129 }
130 "cd" => {
131 if let [dir] = args
133 && !dir.starts_with('-')
134 && is_plain(dir)
135 {
136 cwd = Some(join(cwd.as_deref(), dir));
137 }
138 }
139 "cat" | "less" | "more" | "bat" | "batcat" => {
140 if redirected_out {
141 continue;
143 }
144 let (files, ranged) = reader_files(bin, args);
145 let kind = if ranged || piped_out {
146 ShellReadKind::Partial
147 } else {
148 ShellReadKind::Full
149 };
150 for file in files {
151 push_read(&mut effects.reads, join(cwd.as_deref(), file), kind);
152 }
153 }
154 "head" | "tail" => {
155 for file in head_tail_files(args) {
156 push_read(
157 &mut effects.reads,
158 join(cwd.as_deref(), file),
159 ShellReadKind::Partial,
160 );
161 }
162 }
163 "sed" | "perl" => {
164 let (in_place, files) = script_tool_files(bin, args);
165 for file in files {
166 let path = join(cwd.as_deref(), file);
167 if in_place {
168 push_unique(&mut effects.edits, path);
169 } else if bin == "sed" && !redirected_out {
170 push_read(&mut effects.reads, path, ShellReadKind::Partial);
171 }
172 }
173 }
174 "tee" => {
175 for file in positional(args, &[]) {
176 if is_edit_target(file) {
177 push_unique(&mut effects.edits, join(cwd.as_deref(), file));
178 }
179 }
180 }
181 "cp" | "mv" | "install" => {
182 for target in copy_targets(args) {
183 push_unique(&mut effects.edits, join(cwd.as_deref(), &target));
184 }
185 }
186 _ => {}
187 }
188 }
189 effects
190}
191
192#[must_use]
196pub fn apply_patch_targets(text: &str) -> Vec<String> {
197 if !text.contains("*** Begin Patch") {
198 return Vec::new();
199 }
200 let mut paths = Vec::new();
201 for line in text.lines() {
202 let line = line.trim();
203 let path = [
204 "*** Update File:",
205 "*** Add File:",
206 "*** Delete File:",
207 "*** Move to:",
208 ]
209 .iter()
210 .find_map(|prefix| line.strip_prefix(prefix));
211 if let Some(path) = path.map(str::trim).filter(|p| !p.is_empty()) {
212 push_unique(&mut paths, path.to_string());
213 }
214 }
215 paths
216}
217
218fn push_unique(list: &mut Vec<String>, path: String) {
219 if !list.contains(&path) {
220 list.push(path);
221 }
222}
223
224fn push_read(reads: &mut Vec<(String, ShellReadKind)>, path: String, kind: ShellReadKind) {
225 if path.starts_with("/dev/") || path == "-" {
226 return;
227 }
228 if !reads.iter().any(|(p, k)| *p == path && *k == kind) {
229 reads.push((path, kind));
230 }
231}
232
233fn join(cwd: Option<&str>, path: &str) -> String {
234 match cwd {
235 Some(dir) if !path.starts_with('/') && !path.starts_with('~') => {
236 format!("{}/{path}", dir.trim_end_matches('/'))
237 }
238 _ => path.to_string(),
239 }
240}
241
242fn is_plain(word: &str) -> bool {
244 !word.is_empty() && !word.contains(['$', '`', '*', '?', '[', '{', '(', ')', '<', '>'])
245}
246
247fn is_edit_target(word: &str) -> bool {
248 is_plain(word) && word != "-" && !word.starts_with("/dev/") && !word.starts_with('-')
249}
250
251fn is_env_assignment(word: &str) -> bool {
252 word.split_once('=').is_some_and(|(name, _)| {
253 !name.is_empty()
254 && !name.starts_with(|c: char| c.is_ascii_digit())
255 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
256 })
257}
258
259fn positional<'a>(args: &[&'a str], takes_value: &[&str]) -> Vec<&'a str> {
262 let mut out = Vec::new();
263 let mut iter = args.iter();
264 while let Some(&arg) = iter.next() {
265 if arg == "--" {
266 out.extend(iter.copied());
267 break;
268 }
269 if arg.starts_with('-') && arg.len() > 1 {
270 if takes_value.contains(&arg) {
271 iter.next();
272 }
273 continue;
274 }
275 out.push(arg);
276 }
277 out
278}
279
280fn reader_files<'a>(bin: &str, args: &[&'a str]) -> (Vec<&'a str>, bool) {
282 match bin {
283 "bat" | "batcat" => {
284 const VALUE_FLAGS: &[&str] = &[
285 "-l",
286 "--language",
287 "-r",
288 "--line-range",
289 "-H",
290 "--highlight-line",
291 "-m",
292 "--map-syntax",
293 "--style",
294 "--theme",
295 "--tabs",
296 "--wrap",
297 "--terminal-width",
298 "--color",
299 "--decorations",
300 "--paging",
301 "--italic-text",
302 "--file-name",
303 "--diff-context",
304 ];
305 let ranged = args
306 .iter()
307 .any(|a| *a == "-r" || *a == "--line-range" || a.starts_with("--line-range="));
308 (positional(args, VALUE_FLAGS), ranged)
309 }
310 "less" | "more" => (
313 args.iter()
314 .copied()
315 .filter(|a| !a.starts_with('-') && !a.starts_with('+'))
316 .collect(),
317 false,
318 ),
319 _ => (positional(args, &[]), false),
320 }
321}
322
323fn head_tail_files<'a>(args: &[&'a str]) -> Vec<&'a str> {
324 positional(args, &["-n", "-c", "--lines", "--bytes"])
325 .into_iter()
326 .filter(|a| !a.starts_with('+'))
327 .collect()
328}
329
330fn script_tool_files<'a>(bin: &str, args: &[&'a str]) -> (bool, Vec<&'a str>) {
337 let script_flags: &[char] = if bin == "perl" {
338 &['e', 'E']
339 } else {
340 &['e', 'f']
341 };
342 let mut in_place = false;
343 let mut has_script = false;
344 let mut operands = Vec::new();
345 let mut iter = args.iter();
346 while let Some(&arg) = iter.next() {
347 if arg == "--" {
348 operands.extend(iter.copied());
349 break;
350 }
351 if let Some(long) = arg.strip_prefix("--") {
352 if long == "in-place" || long.starts_with("in-place=") {
353 in_place = true;
354 } else if long == "expression" || long == "file" {
355 has_script = true;
356 iter.next();
357 } else if long.starts_with("expression=") || long.starts_with("file=") {
358 has_script = true;
359 }
360 continue;
361 }
362 if let Some(cluster) = arg.strip_prefix('-')
363 && !cluster.is_empty()
364 {
365 if bin == "sed" && cluster == "i" && iter.as_slice().first() == Some(&"") {
367 iter.next();
368 }
369 for (idx, ch) in cluster.char_indices() {
370 if ch == 'i' {
371 in_place = true;
372 break;
373 }
374 if script_flags.contains(&ch) {
375 has_script = true;
376 if idx + ch.len_utf8() == cluster.len() {
377 iter.next();
378 }
379 break;
380 }
381 if ch == 'l' && bin == "sed" {
382 if idx + 1 == cluster.len() {
383 iter.next();
384 }
385 break;
386 }
387 }
388 continue;
389 }
390 operands.push(arg);
391 }
392 if !has_script && !operands.is_empty() {
393 operands.remove(0);
394 }
395 (
396 in_place,
397 operands.into_iter().filter(|a| is_plain(a)).collect(),
398 )
399}
400
401fn copy_targets(args: &[&str]) -> Vec<String> {
406 const VALUE_FLAGS: &[&str] = &[
407 "-S", "--suffix", "-m", "--mode", "-o", "--owner", "-g", "--group",
408 ];
409 let mut target_dir: Option<&str> = None;
410 let mut iter = args.iter();
411 let mut rest = Vec::new();
412 while let Some(&arg) = iter.next() {
413 if arg == "-t" || arg == "--target-directory" {
414 target_dir = iter.next().copied();
415 } else if let Some(dir) = arg.strip_prefix("--target-directory=") {
416 target_dir = Some(dir);
417 } else {
418 rest.push(arg);
419 }
420 }
421 let operands: Vec<&str> = positional(&rest, VALUE_FLAGS)
422 .into_iter()
423 .filter(|a| is_edit_target(a))
424 .collect();
425 let basename = |src: &str| {
426 src.trim_end_matches('/')
427 .rsplit('/')
428 .next()
429 .map(str::to_string)
430 };
431 let mut out = Vec::new();
432 if let Some(dir) = target_dir.filter(|d| is_edit_target(d)) {
433 for src in &operands {
434 if let Some(name) = basename(src) {
435 out.push(format!("{}/{name}", dir.trim_end_matches('/')));
436 }
437 }
438 return out;
439 }
440 let Some((dest, sources)) = operands.split_last() else {
441 return out;
442 };
443 if sources.is_empty() {
444 return out;
445 }
446 out.push((*dest).to_string());
447 for src in sources {
448 if let Some(name) = basename(src) {
449 out.push(format!("{}/{name}", dest.trim_end_matches('/')));
450 }
451 }
452 out
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
457enum Word {
458 Text { text: String, plain_path: bool },
461 Redirect { output: bool, dup: bool },
463}
464
465fn lex(segment: &str) -> Vec<Word> {
469 let mut words = Vec::new();
470 let mut current = String::new();
471 let mut started = false;
472 let mut plain = true;
473 let mut in_single = false;
474 let mut in_double = false;
475 let chars: Vec<char> = segment.chars().collect();
476 let mut i = 0;
477
478 let flush =
479 |words: &mut Vec<Word>, current: &mut String, started: &mut bool, plain: &mut bool| {
480 if *started {
481 words.push(Word::Text {
482 text: std::mem::take(current),
483 plain_path: *plain,
484 });
485 }
486 *started = false;
487 *plain = true;
488 };
489
490 while i < chars.len() {
491 let c = chars[i];
492 if in_single {
493 if c == '\'' {
494 in_single = false;
495 } else {
496 current.push(c);
497 }
498 i += 1;
499 continue;
500 }
501 if in_double {
502 match c {
503 '"' => in_double = false,
504 '\\' if matches!(chars.get(i + 1), Some('$' | '`' | '"' | '\\')) => {
505 current.push(chars[i + 1]);
506 i += 1;
507 }
508 '$' | '`' => {
509 plain = false;
510 current.push(c);
511 }
512 _ => current.push(c),
513 }
514 i += 1;
515 continue;
516 }
517 match c {
518 '\'' => {
519 in_single = true;
520 started = true;
521 }
522 '"' => {
523 in_double = true;
524 started = true;
525 }
526 '\\' => {
527 if let Some(&next) = chars.get(i + 1) {
528 current.push(next);
529 started = true;
530 i += 1;
531 }
532 }
533 c if c.is_whitespace() => flush(&mut words, &mut current, &mut started, &mut plain),
534 '>' | '<' if chars.get(i + 1) != Some(&'(') => {
535 let fd_prefix = started
538 && !current.is_empty()
539 && (current.chars().all(|ch| ch.is_ascii_digit()) || current == "&");
540 if fd_prefix {
541 current.clear();
542 started = false;
543 plain = true;
544 } else {
545 flush(&mut words, &mut current, &mut started, &mut plain);
546 }
547 let output = c == '>';
548 let mut j = i + 1;
549 while j < chars.len() && matches!(chars[j], '>' | '<' | '|' | '-') {
551 if chars[j] == '-' && !(c == '<' && chars[j - 1] == '<') {
552 break;
553 }
554 j += 1;
555 }
556 let dup = chars.get(j) == Some(&'&');
557 if dup {
558 j += 1;
559 }
560 words.push(Word::Redirect { output, dup });
561 i = j;
562 continue;
563 }
564 '$' | '`' | '*' | '?' => {
565 plain = false;
566 started = true;
567 current.push(c);
568 }
569 _ => {
570 started = true;
571 current.push(c);
572 }
573 }
574 i += 1;
575 }
576 flush(&mut words, &mut current, &mut started, &mut plain);
577 words
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn edits(cmd: &str) -> Vec<String> {
585 analyze(cmd).edits
586 }
587
588 fn reads(cmd: &str) -> Vec<(String, ShellReadKind)> {
589 analyze(cmd).reads
590 }
591
592 use ShellReadKind::{Full, Partial};
593
594 const NO_EDITS: [String; 0] = [];
595 const NO_READS: [(String, ShellReadKind); 0] = [];
596
597 #[test]
598 fn sed_in_place_is_an_edit() {
599 assert_eq!(edits("sed -i 's/a/b/' src/a.rs"), ["src/a.rs"]);
600 assert_eq!(edits("sed -i.bak -e 's/a/b/' a.rs b.rs"), ["a.rs", "b.rs"]);
601 assert_eq!(edits("sed -i '' 's/a/b/' 'my file.rs'"), ["my file.rs"]);
602 assert_eq!(edits("sed -Ei 's/a/b/g' a.rs"), ["a.rs"]);
603 assert_eq!(edits("sed --in-place -e 's/x/y/' a.rs"), ["a.rs"]);
604 assert_eq!(edits("sed 's/a/b/' a.rs"), NO_EDITS);
605 }
606
607 #[test]
608 fn sed_without_in_place_is_a_partial_read() {
609 assert_eq!(
610 reads("sed -n '10,20p' src/a.rs"),
611 [("src/a.rs".into(), Partial)]
612 );
613 assert_eq!(analyze("sed -n '10,20p' src/a.rs").edits, NO_EDITS);
614 }
615
616 #[test]
617 fn perl_in_place_is_an_edit() {
618 assert_eq!(edits("perl -0pi -e 's/a/b/' a.rs"), ["a.rs"]);
619 assert_eq!(edits("perl -pi -e 's/a/b/' a.rs b.rs"), ["a.rs", "b.rs"]);
620 assert_eq!(edits("perl -ne 'print' a.rs"), NO_EDITS);
621 }
622
623 #[test]
624 fn output_redirections_are_edits() {
625 assert_eq!(edits("echo hi > out.txt"), ["out.txt"]);
626 assert_eq!(edits("echo hi >> log.txt"), ["log.txt"]);
627 assert_eq!(edits("echo hi>out.txt"), ["out.txt"]);
628 assert_eq!(edits("printf x >| clobber.txt"), ["clobber.txt"]);
629 assert_eq!(edits("cmd &> both.log"), ["both.log"]);
630 assert_eq!(edits("cmd 1> one.txt"), ["one.txt"]);
631 assert_eq!(edits("echo hi > \"with space.txt\""), ["with space.txt"]);
632 assert_eq!(edits("cat > new.rs <<'EOF'\nfn main() {}\nEOF"), ["new.rs"]);
633 }
634
635 #[test]
636 fn stream_plumbing_is_never_an_edit() {
637 assert_eq!(edits("cargo build 2>/dev/null"), NO_EDITS);
638 assert_eq!(edits("cargo build 2>&1"), NO_EDITS);
639 assert_eq!(edits("echo oops >&2"), NO_EDITS);
640 assert_eq!(edits("cmd > /dev/null 2>&1"), NO_EDITS);
641 assert_eq!(edits("cmd &>/dev/null"), NO_EDITS);
642 assert_eq!(edits("echo hi > \"$OUT\""), NO_EDITS);
643 assert_eq!(edits("echo hi > $(mktemp)"), NO_EDITS);
644 assert_eq!(edits("grep '>' src/a.rs"), NO_EDITS);
645 assert_eq!(edits("diff <(sort a) >(cat)"), NO_EDITS);
646 assert_eq!(edits("wc -l < input.txt"), NO_EDITS);
647 }
648
649 #[test]
650 fn tee_targets_are_edits() {
651 assert_eq!(edits("echo x | tee out.txt"), ["out.txt"]);
652 assert_eq!(edits("echo x | tee -a a.txt b.txt"), ["a.txt", "b.txt"]);
653 assert_eq!(edits("echo x | tee /dev/stderr"), NO_EDITS);
654 }
655
656 #[test]
657 fn copy_and_move_destinations_are_edits() {
658 assert_eq!(edits("cp new.rs src/a.rs"), ["src/a.rs", "src/a.rs/new.rs"]);
659 assert_eq!(
660 edits("mv -f tmp.rs src/a.rs"),
661 ["src/a.rs", "src/a.rs/tmp.rs"]
662 );
663 assert_eq!(edits("cp -t src a.rs b.rs"), ["src/a.rs", "src/b.rs"]);
664 assert_eq!(edits("cp only_one"), NO_EDITS);
665 }
666
667 #[test]
668 fn chains_and_cd_are_followed() {
669 assert_eq!(
670 edits("cd rust && sed -i 's/a/b/' src/a.rs; echo done > /tmp/x.log"),
671 ["rust/src/a.rs", "/tmp/x.log"]
672 );
673 assert_eq!(edits("make || echo fail > err.txt"), ["err.txt"]);
674 assert_eq!(edits("lean-ctx -c \"sed -i 's/a/b/' a.rs\""), ["a.rs"]);
675 assert_eq!(edits("cd sub && bash -c 'echo x > o.txt'"), ["sub/o.txt"]);
676 }
677
678 #[test]
679 fn apply_patch_bodies_are_edits() {
680 let cmd = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: src/a.rs\n@@\n-a\n+b\n*** Add File: src/new.rs\n+x\n*** Delete File: old.rs\n*** Update File: src/m.rs\n*** Move to: src/n.rs\n*** End Patch\nEOF";
681 assert_eq!(
682 edits(cmd),
683 ["src/a.rs", "src/new.rs", "old.rs", "src/m.rs", "src/n.rs"]
684 );
685 assert_eq!(
687 edits("apply_patch '*** Begin Patch\n*** Update File: a.rs\n+x > y\n*** End Patch'"),
688 ["a.rs"]
689 );
690 }
691
692 #[test]
693 fn full_and_partial_reads() {
694 assert_eq!(reads("cat src/a.rs"), [("src/a.rs".into(), Full)]);
695 assert_eq!(
696 reads("cat -n a.rs b.rs"),
697 [("a.rs".into(), Full), ("b.rs".into(), Full)]
698 );
699 assert_eq!(reads("bat --paging never a.rs"), [("a.rs".into(), Full)]);
700 assert_eq!(reads("less +40 a.rs"), [("a.rs".into(), Full)]);
701 assert_eq!(reads("bat -r 10:20 a.rs"), [("a.rs".into(), Partial)]);
702 assert_eq!(reads("head -n 50 a.rs"), [("a.rs".into(), Partial)]);
703 assert_eq!(reads("tail -20 a.rs"), [("a.rs".into(), Partial)]);
704 assert_eq!(reads("cat a.rs | head -5"), [("a.rs".into(), Partial)]);
705 assert_eq!(reads("cat a.rs | grep x"), [("a.rs".into(), Partial)]);
706 assert_eq!(
707 reads("cd docs && cat guide.md"),
708 [("docs/guide.md".into(), Full)]
709 );
710 assert_eq!(reads("cat a.rs > b.rs"), NO_READS);
711 assert_eq!(reads("cat"), NO_READS);
712 assert_eq!(reads("ls -la"), NO_READS);
713 }
714}