1use std::borrow::Cow;
2use std::collections::{BTreeMap, BTreeSet};
3use std::ops::Deref;
4use std::path::{Path, PathBuf};
5
6use crate::config;
7#[cfg(test)]
8use crate::rule_pack as yarp_rule_pack;
9use crate::rule_pack::{Action, CompiledPack, Rule};
10
11mod generated {
12 include!(concat!(env!("OUT_DIR"), "/builtin_rules.rs"));
13}
14
15pub use generated::{BUILTIN_PACK_ID, BUILTIN_SOURCE_DIGEST};
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct PackRequest {
19 pub path: PathBuf,
20 pub expected_digest: Option<[u8; 32]>,
21 pub expected_compiled_digest: Option<[u8; 32]>,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct PackReference {
26 pub path: PathBuf,
27 pub source_digest: [u8; 32],
28 pub compiled_digest: [u8; 32],
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct SelectedRule {
33 pub pack_id: Cow<'static, str>,
34 pub source_digest: [u8; 32],
35 pub rule: SelectedRuleData,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
39pub enum SelectedRuleData {
40 Builtin(&'static Rule),
41 External(Box<Rule>),
42}
43
44impl Deref for SelectedRuleData {
45 type Target = Rule;
46
47 fn deref(&self) -> &Self::Target {
48 match self {
49 Self::Builtin(rule) => rule,
50 Self::External(rule) => rule,
51 }
52 }
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum Selection {
57 Reduce(SelectedRule),
58 Transform(SelectedRule),
59 Passthrough(Vec<String>),
60 Ambiguous(Vec<String>),
61 Unsupported,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct RuleSummary {
66 pub pack_id: String,
67 pub rule: Rule,
68}
69
70struct ExternalPack {
71 pack: CompiledPack,
72 disabled: bool,
73 recheck_digest_after_selection: bool,
74}
75
76pub struct Registry {
77 external: Vec<ExternalPack>,
78 references: Vec<PackReference>,
79 diagnostics: Vec<String>,
80}
81
82impl Registry {
83 pub fn load(requests: &[PackRequest]) -> Result<Self, String> {
89 let mut external = Vec::<ExternalPack>::new();
90 let mut seen_paths = BTreeSet::new();
91 for request in requests {
92 let pack = CompiledPack::open(
93 &request.path,
94 request.expected_digest,
95 request.expected_compiled_digest,
96 )?;
97 if !seen_paths.insert(pack.path.clone()) {
98 if request.expected_compiled_digest.is_some()
99 && let Some(existing) = external
100 .iter_mut()
101 .find(|existing| existing.pack.path == pack.path)
102 {
103 existing.recheck_digest_after_selection = true;
104 }
105 continue;
106 }
107 external.push(ExternalPack {
108 pack,
109 disabled: false,
110 recheck_digest_after_selection: request.expected_compiled_digest.is_some(),
111 });
112 }
113 let references = external
114 .iter()
115 .map(|external| PackReference {
116 path: external.pack.path.clone(),
117 source_digest: external.pack.source_digest,
118 compiled_digest: external.pack.compiled_digest,
119 })
120 .collect();
121 let diagnostics = disable_conflicts(&mut external);
122 Ok(Self {
123 external,
124 references,
125 diagnostics,
126 })
127 }
128
129 #[must_use]
130 pub fn builtins_only() -> Self {
131 Self {
132 external: Vec::new(),
133 references: Vec::new(),
134 diagnostics: Vec::new(),
135 }
136 }
137
138 pub fn select(&mut self, arguments: &[String]) -> Result<Selection, String> {
144 let Some(program) = arguments.first() else {
145 return Ok(Selection::Unsupported);
146 };
147 let mut matches = MatchAccumulator::default();
148 if let Ok(index) =
149 generated::BUILTIN_PROGRAM_INDEX.binary_search_by(|entry| entry.0.cmp(program.as_str()))
150 {
151 for rule_index in generated::BUILTIN_PROGRAM_INDEX[index].1 {
152 let rule = &generated::BUILTIN_RULES[*rule_index];
153 if rule.matcher.matches(arguments) {
154 matches.push(SelectedRule {
155 pack_id: Cow::Borrowed(BUILTIN_PACK_ID),
156 source_digest: BUILTIN_SOURCE_DIGEST,
157 rule: SelectedRuleData::Builtin(rule),
158 });
159 }
160 }
161 }
162 for external in &mut self.external {
163 if external.disabled {
164 continue;
165 }
166 let candidates = external.pack.candidate_indices(program).to_vec();
167 for candidate in candidates {
168 let rule = external.pack.read_rule(candidate)?;
169 if rule.matcher.matches(arguments) {
170 matches.push(SelectedRule {
171 pack_id: Cow::Owned(external.pack.id.clone()),
172 source_digest: external.pack.source_digest,
173 rule: SelectedRuleData::External(Box::new(rule)),
174 });
175 }
176 }
177 if external.recheck_digest_after_selection {
178 external.pack.verify_compiled_digest()?;
179 }
180 }
181 Ok(resolve(matches))
182 }
183
184 #[must_use]
185 pub fn references(&self) -> &[PackReference] {
186 &self.references
187 }
188
189 #[must_use]
190 pub fn diagnostics(&self) -> &[String] {
191 &self.diagnostics
192 }
193
194 pub fn summaries(&mut self) -> Result<Vec<RuleSummary>, String> {
200 let mut summaries = generated::BUILTIN_RULES
201 .iter()
202 .cloned()
203 .map(|rule| RuleSummary {
204 pack_id: BUILTIN_PACK_ID.to_owned(),
205 rule,
206 })
207 .collect::<Vec<_>>();
208 for external in &mut self.external {
209 if external.disabled {
210 continue;
211 }
212 for index in 0..external.pack.rules.len() {
213 let rule = external.pack.read_rule(
214 u32::try_from(index)
215 .map_err(|_| "external rule index does not fit u32".to_owned())?,
216 )?;
217 summaries.push(RuleSummary {
218 pack_id: external.pack.id.clone(),
219 rule,
220 });
221 }
222 }
223 summaries.sort_by(|left, right| {
224 (&left.pack_id, &left.rule.id).cmp(&(&right.pack_id, &right.rule.id))
225 });
226 Ok(summaries)
227 }
228}
229
230#[must_use]
231pub fn requests_from_paths(paths: &[PathBuf]) -> Vec<PackRequest> {
232 paths
233 .iter()
234 .map(|path| PackRequest {
235 path: path.clone(),
236 expected_digest: None,
237 expected_compiled_digest: None,
238 })
239 .collect()
240}
241
242pub fn requests_from_config() -> Result<Vec<PackRequest>, String> {
248 config::load().map(|resolved| requests_from_paths(&resolved.rules.packs))
249}
250
251#[must_use]
252pub fn digest_hex(digest: &[u8; 32]) -> String {
253 const HEX: &[u8; 16] = b"0123456789abcdef";
254 let mut output = String::with_capacity(64);
255 for byte in digest {
256 output.push(char::from(HEX[usize::from(byte >> 4)]));
257 output.push(char::from(HEX[usize::from(byte & 0x0f)]));
258 }
259 output
260}
261
262pub fn parse_digest(value: &str) -> Result<[u8; 32], String> {
268 if value.len() != 64 {
269 return Err("rule pack digest must contain 32 bytes".to_owned());
270 }
271 let mut output = [0_u8; 32];
272 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
273 let high = hex_digit(pair[0])?;
274 let low = hex_digit(pair[1])?;
275 output[index] = (high << 4) | low;
276 }
277 Ok(output)
278}
279
280fn hex_digit(value: u8) -> Result<u8, String> {
281 match value {
282 b'0'..=b'9' => Ok(value - b'0'),
283 b'a'..=b'f' => Ok(value - b'a' + 10),
284 b'A'..=b'F' => Ok(value - b'A' + 10),
285 _ => Err("rule pack digest must be hexadecimal".to_owned()),
286 }
287}
288
289pub fn canonical_project_pack(project_root: &Path, candidate: &Path) -> Result<PathBuf, String> {
295 let root = std::fs::canonicalize(project_root)
296 .map_err(|error| format!("could not resolve project root: {error}"))?;
297 let candidate = if candidate.is_absolute() {
298 candidate.to_path_buf()
299 } else {
300 project_root.join(candidate)
301 };
302 let relative = candidate
303 .strip_prefix(project_root)
304 .or_else(|_| candidate.strip_prefix(&root))
305 .map_err(|_| "project rule pack is not beneath the trusted project root".to_owned())?;
306 let mut current = root.clone();
307 for component in relative.components() {
308 current.push(component.as_os_str());
309 let metadata = std::fs::symlink_metadata(¤t)
310 .map_err(|error| format!("could not inspect project rule pack: {error}"))?;
311 if metadata.file_type().is_symlink() {
312 return Err("project rule pack path must not contain symlinks".to_owned());
313 }
314 }
315 let metadata = std::fs::metadata(&candidate)
316 .map_err(|error| format!("could not inspect project rule pack: {error}"))?;
317 if !metadata.is_file() {
318 return Err("project rule pack must be a regular file".to_owned());
319 }
320 let resolved = std::fs::canonicalize(&candidate)
321 .map_err(|error| format!("could not resolve project rule pack: {error}"))?;
322 if !resolved.starts_with(root) {
323 return Err("project rule pack escapes the trusted project root".to_owned());
324 }
325 Ok(resolved)
326}
327
328#[derive(Default)]
329struct MatchAccumulator {
330 passthrough: Vec<String>,
331 actions: ActionMatches,
332}
333
334#[derive(Default)]
335enum ActionMatches {
336 #[default]
337 None,
338 One(SelectedRule),
339 Many(Vec<String>),
340}
341
342impl MatchAccumulator {
343 fn push(&mut self, selected: SelectedRule) {
344 if selected.rule.action == Action::Passthrough {
345 self.passthrough.push(qualified_id(&selected));
346 return;
347 }
348 self.actions = match std::mem::take(&mut self.actions) {
349 ActionMatches::None => ActionMatches::One(selected),
350 ActionMatches::One(first) => {
351 ActionMatches::Many(vec![qualified_id(&first), qualified_id(&selected)])
352 }
353 ActionMatches::Many(mut ids) => {
354 ids.push(qualified_id(&selected));
355 ActionMatches::Many(ids)
356 }
357 };
358 }
359}
360
361fn resolve(mut matches: MatchAccumulator) -> Selection {
362 if !matches.passthrough.is_empty() {
363 matches.passthrough.sort();
364 return Selection::Passthrough(matches.passthrough);
365 }
366 match matches.actions {
367 ActionMatches::None => Selection::Unsupported,
368 ActionMatches::One(selected) => match selected.rule.action {
369 Action::Reduce => Selection::Reduce(selected),
370 Action::Transform => Selection::Transform(selected),
371 Action::Passthrough => {
372 unreachable!("passthrough matches return before action resolution")
373 }
374 },
375 ActionMatches::Many(mut ids) => {
376 ids.sort();
377 Selection::Ambiguous(ids)
378 }
379 }
380}
381
382fn qualified_id(rule: &SelectedRule) -> String {
383 format!("{}/{}", rule.pack_id, rule.rule.id)
384}
385
386fn disable_conflicts(external: &mut [ExternalPack]) -> Vec<String> {
387 let builtin_ids: BTreeSet<&str> = generated::BUILTIN_RULES
388 .iter()
389 .map(|rule| rule.id.as_str())
390 .collect();
391 let mut pack_owners = BTreeMap::<String, Vec<usize>>::new();
392 let mut rule_owners = BTreeMap::<String, Vec<usize>>::new();
393 for (index, candidate) in external.iter().enumerate() {
394 pack_owners
395 .entry(candidate.pack.id.clone())
396 .or_default()
397 .push(index);
398 for rule in &candidate.pack.rules {
399 rule_owners.entry(rule.id.clone()).or_default().push(index);
400 }
401 }
402 let mut disabled = BTreeSet::new();
403 let mut diagnostics = BTreeSet::new();
404 if let Some(owners) = pack_owners.get(BUILTIN_PACK_ID) {
405 disabled.extend(owners.iter().copied());
406 diagnostics.insert(format!(
407 "external pack id conflicts with the built-in pack: {BUILTIN_PACK_ID}"
408 ));
409 }
410 for (pack_id, owners) in pack_owners.iter().filter(|(_, owners)| owners.len() > 1) {
411 disabled.extend(owners.iter().copied());
412 diagnostics.insert(format!(
413 "duplicate external pack id disables all copies: {pack_id}"
414 ));
415 }
416 for (rule_id, owners) in rule_owners {
417 if builtin_ids.contains(rule_id.as_str()) {
418 disabled.extend(owners.iter().copied());
419 diagnostics.insert(format!(
420 "external rule conflicts with a built-in rule: {rule_id}"
421 ));
422 } else if owners.len() > 1 {
423 disabled.extend(owners.iter().copied());
424 diagnostics.insert(format!(
425 "duplicate external rule id disables its packs: {rule_id}"
426 ));
427 }
428 }
429 for index in disabled {
430 external[index].disabled = true;
431 }
432 diagnostics.into_iter().collect()
433}
434
435#[cfg(test)]
436mod tests {
437 use std::io::{Seek as _, SeekFrom, Write as _};
438
439 use crate::rule_pack::SourcePack;
440 use serde::Deserialize;
441 use sha2::{Digest as _, Sha256};
442 use tempfile::TempDir;
443
444 use super::*;
445
446 #[derive(Deserialize)]
447 #[serde(deny_unknown_fields)]
448 struct FixtureFile {
449 schema_version: u32,
450 cases: Vec<MatchFixture>,
451 }
452
453 #[derive(Deserialize)]
454 #[serde(deny_unknown_fields)]
455 struct MatchFixture {
456 id: String,
457 rule_id: String,
458 argv: Vec<String>,
459 expected_action: String,
460 }
461
462 fn strings(values: &[&str]) -> Vec<String> {
463 values.iter().map(ToString::to_string).collect()
464 }
465
466 #[test]
467 fn builtins_use_indexed_matching_and_guards() {
468 let mut registry = Registry::builtins_only();
469 let selection = registry
470 .select(&strings(&["cargo", "test", "--workspace"]))
471 .expect("selection");
472 assert!(matches!(selection, Selection::Reduce(_)));
473 let selection = registry
474 .select(&strings(&["cargo", "test", "--message-format=json"]))
475 .expect("selection");
476 assert!(matches!(selection, Selection::Passthrough(_)));
477 let selection = registry
478 .select(&strings(&["head", "-50"]))
479 .expect("selection");
480 assert!(matches!(selection, Selection::Transform(_)));
481 let selection = registry
482 .select(&strings(&["head", "--bytes=50"]))
483 .expect("selection");
484 assert!(matches!(selection, Selection::Passthrough(_)));
485 assert_eq!(
486 registry
487 .select(&strings(&["git", "push"]))
488 .expect("selection"),
489 Selection::Unsupported
490 );
491 }
492
493 #[test]
494 fn every_builtin_rule_matches_its_synthetic_fixture() {
495 let fixtures: FixtureFile =
496 serde_json::from_str(include_str!("../../rules/fixtures/builtin.json"))
497 .expect("fixtures");
498 assert_eq!(fixtures.schema_version, 1);
499 let mut covered = BTreeSet::new();
500 for fixture in fixtures.cases {
501 let mut registry = Registry::builtins_only();
502 let selection = registry.select(&fixture.argv).expect("selection");
503 match (fixture.expected_action.as_str(), selection) {
504 ("reduce", Selection::Reduce(selected))
505 | ("transform", Selection::Transform(selected)) => {
506 assert_eq!(selected.rule.id, fixture.rule_id, "{}", fixture.id);
507 }
508 ("passthrough", Selection::Passthrough(ids)) => {
509 assert!(
510 ids.iter().any(|id| id.ends_with(&fixture.rule_id)),
511 "{}: {ids:?}",
512 fixture.id
513 );
514 }
515 (expected, actual) => panic!("{}: expected {expected}, got {actual:?}", fixture.id),
516 }
517 covered.insert(fixture.rule_id);
518 }
519 assert_eq!(covered.len(), generated::BUILTIN_RULES.len());
520 }
521
522 #[cfg(unix)]
523 #[test]
524 fn expected_pack_digest_is_rechecked_after_selection() {
525 let directory = TempDir::new().expect("temp directory");
526 std::fs::create_dir(directory.path().join("rules")).expect("rules directory");
527 std::fs::write(
528 directory.path().join("pack.json"),
529 r#"{"schema_version":1,"id":"external-pack","rules":["rules/test.json"]}"#,
530 )
531 .expect("manifest");
532 std::fs::write(
533 directory.path().join("rules/test.json"),
534 r#"{"id":"tests/external","match":{"program":["external-tool"],"argv_prefix":["run"]},"action":"reduce","reducer":{"kind":"test_summary"},"success":{"max_line_bytes":16384,"max_output_bytes":32768,"min_savings_bytes":120,"min_savings_basis_points":1000},"failure":{"max_line_bytes":16384,"max_output_bytes":65536,"min_savings_bytes":120,"min_savings_basis_points":500}}"#,
535 )
536 .expect("rule");
537 let source = SourcePack::load(directory.path()).expect("source pack");
538 let compiled = yarp_rule_pack::compile(&source).expect("compiled pack");
539 let compiled_digest = Sha256::digest(&compiled).into();
540 let pack_path = directory.path().join("pack.yrp");
541 std::fs::write(&pack_path, &compiled).expect("write pack");
542 let mut registry = Registry::load(&[PackRequest {
543 path: pack_path.clone(),
544 expected_digest: Some(source.source_digest),
545 expected_compiled_digest: Some(compiled_digest),
546 }])
547 .expect("registry");
548
549 let mut file = std::fs::OpenOptions::new()
550 .write(true)
551 .open(pack_path)
552 .expect("open pack for mutation");
553 file.seek(SeekFrom::Start(0)).expect("seek");
554 file.write_all(&[compiled[0] ^ 1]).expect("mutate header");
555 file.flush().expect("flush mutation");
556
557 assert!(
558 registry
559 .select(&strings(&["external-tool", "run"]))
560 .expect_err("changed pack")
561 .contains("changed while loading")
562 );
563 }
564
565 #[test]
566 fn project_pack_rejects_symlinked_path_components() {
567 use std::os::unix::fs::symlink;
568
569 let directory = tempfile::TempDir::new().expect("temp directory");
570 let real = directory.path().join("real");
571 std::fs::create_dir(&real).expect("real directory");
572 std::fs::write(real.join("rules.yrp"), b"pack").expect("pack");
573 symlink(&real, directory.path().join(".yarp")).expect("symlink");
574 let error =
575 canonical_project_pack(directory.path(), &directory.path().join(".yarp/rules.yrp"))
576 .expect_err("symlink rejection");
577 assert!(error.contains("symlink"));
578 }
579
580 #[test]
581 fn digest_encoding_is_strict() {
582 let digest = [7_u8; 32];
583 assert_eq!(parse_digest(&digest_hex(&digest)).expect("digest"), digest);
584 assert!(parse_digest("07").is_err());
585 assert!(parse_digest("not-hex").is_err());
586 }
587}