Skip to main content

lean_ctx/core/session_rights/
shell.rs

1// SPDX-License-Identifier: Apache-2.0
2//! What a shell command line writes, for the session rights (process-v1 §9).
3//!
4//! [`analyse`] reads a command line the way the shell would split it and
5//! returns every write target it names plus a refusal for everything it
6//! cannot judge. The principle: under an active profile, what cannot be
7//! analysed is refused with a named rule, never passed.
8//!
9//! Seen and judged: redirections (`>`, `>>`, `>|`, `&>`, `n>`), command and
10//! process substitutions (analysed as commands of their own), `cd` (every
11//! directory a later relative path may mean is checked), and the writers
12//! `tee cp mv install ln rsync rm rmdir unlink touch mkdir shred truncate
13//! chmod chown chgrp sed -i dd sort -o uniq curl -o wget -O tar -cf`, the
14//! compressors, and the git commands that name their paths.
15//!
16//! Refused (`unanalysable`): `sh/bash/zsh -c` (every option before the script
17//! is read, `-o`/`-O` values skipped) or a shell reading its script from
18//! stdin, `eval`, `source`/`.`, `alias`, interpreters with inline code (`-c`,
19//! `-e`, `-`, a heredoc or pipe), a shell or interpreter with inline code
20//! anywhere in a command's words (`setsid bash -c …`), runners this check
21//! cannot follow (`watch`, `su`, `script`, `strace`, `flock -c`, …), an
22//! assignment of a command-valued variable (`GIT_EXTERNAL_DIFF=`, `PAGER=`,
23//! `GIT_SSH_COMMAND=`, …), `awk -i`/an awk program that writes, a sed script
24//! that writes or runs, `xargs`, `parallel`, `find -delete/-exec`, archive
25//! extraction, `patch`, editors, `pushd`/`popd`, brace expansion in a target.
26//! `git_rewrite`: checkout/switch/restore/reset --hard/stash/apply/am/
27//! cherry-pick/revert/clean/rebase and every git command not known to keep
28//! the worktree, unless it names every path it writes; `merge` of anything but
29//! the bound `base_commit`, `pull`. `git_config`: `git -c`, `git config`
30//! writes, `--exec-path`, `--upload-pack`/`ext::` transports, `grep -O`. The
31//! output files of kept git commands (`diff --output=f`, `archive -o f`,
32//! `format-patch -o d`) are judged as targets. `daemon_socket`: talking to
33//! the LeanCTX daemon directly. `rights_env`: naming `LEAN_CTX_RIGHTS_FILE`.
34//! `bash_unresolved_target`: a target behind a variable or substitution.
35//!
36//! Running a script file or a build tool (`python3 tools/x.py`, `cargo`,
37//! `npm`) stays allowed: what such a program opens is not seen here.
38
39use 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/// One result of the analysis.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Finding {
51    /// The command writes (creates, changes, removes) this path.
52    Write(PathBuf),
53    /// The command cannot run under the profile.
54    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];
99/// Environment variables whose value git, a pager or the shell runs.
100const 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];
143/// Programs that run another command in a way this check cannot follow.
144const 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    /// The base line's name (`base_ref` of the rights file).
167    base_ref: Option<&'a str>,
168    /// The base commit a Developer may merge (`base_commit`).
169    base_commit: Option<&'a str>,
170    /// An earlier command may have moved refs (`fetch`, `tag`, …).
171    refs_may_move: bool,
172    /// Every directory a relative path may mean at this point.
173    cwds: Vec<PathBuf>,
174    /// A `cd` to a place this analysis cannot tell.
175    cwd_unknown: bool,
176    /// Names the caller will add to the child's environment (ctx_shell `env`).
177    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/// The base line of the rights file: its name and its bound commit.
191#[derive(Debug, Clone, Copy, Default)]
192pub struct Base<'a> {
193    pub name: Option<&'a str>,
194    pub commit: Option<&'a str>,
195}
196
197/// Every write and refusal a command line holds, relative to `cwd`.
198pub 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    // The caller's `env` argument is another way to set a command-valued or
209    // namespace variable; judge each key like a leading `KEY=value`.
210    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    // An unquoted heredoc expands `$(…)`, so only quoted bodies are text.
237    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        // A subshell `( … )` or group `{ …; }` keeps its separators inside
257        // one segment: its commands are analysed on their own.
258        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
270/// Split out `( … )` and `{ …; }` groups that start a word. In the returned
271/// text each group becomes `$GROUP`, an unresolved word. A `{` directly
272/// followed by text (`{a,b}`) is brace expansion, not a group, and stays.
273fn 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
320/// Index of the `}` closing a `{ …; }` group whose body starts at `start`.
321fn 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
345/// Talking to the LeanCTX daemon directly would run a call without the
346/// session's rights.
347fn 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
388/// Split out `$(…)`, `` `…` ``, `<(…)` and `>(…)`: their bodies are commands
389/// of their own. In the returned outer text a command substitution becomes
390/// `$SUBST` (an unresolved word) and a process substitution `/dev/fd/63`.
391fn 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
446/// Index of the `)` closing a group whose body starts at `start`.
447fn 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
476/// `piped`: the segment reads the previous one's output.
477fn 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    // A shell or interpreter later in the words (`setsid bash -c …`,
489    // `chrt 5 python3 -c …`) still runs inline code.
490    for (at, word) in args.iter().enumerate() {
491        let name = base_name(word);
492        let rest = &args[at + 1..];
493        // Inline code, or no script while stdin feeds it (a pipe, a heredoc).
494        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
561/// Drop subshell and group markers and the keywords of compound commands.
562fn 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        // A `case` pattern (`pat)`) in front of its command.
579        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
596/// Pull redirections out of `tokens`: output targets are recorded, input
597/// redirections dropped. Returns the remaining words and whether stdin was
598/// redirected (a heredoc, a here-string or a file).
599fn 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        // A `>` inside a word with whitespace was quoted text (`awk '{print > x}'`);
618        // an operator at the start still redirects (`>"my file"`).
619        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            // `2>&1`, `>&2`: a descriptor, not a file.
643            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    // The archive a create or append writes.
745    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
765/// `sed -i` writes its files; a script that writes (`w`, `W`, `s///w`) or
766/// runs commands (`e`, `s///e`) is refused.
767fn 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
821/// Whether a sed script holds a `w`/`W`/`e` command or an `s` flag `w`/`e`.
822/// A script this scanner cannot follow counts as writing.
823fn 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                    // An address regex: skip to its closing delimiter.
834                    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                    // Text or a label up to the end of the line.
873                    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
888/// Index of the unescaped `delim` at or after `start`.
889fn 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        // Hard and symbolic links: the source is judged too, so no new name
939        // can lead to a locked file.
940        "ln" | "link" => {
941            let mut out = positional(args, &["-t", "-S", "--target-directory", "--suffix"]);
942            // A relative symlink source is read from the link's directory.
943            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            // `cp -l`/`-s` make links: the sources are judged like `ln`'s.
978            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
1083/// A word as a path, or `None` when only the shell could tell (a variable, a
1084/// substitution, another user's home).
1085fn 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            // The pattern stands for its directory and every file it matches now.
1137            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
1149/// The files an `apply_patch` call names (`*** Add File:`, `*** Update File:`,
1150/// `*** Delete File:`, `*** Move to:`), from any string in its input.
1151pub 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
1186/// A command given as an argument vector, as one command line. `[sh, -c,
1187/// script]` (and `-lc`) is the script itself.
1188pub 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
1205/// `word` as one shell word.
1206fn 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}