Skip to main content

safe_chains/cst/
mod.rs

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