1use std::path::{Path, PathBuf};
40
41use serde_json::Value;
42
43use super::ENV_FILE;
44use crate::core::shell_allowlist::{
45 segments_with_separators, shell_tokenize, strip_all_heredoc_bodies, strip_for_analysis,
46};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Finding {
51 Write(PathBuf),
53 Refuse { rule: &'static str, detail: String },
55}
56
57#[path = "git.rs"]
58mod git;
59#[path = "runners.rs"]
60mod runners;
61use git::git_rule;
62use runners::{
63 command_env, interpreter_inline, interpreter_rule, is_interpreter, shell_inline, shell_rule,
64 unwrap_command,
65};
66
67const MAX_DEPTH: usize = 8;
68const SINKS: &[&str] = &[
69 "/dev/null",
70 "/dev/stdout",
71 "/dev/stderr",
72 "/dev/tty",
73 "/dev/zero",
74];
75const SHELLS: &[&str] = &[
76 "sh", "bash", "zsh", "dash", "ksh", "mksh", "fish", "csh", "tcsh",
77];
78const EDITORS: &[&str] = &[
79 "vi", "vim", "nvim", "view", "ex", "ed", "nano", "pico", "emacs", "micro", "hx", "kak", "joe",
80];
81const ARCHIVERS: &[&str] = &[
82 "unzip", "cpio", "7z", "7za", "7zr", "unrar", "ar", "pax", "rpm2cpio", "jar",
83];
84const COMPRESSORS: &[&str] = &[
85 "gzip",
86 "gunzip",
87 "bzip2",
88 "bunzip2",
89 "xz",
90 "unxz",
91 "zstd",
92 "unzstd",
93 "lz4",
94 "lzma",
95 "unlzma",
96 "compress",
97 "uncompress",
98];
99const COMMAND_ENV: &[&str] = &[
101 "GIT_EXTERNAL_DIFF",
102 "GIT_PAGER",
103 "PAGER",
104 "MANPAGER",
105 "GIT_SSH",
106 "GIT_SSH_COMMAND",
107 "GIT_EDITOR",
108 "GIT_SEQUENCE_EDITOR",
109 "EDITOR",
110 "VISUAL",
111 "GIT_ASKPASS",
112 "SSH_ASKPASS",
113 "GIT_PROXY_COMMAND",
114 "GIT_EXEC_PATH",
115 "GIT_CONFIG",
116 "GIT_CONFIG_GLOBAL",
117 "GIT_CONFIG_SYSTEM",
118 "GIT_CONFIG_PARAMETERS",
119 "GIT_CONFIG_COUNT",
120 "GIT_DIR",
121 "GIT_WORK_TREE",
122 "GIT_INDEX_FILE",
123 "GIT_TEMPLATE_DIR",
124 "GIT_REPLACE_REF_BASE",
125 "GIT_GRAFT_FILE",
126 "GIT_COMMON_DIR",
127 "GIT_OBJECT_DIRECTORY",
128 "GIT_ALTERNATE_OBJECT_DIRECTORIES",
129 "GIT_NAMESPACE",
130 "LESSOPEN",
131 "LESSCLOSE",
132 "BASH_ENV",
133 "ENV",
134 "PROMPT_COMMAND",
135 "LD_PRELOAD",
136 "LD_LIBRARY_PATH",
137 "DYLD_INSERT_LIBRARIES",
138 "PYTHONSTARTUP",
139 "NODE_OPTIONS",
140 "PERL5OPT",
141 "RUBYOPT",
142];
143const OPAQUE_RUNNERS: &[&str] = &[
145 "watch",
146 "su",
147 "runuser",
148 "sg",
149 "newgrp",
150 "script",
151 "strace",
152 "ltrace",
153 "gdb",
154 "lldb",
155 "expect",
156 "tmux",
157 "screen",
158 "at",
159 "batch",
160 "crontab",
161 "systemd-run",
162 "dbus-run-session",
163];
164
165struct Walk<'a> {
166 base_ref: Option<&'a str>,
168 base_commit: Option<&'a str>,
170 refs_may_move: bool,
172 cwds: Vec<PathBuf>,
174 cwd_unknown: bool,
176 env_keys: &'a [&'a str],
178 out: Vec<Finding>,
179}
180
181impl Walk<'_> {
182 fn refuse(&mut self, rule: &'static str, detail: impl Into<String>) {
183 self.out.push(Finding::Refuse {
184 rule,
185 detail: detail.into(),
186 });
187 }
188}
189
190#[derive(Debug, Clone, Copy, Default)]
192pub struct Base<'a> {
193 pub name: Option<&'a str>,
194 pub commit: Option<&'a str>,
195}
196
197pub fn analyse(command: &str, cwd: &Path, base: Base<'_>, env_keys: &[&str]) -> Vec<Finding> {
199 let mut walk = Walk {
200 base_ref: base.name,
201 base_commit: base.commit,
202 refs_may_move: false,
203 cwds: vec![cwd.to_path_buf()],
204 cwd_unknown: false,
205 env_keys,
206 out: Vec::new(),
207 };
208 for key in env_keys {
211 if COMMAND_ENV.contains(key) || key.starts_with("GIT_CONFIG_") {
212 walk.refuse(
213 "unanalysable",
214 format!("the env key {key} names a command or namespace that runs or redirects what this check cannot see"),
215 );
216 }
217 }
218 analyse_into(command, &mut walk, 0);
219 walk.out
220}
221
222fn analyse_into(command: &str, walk: &mut Walk<'_>, depth: usize) {
223 if depth > MAX_DEPTH {
224 walk.refuse("unanalysable", "substitutions are nested too deep");
225 return;
226 }
227 if command.contains(ENV_FILE) {
228 walk.refuse(
229 "rights_env",
230 format!("a command may not name or change {ENV_FILE}"),
231 );
232 }
233 if let Some(why) = daemon_access(command) {
234 walk.refuse("daemon_socket", why);
235 }
236 let text = strip_for_analysis(command, true, false);
238 let (outer, bodies) = match extract_substitutions(&text) {
239 Ok(parts) => parts,
240 Err(reason) => {
241 walk.refuse("unanalysable", reason);
242 return;
243 }
244 };
245 for body in bodies {
246 analyse_into(&body, walk, depth + 1);
247 }
248 let outer = strip_all_heredoc_bodies(&outer);
249 let mut piped = false;
250 for (segment, separator) in segments_with_separators(&outer) {
251 let fed = piped;
252 piped = matches!(
253 separator,
254 Some(crate::core::shell_allowlist::Separator::Pipe)
255 );
256 match extract_groups(&segment) {
259 Ok((rest, groups)) => {
260 for group in groups {
261 analyse_into(&group, walk, depth + 1);
262 }
263 analyse_segment(&rest, walk, fed);
264 }
265 Err(reason) => walk.refuse("unanalysable", reason),
266 }
267 }
268}
269
270fn extract_groups(segment: &str) -> Result<(String, Vec<String>), String> {
274 let chars: Vec<char> = segment.chars().collect();
275 let mut rest = String::with_capacity(segment.len());
276 let mut groups = Vec::new();
277 let mut i = 0;
278 let mut in_single = false;
279 let mut in_double = false;
280 while i < chars.len() {
281 let c = chars[i];
282 if c == '\\' && !in_single {
283 rest.push(c);
284 if let Some(&next) = chars.get(i + 1) {
285 rest.push(next);
286 }
287 i += 2;
288 continue;
289 }
290 if c == '\'' && !in_double {
291 in_single = !in_single;
292 } else if c == '"' && !in_single {
293 in_double = !in_double;
294 }
295 let word_start = i == 0 || chars[i - 1].is_whitespace() || "&|;".contains(chars[i - 1]);
296 if !in_single && !in_double && word_start {
297 if c == '(' {
298 let end = matching_paren(&chars, i + 1)
299 .ok_or_else(|| "a subshell is not closed".to_string())?;
300 groups.push(chars[i + 1..end].iter().collect());
301 rest.push_str("$GROUP");
302 i = end + 1;
303 continue;
304 }
305 if c == '{' && chars.get(i + 1).is_some_and(|n| n.is_whitespace()) {
306 let end = matching_brace(&chars, i + 1)
307 .ok_or_else(|| "a command group is not closed".to_string())?;
308 groups.push(chars[i + 1..end].iter().collect());
309 rest.push_str("$GROUP");
310 i = end + 1;
311 continue;
312 }
313 }
314 rest.push(c);
315 i += 1;
316 }
317 Ok((rest, groups))
318}
319
320fn matching_brace(chars: &[char], start: usize) -> Option<usize> {
322 let mut depth = 1usize;
323 let mut in_single = false;
324 let mut in_double = false;
325 let mut i = start;
326 while i < chars.len() {
327 match chars[i] {
328 '\\' if !in_single => i += 1,
329 '\'' if !in_double => in_single = !in_single,
330 '"' if !in_single => in_double = !in_double,
331 '{' if !in_single && !in_double => depth += 1,
332 '}' if !in_single && !in_double => {
333 depth -= 1;
334 if depth == 0 {
335 return Some(i);
336 }
337 }
338 _ => {}
339 }
340 i += 1;
341 }
342 None
343}
344
345fn daemon_access(command: &str) -> Option<String> {
348 let mut needles: Vec<String> = [
349 "daemon.sock",
350 "--unix-socket",
351 "/v1/tools/call",
352 "UNIX-CONNECT",
353 "UNIX-CLIENT",
354 "UNIX-SENDTO",
355 "ABSTRACT-CONNECT",
356 ]
357 .iter()
358 .map(|s| (*s).to_string())
359 .collect();
360 let mut dirs: Vec<PathBuf> = Vec::new();
361 if let Ok(dir) = crate::core::data_dir::resolve_data_dir() {
362 dirs.push(dir);
363 }
364 dirs.push(crate::ipc::legacy_endpoint_dir());
365 let home = dirs::home_dir();
366 for dir in dirs {
367 let shown = dir.to_string_lossy().to_string();
368 if shown.len() > 1 {
369 needles.push(shown);
370 }
371 if let Some(rel) = home.as_ref().and_then(|h| dir.strip_prefix(h).ok()) {
372 let rel = rel.to_string_lossy();
373 if !rel.is_empty() {
374 needles.push(format!("~/{rel}"));
375 needles.push(format!("$HOME/{rel}"));
376 needles.push(format!("${{HOME}}/{rel}"));
377 }
378 }
379 }
380 needles
381 .iter()
382 .find(|needle| command.contains(needle.as_str()))
383 .map(|needle| {
384 format!("a profiled session may not reach the LeanCTX daemon or its files (`{needle}`)")
385 })
386}
387
388fn extract_substitutions(text: &str) -> Result<(String, Vec<String>), String> {
392 let chars: Vec<char> = text.chars().collect();
393 let mut outer = String::with_capacity(text.len());
394 let mut bodies = Vec::new();
395 let mut i = 0;
396 let mut in_single = false;
397 let mut in_double = false;
398 while i < chars.len() {
399 let c = chars[i];
400 if c == '\\' && !in_single {
401 outer.push(c);
402 if let Some(&next) = chars.get(i + 1) {
403 outer.push(next);
404 }
405 i += 2;
406 continue;
407 }
408 if c == '\'' && !in_double {
409 in_single = !in_single;
410 } else if c == '"' && !in_single {
411 in_double = !in_double;
412 }
413 if !in_single {
414 let opener = chars.get(i + 1) == Some(&'(');
415 if opener && (c == '$' || ((c == '<' || c == '>') && !in_double)) {
416 let end = matching_paren(&chars, i + 2)
417 .ok_or_else(|| "a substitution is not closed".to_string())?;
418 bodies.push(chars[i + 2..end].iter().collect());
419 outer.push_str(if c == '$' { "$SUBST" } else { "/dev/fd/63" });
420 i = end + 1;
421 continue;
422 }
423 if c == '`' {
424 let mut j = i + 1;
425 while j < chars.len() && chars[j] != '`' {
426 if chars[j] == '\\' {
427 j += 1;
428 }
429 j += 1;
430 }
431 if j >= chars.len() {
432 return Err("a backquote substitution is not closed".into());
433 }
434 bodies.push(chars[i + 1..j].iter().collect());
435 outer.push_str("$SUBST");
436 i = j + 1;
437 continue;
438 }
439 }
440 outer.push(c);
441 i += 1;
442 }
443 Ok((outer, bodies))
444}
445
446fn matching_paren(chars: &[char], start: usize) -> Option<usize> {
448 let mut depth = 1usize;
449 let mut in_single = false;
450 let mut in_double = false;
451 let mut i = start;
452 while i < chars.len() {
453 let c = chars[i];
454 match c {
455 '\\' if !in_single => i += 1,
456 '\'' if !in_double => in_single = !in_single,
457 '"' if !in_single => in_double = !in_double,
458 '(' if !in_single && !in_double => depth += 1,
459 ')' if !in_single && !in_double => {
460 depth -= 1;
461 if depth == 0 {
462 return Some(i);
463 }
464 }
465 _ => {}
466 }
467 i += 1;
468 }
469 None
470}
471
472fn base_name(word: &str) -> &str {
473 word.rsplit('/').next().unwrap_or(word)
474}
475
476fn analyse_segment(segment: &str, walk: &mut Walk<'_>, piped: bool) {
478 let mut tokens = shell_tokenize(segment);
479 normalise_groups(&mut tokens);
480 let (words, redirected) = redirections(&tokens, walk);
481 let stdin = redirected || piped;
482 let words = unwrap_command(&words, walk);
483 let Some(first) = words.first() else {
484 return;
485 };
486 let base = base_name(first).to_string();
487 let args = &words[1..];
488 for (at, word) in args.iter().enumerate() {
491 let name = base_name(word);
492 let rest = &args[at + 1..];
493 let inline = if SHELLS.contains(&name) {
495 let (inline, script) = shell_inline(rest);
496 inline || (!script && stdin)
497 } else if is_interpreter(name) {
498 let (inline, script, _) = interpreter_inline(name, rest);
499 inline || (!script && stdin)
500 } else {
501 false
502 };
503 if inline {
504 walk.refuse(
505 "unanalysable",
506 format!("{name} with inline code runs what this check cannot see"),
507 );
508 return;
509 }
510 }
511 match base.as_str() {
512 "cd" => change_dir(args.first().map(String::as_str), walk),
513 b if OPAQUE_RUNNERS.contains(&b) => walk.refuse(
514 "unanalysable",
515 format!("{b} runs a command this check cannot follow"),
516 ),
517 "alias" => walk.refuse("unanalysable", "an alias changes what a later word runs"),
518 "export" | "declare" | "typeset" | "readonly" | "local" => {
519 for arg in args {
520 command_env(arg, walk);
521 }
522 }
523 "pushd" | "popd" => walk.refuse("unanalysable", "pushd/popd change the directory stack"),
524 "eval" | "source" | "." => walk.refuse("unanalysable", format!("{base} runs text as code")),
525 "xargs" | "parallel" => walk.refuse(
526 "unanalysable",
527 format!("{base} builds commands from its input"),
528 ),
529 "patch" => walk.refuse("unanalysable", "patch writes the files its input names"),
530 b if SHELLS.contains(&b) => shell_rule(b, args, walk),
531 b if EDITORS.contains(&b) => {
532 walk.refuse("unanalysable", format!("the editor {b} can write any file"));
533 }
534 b if ARCHIVERS.contains(&b) => walk.refuse(
535 "unanalysable",
536 format!("{b} writes the files its archive holds"),
537 ),
538 b if is_interpreter(b) => interpreter_rule(b, args, stdin, walk),
539 "awk" | "gawk" | "mawk" | "nawk" | "busybox" => awk_rule(&base, args, walk),
540 "find" => find_rule(args, walk),
541 "tar" | "bsdtar" | "gtar" => tar_rule(args, walk),
542 "nc" | "ncat" | "netcat" if args.iter().any(|a| a == "-U" || a == "--unixsock") => walk
543 .refuse(
544 "daemon_socket",
545 "a profiled session may not open a local socket",
546 ),
547 "socat" if args.iter().any(|a| a.to_ascii_uppercase().contains("UNIX")) => walk.refuse(
548 "daemon_socket",
549 "a profiled session may not open a local socket",
550 ),
551 "git" => git_rule(args, walk),
552 "sed" => sed_rule(args, walk),
553 _ => {
554 for raw in command_targets(&base, args) {
555 push_target(&raw, walk);
556 }
557 }
558 }
559}
560
561fn normalise_groups(tokens: &mut Vec<String>) {
563 const KEYWORDS: &[&str] = &[
564 "if", "then", "else", "elif", "do", "while", "until", "!", "{", "(",
565 ];
566 loop {
567 let Some(first) = tokens.first_mut() else {
568 return;
569 };
570 if first.len() > 1 && first.starts_with('(') && !first.starts_with("((") {
571 first.remove(0);
572 continue;
573 }
574 if KEYWORDS.contains(&first.as_str()) {
575 tokens.remove(0);
576 continue;
577 }
578 if first.ends_with(')') && !first.contains('(') && tokens.len() > 1 {
580 tokens.remove(0);
581 continue;
582 }
583 break;
584 }
585 while let Some(last) = tokens.last_mut() {
586 if matches!(last.as_str(), ")" | "}" | "fi" | "done" | "esac" | ";;") {
587 tokens.pop();
588 } else if last.len() > 1 && last.ends_with(')') && !last.contains('(') {
589 last.pop();
590 } else {
591 break;
592 }
593 }
594}
595
596fn redirections(tokens: &[String], walk: &mut Walk<'_>) -> (Vec<String>, bool) {
600 let mut words = Vec::new();
601 let mut stdin = false;
602 let mut i = 0;
603 while i < tokens.len() {
604 let token = &tokens[i];
605 if token.starts_with('<')
606 && !token.starts_with("<>")
607 && !token.contains(char::is_whitespace)
608 {
609 stdin = true;
610 i += if token.trim_start_matches('<').is_empty() {
611 2
612 } else {
613 1
614 };
615 continue;
616 }
617 let leads = token.trim_start_matches(|c: char| c.is_ascii_digit() || c == '&');
620 let operator = if token.contains(char::is_whitespace) && !leads.starts_with('>') {
621 None
622 } else {
623 token.find('>')
624 };
625 let Some(at) = operator else {
626 words.push(token.clone());
627 i += 1;
628 continue;
629 };
630 let before = &token[..at];
631 if before.ends_with(['-', '=']) {
632 words.push(token.clone());
633 i += 1;
634 continue;
635 }
636 let before = before.trim_end_matches('<');
637 if !before.is_empty() && !before.chars().all(|c| c.is_ascii_digit() || c == '&') {
638 words.push(before.to_string());
639 }
640 let rest = token[at..].trim_start_matches('>').trim_start_matches('|');
641 if rest.starts_with('&') {
642 i += 1;
644 continue;
645 }
646 if rest.is_empty() {
647 if let Some(next) = tokens.get(i + 1) {
648 push_target(next, walk);
649 }
650 i += 2;
651 } else {
652 push_target(rest, walk);
653 i += 1;
654 }
655 }
656 (words, stdin)
657}
658
659fn change_dir(target: Option<&str>, walk: &mut Walk<'_>) {
660 let Some(raw) = target.filter(|t| *t != "-") else {
661 if let Some(home) = dirs::home_dir() {
662 walk.cwds.push(home);
663 }
664 return;
665 };
666 let mut next = Vec::new();
667 for cwd in &walk.cwds {
668 match resolve_word(raw, cwd) {
669 Some(dir) => next.push(dir),
670 None => walk.cwd_unknown = true,
671 }
672 }
673 walk.cwds.extend(next);
674}
675
676fn awk_rule(base: &str, args: &[String], walk: &mut Walk<'_>) {
677 if base == "busybox" {
678 walk.refuse("unanalysable", "busybox runs any applet");
679 return;
680 }
681 if args
682 .iter()
683 .any(|a| a == "-i" || a.starts_with("--include") || a == "-f" || a.starts_with("--file"))
684 {
685 walk.refuse(
686 "unanalysable",
687 format!("{base} -i/-f runs a program this check cannot see"),
688 );
689 return;
690 }
691 let mut program = None;
692 let mut i = 0;
693 while i < args.len() {
694 let a = &args[i];
695 if a == "-v" || a == "-F" {
696 i += 2;
697 continue;
698 }
699 if !a.starts_with('-') {
700 program = Some(a);
701 break;
702 }
703 i += 1;
704 }
705 if program.is_some_and(|p| p.contains('>') || p.contains('|') || p.contains("system")) {
706 walk.refuse(
707 "unanalysable",
708 format!("the {base} program writes or runs commands"),
709 );
710 }
711}
712
713fn find_rule(args: &[String], walk: &mut Walk<'_>) {
714 const ACTIONS: &[&str] = &[
715 "-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls",
716 ];
717 if let Some(action) = args.iter().find(|a| ACTIONS.contains(&a.as_str())) {
718 walk.refuse(
719 "unanalysable",
720 format!("find {action} acts on every file it finds"),
721 );
722 }
723}
724
725fn tar_rule(args: &[String], walk: &mut Walk<'_>) {
726 let first = args.first().map(String::as_str).unwrap_or("");
727 let old_style = !first.starts_with('-') && !first.is_empty();
728 let clusters: Vec<&str> = args
729 .iter()
730 .map(String::as_str)
731 .filter(|a| a.starts_with('-') && !a.starts_with("--"))
732 .map(|a| &a[1..])
733 .chain(old_style.then_some(first))
734 .collect();
735 let extract = clusters.iter().any(|c| c.contains('x'))
736 || args.iter().any(|a| a == "--extract" || a == "--get");
737 if extract {
738 walk.refuse(
739 "unanalysable",
740 "tar extraction writes the files its archive holds",
741 );
742 return;
743 }
744 let mut i = 0;
746 while i < args.len() {
747 let a = &args[i];
748 if let Some(file) = a.strip_prefix("--file=") {
749 push_target(file, walk);
750 } else if a == "--file"
751 || (a.starts_with('-') && !a.starts_with("--") && a.ends_with('f'))
752 || (i == 0 && old_style && a.ends_with('f'))
753 {
754 if let Some(file) = args.get(i + 1) {
755 if file != "-" {
756 push_target(file, walk);
757 }
758 }
759 i += 1;
760 }
761 i += 1;
762 }
763}
764
765fn sed_rule(args: &[String], walk: &mut Walk<'_>) {
768 let mut scripts = Vec::new();
769 let mut files = Vec::new();
770 let mut in_place = false;
771 let mut i = 0;
772 while i < args.len() {
773 let a = &args[i];
774 if a == "-e" || a == "--expression" {
775 if let Some(s) = args.get(i + 1) {
776 scripts.push(s.clone());
777 }
778 i += 2;
779 continue;
780 }
781 if let Some(s) = a.strip_prefix("--expression=") {
782 scripts.push(s.to_string());
783 } else if a == "-f" || a == "--file" || a.starts_with("--file=") {
784 walk.refuse("unanalysable", "sed -f runs a script this check cannot see");
785 return;
786 } else if a == "--in-place" || a.starts_with("--in-place=") {
787 in_place = true;
788 } else if a.starts_with('-') && !a.starts_with("--") && a.len() > 1 {
789 if a[1..].contains('i') {
790 in_place = true;
791 }
792 if a[1..].contains('e') && !a.starts_with("-i") {
793 if let Some(s) = args.get(i + 1) {
794 scripts.push(s.clone());
795 }
796 i += 2;
797 continue;
798 }
799 } else if !a.starts_with('-') {
800 files.push(a.clone());
801 }
802 i += 1;
803 }
804 if scripts.is_empty() && !files.is_empty() {
805 scripts.push(files.remove(0));
806 }
807 if scripts.iter().any(|s| sed_script_writes(s)) {
808 walk.refuse(
809 "unanalysable",
810 "the sed script writes files or runs commands",
811 );
812 return;
813 }
814 if in_place {
815 for file in files {
816 push_target(&file, walk);
817 }
818 }
819}
820
821fn sed_script_writes(script: &str) -> bool {
824 let chars: Vec<char> = script.chars().collect();
825 let mut i = 0;
826 let mut at_command = true;
827 while i < chars.len() {
828 let c = chars[i];
829 if at_command {
830 match c {
831 ' ' | '\t' | ';' | '\n' | '{' | '}' | '!' | ',' | '$' | '0'..='9' | '~' | '+' => {}
832 '/' | '\\' => {
833 let delim = if c == '\\' {
835 i += 1;
836 match chars.get(i) {
837 Some(&d) => d,
838 None => return true,
839 }
840 } else {
841 '/'
842 };
843 match skip_delimited(&chars, i + 1, delim) {
844 Some(end) => i = end,
845 None => return true,
846 }
847 }
848 's' | 'y' => {
849 let Some(&delim) = chars.get(i + 1) else {
850 return true;
851 };
852 let Some(mid) = skip_delimited(&chars, i + 2, delim) else {
853 return true;
854 };
855 let Some(end) = skip_delimited(&chars, mid + 1, delim) else {
856 return true;
857 };
858 i = end + 1;
859 let flags_start = i;
860 while i < chars.len() && (chars[i].is_ascii_alphanumeric()) {
861 i += 1;
862 }
863 let flags: String = chars[flags_start..i].iter().collect();
864 if c == 's' && (flags.contains('w') || flags.contains('e')) {
865 return true;
866 }
867 at_command = false;
868 continue;
869 }
870 'w' | 'W' | 'e' => return true,
871 'a' | 'i' | 'c' | 'r' | 'R' | 'b' | 't' | 'T' | ':' => {
872 while i < chars.len() && chars[i] != '\n' {
874 i += 1;
875 }
876 continue;
877 }
878 _ => at_command = false,
879 }
880 } else if matches!(c, ';' | '\n' | '}') {
881 at_command = true;
882 }
883 i += 1;
884 }
885 false
886}
887
888fn skip_delimited(chars: &[char], start: usize, delim: char) -> Option<usize> {
890 let mut i = start;
891 while i < chars.len() {
892 if chars[i] == '\\' {
893 i += 2;
894 continue;
895 }
896 if chars[i] == delim {
897 return Some(i);
898 }
899 i += 1;
900 }
901 None
902}
903
904fn positional(args: &[String], takes_value: &[&str]) -> Vec<String> {
905 let mut out = Vec::new();
906 let mut i = 0;
907 let mut after_dashdash = false;
908 while i < args.len() {
909 let a = &args[i];
910 if after_dashdash {
911 out.push(a.clone());
912 } else if a == "--" {
913 after_dashdash = true;
914 } else if a.starts_with('-') && a.len() > 1 {
915 if takes_value.contains(&a.as_str()) {
916 i += 1;
917 }
918 } else {
919 out.push(a.clone());
920 }
921 i += 1;
922 }
923 out
924}
925
926fn command_targets(base: &str, args: &[String]) -> Vec<String> {
927 match base {
928 "tee" | "rm" | "rmdir" | "unlink" | "touch" | "mkdir" | "shred" | "mv" => {
929 let mut out = positional(args, &["-t", "-S", "--target-directory"]);
930 if let Some(i) = args
931 .iter()
932 .position(|a| a == "-t" || a == "--target-directory")
933 {
934 out.extend(args.get(i + 1).cloned());
935 }
936 out
937 }
938 "ln" | "link" => {
941 let mut out = positional(args, &["-t", "-S", "--target-directory", "--suffix"]);
942 let symbolic = args.iter().any(|a| {
944 a == "--symbolic" || (a.starts_with('-') && !a.starts_with("--") && a.contains('s'))
945 });
946 if symbolic && out.len() >= 2 {
947 let link = out.last().cloned().unwrap_or_default();
948 let dir = Path::new(&link)
949 .parent()
950 .map(Path::to_path_buf)
951 .unwrap_or_default();
952 let sources: Vec<String> = out[..out.len() - 1]
953 .iter()
954 .filter(|s| !s.starts_with('/'))
955 .map(|s| dir.join(s).to_string_lossy().into_owned())
956 .collect();
957 out.extend(sources);
958 }
959 out
960 }
961 "truncate" => positional(args, &["-s", "-r", "--size", "--reference"]),
962 "chmod" | "chown" | "chgrp" => positional(args, &["--reference"])
963 .into_iter()
964 .skip(1)
965 .collect(),
966 "cp" | "install" | "rsync" | "scp" => {
967 if let Some(i) = args
968 .iter()
969 .position(|a| a == "-t" || a == "--target-directory")
970 {
971 return args.get(i + 1).cloned().into_iter().collect();
972 }
973 let pos = positional(
974 args,
975 &["-m", "-o", "-g", "-S", "-e", "--mode", "--owner", "--group"],
976 );
977 let links = base == "cp"
979 && args.iter().any(|a| {
980 matches!(a.as_str(), "--link" | "--symbolic-link")
981 || (a.starts_with('-')
982 && !a.starts_with("--")
983 && (a.contains('l') || a.contains('s')))
984 });
985 if links {
986 return pos;
987 }
988 if pos.len() >= 2 {
989 pos.last().cloned().into_iter().collect()
990 } else {
991 Vec::new()
992 }
993 }
994 "dd" => args
995 .iter()
996 .filter_map(|a| a.strip_prefix("of=").map(str::to_string))
997 .collect(),
998 "sort" => {
999 let mut out = Vec::new();
1000 for (i, a) in args.iter().enumerate() {
1001 if a == "-o" || a == "--output" {
1002 out.extend(args.get(i + 1).cloned());
1003 } else if let Some(file) = a.strip_prefix("--output=") {
1004 out.push(file.to_string());
1005 } else if let Some(file) = a.strip_prefix("-o").filter(|f| !f.is_empty()) {
1006 out.push(file.to_string());
1007 }
1008 }
1009 out
1010 }
1011 "uniq" => positional(args, &["-f", "-s", "-w"])
1012 .into_iter()
1013 .skip(1)
1014 .take(1)
1015 .collect(),
1016 "curl" => {
1017 let mut out = Vec::new();
1018 for (i, a) in args.iter().enumerate() {
1019 match a.as_str() {
1020 "-o" | "--output" | "-D" | "--dump-header" | "-c" | "--cookie-jar" => {
1021 out.extend(args.get(i + 1).cloned());
1022 }
1023 "-O"
1024 | "--remote-name"
1025 | "--remote-name-all"
1026 | "-J"
1027 | "--remote-header-name" => {
1028 out.push("$REMOTE_NAME".to_string());
1029 }
1030 _ => {}
1031 }
1032 }
1033 out
1034 }
1035 "wget" => {
1036 let mut named = false;
1037 let mut out = Vec::new();
1038 for (i, a) in args.iter().enumerate() {
1039 if a == "-O" || a == "--output-document" || a == "-o" || a == "--output-file" {
1040 named |= a == "-O" || a == "--output-document";
1041 out.extend(args.get(i + 1).cloned().filter(|f| f != "-"));
1042 } else if let Some(f) = a.strip_prefix("--output-document=") {
1043 named = true;
1044 if f != "-" {
1045 out.push(f.to_string());
1046 }
1047 } else if a == "-P" || a == "--directory-prefix" {
1048 out.extend(args.get(i + 1).cloned());
1049 }
1050 }
1051 if !named {
1052 out.push("$REMOTE_NAME".to_string());
1053 }
1054 out
1055 }
1056 b if COMPRESSORS.contains(&b) => {
1057 if args.iter().any(|a| {
1058 matches!(
1059 a.as_str(),
1060 "-c" | "--stdout" | "-l" | "--list" | "-t" | "--test"
1061 )
1062 }) {
1063 return Vec::new();
1064 }
1065 let mut out = Vec::new();
1066 for file in positional(args, &["-S", "--suffix", "-o"]) {
1067 if let Some((stem, _)) = file.rsplit_once('.') {
1068 out.push(stem.to_string());
1069 }
1070 out.push(format!("{file}.gz"));
1071 out.push(file);
1072 }
1073 out
1074 }
1075 "split" | "csplit" => positional(args, &["-l", "-b", "-n", "-a", "-C", "-d", "-f"])
1076 .into_iter()
1077 .skip(1)
1078 .collect(),
1079 _ => Vec::new(),
1080 }
1081}
1082
1083fn resolve_word(raw: &str, cwd: &Path) -> Option<PathBuf> {
1086 if raw.is_empty() || raw.contains(['$', '`']) {
1087 return None;
1088 }
1089 let expanded = if raw == "~" || raw.starts_with("~/") {
1090 dirs::home_dir()?.join(raw.trim_start_matches('~').trim_start_matches('/'))
1091 } else if raw.starts_with('~') {
1092 return None;
1093 } else {
1094 PathBuf::from(raw)
1095 };
1096 Some(if expanded.is_absolute() {
1097 expanded
1098 } else {
1099 cwd.join(expanded)
1100 })
1101}
1102
1103fn push_target(raw: &str, walk: &mut Walk<'_>) {
1104 if SINKS.contains(&raw) || raw.starts_with("/dev/fd/") || raw.starts_with("/proc/self/fd/") {
1105 return;
1106 }
1107 if raw.contains('{') && raw.contains('}') {
1108 walk.refuse(
1109 "unanalysable",
1110 format!("brace expansion in `{raw}` names paths this check cannot list"),
1111 );
1112 return;
1113 }
1114 let relative = !raw.starts_with('/') && !raw.starts_with('~');
1115 if raw.is_empty() || raw.contains(['$', '`']) || (relative && walk.cwd_unknown) {
1116 walk.refuse(
1117 "bash_unresolved_target",
1118 format!("cannot tell where `{raw}` points; write it with a literal path"),
1119 );
1120 return;
1121 }
1122 let cwds = if relative {
1123 walk.cwds.clone()
1124 } else {
1125 vec![PathBuf::from("/")]
1126 };
1127 for cwd in cwds {
1128 let Some(path) = resolve_word(raw, &cwd) else {
1129 walk.refuse(
1130 "bash_unresolved_target",
1131 format!("cannot tell where `{raw}` points; write it with a literal path"),
1132 );
1133 return;
1134 };
1135 if raw.contains(['*', '?', '[']) {
1136 if let Some(parent) = path.parent() {
1138 walk.out.push(Finding::Write(parent.to_path_buf()));
1139 }
1140 if let Ok(paths) = glob::glob(&path.to_string_lossy()) {
1141 walk.out.extend(paths.flatten().map(Finding::Write));
1142 }
1143 } else {
1144 walk.out.push(Finding::Write(path));
1145 }
1146 }
1147}
1148
1149pub fn patch_targets(input: &Value) -> Vec<String> {
1152 const MARKERS: &[&str] = &[
1153 "*** Add File:",
1154 "*** Update File:",
1155 "*** Delete File:",
1156 "*** Move to:",
1157 ];
1158 let mut texts = Vec::new();
1159 collect_strings(input, &mut texts);
1160 let mut out = Vec::new();
1161 for text in texts {
1162 for line in text.lines() {
1163 let line = line.trim_start();
1164 for marker in MARKERS {
1165 if let Some(path) = line.strip_prefix(marker) {
1166 let path = path.trim();
1167 if !path.is_empty() {
1168 out.push(path.to_string());
1169 }
1170 }
1171 }
1172 }
1173 }
1174 out
1175}
1176
1177fn collect_strings<'a>(value: &'a Value, out: &mut Vec<&'a str>) {
1178 match value {
1179 Value::String(s) => out.push(s),
1180 Value::Array(items) => items.iter().for_each(|v| collect_strings(v, out)),
1181 Value::Object(map) => map.values().for_each(|v| collect_strings(v, out)),
1182 _ => {}
1183 }
1184}
1185
1186pub fn argv_command(argv: &[Value]) -> Option<String> {
1189 let words: Vec<&str> = argv.iter().map(Value::as_str).collect::<Option<_>>()?;
1190 if let [shell, flag, script] = words.as_slice()
1191 && SHELLS.contains(&base_name(shell))
1192 && matches!(*flag, "-c" | "-lc")
1193 {
1194 return Some((*script).to_string());
1195 }
1196 Some(
1197 words
1198 .iter()
1199 .map(|w| quote_word(w))
1200 .collect::<Vec<_>>()
1201 .join(" "),
1202 )
1203}
1204
1205fn quote_word(word: &str) -> String {
1207 let plain = !word.is_empty()
1208 && word
1209 .chars()
1210 .all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%".contains(c));
1211 if plain {
1212 word.to_string()
1213 } else {
1214 format!("'{}'", word.replace('\'', r"'\''"))
1215 }
1216}