Skip to main content

doover_core/
registry.rs

1//! Reversibility registry: classifies commands and shell constructs by effect,
2//! affected-path scope, and undo strategy. Data lives in `registry/*.yaml`
3//! (CC0); a user overlay directory can add rules or *upgrade* severity, but a
4//! shipped destructive classification can never be silently downgraded.
5
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9
10/// Effect classes ordered by severity: later variants are strictly more
11/// dangerous. `Ord` is load-bearing — the overlay downgrade check relies on it.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Effect {
15    Safe,
16    Mutating,
17    Externalizing,
18    Destructive,
19    Irreversible,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum UndoStrategy {
25    SnapshotRestore,
26    None,
27    Recompute,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
31#[serde(rename_all = "kebab-case")]
32pub enum PathSource {
33    Positional,
34    PositionalLast,
35    RedirectTarget,
36    Repo,
37    None,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct ScopeSpec {
43    pub paths: PathSource,
44    #[serde(default = "default_true")]
45    pub globs: bool,
46    /// Leading positional arguments that are not paths (sed scripts, chmod
47    /// modes) and must be dropped before scope extraction.
48    #[serde(default)]
49    pub skip: usize,
50    /// Flags that consume the following argument (`truncate -s 0`): that
51    /// argument is not a path and must not enter the scope.
52    #[serde(default)]
53    pub flag_args: Vec<String>,
54    /// Flags whose value *is* a target path to snapshot, in either the
55    /// separate (`-o out.txt`) or attached (`--output=out.txt`) form.
56    #[serde(default)]
57    pub path_flags: Vec<String>,
58    #[serde(default)]
59    pub recursive_flags: Vec<String>,
60}
61
62fn default_true() -> bool {
63    true
64}
65
66#[derive(Debug, Clone, Default, Deserialize)]
67#[serde(deny_unknown_fields)]
68pub struct MatchSpec {
69    pub command: Option<String>,
70    pub subcommand: Option<String>,
71    pub flags_any: Option<Vec<String>>,
72    pub redirect: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct Rule {
78    pub id: String,
79    #[serde(rename = "match")]
80    pub matcher: MatchSpec,
81    pub effect: Effect,
82    #[serde(default)]
83    pub scope: Option<ScopeSpec>,
84    pub undo: UndoStrategy,
85    #[serde(default)]
86    pub notes: Option<String>,
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(deny_unknown_fields)]
91struct RegistryFile {
92    rules: Vec<Rule>,
93}
94
95#[derive(Debug, thiserror::Error)]
96pub enum RegistryError {
97    #[error("failed to parse {file}: {source}")]
98    Parse {
99        file: String,
100        #[source]
101        source: serde_yaml::Error,
102    },
103    #[error("invalid rule `{id}` in {file}: {reason}")]
104    InvalidRule {
105        file: String,
106        id: String,
107        reason: String,
108    },
109    #[error("duplicate rule id `{id}`")]
110    DuplicateId { id: String },
111    #[error("failed to read {path}: {source}")]
112    Io {
113        path: String,
114        #[source]
115        source: std::io::Error,
116    },
117}
118
119/// Shipped registry data, embedded at compile time.
120const SHIPPED: &[(&str, &str)] = &[
121    ("coreutils.yaml", include_str!("../registry/coreutils.yaml")),
122    ("shell.yaml", include_str!("../registry/shell.yaml")),
123    ("git.yaml", include_str!("../registry/git.yaml")),
124    ("net.yaml", include_str!("../registry/net.yaml")),
125    ("posix.yaml", include_str!("../registry/posix.yaml")),
126    ("services.yaml", include_str!("../registry/services.yaml")),
127];
128
129pub struct Registry {
130    rules: Vec<Rule>,
131    index: HashMap<String, usize>,
132    /// Number of leading rules that are SHIPPED (built-in). Everything at or
133    /// after this index is a user overlay. Lookup uses this to enforce a
134    /// protection floor: an overlay can never resolve a command to a WEAKER
135    /// effect than the shipped registry alone would (the shadow-attack guard,
136    /// round 21) — regardless of rule id, match score, or tie-breaks.
137    shipped_count: usize,
138}
139
140impl Registry {
141    /// Parse and validate the rules of one YAML document. Public so tests and
142    /// future tooling (registry linters, the doctor command) can reuse it.
143    pub fn parse_rules(file: &str, contents: &str) -> Result<Vec<Rule>, RegistryError> {
144        let parsed: RegistryFile =
145            serde_yaml::from_str(contents).map_err(|source| RegistryError::Parse {
146                file: file.to_string(),
147                source,
148            })?;
149        for rule in &parsed.rules {
150            validate(file, rule)?;
151        }
152        Ok(parsed.rules)
153    }
154
155    /// Build a registry from already-parsed rules, rejecting duplicate ids.
156    pub fn from_rules(rules: Vec<Rule>) -> Result<Self, RegistryError> {
157        let mut index = HashMap::with_capacity(rules.len());
158        for (i, rule) in rules.iter().enumerate() {
159            if index.insert(rule.id.clone(), i).is_some() {
160                return Err(RegistryError::DuplicateId {
161                    id: rule.id.clone(),
162                });
163            }
164        }
165        let shipped_count = rules.len();
166        Ok(Self {
167            rules,
168            index,
169            shipped_count,
170        })
171    }
172
173    /// The shipped registry. Must always be valid — a failure here is a bug
174    /// caught by the T2 suite, never a runtime condition.
175    pub fn builtin() -> Result<Self, RegistryError> {
176        let mut rules = Vec::new();
177        for (file, contents) in SHIPPED {
178            rules.extend(Self::parse_rules(file, contents)?);
179        }
180        Self::from_rules(rules)
181    }
182
183    /// Builtin rules plus a user overlay directory (`*.yaml`, lexical order).
184    /// Overlay problems are warnings, never failures: a broken user file must
185    /// not take the safety net down with it. Severity downgrades of shipped
186    /// destructive/irreversible rules are rejected loudly.
187    pub fn with_overlay(dir: &Path) -> Result<(Self, Vec<String>), RegistryError> {
188        let mut registry = Self::builtin()?;
189        let mut warnings = Vec::new();
190
191        if !dir.is_dir() {
192            return Ok((registry, warnings));
193        }
194        let mut entries: Vec<_> = std::fs::read_dir(dir)
195            .map_err(|source| RegistryError::Io {
196                path: dir.display().to_string(),
197                source,
198            })?
199            .filter_map(Result::ok)
200            .map(|e| e.path())
201            .filter(|p| {
202                p.extension()
203                    .is_some_and(|ext| ext == "yaml" || ext == "yml")
204            })
205            .collect();
206        entries.sort();
207
208        for path in entries {
209            let file = path.display().to_string();
210            let contents = match std::fs::read_to_string(&path) {
211                Ok(c) => c,
212                Err(e) => {
213                    warnings.push(format!("skipping {file}: {e}"));
214                    continue;
215                }
216            };
217            let rules = match Self::parse_rules(&file, &contents) {
218                Ok(r) => r,
219                Err(e) => {
220                    warnings.push(format!("skipping {file}: {e}"));
221                    continue;
222                }
223            };
224            for rule in rules {
225                registry.insert_overlay(rule, &mut warnings);
226            }
227        }
228        Ok((registry, warnings))
229    }
230
231    fn insert_overlay(&mut self, rule: Rule, warnings: &mut Vec<String>) {
232        match self.index.get(&rule.id) {
233            Some(&i) => {
234                let shipped = &self.rules[i];
235                // never let a user overlay quietly weaken a safety-relevant
236                // shipped classification. Both data-loss (destructive/
237                // irreversible) and exfiltration (externalizing) qualify —
238                // downgrading either could disable a protection the user relies
239                // on without them noticing.
240                if is_protected(shipped.effect) && rule.effect < shipped.effect {
241                    warnings.push(format!(
242                        "refusing to downgrade `{}` from {:?} to {:?}; overlay rule ignored",
243                        rule.id, shipped.effect, rule.effect
244                    ));
245                    return;
246                }
247                self.rules[i] = rule;
248            }
249            None => {
250                // A new-id overlay rule that matches the same command as a
251                // protected shipped rule but classifies it WEAKER is a shadow
252                // attempt. The lookup floor already prevents the downgrade;
253                // warn so the user knows their rule was (partially) ignored
254                // (round 21).
255                if let Some(cmd) = rule.matcher.command.as_deref() {
256                    let shadows_protected = self.rules[..self.shipped_count].iter().any(|s| {
257                        s.matcher.command.as_deref() == Some(cmd)
258                            && is_protected(s.effect)
259                            && rule.effect < s.effect
260                    });
261                    if shadows_protected {
262                        warnings.push(format!(
263                            "overlay `{}` classifies `{cmd}` weaker than the shipped protection; \
264                             the downgrade is ignored at lookup (safety floor)",
265                            rule.id
266                        ));
267                    }
268                }
269                self.index.insert(rule.id.clone(), self.rules.len());
270                self.rules.push(rule);
271            }
272        }
273    }
274
275    /// Most-specific match for a simple command: a rule with a matching
276    /// subcommand outranks a bare-command rule, and a matching `flags_any`
277    /// outranks both. Rules with a subcommand or flags requirement that the
278    /// invocation doesn't satisfy don't match at all.
279    pub fn lookup_command<'a>(
280        &'a self,
281        command: &str,
282        subcommand: Option<&str>,
283        flags: &[String],
284    ) -> Option<&'a Rule> {
285        let best = |rules: &'a [Rule]| -> Option<&'a Rule> {
286            rules
287                .iter()
288                .filter_map(|rule| {
289                    score_command_match(rule, command, subcommand, flags).map(|s| (s, rule))
290                })
291                // most specific match wins; on a tie, the STRONGER effect wins
292                // (a protection tool must never resolve a tie toward the less
293                // dangerous classification); id only for final determinism
294                .max_by(|(sa, ra), (sb, rb)| {
295                    sa.cmp(sb)
296                        .then_with(|| ra.effect.cmp(&rb.effect))
297                        .then_with(|| rb.id.cmp(&ra.id))
298                })
299                .map(|(_, rule)| rule)
300        };
301        let overall = best(&self.rules);
302        // Protection floor (shadow-attack guard, round 21): an overlay may
303        // add or STRENGTHEN a classification, never weaken the shipped one.
304        // If the shipped-only best is protected and stronger than the overall
305        // winner, the shipped rule stands — no overlay id/score/tie-break can
306        // downgrade `rm` to safe.
307        let shipped = best(&self.rules[..self.shipped_count]);
308        match (overall, shipped) {
309            (Some(o), Some(s)) if is_protected(s.effect) && s.effect > o.effect => Some(s),
310            (o, _) => o,
311        }
312    }
313
314    pub fn lookup_redirect(&self, op: &str) -> Option<&Rule> {
315        self.rules
316            .iter()
317            .find(|rule| rule.matcher.redirect.as_deref() == Some(op))
318    }
319
320    pub fn rules(&self) -> impl Iterator<Item = &Rule> {
321        self.rules.iter()
322    }
323
324    pub fn len(&self) -> usize {
325        self.rules.len()
326    }
327
328    pub fn is_empty(&self) -> bool {
329        self.rules.is_empty()
330    }
331}
332
333/// Match score of `rule` against a command invocation, or `None` if the rule
334/// does not apply. Shared by the overall and shipped-only lookup passes so the
335/// protection floor compares like with like.
336fn score_command_match(
337    rule: &Rule,
338    command: &str,
339    subcommand: Option<&str>,
340    flags: &[String],
341) -> Option<usize> {
342    let m = &rule.matcher;
343    if m.command.as_deref() != Some(command) {
344        return None;
345    }
346    let mut score = 1usize;
347    if let Some(want) = m.subcommand.as_deref() {
348        if subcommand != Some(want) {
349            return None;
350        }
351        score += 2;
352    }
353    if let Some(want_any) = &m.flags_any {
354        // a flag is value-taking when the rule itself declares it
355        // consumes/carries a value; only then can `-oVALUE` attached-short
356        // forms match (never `-rf` boolean clusters)
357        let value_taking = |w: &str| {
358            rule.scope.as_ref().is_some_and(|s| {
359                s.path_flags.iter().any(|x| x == w) || s.flag_args.iter().any(|x| x == w)
360            })
361        };
362        if !want_any
363            .iter()
364            .any(|w| flags.iter().any(|f| flag_matches(f, w, value_taking(w))))
365        {
366            return None;
367        }
368        score += 4;
369    }
370    Some(score)
371}
372
373/// Whether an observed flag token satisfies a wanted flag: exact match, the
374/// `--long=value` attached form, or — only for value-taking flags — the
375/// `-oVALUE` attached-short form.
376fn flag_matches(observed: &str, want: &str, value_taking: bool) -> bool {
377    observed == want
378        || (want.starts_with("--")
379            && observed
380                .split_once('=')
381                .is_some_and(|(name, _)| name == want))
382        || (value_taking
383            && want.len() == 2
384            && want.starts_with('-')
385            && !want.starts_with("--")
386            && observed.len() > 2
387            && observed.starts_with(want))
388}
389
390/// Safety-relevant classifications an overlay may not silently weaken:
391/// externalizing (exfiltration) and above (data loss).
392fn is_protected(effect: Effect) -> bool {
393    effect >= Effect::Externalizing
394}
395
396fn validate(file: &str, rule: &Rule) -> Result<(), RegistryError> {
397    let fail = |reason: &str| {
398        Err(RegistryError::InvalidRule {
399            file: file.to_string(),
400            id: rule.id.clone(),
401            reason: reason.to_string(),
402        })
403    };
404    if rule.id.trim().is_empty() {
405        return fail("empty id");
406    }
407    let m = &rule.matcher;
408    match (&m.command, &m.redirect) {
409        (Some(_), Some(_)) => {
410            return fail("match must have exactly one of `command` or `redirect`, not both");
411        }
412        (None, None) => return fail("match must have one of `command` or `redirect`"),
413        (None, Some(_)) if m.subcommand.is_some() || m.flags_any.is_some() => {
414            return fail("`subcommand`/`flags_any` require `command`");
415        }
416        _ => {}
417    }
418    Ok(())
419}