Skip to main content

safe_chains/cst/
mod.rs

1mod budget;
2pub(crate) mod check;
3mod display;
4pub(crate) mod eval;
5mod explain;
6mod parse;
7#[cfg(test)]
8mod proptests;
9mod reserved;
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(crate) use explain::denied_inner_words;
183pub use explain::{Explanation, SegmentReport, explain, explain_with_coverage};
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(self.0.iter().map(|stmt| Stmt { pipeline: stmt.pipeline.normalize(), op: stmt.op }).collect())
230    }
231
232    pub fn normalize_as_body(&self) -> Self {
233        let mut s = self.normalize();
234        if let Some(last) = s.0.last_mut()
235            && last.op.is_none()
236        {
237            last.op = Some(ListOp::Semi);
238        }
239        s
240    }
241}
242
243impl Pipeline {
244    fn normalize(&self) -> Self {
245        Pipeline { bang: self.bang, commands: self.commands.iter().map(|c| c.normalize()).collect() }
246    }
247}
248
249impl Cmd {
250    fn normalize(&self) -> Self {
251        match self {
252            Cmd::Simple(s) => Cmd::Simple(s.normalize()),
253            Cmd::Subshell { body, redirs } => Cmd::Subshell { body: body.normalize(), redirs: normalize_redirs(redirs) },
254            Cmd::BraceGroup { body, redirs } => Cmd::BraceGroup { body: body.normalize_as_body(), redirs: normalize_redirs(redirs) },
255            Cmd::For { var, items, body, redirs } => Cmd::For {
256                var: var.clone(),
257                items: items.iter().map(|w| w.normalize()).collect(),
258                body: body.normalize_as_body(),
259                redirs: normalize_redirs(redirs),
260            },
261            Cmd::While { cond, body, redirs } => {
262                Cmd::While { cond: cond.normalize_as_body(), body: body.normalize_as_body(), redirs: normalize_redirs(redirs) }
263            }
264            Cmd::Until { cond, body, redirs } => {
265                Cmd::Until { cond: cond.normalize_as_body(), body: body.normalize_as_body(), redirs: normalize_redirs(redirs) }
266            }
267            Cmd::If { branches, else_body, redirs } => Cmd::If {
268                branches: branches
269                    .iter()
270                    .map(|b| Branch { cond: b.cond.normalize_as_body(), body: b.body.normalize_as_body() })
271                    .collect(),
272                else_body: else_body.as_ref().map(|e| e.normalize_as_body()),
273                redirs: normalize_redirs(redirs),
274            },
275            Cmd::DoubleBracket { words, redirs } => {
276                Cmd::DoubleBracket { words: words.iter().map(|w| w.normalize()).collect(), redirs: normalize_redirs(redirs) }
277            }
278            Cmd::Case { subject, arms, redirs } => Cmd::Case {
279                subject: subject.normalize(),
280                arms: arms
281                    .iter()
282                    .map(|a| CaseArm { patterns: a.patterns.iter().map(|w| w.normalize()).collect(), body: a.body.normalize_as_body() })
283                    .collect(),
284                redirs: normalize_redirs(redirs),
285            },
286            Cmd::FunctionDef { name, body } => Cmd::FunctionDef { name: name.clone(), body: body.normalize_as_body() },
287        }
288    }
289}
290
291impl SimpleCmd {
292    fn normalize(&self) -> Self {
293        SimpleCmd {
294            env: self.env.iter().map(|(k, v)| (k.clone(), v.normalize())).collect(),
295            words: self.words.iter().map(|w| w.normalize()).collect(),
296            redirs: normalize_redirs(&self.redirs),
297        }
298    }
299}
300
301fn normalize_redirs(redirs: &[Redir]) -> Vec<Redir> {
302    redirs
303        .iter()
304        .map(|r| match r {
305            Redir::Write { fd, target, mode } => Redir::Write { fd: *fd, target: target.normalize(), mode: *mode },
306            Redir::Read { fd, target } => Redir::Read { fd: *fd, target: target.normalize() },
307            Redir::ReadWrite { fd, target } => Redir::ReadWrite { fd: *fd, target: target.normalize() },
308            Redir::HereStr(w) => Redir::HereStr(w.normalize()),
309            Redir::HereDoc { .. } | Redir::DupFd { .. } => r.clone(),
310        })
311        .collect()
312}