ocserve_tools/
bash_patterns.rs1use 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
39pub 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 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
84pub 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
119pub 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
136pub struct BashScan {
139 pub patterns: Vec<String>,
140 pub always: Vec<String>,
141}
142
143pub 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 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 assert_eq!(
178 arity_prefix(&["git".into(), "status".into(), "--short".into()]),
179 vec!["git".to_string(), "status".to_string()]
180 );
181 assert_eq!(
183 arity_prefix(&["npm".into(), "run".into(), "dev".into()]),
184 vec!["npm".to_string(), "run".to_string(), "dev".to_string()]
185 );
186 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 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}