use super::classify::{Classified, Effect};
use super::lex::{Segment, segments};
use super::policy::{Policy, Row};
use super::root::Root;
use super::rules::Reach;
pub fn classify(command: &str, root: &Root, policy: &Policy) -> Classified {
let rows = policy.rows();
let secrets = policy.secret_fragments();
segments(command)
.iter()
.map(|seg| segment(seg, root, &rows, &secrets))
.reduce(|a, b| if b.effect > a.effect { b } else { a })
.unwrap_or_else(|| Classified {
effect: Effect::Read,
why: "runs no command".to_owned(),
})
}
fn segment(seg: &Segment, root: &Root, rows: &[Row], secrets: &[String]) -> Classified {
let mut found = program_class(seg, root, rows);
if let Some(target) = seg.redirects.iter().find(|t| !root.holds(&root.resolve(t))) {
found = Classified {
effect: found.effect.worst(Effect::OpenWorld),
why: format!("redirects output to {target}, outside the writable root"),
};
} else if !seg.redirects.is_empty() {
found.effect = found.effect.worst(Effect::TargetWrite);
}
if let Some(fragment) = secret_fragment(seg, secrets) {
found = Classified {
effect: found.effect.worst(Effect::Secret),
why: format!("names {fragment}, which is credential-adjacent"),
};
}
found
}
fn program_class(seg: &Segment, root: &Root, rows: &[Row]) -> Classified {
let words = strip_prefix(&seg.words);
let program = program_of(words);
if let Some(rule) = rule_for(program, words, rows) {
return matched(&rule, program, words, root);
}
if words.is_empty() {
let bare_env = program_of(&seg.words) == "env";
return Classified {
effect: if bare_env {
Effect::Secret
} else {
Effect::Read
},
why: if bare_env {
"dumps the environment".to_owned()
} else {
"runs no program".to_owned()
},
};
}
Classified {
effect: Effect::OpenWorld,
why: format!("no rule classifies `{program}`"),
}
}
fn matched(rule: &Row, program: &str, words: &[String], root: &Root) -> Classified {
match rule.reach {
Reach::Fixed(effect) => Classified {
effect,
why: format!("`{program}`"),
},
Reach::ByRoot { inside, outside } => {
let ops = operands(words);
match ops.iter().find(|o| !root.holds(&root.resolve(o))) {
None => Classified {
effect: inside,
why: format!("`{program}` stays inside the writable root"),
},
Some(operand) => Classified {
effect: outside,
why: format!("`{program}` reaches {operand}, outside the writable root"),
},
}
}
}
}
fn rule_for(program: &str, words: &[String], rows: &[Row]) -> Option<Row> {
rows.iter()
.find(|row| row.program == program && row.words.iter().all(|w| has_word(words, w)))
.cloned()
}
fn program_of(words: &[String]) -> &str {
words
.first()
.map(String::as_str)
.unwrap_or_default()
.rsplit('/')
.next()
.unwrap_or_default()
}
fn strip_prefix(words: &[String]) -> &[String] {
let mut rest = words;
loop {
let Some(first) = rest.first() else {
return rest;
};
let is_assignment = first
.split_once('=')
.is_some_and(|(name, _)| !name.is_empty() && !name.contains('/'));
let is_env = first.rsplit('/').next() == Some("env");
if !is_assignment && !is_env {
return rest;
}
rest = rest.get(1..).unwrap_or_default();
}
}
pub fn has_word(words: &[String], want: &str) -> bool {
if words.iter().any(|w| w == want) {
return true;
}
let Some(letters) = short_flag(want) else {
return false;
};
words
.iter()
.filter_map(|w| short_flag(w))
.any(|bundle| letters.chars().all(|c| bundle.contains(c)))
}
fn short_flag(word: &str) -> Option<&str> {
word.strip_prefix('-')
.filter(|rest| !rest.is_empty() && !rest.starts_with('-'))
}
fn operands(words: &[String]) -> Vec<String> {
words
.iter()
.skip(1)
.filter(|w| !w.starts_with('-'))
.cloned()
.collect()
}
fn secret_fragment(seg: &Segment, secrets: &[String]) -> Option<String> {
seg.words
.iter()
.chain(seg.redirects.iter())
.find_map(|w| secrets.iter().find(|f| w.contains(f.as_str())).cloned())
}
#[cfg(test)]
mod tests;