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