Skip to main content

ocserve_tools/
bash_patterns.rs

1//! Bash command → permission patterns (upstream `permission/arity.ts` parity).
2//!
3//! Upstream's shell tool scans a command into (a) `patterns` — the concrete
4//! subcommands, and (b) `always` — an arity *prefix* per subcommand
5//! (`"git status --short"` → always `"git status *"`), so an "always" grant
6//! covers the whole command family. It also collects `directories` referenced
7//! by path-taking commands for the `external_directory` gate. ocserve
8//! previously used the whole command string as both pattern and always, so
9//! "always" only remembered the exact string and compound commands collapsed
10//! to one resource.
11//!
12//! The ARITY table is pinned data extracted from the freeze
13//! (`assets/permission/1.18.31.rules.json`, `scripts/extract-rules.sh`) — the
14//! same discipline as the OpenAPI spec and web bundle. No table is
15//! hand-maintained here. It lives INSIDE this crate so it is packaged and
16//! published with it (embedding from `bench/`, outside the crate root, is not
17//! in the `.crate` tarball — the 0.3.0 defect; guard rule 30).
18
19use std::collections::BTreeMap;
20use std::sync::OnceLock;
21
22const RULES_JSON: &str = include_str!("../assets/permission/1.18.31.rules.json");
23
24fn arity_table() -> &'static BTreeMap<String, usize> {
25    static TABLE: OnceLock<BTreeMap<String, usize>> = OnceLock::new();
26    TABLE.get_or_init(|| {
27        let doc: serde_json::Value = serde_json::from_str(RULES_JSON).unwrap_or_default();
28        doc.get("arity")
29            .and_then(|a| a.as_object())
30            .map(|m| {
31                m.iter()
32                    .filter_map(|(k, v)| v.as_u64().map(|n| (k.clone(), n as usize)))
33                    .collect()
34            })
35            .unwrap_or_default()
36    })
37}
38
39/// Split a shell command into subcommands, respecting quotes. Splits on the
40/// control operators upstream's parser treats as command boundaries:
41/// `&&`, `||`, `;`, `|`, `&`, and newlines.
42pub fn split_commands(command: &str) -> Vec<String> {
43    let mut out = Vec::new();
44    let mut cur = String::new();
45    let mut chars = command.chars().peekable();
46    let mut quote: Option<char> = None;
47    while let Some(c) = chars.next() {
48        match quote {
49            Some(q) => {
50                cur.push(c);
51                if c == q {
52                    quote = None;
53                }
54            }
55            None => match c {
56                '\'' | '"' => {
57                    quote = Some(c);
58                    cur.push(c);
59                }
60                ';' | '\n' | '&' | '|' => {
61                    // swallow a doubled operator (&&, ||)
62                    if let Some(&n) = chars.peek()
63                        && n == c
64                    {
65                        chars.next();
66                    }
67                    let trimmed = cur.trim();
68                    if !trimmed.is_empty() {
69                        out.push(trimmed.to_string());
70                    }
71                    cur.clear();
72                }
73                _ => cur.push(c),
74            },
75        }
76    }
77    let trimmed = cur.trim();
78    if !trimmed.is_empty() {
79        out.push(trimmed.to_string());
80    }
81    out
82}
83
84/// Naive shell tokenizer for arity lookup: split on whitespace, keeping quoted
85/// runs together (the quotes are stripped, matching how the arity table keys
86/// are written).
87pub fn tokenize(segment: &str) -> Vec<String> {
88    let mut out = Vec::new();
89    let mut cur = String::new();
90    let mut quote: Option<char> = None;
91    for c in segment.chars() {
92        match quote {
93            Some(q) => {
94                if c == q {
95                    quote = None;
96                } else {
97                    cur.push(c);
98                }
99            }
100            None => {
101                if c == '\'' || c == '"' {
102                    quote = Some(c);
103                } else if c.is_whitespace() {
104                    if !cur.is_empty() {
105                        out.push(std::mem::take(&mut cur));
106                    }
107                } else {
108                    cur.push(c);
109                }
110            }
111        }
112    }
113    if !cur.is_empty() {
114        out.push(cur);
115    }
116    out
117}
118
119/// Upstream `BashArity.prefix`: longest matching table prefix wins; fall back
120/// to the first token; empty for no tokens.
121pub fn arity_prefix(tokens: &[String]) -> Vec<String> {
122    let table = arity_table();
123    for len in (1..=tokens.len()).rev() {
124        let prefix = tokens[..len].join(" ");
125        if let Some(&arity) = table.get(&prefix) {
126            let n = arity.min(tokens.len());
127            return tokens[..n].to_vec();
128        }
129    }
130    if tokens.is_empty() {
131        return Vec::new();
132    }
133    vec![tokens[0].clone()]
134}
135
136/// A bash command's permission surfaces: per-subcommand `patterns` and their
137/// `always` grants.
138pub struct BashScan {
139    pub patterns: Vec<String>,
140    pub always: Vec<String>,
141}
142
143/// Scan a bash command into (patterns, always), matching upstream shell.ts:
144/// each subcommand is a pattern; its arity prefix + " *" is the always grant.
145pub fn scan(command: &str) -> BashScan {
146    let mut patterns = Vec::new();
147    let mut always = Vec::new();
148    for seg in split_commands(command) {
149        let tokens = tokenize(&seg);
150        if tokens.is_empty() {
151            continue;
152        }
153        patterns.push(seg.clone());
154        let prefix = arity_prefix(&tokens);
155        if !prefix.is_empty() {
156            always.push(format!("{} *", prefix.join(" ")));
157        }
158    }
159    // de-dup preserving order (a repeated subcommand grants once)
160    dedup(&mut patterns);
161    dedup(&mut always);
162    BashScan { patterns, always }
163}
164
165fn dedup(v: &mut Vec<String>) {
166    let mut seen = std::collections::HashSet::new();
167    v.retain(|x| seen.insert(x.clone()));
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn arity_prefix_uses_table() {
176        // `git status` → bare `git` arity 2 → ["git","status"]
177        assert_eq!(
178            arity_prefix(&["git".into(), "status".into(), "--short".into()]),
179            vec!["git".to_string(), "status".to_string()]
180        );
181        // `npm run dev` → `npm run` arity 3
182        assert_eq!(
183            arity_prefix(&["npm".into(), "run".into(), "dev".into()]),
184            vec!["npm".to_string(), "run".to_string(), "dev".to_string()]
185        );
186        // unknown command → first token only
187        assert_eq!(
188            arity_prefix(&["frobnicate".into(), "x".into()]),
189            vec!["frobnicate".to_string()]
190        );
191    }
192
193    #[test]
194    fn scan_splits_compounds() {
195        let s = scan("git status && rm -rf /tmp/x");
196        assert_eq!(s.patterns.len(), 2, "two subcommands");
197        assert!(s.always.contains(&"git status *".to_string()));
198        assert!(s.always.contains(&"rm *".to_string()));
199    }
200
201    #[test]
202    fn scan_respects_quotes() {
203        let s = scan("echo 'a; b' && ls");
204        assert_eq!(s.patterns.len(), 2, "the ; is inside quotes");
205    }
206
207    #[test]
208    fn scan_arity_family() {
209        // "git status --short" and "git commit -m x" share the "git status"? no —
210        // they share the `git` arity-2 prefix per subcommand
211        let s = scan("git status --short");
212        assert_eq!(s.always, vec!["git status *".to_string()]);
213        let s2 = scan("git commit -m x");
214        assert_eq!(s2.always, vec!["git commit *".to_string()]);
215    }
216}