//! Matching a call against the grants: which grants cover it, and which one wins. use super::{Label, Matched, SessionState}; use crate::args::{ToolArgs, host_matches, inside}; use crate::grants::GrantSet; use proto::{DataClass, Grant, Hash32, Mode, Timestamp}; /// The grants left after matching, and the two flags that pick the denial reason when none are /// left. pub(super) struct MatchResult { pub(super) left: Vec, pub(super) expired: bool, pub(super) tainted: bool, } #[derive(Clone)] pub(super) struct Candidate { pub(super) id: String, pub(super) grant_sha256: Hash32, pub(super) mode: Mode, pub(super) matched_path: Option, pub(super) paths: Vec, pub(super) hosts: Vec, pub(super) expires: Option, pub(super) result_class: DataClass, pub(super) untrusted: bool, } /// Match a call against every grant, in id order, keeping the ones that still stand. pub(super) fn match_grants( args: &ToolArgs, grants: &GrantSet, state: SessionState, now: Timestamp, ) -> MatchResult { let mut left: Vec = Vec::new(); let mut expired = false; let mut tainted = false; for loaded in grants.grants() { // M1: only grants for this tool play a part. if loaded.grant.tool != args.tool().as_str() { continue; } // M2: does this grant cover the arguments, and with which path (if any)? let Some(matched_path) = covers(args, &loaded.grant) else { continue; }; // M3: a grant that would match but is past its expiry or beyond the session's taint does // not stand. Remember each reason, so the denial can name the right one. let expired_at = loaded.grant.expires.is_some_and(|at| now >= at); let too_tainted = state.taint > loaded.grant.max_taint; if expired_at && !too_tainted { expired = true; } if too_tainted && !expired_at { tainted = true; } if !expired_at && !too_tainted { left.push(Candidate { id: loaded.id.clone(), grant_sha256: loaded.sha256, mode: loaded.grant.mode, matched_path, paths: loaded.grant.constraints.paths.clone(), hosts: loaded.grant.constraints.hosts.clone(), expires: loaded.grant.expires, result_class: loaded.grant.result_class, untrusted: loaded.grant.untrusted, }); } } MatchResult { left, expired, tainted, } } /// Whether the grant covers the call, and the longest of its paths that holds the call. `None` /// means the grant does not cover the call at all. fn covers(args: &ToolArgs, grant: &Grant) -> Option> { match args { ToolArgs::ReadFile { path } => best_path(grant, path, true), ToolArgs::WriteFile { path, .. } => best_path(grant, path, false), ToolArgs::Shell { cwd: None, .. } => { if grant.constraints.paths.is_empty() { Some(None) } else { None } } ToolArgs::Shell { cwd: Some(cwd), .. } => best_path(grant, cwd, true), ToolArgs::HttpFetch(target) => { if grant .constraints .hosts .iter() .any(|pattern| host_matches(pattern, target.host())) { Some(None) } else { None } } } } /// The longest granted path `p` such that `inside(p, target)`. For writes, a path equal to the /// argument does not count. fn best_path(grant: &Grant, target: &str, itself_counts: bool) -> Option> { let mut best: Option = None; for candidate in &grant.constraints.paths { if !inside(candidate, target) { continue; } if !itself_counts && candidate == target { continue; } if best .as_ref() .is_none_or(|found| candidate.len() > found.len()) { best = Some(candidate.clone()); } } // `None` means no path holds, so the grant does not cover the call; a held path becomes // `Some(Some(p))` so `covers` can tell it apart from a covered call with no path. best.map(Some) } /// Build the winning grant's `Matched`, with the label over every standing grant. pub(super) fn build_matched(left: &[Candidate], winner: &Candidate) -> Matched { Matched { grant: winner.id.clone(), grant_sha256: winner.grant_sha256, matched_path: winner.matched_path.clone(), paths: winner.paths.clone(), hosts: winner.hosts.clone(), expires: winner.expires, label: combined_label(left), } } /// The most restrictive standing grant, then the longest matched path, then the lowest id. pub(super) fn winner(candidates: &[Candidate]) -> Option { for &mode in [Mode::Deny, Mode::Ask, Mode::Auto].iter() { let mut best: Option<&Candidate> = None; for candidate in candidates.iter().filter(|c| c.mode == mode) { best = Some(match best { Some(found) if !better(candidate, found) => found, _ => candidate, }); } if let Some(found) = best { return Some(found.clone()); } } None } /// Is `a` a better winner than `b`: a longer matched path, or the same path and a lower id. fn better(a: &Candidate, b: &Candidate) -> bool { let (la, lb) = ( a.matched_path.as_ref().map_or(0, String::len), b.matched_path.as_ref().map_or(0, String::len), ); match la.cmp(&lb) { std::cmp::Ordering::Greater => true, std::cmp::Ordering::Less => false, std::cmp::Ordering::Equal => a.id < b.id, } } /// The label over every standing grant: the highest data class, and untrusted if any says so. fn combined_label(candidates: &[Candidate]) -> Label { let mut class = DataClass::Public; for candidate in candidates { if candidate.result_class > class { class = candidate.result_class; } } let untrusted = candidates.iter().any(|c| c.untrusted); Label { class, untrusted } }