use std::collections::BTreeMap;
use std::sync::OnceLock;
const RULES_JSON: &str = include_str!("../../../bench/permission/1.18.31.rules.json");
fn arity_table() -> &'static BTreeMap<String, usize> {
static TABLE: OnceLock<BTreeMap<String, usize>> = OnceLock::new();
TABLE.get_or_init(|| {
let doc: serde_json::Value = serde_json::from_str(RULES_JSON).unwrap_or_default();
doc.get("arity")
.and_then(|a| a.as_object())
.map(|m| {
m.iter()
.filter_map(|(k, v)| v.as_u64().map(|n| (k.clone(), n as usize)))
.collect()
})
.unwrap_or_default()
})
}
pub fn split_commands(command: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = command.chars().peekable();
let mut quote: Option<char> = None;
while let Some(c) = chars.next() {
match quote {
Some(q) => {
cur.push(c);
if c == q {
quote = None;
}
}
None => match c {
'\'' | '"' => {
quote = Some(c);
cur.push(c);
}
';' | '\n' | '&' | '|' => {
if let Some(&n) = chars.peek()
&& n == c
{
chars.next();
}
let trimmed = cur.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
cur.clear();
}
_ => cur.push(c),
},
}
}
let trimmed = cur.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
out
}
pub fn tokenize(segment: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in segment.chars() {
match quote {
Some(q) => {
if c == q {
quote = None;
} else {
cur.push(c);
}
}
None => {
if c == '\'' || c == '"' {
quote = Some(c);
} else if c.is_whitespace() {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
} else {
cur.push(c);
}
}
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
pub fn arity_prefix(tokens: &[String]) -> Vec<String> {
let table = arity_table();
for len in (1..=tokens.len()).rev() {
let prefix = tokens[..len].join(" ");
if let Some(&arity) = table.get(&prefix) {
let n = arity.min(tokens.len());
return tokens[..n].to_vec();
}
}
if tokens.is_empty() {
return Vec::new();
}
vec![tokens[0].clone()]
}
pub struct BashScan {
pub patterns: Vec<String>,
pub always: Vec<String>,
}
pub fn scan(command: &str) -> BashScan {
let mut patterns = Vec::new();
let mut always = Vec::new();
for seg in split_commands(command) {
let tokens = tokenize(&seg);
if tokens.is_empty() {
continue;
}
patterns.push(seg.clone());
let prefix = arity_prefix(&tokens);
if !prefix.is_empty() {
always.push(format!("{} *", prefix.join(" ")));
}
}
dedup(&mut patterns);
dedup(&mut always);
BashScan { patterns, always }
}
fn dedup(v: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
v.retain(|x| seen.insert(x.clone()));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arity_prefix_uses_table() {
assert_eq!(
arity_prefix(&["git".into(), "status".into(), "--short".into()]),
vec!["git".to_string(), "status".to_string()]
);
assert_eq!(
arity_prefix(&["npm".into(), "run".into(), "dev".into()]),
vec!["npm".to_string(), "run".to_string(), "dev".to_string()]
);
assert_eq!(
arity_prefix(&["frobnicate".into(), "x".into()]),
vec!["frobnicate".to_string()]
);
}
#[test]
fn scan_splits_compounds() {
let s = scan("git status && rm -rf /tmp/x");
assert_eq!(s.patterns.len(), 2, "two subcommands");
assert!(s.always.contains(&"git status *".to_string()));
assert!(s.always.contains(&"rm *".to_string()));
}
#[test]
fn scan_respects_quotes() {
let s = scan("echo 'a; b' && ls");
assert_eq!(s.patterns.len(), 2, "the ; is inside quotes");
}
#[test]
fn scan_arity_family() {
let s = scan("git status --short");
assert_eq!(s.always, vec!["git status *".to_string()]);
let s2 = scan("git commit -m x");
assert_eq!(s2.always, vec!["git commit *".to_string()]);
}
}