Skip to main content

safe_chains/cst/
mod.rs

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