Skip to main content

safe_chains/cst/
mod.rs

1mod ansi_c;
2mod budget;
3pub(crate) mod check;
4mod classify_budget;
5#[cfg(test)]
6mod classify_budget_tests;
7mod display;
8pub(crate) mod eval;
9mod explain;
10#[cfg(test)]
11mod glob_tests;
12pub(crate) mod netargs;
13#[cfg(test)]
14mod normalize_tests;
15pub(crate) mod opaque;
16#[cfg(test)]
17mod opaque_tests;
18mod parse;
19#[cfg(test)]
20mod proptests;
21mod reserved;
22mod scope_budget;
23mod sub_close;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Script(pub Vec<Stmt>);
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Stmt {
30    pub pipeline: Pipeline,
31    pub op: Option<ListOp>,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ListOp {
36    And,
37    Or,
38    Semi,
39    Amp,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Pipeline {
44    pub bang: bool,
45    pub commands: Vec<Cmd>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Cmd {
50    Simple(SimpleCmd),
51    Subshell {
52        body: Script,
53        redirs: Vec<Redir>,
54    },
55    BraceGroup {
56        body: Script,
57        redirs: Vec<Redir>,
58    },
59    For {
60        var: String,
61        items: Vec<Word>,
62        body: Script,
63        redirs: Vec<Redir>,
64    },
65    While {
66        cond: Script,
67        body: Script,
68        redirs: Vec<Redir>,
69    },
70    Until {
71        cond: Script,
72        body: Script,
73        redirs: Vec<Redir>,
74    },
75    If {
76        branches: Vec<Branch>,
77        else_body: Option<Script>,
78        redirs: Vec<Redir>,
79    },
80    DoubleBracket {
81        words: Vec<Word>,
82        redirs: Vec<Redir>,
83    },
84    /// `case WORD in PATTERN) BODY ;; … esac` (POSIX 2.9.4.3). Which arm runs depends on a value
85    /// resolved at runtime, so — like [`Cmd::If`] — every arm body is classified and the command
86    /// is only as safe as its worst arm.
87    Case {
88        subject: Word,
89        arms: Vec<CaseArm>,
90        redirs: Vec<Redir>,
91    },
92    /// `name() { body }` (or `function name { body }`). Defining a function has NO effect — it is
93    /// classified Inert. The body's safety matters only when the function is CALLED (resolved in
94    /// `check`), so it is stored, not flattened.
95    FunctionDef {
96        name: String,
97        body: Script,
98    },
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Branch {
103    pub cond: Script,
104    pub body: Script,
105}
106
107/// One `PATTERN|PATTERN) BODY ;;` arm of a [`Cmd::Case`]. The patterns are glob words matched
108/// against the subject; they are never executed, so only `body` carries risk.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct CaseArm {
111    pub patterns: Vec<Word>,
112    pub body: Script,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct SimpleCmd {
117    pub env: Vec<(String, Word)>,
118    pub words: Vec<Word>,
119    pub redirs: Vec<Redir>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Word(pub Vec<WordPart>);
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum WordPart {
127    Lit(String),
128    Escape(char),
129    SQuote(String),
130    /// `$'…'`, holding the text between the quotes as typed; [`Word::eval`] decodes it.
131    AnsiC(String),
132    DQuote(Word),
133    CmdSub(Script),
134    ProcSub(Script),
135    Backtick(String),
136    /// `$(( … ))`. Holds a `Word`, not raw text, because the body is not opaque: a `$( )` inside it
137    /// RUNS. The arithmetic itself is inert — it can only produce a number, and bash, zsh and dash
138    /// all evaluate `$((id))` to 0 rather than executing `id` — so the inner command is what
139    /// decides, and storing parts is what lets the ordinary substitution walkers reach it.
140    Arith(Word),
141}
142
143/// How an output redirect opens its target. All three land the same bytes somewhere, so they
144/// classify identically — the distinction is kept so `--explain` can echo the command the user
145/// actually typed rather than a normalized one. Mutually exclusive by construction: `>>|` is not
146/// a redirect, and a bool pair would let a generator build one.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum WriteMode {
149    /// `>` — truncate.
150    Truncate,
151    /// `>>` — append.
152    Append,
153    /// `&>` (and the equivalent `>&FILE`) — stdout AND stderr to the file, truncating. Both
154    /// streams land on ONE target, so the locus gate has exactly one path to judge, the same as
155    /// `>`; the variant exists so `--explain` echoes the operator that was typed.
156    TruncateBoth,
157    /// `&>>` — stdout AND stderr to the file, appending.
158    AppendBoth,
159    /// `>|` — truncate, overriding `noclobber` (POSIX 2.7.2).
160    Clobber,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum Redir {
165    Write {
166        fd: u32,
167        target: Word,
168        mode: WriteMode,
169    },
170    Read {
171        fd: u32,
172        target: Word,
173    },
174    /// `<>` — the target is opened for reading AND writing (POSIX 2.7.5), so it is gated on both
175    /// faces. Neither alone is sufficient: the write gate would miss the disclosure of reading a
176    /// secret, and the read gate would miss the overwrite.
177    ReadWrite {
178        fd: u32,
179        target: Word,
180    },
181    HereStr(Word),
182    HereDoc {
183        delimiter: String,
184        strip_tabs: bool,
185        /// The body's parsed EXPANSIONS. A heredoc body is data only when the delimiter is quoted
186        /// (`<<'EOF'`, `<<"EOF"`, `<<\EOF`, `<<E"O"F`); with a bare `<<EOF` the shell expands the
187        /// body exactly as it would a double-quoted string, so `$(…)` and backticks in it RUN.
188        /// Empty when the delimiter is quoted, so a quoted body stays pure data.
189        body: Word,
190    },
191    DupFd {
192        src: u32,
193        dst: String,
194    },
195}
196
197pub use check::{command_verdict, is_safe_command, is_safe_pipeline};
198pub(crate) use explain::denied_inner_words;
199pub use explain::{Explanation, SegmentReport, explain, explain_with_coverage};
200pub use parse::parse;
201
202impl Word {
203    pub fn eval(&self) -> String {
204        eval::eval_word(self)
205    }
206
207    /// The set of literal words this word produces under UNQUOTED brace expansion (`{a,b}` → two
208    /// words). Every produced word must be classified, so a braced alternative can't hide a system
209    /// path from the gate (`cat {/etc/shadow,x}`). Non-braced words expand to `[self.eval()]`.
210    pub fn expand(&self) -> Vec<String> {
211        eval::expand_word(self)
212    }
213
214    pub fn literal(s: &str) -> Self {
215        Word(vec![WordPart::Lit(s.to_string())])
216    }
217
218    pub fn normalize(&self) -> Self {
219        let mut parts = Vec::new();
220        for part in &self.0 {
221            let part = match part {
222                WordPart::DQuote(inner) => WordPart::DQuote(inner.normalize()),
223                WordPart::CmdSub(s) => WordPart::CmdSub(s.normalize()),
224                WordPart::ProcSub(s) => WordPart::ProcSub(s.normalize()),
225                other => other.clone(),
226            };
227            if let WordPart::Lit(s) = &part
228                && let Some(WordPart::Lit(prev)) = parts.last_mut()
229            {
230                prev.push_str(s);
231                continue;
232            }
233            parts.push(part);
234        }
235        Word(parts)
236    }
237}
238
239impl Script {
240    pub fn is_empty(&self) -> bool {
241        self.0.is_empty()
242    }
243
244    pub fn normalize(&self) -> Self {
245        Script(self.0.iter().map(|stmt| Stmt { pipeline: stmt.pipeline.normalize(), op: stmt.op }).collect())
246    }
247
248    pub fn normalize_as_body(&self) -> Self {
249        let mut s = self.normalize();
250        if let Some(last) = s.0.last_mut()
251            && last.op.is_none()
252        {
253            last.op = Some(ListOp::Semi);
254        }
255        s
256    }
257}
258
259impl Pipeline {
260    fn normalize(&self) -> Self {
261        Pipeline { bang: self.bang, commands: self.commands.iter().map(|c| c.normalize()).collect() }
262    }
263}
264
265impl Cmd {
266    fn normalize(&self) -> Self {
267        match self {
268            Cmd::Simple(s) => Cmd::Simple(s.normalize()),
269            Cmd::Subshell { body, redirs } => Cmd::Subshell { body: body.normalize(), redirs: normalize_redirs(redirs) },
270            Cmd::BraceGroup { body, redirs } => Cmd::BraceGroup { body: body.normalize_as_body(), redirs: normalize_redirs(redirs) },
271            Cmd::For { var, items, body, redirs } => Cmd::For {
272                var: var.clone(),
273                items: items.iter().map(|w| w.normalize()).collect(),
274                body: body.normalize_as_body(),
275                redirs: normalize_redirs(redirs),
276            },
277            Cmd::While { cond, body, redirs } => {
278                Cmd::While { cond: cond.normalize_as_body(), body: body.normalize_as_body(), redirs: normalize_redirs(redirs) }
279            }
280            Cmd::Until { cond, body, redirs } => {
281                Cmd::Until { cond: cond.normalize_as_body(), body: body.normalize_as_body(), redirs: normalize_redirs(redirs) }
282            }
283            Cmd::If { branches, else_body, redirs } => Cmd::If {
284                branches: branches
285                    .iter()
286                    .map(|b| Branch { cond: b.cond.normalize_as_body(), body: b.body.normalize_as_body() })
287                    .collect(),
288                else_body: else_body.as_ref().map(|e| e.normalize_as_body()),
289                redirs: normalize_redirs(redirs),
290            },
291            Cmd::DoubleBracket { words, redirs } => {
292                Cmd::DoubleBracket { words: words.iter().map(|w| w.normalize()).collect(), redirs: normalize_redirs(redirs) }
293            }
294            Cmd::Case { subject, arms, redirs } => Cmd::Case {
295                subject: subject.normalize(),
296                arms: arms
297                    .iter()
298                    .map(|a| CaseArm { patterns: a.patterns.iter().map(|w| w.normalize()).collect(), body: a.body.normalize_as_body() })
299                    .collect(),
300                redirs: normalize_redirs(redirs),
301            },
302            Cmd::FunctionDef { name, body } => Cmd::FunctionDef { name: name.clone(), body: body.normalize_as_body() },
303        }
304    }
305}
306
307impl SimpleCmd {
308    fn normalize(&self) -> Self {
309        SimpleCmd {
310            env: self.env.iter().map(|(k, v)| (k.clone(), v.normalize())).collect(),
311            words: self.words.iter().map(|w| w.normalize()).collect(),
312            redirs: normalize_redirs(&self.redirs),
313        }
314    }
315}
316
317fn normalize_redirs(redirs: &[Redir]) -> Vec<Redir> {
318    redirs
319        .iter()
320        .map(|r| match r {
321            Redir::Write { fd, target, mode } => Redir::Write { fd: *fd, target: target.normalize(), mode: *mode },
322            Redir::Read { fd, target } => Redir::Read { fd: *fd, target: target.normalize() },
323            Redir::ReadWrite { fd, target } => Redir::ReadWrite { fd: *fd, target: target.normalize() },
324            Redir::HereStr(w) => Redir::HereStr(w.normalize()),
325            Redir::HereDoc { .. } | Redir::DupFd { .. } => r.clone(),
326        })
327        .collect()
328}