Skip to main content

safe_chains/cst/
mod.rs

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