1use serde::Deserialize;
7use std::collections::HashMap;
8use std::path::Path;
9
10#[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 #[serde(default)]
49 pub skip: usize,
50 #[serde(default)]
53 pub flag_args: Vec<String>,
54 #[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
119const 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 shipped_count: usize,
138}
139
140impl Registry {
141 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 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 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 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 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 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 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 .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 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
333fn 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 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
373fn 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
390fn 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}