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