Skip to main content

safe_chains/cst/
mod.rs

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