Skip to main content

doover_core/
resolver.rs

1//! Bash command parsing and affected-path scope resolution.
2//!
3//! Parsing uses `brush-parser` (pure Rust). The original tree-sitter-bash
4//! backend was removed after fuzzing found glibc-only segfaults in its C
5//! error recovery (heap-layout-dependent; 7-byte minimized repro) — a safety
6//! tool whose input is adversarial by definition cannot keep a memory-unsafe
7//! parser in the trust path. With pure Rust, worst-case failures are panics,
8//! which the thread wrapper in [`resolve`] converts into a conservative
9//! Unknown resolution.
10//!
11//! Quote-context correctness: every word is carried as *segments* with an
12//! expandability mask (unquoted vs quoted/escaped), because bash only
13//! brace-expands, globs, and tilde-expands unquoted spans. `'{a,b}'{c,d}`
14//! expands only the second group; `'*'x` never globs; `'~/x'` is a literal.
15//! The bash differential oracle (tests/resolver_oracle.rs) enforces this
16//! against real bash.
17//!
18//! Design invariants (property-tested):
19//! - never panics or crashes the caller on any input;
20//! - anything the resolver cannot fully account for — opaque constructs
21//!   (`eval`, `$( )`, `sh -c`, `xargs`, wrappers like `sudo`), unresolvable
22//!   variables, parse errors, control-flow compounds, or a destructive rule
23//!   that yielded zero concrete paths — sets `has_unknown`, which routes the
24//!   action through the engine's unknown policy instead of silently
25//!   under-protecting;
26//! - resolution is deterministic and purely lexical except for glob expansion
27//!   and git-repo-root discovery, which read (never write) the filesystem.
28
29use crate::registry::{Effect, PathSource, Registry, Rule};
30use brush_parser::ast;
31use brush_parser::word::{TildeExpr, WordPiece, WordPieceWithSource};
32use std::collections::BTreeSet;
33use std::path::{Component, Path, PathBuf};
34
35/// Classification severity. Extends registry [`Effect`] with `Unknown`,
36/// ordered so that a destructive-with-known-scope part still dominates an
37/// unknown part in reporting (the `has_unknown` flag carries the safety
38/// obligation separately).
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Severity {
42    #[default]
43    Safe,
44    Mutating,
45    Externalizing,
46    Unknown,
47    Destructive,
48    Irreversible,
49}
50
51impl Severity {
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Severity::Safe => "safe",
55            Severity::Mutating => "mutating",
56            Severity::Externalizing => "externalizing",
57            Severity::Unknown => "unknown",
58            Severity::Destructive => "destructive",
59            Severity::Irreversible => "irreversible",
60        }
61    }
62}
63
64impl From<Effect> for Severity {
65    fn from(e: Effect) -> Self {
66        match e {
67            Effect::Safe => Severity::Safe,
68            Effect::Mutating => Severity::Mutating,
69            Effect::Externalizing => Severity::Externalizing,
70            Effect::Destructive => Severity::Destructive,
71            Effect::Irreversible => Severity::Irreversible,
72        }
73    }
74}
75
76pub struct Ctx<'a> {
77    pub cwd: &'a Path,
78    pub home: &'a Path,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Resolution {
83    pub severity: Severity,
84    /// Affected paths, absolute, lexically normalized, sorted, deduplicated.
85    pub paths: Vec<PathBuf>,
86    /// Registry rule of the highest-severity contribution.
87    pub rule_id: Option<String>,
88    /// True when any part of the line escaped full accounting.
89    pub has_unknown: bool,
90}
91
92/// Commands that execute other commands or otherwise defeat static scoping.
93/// Wrapper stripping (sudo, env, …) is a future refinement; opaque is safe.
94const OPAQUE_COMMANDS: &[&str] = &[
95    "eval", "source", ".", "exec", "sh", "bash", "zsh", "dash", "ksh", "fish", "xargs", "sudo",
96    "doas", "env", "nohup", "nice", "time", "timeout", "command", "builtin", "script", "watch",
97];
98
99const GLOB_CHARS: &[char] = &['*', '?', '['];
100
101/// Nesting cap for subshells/brace groups and for literal reconstruction; no
102/// legitimate agent command comes close, and capping keeps stack usage bounded
103/// on adversarial input. Fail safe (Unknown) beyond it.
104const MAX_WALK_DEPTH: usize = 256;
105const MAX_LITERAL_DEPTH: usize = 64;
106
107/// Cap on brace-expansion fan-out; beyond it the scope is treated as unknown
108/// rather than materializing a huge path list.
109const MAX_BRACE_EXPANSION: usize = 1024;
110
111/// Generous stack for the recursive-descent parser on pathological inputs;
112/// panics anywhere in resolution degrade to a conservative Unknown.
113const RESOLVER_STACK_BYTES: usize = 32 * 1024 * 1024;
114
115pub fn resolve(command: &str, registry: &Registry, ctx: &Ctx) -> Resolution {
116    std::thread::scope(|scope| {
117        let handle = std::thread::Builder::new()
118            .name("doover-resolver".into())
119            .stack_size(RESOLVER_STACK_BYTES)
120            .spawn_scoped(scope, || resolve_inner(command, registry, ctx));
121        match handle.map(std::thread::ScopedJoinHandle::join) {
122            Ok(Ok(resolution)) => resolution,
123            _ => Resolution {
124                severity: Severity::Unknown,
125                paths: Vec::new(),
126                rule_id: None,
127                has_unknown: true,
128            },
129        }
130    })
131}
132
133fn resolve_inner(command: &str, registry: &Registry, ctx: &Ctx) -> Resolution {
134    let mut walker = Walker {
135        registry,
136        ctx,
137        options: brush_parser::ParserOptions::default(),
138        out: Out::default(),
139    };
140    let mut cur = Some(normalize_lexical(ctx.cwd));
141    match brush_parser::tokenize_str(command) {
142        Ok(tokens) => match brush_parser::parse_tokens(&tokens, &walker.options) {
143            Ok(program) => {
144                for complete_command in &program.complete_commands {
145                    walker.walk_compound_list(complete_command, &mut cur, 0);
146                }
147            }
148            Err(_) => walker.out.mark_unknown(),
149        },
150        Err(_) => walker.out.mark_unknown(),
151    }
152    walker.out.finish()
153}
154
155#[derive(Default)]
156struct Out {
157    severity: Severity,
158    paths: BTreeSet<PathBuf>,
159    rule_id: Option<String>,
160    rule_sev: Severity,
161    has_unknown: bool,
162}
163
164impl Out {
165    fn mark_unknown(&mut self) {
166        self.severity = self.severity.max(Severity::Unknown);
167        self.has_unknown = true;
168    }
169
170    fn contribute(&mut self, sev: Severity, rule_id: &str) {
171        self.severity = self.severity.max(sev);
172        if self.rule_id.is_none() || sev > self.rule_sev {
173            self.rule_sev = sev;
174            self.rule_id = Some(rule_id.to_string());
175        }
176    }
177
178    fn finish(self) -> Resolution {
179        Resolution {
180            severity: self.severity,
181            paths: self.paths.into_iter().collect(),
182            rule_id: self.rule_id,
183            has_unknown: self.has_unknown,
184        }
185    }
186}
187
188/// A word as quote-aware segments: `(text, expandable)`. Only expandable
189/// (unquoted, unescaped) spans participate in brace/glob expansion.
190/// `tilde_home` records an unquoted leading `~` (brush emits a tilde piece
191/// only where bash would actually expand one).
192#[derive(Clone, Debug)]
193struct Word {
194    segs: Vec<(String, bool)>,
195    tilde_home: bool,
196}
197
198impl Word {
199    fn text(&self) -> String {
200        self.segs.iter().map(|(s, _)| s.as_str()).collect()
201    }
202
203    fn masked_chars(&self) -> Vec<(char, bool)> {
204        self.segs
205            .iter()
206            .flat_map(|(s, exp)| s.chars().map(move |c| (c, *exp)))
207            .collect()
208    }
209
210    fn has_expandable(&self, ch: char) -> bool {
211        self.segs.iter().any(|(s, exp)| *exp && s.contains(ch))
212    }
213
214    /// Split at a byte offset of `text()`, preserving masks; used to peel the
215    /// value off attached flag forms (`-ovalue`, `--output=value`).
216    fn split_off_bytes(&self, at: usize) -> Word {
217        let mut consumed = 0usize;
218        let mut segs = Vec::new();
219        for (s, exp) in &self.segs {
220            let end = consumed + s.len();
221            if end > at {
222                let start = at.saturating_sub(consumed);
223                segs.push((s[start..].to_string(), *exp));
224            }
225            consumed = end;
226        }
227        Word {
228            segs,
229            tilde_home: false,
230        }
231    }
232}
233
234enum ArgVal {
235    Lit(Word),
236    Opaque,
237}
238
239enum Tok {
240    Flag(Word),
241    Pos(Word),
242}
243
244/// What the next positional token should become, given the preceding flag.
245enum Consume {
246    No,
247    Drop,
248    Path,
249}
250
251struct Walker<'a> {
252    registry: &'a Registry,
253    ctx: &'a Ctx<'a>,
254    options: brush_parser::ParserOptions,
255    out: Out,
256}
257
258impl Walker<'_> {
259    /// `cur` is the tracked working directory; `None` means a `cd` made it
260    /// unresolvable and every later relative path is unknown.
261    fn walk_compound_list(
262        &mut self,
263        list: &ast::CompoundList,
264        cur: &mut Option<PathBuf>,
265        depth: usize,
266    ) {
267        if depth > MAX_WALK_DEPTH {
268            self.out.mark_unknown();
269            return;
270        }
271        for ast::CompoundListItem(and_or, _sep) in &list.0 {
272            self.walk_pipeline(&and_or.first, cur, depth);
273            for branch in &and_or.additional {
274                let (ast::AndOr::And(p) | ast::AndOr::Or(p)) = branch;
275                self.walk_pipeline(p, cur, depth);
276            }
277        }
278    }
279
280    fn walk_pipeline(&mut self, pipeline: &ast::Pipeline, cur: &mut Option<PathBuf>, depth: usize) {
281        if pipeline.seq.len() == 1 {
282            self.walk_command(&pipeline.seq[0], cur, depth);
283        } else {
284            // each segment runs in its own subshell: isolate cwd changes
285            for command in &pipeline.seq {
286                let mut seg_cur = cur.clone();
287                self.walk_command(command, &mut seg_cur, depth);
288            }
289        }
290    }
291
292    fn walk_command(&mut self, command: &ast::Command, cur: &mut Option<PathBuf>, depth: usize) {
293        match command {
294            ast::Command::Simple(simple) => self.handle_simple(simple, cur),
295            ast::Command::Compound(compound, redirects) => {
296                match compound {
297                    ast::CompoundCommand::Subshell(subshell) => {
298                        let mut sub_cur = cur.clone();
299                        self.walk_compound_list(&subshell.list, &mut sub_cur, depth + 1);
300                    }
301                    ast::CompoundCommand::BraceGroup(group) => {
302                        self.walk_compound_list(&group.list, cur, depth + 1);
303                    }
304                    // control flow (if/for/while/case/arith/coproc) executes
305                    // data-dependent bodies: conservative until refined
306                    _ => self.out.mark_unknown(),
307                }
308                if let Some(list) = redirects {
309                    for redirect in &list.0 {
310                        self.handle_redirect(redirect, cur);
311                    }
312                }
313            }
314            // a function *definition* executes nothing; calling it later hits
315            // the unregistered-command path and classifies unknown
316            ast::Command::Function(_) => {}
317            // [[ ... ]] evaluates without filesystem writes
318            ast::Command::ExtendedTest(_, redirects) => {
319                if let Some(list) = redirects {
320                    for redirect in &list.0 {
321                        self.handle_redirect(redirect, cur);
322                    }
323                }
324            }
325        }
326    }
327
328    fn handle_simple(&mut self, simple: &ast::SimpleCommand, cur: &mut Option<PathBuf>) {
329        if let Some(prefix) = &simple.prefix {
330            self.handle_prefix_suffix_items(&prefix.0, None, cur);
331        }
332        // suffix first so redirects are honored even when the command name is
333        // opaque (`$CMD > file` must still protect file)
334        let mut args: Vec<ArgVal> = Vec::new();
335        if let Some(suffix) = &simple.suffix {
336            self.handle_prefix_suffix_items(&suffix.0, Some(&mut args), cur);
337        }
338
339        let Some(name_word) = &simple.word_or_name else {
340            return; // bare assignments / redirects only
341        };
342        let Some(name_lit) = self.extract_word(&name_word.value) else {
343            self.out.mark_unknown();
344            return;
345        };
346        if name_lit.tilde_home {
347            self.out.mark_unknown();
348            return;
349        }
350        let name = name_lit.text();
351
352        if name == "cd" {
353            self.handle_cd(&args, cur);
354            return;
355        }
356        if OPAQUE_COMMANDS.contains(&name.as_str()) {
357            self.out.mark_unknown();
358            return;
359        }
360
361        let mut any_opaque = false;
362        let mut tokens: Vec<Tok> = Vec::new();
363        let mut after_double_dash = false;
364        for arg in &args {
365            match arg {
366                ArgVal::Opaque => any_opaque = true,
367                ArgVal::Lit(word) => {
368                    let text = word.text();
369                    if !after_double_dash && text == "--" {
370                        after_double_dash = true;
371                    } else if !after_double_dash && text.len() > 1 && text.starts_with('-') {
372                        tokens.push(Tok::Flag(word.clone()));
373                    } else {
374                        tokens.push(Tok::Pos(word.clone()));
375                    }
376                }
377            }
378        }
379        if any_opaque {
380            self.out.mark_unknown();
381        }
382
383        let flags: Vec<String> = tokens
384            .iter()
385            .filter_map(|t| match t {
386                Tok::Flag(w) => Some(w.text()),
387                Tok::Pos(_) => None,
388            })
389            .collect();
390        let subs: Vec<String> = tokens
391            .iter()
392            .filter_map(|t| match t {
393                Tok::Pos(w) => Some(w.text()),
394                Tok::Flag(_) => None,
395            })
396            .collect();
397        let Some(rule) =
398            self.registry
399                .lookup_command(&name, subs.first().map(String::as_str), &flags)
400        else {
401            self.out.mark_unknown();
402            return;
403        };
404        let sev = Severity::from(rule.effect);
405        self.out.contribute(sev, &rule.id);
406
407        // separate positionals from flag-carried values. `flag_args` flags
408        // consume a non-path value (dropped); `path_flags` flags carry a path
409        // value (captured) — in separate (`-o v`), attached-short (`-ov`), or
410        // attached-long (`--out=v`) form.
411        let empty: &[String] = &[];
412        let (flag_args, path_flags) = rule
413            .scope
414            .as_ref()
415            .map(|s| (s.flag_args.as_slice(), s.path_flags.as_slice()))
416            .unwrap_or((empty, empty));
417        let is_short = |f: &str| f.len() == 2 && f.starts_with('-') && !f.starts_with("--");
418        let mut positionals: Vec<Word> = Vec::new();
419        let mut flag_paths: Vec<Word> = Vec::new();
420        let mut consume: Consume = Consume::No;
421        for tok in &tokens {
422            match tok {
423                Tok::Flag(w) => {
424                    let t = w.text();
425                    if let Some(eq) = t.find('=') {
426                        if path_flags.iter().any(|pf| pf.as_str() == &t[..eq]) {
427                            flag_paths.push(w.split_off_bytes(eq + 1));
428                        }
429                    } else if path_flags.iter().any(|pf| pf.as_str() == t) {
430                        consume = Consume::Path;
431                    } else if flag_args.iter().any(|fa| fa.as_str() == t) {
432                        consume = Consume::Drop;
433                    } else if let Some(pf) = path_flags
434                        .iter()
435                        .find(|pf| is_short(pf) && t.starts_with(pf.as_str()) && t.len() > 2)
436                    {
437                        flag_paths.push(w.split_off_bytes(pf.len()));
438                    } else if flag_args
439                        .iter()
440                        .any(|fa| is_short(fa) && t.starts_with(fa.as_str()) && t.len() > 2)
441                    {
442                        // attached non-path value: nothing to capture or drop
443                    }
444                }
445                Tok::Pos(w) => match std::mem::replace(&mut consume, Consume::No) {
446                    Consume::Path => flag_paths.push(w.clone()),
447                    Consume::Drop => {}
448                    Consume::No => positionals.push(w.clone()),
449                },
450            }
451        }
452
453        let mut contributed = self.extract_scope(rule, &positionals, cur);
454        if let Some(scope) = &rule.scope {
455            for pv in &flag_paths {
456                // path-flag values (e.g. `sort -o out`) are write targets:
457                // a symlink there is written through
458                contributed += self.add_path(pv, scope.globs, cur, true);
459            }
460        }
461        if sev >= Severity::Destructive && contributed == 0 {
462            // destructive action with no pre-snapshottable paths: the engine
463            // must fall back to the unknown policy rather than claim coverage.
464            // (External-state actions doover cannot snapshot are classified
465            // `externalizing` — below this threshold — so they are flagged
466            // without triggering a pointless cwd snapshot.)
467            self.out.mark_unknown();
468        }
469    }
470
471    fn handle_prefix_suffix_items(
472        &mut self,
473        items: &[ast::CommandPrefixOrSuffixItem],
474        mut args: Option<&mut Vec<ArgVal>>,
475        cur: &Option<PathBuf>,
476    ) {
477        for item in items {
478            match item {
479                ast::CommandPrefixOrSuffixItem::Word(word) => {
480                    if let Some(args) = args.as_deref_mut() {
481                        args.push(match self.extract_word(&word.value) {
482                            Some(lit) => ArgVal::Lit(lit),
483                            None => ArgVal::Opaque,
484                        });
485                    }
486                }
487                ast::CommandPrefixOrSuffixItem::IoRedirect(redirect) => {
488                    self.handle_redirect(redirect, cur);
489                }
490                ast::CommandPrefixOrSuffixItem::AssignmentWord(_assignment, word) => {
491                    // FOO=bar is inert, but FOO=$(cmd) — including nesting like
492                    // FOO=${X:-$(cmd)} — executes
493                    if self.word_can_execute(&word.value) {
494                        self.out.mark_unknown();
495                    }
496                }
497                ast::CommandPrefixOrSuffixItem::ProcessSubstitution(..) => {
498                    self.out.mark_unknown();
499                }
500            }
501        }
502    }
503
504    fn extract_scope(&mut self, rule: &Rule, positionals: &[Word], cur: &Option<PathBuf>) -> usize {
505        let Some(scope) = &rule.scope else { return 0 };
506        let skip = usize::from(rule.matcher.subcommand.is_some()) + scope.skip;
507        let path_args: Vec<&Word> = positionals.iter().skip(skip).collect();
508        let mut contributed = 0usize;
509        match scope.paths {
510            PathSource::Positional => {
511                for word in path_args {
512                    contributed += self.add_path(word, scope.globs, cur, false);
513                }
514            }
515            PathSource::PositionalLast => {
516                if let Some(word) = path_args.last() {
517                    contributed += self.add_path(word, scope.globs, cur, false);
518                }
519            }
520            PathSource::Repo => {
521                if let Some(root) = cur.as_deref().and_then(find_repo_root) {
522                    self.insert_scoped(root, false);
523                    contributed += 1;
524                }
525            }
526            PathSource::RedirectTarget | PathSource::None => {}
527        }
528        contributed
529    }
530
531    /// Resolve one path word, applying bash expansion order — brace, then
532    /// tilde/normalize, then glob — each step honoring the quote mask.
533    /// Returns how many paths were added.
534    /// `write_target` marks a value that will be WRITTEN through (redirect and
535    /// path-flag targets), so a symlink there is always dereferenced. For
536    /// ordinary positional args, dereferencing is decided per variant by a
537    /// trailing `/` or `/.` (see [`Self::add_single`]).
538    fn add_path(
539        &mut self,
540        word: &Word,
541        globs: bool,
542        cur: &Option<PathBuf>,
543        write_target: bool,
544    ) -> usize {
545        if word.segs.is_empty() && !word.tilde_home {
546            return 0;
547        }
548        let chars = word.masked_chars();
549        let expanded: Vec<Vec<(char, bool)>> = if word.has_expandable('{') {
550            match expand_braces_masked(&chars, MAX_BRACE_EXPANSION) {
551                Some(list) => list,
552                None => {
553                    // fan-out too large: scope is unresolvable
554                    self.out.mark_unknown();
555                    return 0;
556                }
557            }
558        } else {
559            vec![chars]
560        };
561        let mut total = 0;
562        for variant in expanded {
563            total += self.add_single(&variant, word.tilde_home, globs, cur, write_target);
564        }
565        total
566    }
567
568    /// One fully brace-expanded masked word → resolved path(s), globbing only
569    /// on unquoted glob characters.
570    fn add_single(
571        &mut self,
572        chars: &[(char, bool)],
573        tilde_home: bool,
574        globs: bool,
575        cur: &Option<PathBuf>,
576        write_target: bool,
577    ) -> usize {
578        let text: String = chars.iter().map(|(c, _)| c).collect();
579        if text.is_empty() && !tilde_home {
580            return 0;
581        }
582        // `link/..` addresses the target's PARENT (deref then up): unbounded
583        // scope we can't safely enumerate — fail to unknown
584        if text.ends_with("/..") {
585            self.out.mark_unknown();
586            return 0;
587        }
588
589        // base directory + word remainder
590        let (base, rem): (Option<PathBuf>, &[(char, bool)]) = if tilde_home {
591            let mut start = 0;
592            while start < chars.len() && chars[start].0 == '/' {
593                start += 1;
594            }
595            (Some(self.ctx.home.to_path_buf()), &chars[start..])
596        } else if Path::new(&text).is_absolute() {
597            (None, chars)
598        } else {
599            match cur {
600                Some(dir) => (Some(dir.clone()), chars),
601                None => {
602                    self.out.mark_unknown();
603                    return 0;
604                }
605            }
606        };
607        let rem_text: String = rem.iter().map(|(c, _)| c).collect();
608        let literal = match &base {
609            Some(b) => normalize_lexical(&b.join(&rem_text)),
610            None => normalize_lexical(Path::new(&rem_text)),
611        };
612
613        let active_glob = rem.iter().any(|(c, exp)| *exp && GLOB_CHARS.contains(c));
614        if !(globs && active_glob) {
615            // dereference a symlink only when the usage actually goes through
616            // it: a write target, or a trailing `/` or `/.` (operates on the
617            // target dir). A bare `rm link` scopes only the link (round 8).
618            let derefs = write_target || rem_text.ends_with('/') || rem_text.ends_with("/.");
619            self.insert_scoped(literal, derefs);
620            return 1;
621        }
622
623        // glob pattern: escape the base entirely and any QUOTED metacharacters
624        // in the word — only unquoted ones keep their pattern meaning
625        let mut pattern = String::new();
626        if let Some(b) = &base {
627            pattern.push_str(&glob::Pattern::escape(&b.to_string_lossy()));
628            pattern.push('/');
629        }
630        for (c, exp) in rem {
631            if !exp && GLOB_CHARS.contains(c) {
632                pattern.push_str(&glob::Pattern::escape(&c.to_string()));
633            } else {
634                pattern.push(*c);
635            }
636        }
637        // bash parity for leading dots (audit rounds 5 & 6): a `*`/`?`/`[`
638        // matches a hidden component only when the *corresponding pattern
639        // component* literally begins with `.` — and this holds for EVERY
640        // component, not just the last (`*/f.txt` must not descend `.hidden/`).
641        // The glob crate's require_literal_leading_dot is unreliable (misses
642        // `.h*`), so we glob permissively and filter per-component ourselves.
643        let base_prefix = base.as_ref().map(|b| format!("{}/", b.to_string_lossy()));
644        let pat_components: Vec<&str> = rem_text.trim_matches('/').split('/').collect();
645        match glob::glob(&pattern) {
646            Ok(matches) => {
647                let mut n = 0usize;
648                for m in matches.take(10_000).flatten() {
649                    let m_str = m.to_string_lossy();
650                    let relative = match &base_prefix {
651                        Some(p) => m_str.strip_prefix(p.as_str()).unwrap_or(&m_str),
652                        None => m_str.trim_start_matches('/'),
653                    };
654                    if glob_match_allowed(relative, &pat_components) {
655                        // a glob match is a concrete entry; rm removes the link
656                        // itself, not its target — no deref
657                        self.insert_scoped(normalize_lexical(&m), false);
658                        n += 1;
659                    }
660                }
661                n
662            }
663            Err(_) => {
664                self.insert_scoped(literal, false);
665                1
666            }
667        }
668    }
669
670    /// Record a scoped path. When `deref` and the path is a symlink, also scope
671    /// its resolved target chain — operations that go THROUGH a link act on the
672    /// target: `> linkfile` clobbers the target's content, `rm -rf link/` (or
673    /// `link/.`) deletes the target directory. `deref` is false for a bare
674    /// `rm link`, which touches only the link (round 8: unconditional deref
675    /// pulled whole target trees into the store). `read_link` (not
676    /// canonicalize) so dangling links scope their would-be-created target as
677    /// an absent-marker. Chain hops are capped; loops simply stop contributing.
678    fn insert_scoped(&mut self, path: PathBuf, deref: bool) {
679        let mut hops = 0;
680        let mut cur = path;
681        loop {
682            let is_link = deref && cur.symlink_metadata().is_ok_and(|m| m.is_symlink());
683            self.out.paths.insert(cur.clone());
684            if !is_link || hops >= 8 {
685                return;
686            }
687            let Ok(target) = std::fs::read_link(&cur) else {
688                return;
689            };
690            let resolved = if target.is_absolute() {
691                normalize_lexical(&target)
692            } else {
693                match cur.parent() {
694                    Some(parent) => normalize_lexical(&parent.join(target)),
695                    None => return,
696                }
697            };
698            if self.out.paths.contains(&resolved) {
699                return; // loop or already scoped
700            }
701            cur = resolved;
702            hops += 1;
703        }
704    }
705
706    /// Literal resolution for words that never glob or brace-expand (cd
707    /// targets, redirect targets).
708    fn resolve_literal(&mut self, word: &Word, cur: &Option<PathBuf>) -> Option<PathBuf> {
709        let text = word.text();
710        if word.tilde_home {
711            return Some(normalize_lexical(
712                &self.ctx.home.join(text.trim_start_matches('/')),
713            ));
714        }
715        if Path::new(&text).is_absolute() {
716            return Some(normalize_lexical(Path::new(&text)));
717        }
718        match cur {
719            Some(dir) => Some(normalize_lexical(&dir.join(&text))),
720            None => {
721                self.out.mark_unknown();
722                None
723            }
724        }
725    }
726
727    fn handle_cd(&mut self, args: &[ArgVal], cur: &mut Option<PathBuf>) {
728        let target = args.iter().find(|a| match a {
729            ArgVal::Lit(w) => {
730                let t = w.text();
731                !(t.len() > 1 && t.starts_with('-')) && t != "--"
732            }
733            ArgVal::Opaque => true,
734        });
735        match target {
736            None => *cur = Some(normalize_lexical(self.ctx.home)),
737            Some(ArgVal::Lit(w)) if w.text() == "-" => {
738                // previous directory is untracked
739                self.out.mark_unknown();
740                *cur = None;
741            }
742            Some(ArgVal::Lit(w)) => match self.resolve_literal(w, cur) {
743                Some(p) => *cur = Some(p),
744                None => *cur = None,
745            },
746            Some(ArgVal::Opaque) => {
747                self.out.mark_unknown();
748                *cur = None;
749            }
750        }
751    }
752
753    fn handle_redirect(&mut self, redirect: &ast::IoRedirect, cur: &Option<PathBuf>) {
754        use ast::{IoFileRedirectKind as Kind, IoFileRedirectTarget as Target, IoRedirect as R};
755        match redirect {
756            R::File(_fd, kind, target) => {
757                let op = match kind {
758                    Kind::Write | Kind::Clobber | Kind::ReadAndWrite => ">",
759                    Kind::Append => ">>",
760                    // input redirects don't write a file, but the target word
761                    // itself can execute (`< $(cmd)`), which must not pass as
762                    // safe
763                    Kind::Read | Kind::DuplicateInput | Kind::DuplicateOutput => {
764                        if let Target::Filename(word) = target {
765                            if self.word_can_execute(&word.value) {
766                                self.out.mark_unknown();
767                            }
768                        }
769                        return;
770                    }
771                };
772                match target {
773                    Target::Filename(word) => self.redirect_to(op, &word.value, cur),
774                    Target::Fd(_) | Target::Duplicate(_) => {}
775                    Target::ProcessSubstitution(..) => self.out.mark_unknown(),
776                }
777            }
778            // &> / &>>
779            R::OutputAndError(word, append) => {
780                let op = if *append { ">>" } else { ">" };
781                self.redirect_to(op, &word.value, cur);
782            }
783            // here-string: `<<< $(cmd)` executes the substitution
784            R::HereString(_fd, word) => {
785                if self.word_can_execute(&word.value) {
786                    self.out.mark_unknown();
787                }
788            }
789            // heredoc body is expanded unless the delimiter was quoted
790            R::HereDocument(_fd, here) => {
791                if here.requires_expansion && self.word_can_execute(&here.doc.value) {
792                    self.out.mark_unknown();
793                }
794            }
795        }
796    }
797
798    fn redirect_to(&mut self, op: &str, raw: &str, cur: &Option<PathBuf>) {
799        let Some(word) = self.extract_word(raw) else {
800            self.out.mark_unknown();
801            return;
802        };
803        let text = word.text();
804        // numeric target is an fd, not a file
805        if (text.is_empty() && !word.tilde_home) || text.chars().all(|ch| ch.is_ascii_digit()) {
806            return;
807        }
808        let Some(rule) = self.registry.lookup_redirect(op) else {
809            self.out.mark_unknown();
810            return;
811        };
812        self.out.contribute(Severity::from(rule.effect), &rule.id);
813        if let Some(p) = self.resolve_literal(&word, cur) {
814            // redirects WRITE THROUGH symlinks: deref so the clobbered target
815            // content is snapshotted
816            self.insert_scoped(p, true);
817        }
818    }
819
820    fn extract_word(&self, raw: &str) -> Option<Word> {
821        let pieces = brush_parser::word::parse(raw, &self.options).ok()?;
822        pieces_to_word(&pieces, 0)
823    }
824
825    /// True if the word can execute a command when expanded: a direct command
826    /// substitution, a backquote, or a command substitution nested inside a
827    /// parameter/arithmetic expansion (e.g. `${X:-$(cmd)}`). Single-quoted text
828    /// is inert. Fail-closed: unparseable ⇒ true.
829    fn word_can_execute(&self, raw: &str) -> bool {
830        fn scan(pieces: &[WordPieceWithSource], raw: &str, depth: usize) -> bool {
831            depth > MAX_LITERAL_DEPTH
832                || pieces.iter().any(|pw| match &pw.piece {
833                    WordPiece::CommandSubstitution(_)
834                    | WordPiece::BackquotedCommandSubstitution(_) => true,
835                    WordPiece::DoubleQuotedSequence(inner)
836                    | WordPiece::GettextDoubleQuotedSequence(inner) => scan(inner, raw, depth + 1),
837                    // parameter/arithmetic expansions carry their operands as
838                    // opaque strings; a nested $( ) or backquote there still
839                    // executes. Conservatively flag if the word contains one.
840                    WordPiece::ParameterExpansion(_) | WordPiece::ArithmeticExpression(_) => {
841                        raw.contains("$(") || raw.contains('`')
842                    }
843                    _ => false,
844                })
845        }
846        match brush_parser::word::parse(raw, &self.options) {
847            Ok(pieces) => scan(&pieces, raw, 0),
848            Err(_) => true,
849        }
850    }
851}
852
853/// Reduce parsed word pieces to quote-aware segments. Only a *leading*
854/// unquoted `~` piece becomes tilde expansion, matching where brush emits it.
855fn pieces_to_word(pieces: &[WordPieceWithSource], depth: usize) -> Option<Word> {
856    if depth > MAX_LITERAL_DEPTH {
857        return None;
858    }
859    let mut word = Word {
860        segs: Vec::new(),
861        tilde_home: false,
862    };
863    for (i, pw) in pieces.iter().enumerate() {
864        match &pw.piece {
865            WordPiece::Text(s) => word.segs.push((s.clone(), true)),
866            WordPiece::SingleQuotedText(s) | WordPiece::AnsiCQuotedText(s) => {
867                word.segs.push((s.clone(), false));
868            }
869            WordPiece::DoubleQuotedSequence(inner)
870            | WordPiece::GettextDoubleQuotedSequence(inner) => {
871                // quoted metacharacters don't expand: the whole sequence is a
872                // non-expandable segment
873                let lit = pieces_to_word(inner, depth + 1)?;
874                if lit.tilde_home {
875                    return None; // "~" quoted-with-tilde-piece: shouldn't occur
876                }
877                word.segs.push((lit.text(), false));
878            }
879            WordPiece::EscapeSequence(s) => {
880                word.segs
881                    .push((s.strip_prefix('\\').unwrap_or(s).to_string(), false));
882            }
883            WordPiece::TildeExpansion(TildeExpr::Home) if i == 0 => {
884                word.tilde_home = true;
885            }
886            // ~user / ~+ / ~- / mid-word tilde pieces and all expansions are
887            // unresolvable statically
888            _ => return None,
889        }
890    }
891    Some(word)
892}
893
894/// Bash-style brace expansion over quote-masked characters: only expandable
895/// braces/commas have structural meaning. `Some(list)` on success (a word with
896/// no expandable group yields itself); `None` when fan-out exceeds `limit`.
897fn expand_braces_masked(chars: &[(char, bool)], limit: usize) -> Option<Vec<Vec<(char, bool)>>> {
898    let mut out = Vec::new();
899    if expand_rec(chars, &mut out, limit) {
900        Some(out)
901    } else {
902        None
903    }
904}
905
906fn expand_rec(chars: &[(char, bool)], out: &mut Vec<Vec<(char, bool)>>, limit: usize) -> bool {
907    if out.len() > limit {
908        return false;
909    }
910    for i in 0..chars.len() {
911        if chars[i] != ('{', true) {
912            continue;
913        }
914        match parse_brace(chars, i, limit) {
915            BraceParse::Expand(close, options) => {
916                for opt in options {
917                    let mut next = chars[..i].to_vec();
918                    next.extend(opt);
919                    next.extend_from_slice(&chars[close + 1..]);
920                    if !expand_rec(&next, out, limit) {
921                        return false;
922                    }
923                }
924                return true;
925            }
926            BraceParse::Overflow => return false,
927            BraceParse::NotHere => {}
928        }
929    }
930    out.push(chars.to_vec());
931    out.len() <= limit
932}
933
934enum BraceParse {
935    Expand(usize, Vec<Vec<(char, bool)>>),
936    Overflow,
937    NotHere,
938}
939
940fn parse_brace(chars: &[(char, bool)], open: usize, limit: usize) -> BraceParse {
941    let mut depth = 0usize;
942    let mut close = None;
943    let mut commas = Vec::new();
944    for (i, &(c, exp)) in chars.iter().enumerate().skip(open) {
945        if !exp {
946            continue; // quoted braces/commas are content, not structure
947        }
948        match c {
949            '{' => depth += 1,
950            '}' => {
951                depth -= 1;
952                if depth == 0 {
953                    close = Some(i);
954                    break;
955                }
956            }
957            ',' if depth == 1 => commas.push(i),
958            _ => {}
959        }
960    }
961    let Some(close) = close else {
962        return BraceParse::NotHere;
963    };
964    if !commas.is_empty() {
965        let mut options = Vec::new();
966        let mut start = open + 1;
967        for &c in &commas {
968            options.push(chars[start..c].to_vec());
969            start = c + 1;
970        }
971        options.push(chars[start..close].to_vec());
972        return BraceParse::Expand(close, options);
973    }
974    // range form: only when the whole interior is unquoted
975    let interior = &chars[open + 1..close];
976    if interior.is_empty() || interior.iter().any(|(_, exp)| !exp) {
977        return BraceParse::NotHere;
978    }
979    let inner: String = interior.iter().map(|(c, _)| c).collect();
980    match parse_range(&inner, limit) {
981        Some(Some(options)) => BraceParse::Expand(
982            close,
983            options
984                .into_iter()
985                .map(|s| s.chars().map(|c| (c, true)).collect())
986                .collect(),
987        ),
988        Some(None) => BraceParse::Overflow,
989        None => BraceParse::NotHere, // {literal}
990    }
991}
992
993/// `{m..n}` / `{a..c}` ranges, using i128 internally so extreme i64 bounds
994/// cannot overflow. `Some(Some(list))` on a valid range, `Some(None)` when it
995/// exceeds `limit` OR is version-divergent, `None` when not a range at all.
996///
997/// Version portability (bash-oracle finding): stepped ranges (`{1..9..2}`)
998/// and zero-padded ranges (`{01..03}`) behave differently between bash 3.2
999/// (macOS default) and bash 4+. We cannot know which bash executes the
1000/// command, so those forms are "cannot safely expand" → unknown policy,
1001/// never a confidently-wrong path list.
1002fn parse_range(inner: &str, limit: usize) -> Option<Option<Vec<String>>> {
1003    let parts: Vec<&str> = inner.split("..").collect();
1004    if parts.len() != 2 && parts.len() != 3 {
1005        return None;
1006    }
1007    if parts.len() == 3 {
1008        // stepped range: bash-version-divergent (3.2 leaves it literal)
1009        let looks_like_range = parts[2].parse::<i128>().is_ok();
1010        return if looks_like_range { Some(None) } else { None };
1011    }
1012    let step: i128 = 1;
1013
1014    let make = |a: i128, b: i128, fmt: &dyn Fn(i128) -> String| -> Option<Vec<String>> {
1015        let count = (a - b).unsigned_abs() / step.unsigned_abs() + 1;
1016        if count > limit as u128 {
1017            return None;
1018        }
1019        let dir: i128 = if a <= b { step } else { -step };
1020        let mut v = Vec::with_capacity(count as usize);
1021        let mut cur = a;
1022        while (dir > 0 && cur <= b) || (dir < 0 && cur >= b) {
1023            v.push(fmt(cur));
1024            cur += dir;
1025        }
1026        Some(v)
1027    };
1028
1029    // numeric range
1030    if let (Ok(a), Ok(b)) = (parts[0].parse::<i128>(), parts[1].parse::<i128>()) {
1031        let zero_padded = (parts[0].starts_with('0') && parts[0].len() > 1)
1032            || (parts[1].starts_with('0') && parts[1].len() > 1)
1033            || parts[0].starts_with("-0")
1034            || parts[1].starts_with("-0");
1035        if zero_padded {
1036            // bash-version-divergent (3.2 strips padding, 4+ pads)
1037            return Some(None);
1038        }
1039        return Some(make(a, b, &|n| n.to_string()));
1040    }
1041
1042    // single-character alphabetic range
1043    let (sc, ec) = (
1044        parts[0].chars().collect::<Vec<_>>(),
1045        parts[1].chars().collect::<Vec<_>>(),
1046    );
1047    if sc.len() == 1 && ec.len() == 1 && sc[0].is_ascii_alphabetic() && ec[0].is_ascii_alphabetic()
1048    {
1049        return Some(make(sc[0] as i128, ec[0] as i128, &|n| {
1050            ((n as u8) as char).to_string()
1051        }));
1052    }
1053    None
1054}
1055
1056/// Bash leading-dot rule per component: a matched path component that begins
1057/// with `.` is allowed only when the aligned pattern component also begins
1058/// with a literal `.`. `.` and `..` pseudo-entries are always rejected (rm
1059/// can't act on them; `..` would scope a parent tree).
1060fn glob_match_allowed(relative: &str, pat_components: &[&str]) -> bool {
1061    // empty components (from `//` in pattern or path) carry no matching
1062    // meaning and would misalign the index comparison
1063    let pats: Vec<&str> = pat_components
1064        .iter()
1065        .copied()
1066        .filter(|p| !p.is_empty())
1067        .collect();
1068    for (i, comp) in relative.split('/').filter(|c| !c.is_empty()).enumerate() {
1069        if comp == "." || comp == ".." {
1070            return false;
1071        }
1072        if comp.starts_with('.') {
1073            let pat_dotted = pats.get(i).is_some_and(|p| p.starts_with('.'));
1074            if !pat_dotted {
1075                return false;
1076            }
1077        }
1078    }
1079    true
1080}
1081
1082/// Nearest enclosing git repository (walks up looking for `.git`).
1083pub fn find_repo_root(start: &Path) -> Option<PathBuf> {
1084    start
1085        .ancestors()
1086        .find(|p| p.join(".git").exists())
1087        .map(normalize_lexical)
1088}
1089
1090/// Lexical normalization: resolves `.` and `..` without touching the
1091/// filesystem (no symlink resolution — the snapshot engine handles links).
1092pub fn normalize_lexical(path: &Path) -> PathBuf {
1093    let mut out = PathBuf::new();
1094    for comp in path.components() {
1095        match comp {
1096            Component::CurDir => {}
1097            Component::ParentDir => {
1098                if !out.pop() {
1099                    out.push("..");
1100                }
1101            }
1102            other => out.push(other.as_os_str()),
1103        }
1104    }
1105    out
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    fn masked(parts: &[(&str, bool)]) -> Vec<(char, bool)> {
1113        parts
1114            .iter()
1115            .flat_map(|(s, e)| s.chars().map(move |c| (c, *e)))
1116            .collect()
1117    }
1118
1119    fn texts(result: Option<Vec<Vec<(char, bool)>>>) -> Option<Vec<String>> {
1120        result.map(|list| {
1121            list.into_iter()
1122                .map(|cs| cs.into_iter().map(|(c, _)| c).collect())
1123                .collect()
1124        })
1125    }
1126
1127    #[test]
1128    fn unquoted_group_expands() {
1129        let r = texts(expand_braces_masked(&masked(&[("{a,b}", true)]), 64));
1130        assert_eq!(r, Some(vec!["a".into(), "b".into()]));
1131    }
1132
1133    #[test]
1134    fn quoted_group_is_inert() {
1135        let r = texts(expand_braces_masked(&masked(&[("{a,b}", false)]), 64));
1136        assert_eq!(r, Some(vec!["{a,b}".into()]));
1137    }
1138
1139    #[test]
1140    fn quoted_prefix_with_unquoted_group() {
1141        // the audit-2 bug: '{a,b}'{c,d} must expand ONLY {c,d}
1142        let r = texts(expand_braces_masked(
1143            &masked(&[("{a,b}", false), ("{c,d}", true)]),
1144            64,
1145        ));
1146        assert_eq!(r, Some(vec!["{a,b}c".into(), "{a,b}d".into()]));
1147    }
1148
1149    #[test]
1150    fn cartesian_product() {
1151        let r = texts(expand_braces_masked(&masked(&[("{a,b}{1,2}", true)]), 64));
1152        assert_eq!(
1153            r,
1154            Some(vec!["a1".into(), "a2".into(), "b1".into(), "b2".into()])
1155        );
1156    }
1157
1158    #[test]
1159    fn nested_groups() {
1160        let r = texts(expand_braces_masked(&masked(&[("{a,b{1,2}}", true)]), 64));
1161        assert_eq!(r, Some(vec!["a".into(), "b1".into(), "b2".into()]));
1162    }
1163
1164    #[test]
1165    fn comma_free_brace_is_literal() {
1166        let r = texts(expand_braces_masked(&masked(&[("x{alone}y", true)]), 64));
1167        assert_eq!(r, Some(vec!["x{alone}y".into()]));
1168    }
1169
1170    #[test]
1171    fn portable_ranges_expand() {
1172        assert_eq!(
1173            texts(expand_braces_masked(&masked(&[("{1..3}", true)]), 64)),
1174            Some(vec!["1".into(), "2".into(), "3".into()])
1175        );
1176        assert_eq!(
1177            texts(expand_braces_masked(&masked(&[("{c..a}", true)]), 64)),
1178            Some(vec!["c".into(), "b".into(), "a".into()])
1179        );
1180    }
1181
1182    #[test]
1183    fn version_divergent_ranges_are_unknown_not_guessed() {
1184        // bash 3.2 (macOS) vs 4+ disagree on steps and zero-padding: we must
1185        // never emit a confidently-wrong path list (bash-oracle finding)
1186        assert_eq!(
1187            texts(expand_braces_masked(&masked(&[("{1..9..4}", true)]), 64)),
1188            None
1189        );
1190        assert_eq!(
1191            texts(expand_braces_masked(&masked(&[("{01..03}", true)]), 64)),
1192            None
1193        );
1194    }
1195
1196    #[test]
1197    fn quoted_range_interior_is_literal() {
1198        // {'1..3'} — interior quoted: not a range
1199        let r = texts(expand_braces_masked(
1200            &masked(&[("{", true), ("1..3", false), ("}", true)]),
1201            64,
1202        ));
1203        assert_eq!(r, Some(vec!["{1..3}".into()]));
1204    }
1205
1206    #[test]
1207    fn fanout_cap_returns_none() {
1208        assert_eq!(
1209            texts(expand_braces_masked(&masked(&[("{1..2000}", true)]), 1024)),
1210            None
1211        );
1212    }
1213
1214    #[test]
1215    fn extreme_i64_range_no_panic_no_overflow() {
1216        // would overflow i64 subtraction; must cleanly report over-limit
1217        let r = expand_braces_masked(
1218            &masked(&[("{-9223372036854775808..9223372036854775807}", true)]),
1219            1024,
1220        );
1221        assert!(r.is_none());
1222    }
1223
1224    #[test]
1225    fn identity_without_braces() {
1226        let r = texts(expand_braces_masked(&masked(&[("plain.txt", true)]), 64));
1227        assert_eq!(r, Some(vec!["plain.txt".into()]));
1228    }
1229}