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