openmoat_core/engine/context.rs
1//! What an [`EvalContext`] checks and expands: `moved_dirs` validation and the
2//! spellings of one rule pattern (moved state and host directories, real paths).
3
4use super::EvalContext;
5use crate::paths;
6use crate::policy::PolicyError;
7
8impl EvalContext {
9 /// Reject [`Self::moved_dirs`] entries whose `default` could alias every
10 /// pattern body (empty-after-trim or a bare `/`) or does not look like a
11 /// directory (must start with `/` or `~/`). The engine fails closed on such
12 /// a context: without validation an empty `default` makes [`Self::spellings`]
13 /// emit a spelling under `moved` for every pattern (#399), so a `moved_dirs`
14 /// mistake in the caller would silently widen every rule. The CLI legitimately
15 /// lists the same `default` twice when the moved directory has a resolved
16 /// spelling or comes from both the environment and the recorded host dir,
17 /// so a duplicate `default` is kept (its spellings deduped in [`Self::spellings`]).
18 pub(crate) fn validate_moved_dirs(&self) -> Result<(), PolicyError> {
19 for (default, _) in &self.moved_dirs {
20 let trimmed = default.trim();
21 let valid = !trimmed.is_empty()
22 && trimmed != "/"
23 && (trimmed == "~" || trimmed.starts_with("~/") || paths::is_absolute(trimmed));
24 if !valid {
25 return Err(PolicyError::Rule {
26 rule: "context.moved_dirs".to_owned(),
27 problem: format!(
28 "default directory `{default}` must be absolute (`/…`) or home-rooted (`~/…`), \
29 not empty, whitespace or a bare `/`"
30 ),
31 });
32 }
33 }
34 Ok(())
35 }
36
37 /// `raw` expanded once per spelling of the home directory and project root
38 /// it names (`paths::expand_pattern`), and once more per directory in
39 /// [`Self::moved_dirs`] it falls under; empty when it names no location.
40 /// The policy compiler (`ir`) lowers patterns through the same expansion,
41 /// and `openmoat run` finds where a policy pattern points with it.
42 #[must_use]
43 pub fn spellings(&self, raw: &str) -> Vec<String> {
44 let (negated, body) = crate::pattern::split_negation(raw);
45 let bang = if negated { "!" } else { "" };
46 let moved = self.moved_dirs.iter().filter_map(|(default, dir)| {
47 let rest = body.strip_prefix(default.as_str())?;
48 (rest.is_empty() || rest.starts_with('/')).then(|| format!("{bang}{dir}{rest}"))
49 });
50 let raws: Vec<String> = std::iter::once(raw.to_owned()).chain(moved).collect();
51 let projects: Vec<Option<&str>> = match &self.project {
52 Some(p) => [Some(p), self.real_project.as_ref()]
53 .into_iter()
54 .flatten()
55 .map(|p| Some(p.as_str()))
56 .collect(),
57 None => vec![None],
58 };
59 let mut out = Vec::new();
60 for home in [Some(&self.home), self.real_home.as_ref()]
61 .into_iter()
62 .flatten()
63 {
64 for project in &projects {
65 out.extend(
66 raws.iter()
67 .filter_map(|raw| paths::expand_pattern(raw, home, *project)),
68 );
69 }
70 }
71 out.sort();
72 out.dedup();
73 out
74 }
75}