1use std::collections::BTreeSet;
2use std::path::PathBuf;
3
4use rebecca_core::{
5 Platform, RebeccaError, Result, RuleDefinition, RuleSource, RuleTargetSpec, SafetyLevel,
6 manifest::parse_cleaner_manifest_file_with_safety_knowledge,
7 planner::validate_rule_catalog,
8 protection::{
9 ProtectionAssessment, ProtectionPolicy, is_regenerable_browser_cache_target_shape,
10 },
11 safety_catalog::{SafetyCatalog, SafetyKnowledge, parse_safety_catalog},
12};
13
14macro_rules! builtin_rule_files {
15 ($($path:literal),+ $(,)?) => {
16 &[
17 $(
18 (
19 $path,
20 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path)),
21 ),
22 )+
23 ]
24 };
25}
26
27const BUILTIN_RULE_FILES: &[(&str, &str)] = builtin_rule_files!(
28 "rules/cleanup/user-temp.toml",
29 "rules/cleanup/edge-cache.toml",
30 "rules/cleanup/firefox-profile-cache.toml",
31 "rules/cleanup/chrome-cache.toml",
32 "rules/cleanup/chromium-cache.toml",
33 "rules/cleanup/brave-cache.toml",
34 "rules/cleanup/waterfox-cache.toml",
35 "rules/cleanup/zen-browser-cache.toml",
36 "rules/cleanup/directx-shader-cache.toml",
37 "rules/cleanup/discord-cache.toml",
38 "rules/cleanup/wechat-cache.toml",
39 "rules/cleanup/wxwork-cache.toml",
40 "rules/cleanup/qq-cache.toml",
41 "rules/cleanup/feishu-cache.toml",
42 "rules/cleanup/dingtalk-cache.toml",
43 "rules/cleanup/wps-cache.toml",
44 "rules/cleanup/baidunetdisk-cache.toml",
45 "rules/cleanup/tencent-meeting-cache.toml",
46 "rules/cleanup/qqmusic-cache.toml",
47 "rules/cleanup/tencent-video-cache.toml",
48 "rules/cleanup/postman-cache.toml",
49 "rules/cleanup/notion-cache.toml",
50 "rules/cleanup/figma-cache.toml",
51 "rules/cleanup/slack-cache.toml",
52 "rules/cleanup/zoom-logs.toml",
53 "rules/cleanup/teamviewer-logs.toml",
54 "rules/cleanup/vlc-cache.toml",
55 "rules/cleanup/thunderbird-cache.toml",
56 "rules/cleanup/adobe-reader-cache.toml",
57 "rules/cleanup/steam-cache.toml",
58 "rules/cleanup/steam-install-cache.toml",
59 "rules/cleanup/steam-install-depot-cache.toml",
60 "rules/cleanup/steam-install-logs.toml",
61 "rules/cleanup/steam-install-avatar-cache.toml",
62 "rules/cleanup/steam-install-stats-cache.toml",
63 "rules/cleanup/steam-install-appinfo-cache.toml",
64 "rules/cleanup/steam-install-localization-cache.toml",
65 "rules/cleanup/steam-install-packageinfo-cache.toml",
66 "rules/cleanup/steam-install-download-cache.toml",
67 "rules/cleanup/steam-install-library-cache.toml",
68 "rules/cleanup/steam-install-shader-cache.toml",
69 "rules/cleanup/steam-library-shader-cache.toml",
70 "rules/cleanup/steam-library-downloading-cache.toml",
71 "rules/cleanup/steam-library-temp-cache.toml",
72 "rules/cleanup/npm-cache.toml",
73 "rules/cleanup/pnpm-cache.toml",
74 "rules/cleanup/yarn-cache.toml",
75 "rules/cleanup/bun-cache.toml",
76 "rules/cleanup/corepack-cache.toml",
77 "rules/cleanup/gradle-cache.toml",
78 "rules/cleanup/android-cache.toml",
79 "rules/cleanup/homebrew-cache.toml",
80 "rules/cleanup/cocoapods-cache.toml",
81 "rules/cleanup/xcode-cache.toml",
82 "rules/cleanup/nuget-cache.toml",
83 "rules/cleanup/maven-cache.toml",
84 "rules/cleanup/pip-cache.toml",
85 "rules/cleanup/uv-cache.toml",
86 "rules/cleanup/poetry-cache.toml",
87 "rules/cleanup/conda-cache.toml",
88 "rules/cleanup/go-build-cache.toml",
89 "rules/cleanup/go-module-cache.toml",
90 "rules/cleanup/cargo-cache.toml",
91 "rules/cleanup/rustup-cache.toml",
92 "rules/cleanup/ccache-cache.toml",
93 "rules/cleanup/sccache-cache.toml",
94 "rules/cleanup/huggingface-cache.toml",
95 "rules/cleanup/pytorch-cache.toml",
96 "rules/cleanup/jetbrains-cache.toml",
97 "rules/cleanup/thumbnail-cache.toml",
98 "rules/cleanup/vscode-cache.toml",
99 "rules/cleanup/wer-reports.toml",
100 "rules/cleanup/apt-cache.toml",
101 "rules/cleanup/dnf-cache.toml",
102 "rules/cleanup/pacman-cache.toml",
103 "rules/cleanup/zypper-cache.toml",
104 "rules/cleanup/system-temp.toml",
105 "rules/cleanup/prefetch.toml",
106 "rules/cleanup/update-download-cache.toml",
107 "rules/cleanup/media-player-cache.toml",
108);
109
110const BUILTIN_SAFETY_CATALOG: (&str, &str) = (
111 rebecca_safety::CLEANUP_SAFETY_CATALOG_PATH,
112 rebecca_safety::CLEANUP_SAFETY_CATALOG,
113);
114
115const BUILTIN_RULE_CATEGORIES: &[&str] = &["application", "browser", "development", "system"];
116
117pub fn builtin_rules() -> Result<Vec<RuleDefinition>> {
118 let mut rules = Vec::with_capacity(BUILTIN_RULE_FILES.len());
119 let safety_knowledge = builtin_safety_knowledge()?;
120
121 for (path, raw) in BUILTIN_RULE_FILES {
122 let parsed_rules = parse_rule_file(path, raw, &safety_knowledge)?;
123 validate_builtin_rule_file(path, &parsed_rules)?;
124 rules.extend(parsed_rules);
125 }
126
127 validate_builtin_rule_catalog(&rules)?;
128 validate_rule_catalog(&rules)?;
129 Ok(rules)
130}
131
132pub fn builtin_safety_knowledge() -> Result<SafetyKnowledge> {
133 builtin_safety_catalog()?
134 .default_knowledge()
135 .cloned()
136 .ok_or_else(|| {
137 RebeccaError::SafetyCatalogInvalid(
138 "built-in safety catalog default platform is missing".to_string(),
139 )
140 })
141}
142
143pub fn builtin_safety_knowledge_for_platform(platform: Platform) -> Result<SafetyKnowledge> {
144 builtin_safety_catalog()?
145 .knowledge_for_platform(platform)
146 .cloned()
147 .ok_or_else(|| {
148 RebeccaError::SafetyCatalogInvalid(format!(
149 "built-in safety catalog has no safety knowledge for platform {}",
150 platform.label()
151 ))
152 })
153}
154
155pub fn builtin_safety_catalog() -> Result<SafetyCatalog> {
156 parse_safety_catalog(BUILTIN_SAFETY_CATALOG.0, BUILTIN_SAFETY_CATALOG.1)
157}
158
159pub fn validate_builtin_rules() -> Result<()> {
160 builtin_rules().map(|_| ())
161}
162
163pub fn validate_external_rule_files(paths: &[PathBuf]) -> Result<Vec<RuleDefinition>> {
164 let safety_knowledge = builtin_safety_knowledge()?;
165 let mut rules = Vec::new();
166
167 for path in paths {
168 let raw = std::fs::read_to_string(path).map_err(|err| {
169 RebeccaError::RuleCatalogInvalid(format!(
170 "external rule file {} could not be read: {err}",
171 path.display()
172 ))
173 })?;
174 let path_label = path.to_string_lossy();
175 let parsed_rules = parse_rule_file(path_label.as_ref(), raw.as_str(), &safety_knowledge)?;
176 if parsed_rules.is_empty() {
177 return Err(RebeccaError::RuleCatalogInvalid(format!(
178 "external rule file {} must compile at least one rule",
179 path.display()
180 )));
181 }
182 rules.extend(parsed_rules);
183 }
184
185 validate_external_rule_catalog(&rules)?;
186 Ok(rules)
187}
188
189pub fn validate_external_rule_catalog(rules: &[RuleDefinition]) -> Result<()> {
190 validate_rule_catalog(rules)?;
191 let safety_catalog = builtin_safety_catalog()?;
192
193 for rule in rules {
194 if rule.platform == Platform::Unknown {
195 return Err(RebeccaError::RuleCatalogInvalid(format!(
196 "external rule {} must target a supported platform",
197 rule.id
198 )));
199 }
200 let safety_knowledge = safety_catalog
201 .knowledge_for_platform(rule.platform)
202 .ok_or_else(|| {
203 RebeccaError::RuleCatalogInvalid(format!(
204 "external rule {} targets platform {} without safety knowledge",
205 rule.id,
206 rule.platform.label()
207 ))
208 })?;
209 let policy = ProtectionPolicy::new().with_safety_knowledge(safety_knowledge);
210
211 validate_external_rule_metadata(rule, safety_knowledge)?;
212
213 if rule
214 .restore_hint
215 .as_deref()
216 .map(str::trim)
217 .unwrap_or_default()
218 .is_empty()
219 {
220 return Err(RebeccaError::RuleCatalogInvalid(format!(
221 "external rule {} must define a restore hint before it can be imported",
222 rule.id
223 )));
224 }
225
226 if rule.safety_level == SafetyLevel::Dangerous {
227 return Err(RebeccaError::RuleCatalogInvalid(format!(
228 "external rule {} must not use dangerous safety level",
229 rule.id
230 )));
231 }
232
233 for spec in &rule.path_templates {
234 if rule.category.eq_ignore_ascii_case("browser") {
235 validate_browser_cache_target_shape("external rule", rule, spec)?;
236 }
237
238 validate_builtin_glob_shape("external rule", rule, spec)?;
239
240 if let ProtectionAssessment::Blocked(block) = policy.assess_catalog_target_shape(spec) {
241 return Err(RebeccaError::RuleCatalogInvalid(format!(
242 "external rule {} target {} is blocked by {}: {}",
243 rule.id,
244 spec.placeholder_path().display(),
245 block.kind.label(),
246 block.message
247 )));
248 }
249
250 validate_builtin_linux_package_manager_boundary("external rule", rule, spec)?;
251 validate_builtin_target_shape_basis("external rule", rule, spec)?;
252 validate_builtin_required_shape_warnings("external rule", rule, spec)?;
253 }
254 }
255
256 Ok(())
257}
258
259fn parse_rule_file(
260 path: &str,
261 raw: &str,
262 safety_knowledge: &SafetyKnowledge,
263) -> Result<Vec<RuleDefinition>> {
264 parse_cleaner_manifest_file_with_safety_knowledge(path, raw, safety_knowledge)
265}
266
267fn validate_builtin_rule_file(path: &str, rules: &[RuleDefinition]) -> Result<()> {
268 let Some(file) = builtin_rule_file(path) else {
269 return Err(RebeccaError::RuleCatalogInvalid(format!(
270 "built-in rule file {path} must be a rules/cleanup/<slug>.toml file"
271 )));
272 };
273
274 if rules.is_empty() {
275 return Err(RebeccaError::RuleCatalogInvalid(format!(
276 "built-in rule file {path} must compile at least one rule"
277 )));
278 }
279
280 for rule in rules {
281 if rule.platform == Platform::Unknown {
282 return Err(RebeccaError::RuleCatalogInvalid(format!(
283 "built-in rule file {path} produced rule {} for unsupported platform {}",
284 rule.id,
285 rule.platform.label()
286 )));
287 }
288
289 let expected_id = format!("{}.{}", rule.platform.label(), file.stem);
290 let option_id_prefix = format!("{expected_id}.");
291 if rule.id != expected_id && !rule.id.starts_with(&option_id_prefix) {
292 return Err(RebeccaError::RuleCatalogInvalid(format!(
293 "built-in rule file {path} produced rule {}; expected {expected_id} or {option_id_prefix}*",
294 rule.id
295 )));
296 }
297 }
298
299 Ok(())
300}
301
302#[derive(Debug, Clone, Copy)]
303struct BuiltinRuleFile<'a> {
304 stem: &'a str,
305}
306
307fn builtin_rule_file(path: &str) -> Option<BuiltinRuleFile<'_>> {
308 let parts = path.split(['/', '\\']).collect::<Vec<_>>();
309 let [rules_dir, cleanup_dir, file_name] = parts.as_slice() else {
310 return None;
311 };
312 let rules_dir = *rules_dir;
313 let cleanup_dir = *cleanup_dir;
314 let file_name = *file_name;
315
316 if rules_dir != "rules" || cleanup_dir != "cleanup" {
317 return None;
318 }
319 let stem = file_name.strip_suffix(".toml")?;
320 if stem.is_empty() {
321 return None;
322 }
323
324 Some(BuiltinRuleFile { stem })
325}
326
327fn validate_builtin_rule_catalog(rules: &[RuleDefinition]) -> Result<()> {
328 let safety_catalog = builtin_safety_catalog()?;
329
330 for rule in rules {
331 if rule.platform == Platform::Unknown {
332 return Err(RebeccaError::RuleCatalogInvalid(format!(
333 "built-in rule {} must target a supported platform",
334 rule.id
335 )));
336 }
337 let safety_knowledge = safety_catalog
338 .knowledge_for_platform(rule.platform)
339 .ok_or_else(|| {
340 RebeccaError::RuleCatalogInvalid(format!(
341 "built-in rule {} targets platform {} without safety knowledge",
342 rule.id,
343 rule.platform.label()
344 ))
345 })?;
346 let policy = ProtectionPolicy::new().with_safety_knowledge(safety_knowledge);
347
348 let platform_prefix = rule.platform.label();
349 let expected_id_prefix = format!("{platform_prefix}.");
350 if !rule.id.starts_with(&expected_id_prefix) {
351 return Err(RebeccaError::RuleCatalogInvalid(format!(
352 "built-in rule {} must use a {} rule id prefix matching platform {}",
353 rule.id, expected_id_prefix, platform_prefix
354 )));
355 }
356
357 validate_builtin_rule_metadata(rule, safety_knowledge)?;
358
359 if rule
360 .restore_hint
361 .as_deref()
362 .map(str::trim)
363 .unwrap_or_default()
364 .is_empty()
365 {
366 return Err(RebeccaError::RuleCatalogInvalid(format!(
367 "built-in rule {} must define a restore hint",
368 rule.id
369 )));
370 }
371
372 if rule.provenance.source != RuleSource::Owned {
373 return Err(RebeccaError::RuleCatalogInvalid(format!(
374 "built-in rule {} must use owned provenance source",
375 rule.id
376 )));
377 }
378
379 if rule.provenance.license.trim() != "project-owned" {
380 return Err(RebeccaError::RuleCatalogInvalid(format!(
381 "built-in rule {} must use project-owned provenance license",
382 rule.id
383 )));
384 }
385
386 for spec in &rule.path_templates {
387 if rule.category.eq_ignore_ascii_case("browser") {
388 validate_browser_cache_target_shape("built-in rule", rule, spec)?;
389 }
390
391 validate_builtin_glob_shape("built-in rule", rule, spec)?;
392
393 if let ProtectionAssessment::Blocked(block) = policy.assess_catalog_target_shape(spec) {
394 return Err(RebeccaError::RuleCatalogInvalid(format!(
395 "built-in rule {} target {} is blocked by {}: {}",
396 rule.id,
397 spec.placeholder_path().display(),
398 block.kind.label(),
399 block.message
400 )));
401 }
402
403 validate_builtin_linux_package_manager_boundary("built-in rule", rule, spec)?;
404 validate_builtin_target_shape_basis("built-in rule", rule, spec)?;
405 validate_builtin_required_shape_warnings("built-in rule", rule, spec)?;
406 }
407 }
408
409 Ok(())
410}
411
412fn validate_builtin_rule_metadata(
413 rule: &RuleDefinition,
414 safety_knowledge: &SafetyKnowledge,
415) -> Result<()> {
416 validate_trimmed_rule_metadata(rule, "id", &rule.id)?;
417 validate_trimmed_rule_metadata(rule, "category", &rule.category)?;
418 validate_trimmed_rule_metadata(rule, "name", &rule.name)?;
419 if let Some(restore_hint) = &rule.restore_hint {
420 validate_trimmed_rule_metadata(rule, "restore hint", restore_hint)?;
421 }
422 validate_trimmed_rule_metadata(rule, "provenance license", &rule.provenance.license)?;
423 validate_trimmed_rule_metadata(rule, "provenance notes", &rule.provenance.notes)?;
424 validate_builtin_rule_provenance_notes(rule)?;
425
426 let platform_prefix = rule.platform.label();
427 if !is_canonical_platform_rule_id(&rule.id, platform_prefix) {
428 return Err(RebeccaError::RuleCatalogInvalid(format!(
429 "built-in rule {} must use canonical lowercase {}.<slug> rule id syntax",
430 rule.id, platform_prefix
431 )));
432 }
433
434 if !BUILTIN_RULE_CATEGORIES.contains(&rule.category.as_str()) {
435 return Err(RebeccaError::RuleCatalogInvalid(format!(
436 "built-in rule {} uses unsupported category {}; allowed categories: {}",
437 rule.id,
438 rule.category,
439 BUILTIN_RULE_CATEGORIES.join(", ")
440 )));
441 }
442
443 if matches!(
444 rule.safety_level,
445 SafetyLevel::Risky | SafetyLevel::Dangerous
446 ) {
447 return Err(RebeccaError::RuleCatalogInvalid(format!(
448 "built-in rule {} must not use {} safety level",
449 rule.id,
450 rule.safety_level.label()
451 )));
452 }
453
454 validate_builtin_rule_warnings(rule, safety_knowledge)
455}
456
457fn validate_external_rule_metadata(
458 rule: &RuleDefinition,
459 safety_knowledge: &SafetyKnowledge,
460) -> Result<()> {
461 validate_external_trimmed_rule_metadata(rule, "id", &rule.id)?;
462 validate_external_trimmed_rule_metadata(rule, "category", &rule.category)?;
463 validate_external_trimmed_rule_metadata(rule, "name", &rule.name)?;
464 if let Some(restore_hint) = &rule.restore_hint {
465 validate_external_trimmed_rule_metadata(rule, "restore hint", restore_hint)?;
466 }
467 validate_external_trimmed_rule_metadata(rule, "provenance license", &rule.provenance.license)?;
468 validate_external_trimmed_rule_metadata(rule, "provenance notes", &rule.provenance.notes)?;
469
470 let platform_prefix = rule.platform.label();
471 if !is_canonical_platform_rule_id(&rule.id, platform_prefix) {
472 return Err(RebeccaError::RuleCatalogInvalid(format!(
473 "external rule {} must use canonical lowercase {}.<slug> rule id syntax",
474 rule.id, platform_prefix
475 )));
476 }
477
478 validate_external_rule_warnings(rule, safety_knowledge)
479}
480
481fn validate_trimmed_rule_metadata(rule: &RuleDefinition, field: &str, value: &str) -> Result<()> {
482 if value != value.trim() {
483 return Err(RebeccaError::RuleCatalogInvalid(format!(
484 "built-in rule {} {field} must not contain leading or trailing whitespace",
485 rule.id
486 )));
487 }
488
489 Ok(())
490}
491
492fn validate_external_trimmed_rule_metadata(
493 rule: &RuleDefinition,
494 field: &str,
495 value: &str,
496) -> Result<()> {
497 if value != value.trim() {
498 return Err(RebeccaError::RuleCatalogInvalid(format!(
499 "external rule {} {field} must not contain leading or trailing whitespace",
500 rule.id
501 )));
502 }
503
504 Ok(())
505}
506
507fn validate_builtin_rule_provenance_notes(rule: &RuleDefinition) -> Result<()> {
508 let lower_notes = rule.provenance.notes.to_ascii_lowercase();
509
510 for phrase in [
511 "copied from",
512 "derived from",
513 "imported from",
514 "ported from",
515 ] {
516 if lower_notes.contains(phrase) {
517 return Err(RebeccaError::RuleCatalogInvalid(format!(
518 "built-in rule {} provenance notes must not claim copied or derived rule data",
519 rule.id
520 )));
521 }
522 }
523
524 for source in ["bleachbit", "mole", "winapp2"] {
525 if lower_notes.contains(source)
526 && !(lower_notes.contains("behavior reference only")
527 || lower_notes.contains("discovery index only"))
528 {
529 return Err(RebeccaError::RuleCatalogInvalid(format!(
530 "built-in rule {} provenance notes must mark {source} as reference-only",
531 rule.id
532 )));
533 }
534
535 if lower_notes.contains(source) && !lower_notes.contains("no rule data copied") {
536 return Err(RebeccaError::RuleCatalogInvalid(format!(
537 "built-in rule {} provenance notes must state that no {source} rule data was copied",
538 rule.id
539 )));
540 }
541 }
542
543 Ok(())
544}
545
546fn is_canonical_platform_rule_id(id: &str, platform_prefix: &str) -> bool {
547 let Some(rest) = id.strip_prefix(platform_prefix) else {
548 return false;
549 };
550 let Some(rest) = rest.strip_prefix('.') else {
551 return false;
552 };
553
554 !rest.is_empty() && rest.split('.').all(is_rule_id_slug_segment)
555}
556
557fn is_rule_id_slug_segment(segment: &str) -> bool {
558 !segment.is_empty()
559 && !segment.starts_with('-')
560 && !segment.ends_with('-')
561 && !segment.contains("--")
562 && segment
563 .bytes()
564 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
565}
566
567fn validate_builtin_rule_warnings(
568 rule: &RuleDefinition,
569 safety_knowledge: &SafetyKnowledge,
570) -> Result<()> {
571 let mut seen = BTreeSet::new();
572
573 for warning in &rule.warnings {
574 if !seen.insert(warning.as_str()) {
575 return Err(RebeccaError::RuleCatalogInvalid(format!(
576 "built-in rule {} contains duplicate warning kind {}",
577 rule.id, warning
578 )));
579 }
580
581 if !safety_knowledge
582 .warning_kinds()
583 .iter()
584 .any(|kind| kind.id() == warning)
585 {
586 return Err(RebeccaError::RuleCatalogInvalid(format!(
587 "built-in rule {} warning kind {} must match a canonical safety catalog warning id",
588 rule.id, warning
589 )));
590 }
591 }
592
593 Ok(())
594}
595
596fn validate_external_rule_warnings(
597 rule: &RuleDefinition,
598 safety_knowledge: &SafetyKnowledge,
599) -> Result<()> {
600 let mut seen = BTreeSet::new();
601
602 for warning in &rule.warnings {
603 if !seen.insert(warning.as_str()) {
604 return Err(RebeccaError::RuleCatalogInvalid(format!(
605 "external rule {} contains duplicate warning kind {}",
606 rule.id, warning
607 )));
608 }
609
610 if !safety_knowledge
611 .warning_kinds()
612 .iter()
613 .any(|kind| kind.id() == warning)
614 {
615 return Err(RebeccaError::RuleCatalogInvalid(format!(
616 "external rule {} warning kind {} must match a canonical safety catalog warning id",
617 rule.id, warning
618 )));
619 }
620 }
621
622 Ok(())
623}
624
625fn validate_browser_cache_target_shape(
626 scope: &str,
627 rule: &RuleDefinition,
628 spec: &RuleTargetSpec,
629) -> Result<()> {
630 if is_regenerable_browser_cache_target_shape(spec) {
631 return Ok(());
632 }
633
634 Err(RebeccaError::RuleCatalogInvalid(format!(
635 "{scope} {} browser target {} is outside the regenerable browser cache boundary",
636 rule.id,
637 spec.placeholder_path().display()
638 )))
639}
640
641fn validate_builtin_target_shape_basis(
642 scope: &str,
643 rule: &RuleDefinition,
644 spec: &RuleTargetSpec,
645) -> Result<()> {
646 if matches!(
647 spec,
648 RuleTargetSpec::SteamInstallTemplate(_) | RuleTargetSpec::SteamLibraryTemplate(_)
649 ) {
650 return Ok(());
651 }
652
653 if rule.category.eq_ignore_ascii_case("browser")
654 && is_regenerable_browser_cache_target_shape(spec)
655 {
656 return Ok(());
657 }
658
659 let raw = raw_target_shape(spec);
660 if has_positive_cleanup_basis(&raw) {
661 return Ok(());
662 }
663
664 Err(RebeccaError::RuleCatalogInvalid(format!(
665 "{scope} {} target {} must have a positive cleanup basis such as a cache, temp, log, package-store, shader, download, or approved maintenance shape",
666 rule.id,
667 spec.placeholder_path().display()
668 )))
669}
670
671fn validate_builtin_glob_shape(
672 scope: &str,
673 rule: &RuleDefinition,
674 spec: &RuleTargetSpec,
675) -> Result<()> {
676 let RuleTargetSpec::GlobTemplate(template) = spec else {
677 return Ok(());
678 };
679 let raw = normalize_rule_shape(template.raw());
680 let segments = shape_segments(&raw);
681 let wildcard_segments = segments
682 .iter()
683 .filter(|segment| contains_glob_wildcard(segment))
684 .count();
685
686 if wildcard_segments == 0 {
687 return Err(RebeccaError::RuleCatalogInvalid(format!(
688 "{scope} {} glob target {} must contain an explicit wildcard",
689 rule.id,
690 spec.placeholder_path().display()
691 )));
692 }
693 if wildcard_segments > 3 {
694 return Err(RebeccaError::RuleCatalogInvalid(format!(
695 "{scope} {} glob target {} uses too many wildcard segments; keep discovery bounded",
696 rule.id,
697 spec.placeholder_path().display()
698 )));
699 }
700
701 if wildcard_appears_at_protected_platform_root(&segments) {
702 return Err(RebeccaError::RuleCatalogInvalid(format!(
703 "{scope} {} glob target {} starts discovery from a protected platform root",
704 rule.id,
705 spec.placeholder_path().display()
706 )));
707 }
708
709 if wildcard_appears_at_profile_root(&segments) || wildcard_appears_at_drive_root(&segments) {
710 return Err(RebeccaError::RuleCatalogInvalid(format!(
711 "{scope} {} glob target {} starts discovery from a profile or drive root",
712 rule.id,
713 spec.placeholder_path().display()
714 )));
715 }
716
717 Ok(())
718}
719
720fn validate_builtin_required_shape_warnings(
721 scope: &str,
722 rule: &RuleDefinition,
723 spec: &RuleTargetSpec,
724) -> Result<()> {
725 for warning in required_shape_warnings(spec) {
726 if !rule.warnings.iter().any(|known| known == warning) {
727 return Err(RebeccaError::RuleCatalogInvalid(format!(
728 "{scope} {} target {} requires warning kind {}",
729 rule.id,
730 spec.placeholder_path().display(),
731 warning
732 )));
733 }
734 }
735
736 Ok(())
737}
738
739fn validate_builtin_linux_package_manager_boundary(
740 scope: &str,
741 rule: &RuleDefinition,
742 spec: &RuleTargetSpec,
743) -> Result<()> {
744 if rule.platform != Platform::Linux {
745 return Ok(());
746 }
747
748 let raw = normalize_rule_shape(&raw_target_shape(spec));
749 let segments = shape_segments(&raw);
750 if is_linux_package_manager_cache_namespace(&segments)
751 && !is_linux_package_manager_cache_shape(&segments)
752 {
753 return Err(RebeccaError::RuleCatalogInvalid(format!(
754 "{scope} {} target {} is inside a Linux package-manager cache namespace but not an approved package archive/cache leaf",
755 rule.id,
756 spec.placeholder_path().display()
757 )));
758 }
759
760 Ok(())
761}
762
763fn required_shape_warnings(spec: &RuleTargetSpec) -> Vec<&'static str> {
764 let mut warnings = Vec::new();
765
766 if matches!(
767 spec,
768 RuleTargetSpec::SteamInstallTemplate(_) | RuleTargetSpec::SteamLibraryTemplate(_)
769 ) {
770 warnings.push("source-boundary");
771 }
772
773 let raw = normalize_rule_shape(&raw_target_shape(spec));
774 if raw.starts_with("%windir%/") {
775 warnings.push("privileged-location");
776 }
777 if is_linux_package_manager_cache_shape(&shape_segments(&raw)) {
778 warnings.push("permission-sensitive");
779 }
780
781 if matches!(spec, RuleTargetSpec::GlobTemplate(_)) {
782 let segments = shape_segments(&raw);
783 if wildcard_requires_broad_discovery_warning(&segments) {
784 warnings.push("broad-discovery");
785 }
786 }
787
788 warnings
789}
790
791fn raw_target_shape(spec: &RuleTargetSpec) -> String {
792 match spec {
793 RuleTargetSpec::Template(template)
794 | RuleTargetSpec::GlobTemplate(template)
795 | RuleTargetSpec::SteamInstallTemplate(template)
796 | RuleTargetSpec::SteamLibraryTemplate(template) => template.raw().to_string(),
797 RuleTargetSpec::ExactPath(path) => path.to_string_lossy().into_owned(),
798 }
799}
800
801fn normalize_rule_shape(raw: &str) -> String {
802 raw.trim()
803 .replace('\\', "/")
804 .split('/')
805 .filter(|segment| !segment.is_empty())
806 .collect::<Vec<_>>()
807 .join("/")
808 .to_ascii_lowercase()
809}
810
811fn shape_segments(normalized: &str) -> Vec<&str> {
812 normalized
813 .split('/')
814 .filter(|segment| !segment.is_empty())
815 .collect()
816}
817
818fn contains_glob_wildcard(segment: &str) -> bool {
819 segment.contains('*') || segment.contains('?') || segment.contains('[')
820}
821
822fn wildcard_appears_at_profile_root(segments: &[&str]) -> bool {
823 matches!(segments.first(), Some(root) if matches!(*root, "%userprofile%" | "%home%"))
824 && segments
825 .get(1)
826 .is_some_and(|segment| contains_glob_wildcard(segment))
827}
828
829fn wildcard_appears_at_protected_platform_root(segments: &[&str]) -> bool {
830 wildcard_at(
831 segments,
832 1,
833 &[
834 "%home%",
835 "%xdg_cache_home%",
836 "%xdg_config_home%",
837 "%xdg_data_home%",
838 "%xdg_state_home%",
839 "%macos_cache_home%",
840 "%macos_application_support_home%",
841 "%macos_container_home%",
842 "%macos_group_container_home%",
843 "%macos_log_home%",
844 ],
845 ) || starts_with(segments, &["%home%", ".cache"]) && wildcard_at(segments, 2, &[])
846 || starts_with(segments, &["%home%", ".config"]) && wildcard_at(segments, 2, &[])
847 || starts_with(segments, &["%home%", ".local", "share"]) && wildcard_at(segments, 3, &[])
848 || starts_with(segments, &["%home%", ".var", "app"]) && wildcard_at(segments, 3, &[])
849 || starts_with(segments, &["%home%", "snap"]) && wildcard_at(segments, 2, &[])
850 || starts_with(segments, &["%home%", "library"]) && wildcard_at(segments, 2, &[])
851 || starts_with(segments, &["%home%", "library", "caches"]) && wildcard_at(segments, 3, &[])
852 || starts_with(segments, &["%home%", "library", "application support"])
853 && wildcard_at(segments, 3, &[])
854 || starts_with(segments, &["%home%", "library", "containers"])
855 && wildcard_at(segments, 3, &[])
856 || starts_with(segments, &["%home%", "library", "group containers"])
857 && wildcard_at(segments, 3, &[])
858}
859
860fn wildcard_at(segments: &[&str], index: usize, roots: &[&str]) -> bool {
861 if !roots.is_empty() && !segments.first().is_some_and(|root| roots.contains(root)) {
862 return false;
863 }
864
865 segments
866 .get(index)
867 .is_some_and(|segment| contains_glob_wildcard(segment))
868}
869
870fn starts_with(segments: &[&str], prefix: &[&str]) -> bool {
871 segments.starts_with(prefix)
872}
873
874fn has_sequence(segments: &[&str], sequence: &[&str]) -> bool {
875 !sequence.is_empty()
876 && segments.len() >= sequence.len()
877 && segments
878 .windows(sequence.len())
879 .any(|window| window == sequence)
880}
881
882fn find_sequence(segments: &[&str], sequence: &[&str]) -> Option<usize> {
883 if sequence.is_empty() || segments.len() < sequence.len() {
884 return None;
885 }
886
887 segments
888 .windows(sequence.len())
889 .position(|window| window == sequence)
890}
891
892fn wildcard_appears_at_drive_root(segments: &[&str]) -> bool {
893 segments
894 .first()
895 .is_some_and(|segment| segment.ends_with(':') && segment.len() == 2)
896 && segments
897 .get(1)
898 .is_some_and(|segment| contains_glob_wildcard(segment))
899}
900
901fn wildcard_requires_broad_discovery_warning(segments: &[&str]) -> bool {
902 if star_wildcard_segment_count(segments) >= 2 {
903 return true;
904 }
905
906 let mut fixed_before_first_wildcard = 0usize;
907 let first_wildcard = segments
908 .iter()
909 .skip_while(|segment| segment.starts_with('%') && segment.ends_with('%'))
910 .find(|segment| {
911 if contains_glob_wildcard(segment) {
912 true
913 } else {
914 fixed_before_first_wildcard += 1;
915 false
916 }
917 });
918
919 fixed_before_first_wildcard == 0
920 && first_wildcard.is_some_and(|segment| *segment == "*" || *segment == "?")
921}
922
923fn star_wildcard_segment_count(segments: &[&str]) -> usize {
924 segments
925 .iter()
926 .filter(|segment| segment.contains('*') || segment.contains('?'))
927 .count()
928}
929
930fn has_positive_cleanup_basis(raw: &str) -> bool {
931 let normalized = normalize_rule_shape(raw);
932 let segments = shape_segments(&normalized);
933 let leaf = segments.last().copied().unwrap_or_default();
934
935 if is_linux_package_manager_cache_shape(&segments) {
936 return true;
937 }
938
939 if [
940 ".cache",
941 ".pnpm-store",
942 "cache",
943 "caches",
944 "cache2",
945 "startupcache",
946 "offlinecache",
947 "code cache",
948 "codecache",
949 "gpucache",
950 "dawncache",
951 "graphitedawncache",
952 "grshadercache",
953 "shadercache",
954 "d3dscache",
955 "htmlcache",
956 "httpcache",
957 "filecache",
958 "resource_cache",
959 "musiccache",
960 "updatecache",
961 "whirlcache",
962 "tmp",
963 "temp",
964 "%tmp%",
965 "%tmpdir%",
966 "%temp%",
967 "logs",
968 "crashdump",
969 "corepack",
970 "notifications",
971 "image",
972 "installer.txt",
973 ".pnpm-store",
974 "archives",
975 "pkg",
976 "pkgs",
977 "packages",
978 "repository",
979 "store",
980 "hub",
981 "datasets",
982 "assets",
983 "artistalbum",
984 "xet",
985 "prefetch",
986 "deriveddata",
987 ]
988 .contains(&leaf)
989 {
990 return true;
991 }
992
993 normalized.contains("cache")
994 || normalized.contains("thumbcache_")
995 || normalized.contains("*.idx")
996 || normalized.contains("iconcache_")
997 || normalized.contains("_cacache")
998 || normalized.contains("logfile.log")
999 || normalized.contains("reportarchive")
1000 || normalized.contains("reportqueue")
1001 || normalized.contains("%rustup_home%/downloads")
1002 || normalized.contains(".rustup/downloads")
1003 || normalized.contains("appcache/download")
1004 || normalized.contains("steamapps/downloading")
1005 || normalized.contains("softwaredistribution/download")
1006 || normalized.contains("registry/cache")
1007 || normalized.contains("registry/index")
1008 || normalized.contains("registry/src")
1009 || normalized.contains("git/db")
1010 || normalized.contains("git/checkouts")
1011 || normalized.contains("go-build")
1012 || normalized.contains("pkg/mod")
1013 || normalized.contains("[0-9a-f]/[0-9a-f]")
1014 || normalized.contains("dynamicresource")
1015 || normalized.contains("transcoded files cache")
1016}
1017
1018fn is_linux_package_manager_cache_shape(segments: &[&str]) -> bool {
1019 has_sequence(segments, &["var", "cache", "apt", "archives"])
1020 || is_linux_dnf_package_cache_shape(segments)
1021 || has_sequence(segments, &["var", "cache", "pacman", "pkg"])
1022 || has_sequence(segments, &["var", "cache", "zypp", "packages"])
1023}
1024
1025fn is_linux_package_manager_cache_namespace(segments: &[&str]) -> bool {
1026 has_sequence(segments, &["var", "cache", "apt"])
1027 || has_sequence(segments, &["var", "cache", "dnf"])
1028 || has_sequence(segments, &["var", "cache", "pacman"])
1029 || has_sequence(segments, &["var", "cache", "zypp"])
1030}
1031
1032fn is_linux_dnf_package_cache_shape(segments: &[&str]) -> bool {
1033 find_sequence(segments, &["var", "cache", "dnf"]).is_some_and(|index| {
1034 segments
1035 .get(index + 3..)
1036 .is_some_and(|tail| tail.contains(&"packages"))
1037 })
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use std::{collections::HashSet, fs, path::Path};
1043
1044 use rebecca_core::safety_catalog::{default_safety_catalog, default_safety_knowledge};
1045 use rebecca_core::{
1046 Platform, RuleDefinition, RuleProvenance, RuleSource, RuleTargetSpec, SafetyLevel,
1047 };
1048
1049 use super::{
1050 builtin_rules, builtin_safety_knowledge, builtin_safety_knowledge_for_platform,
1051 parse_rule_file,
1052 };
1053
1054 fn parse_single_rule_file(path: &str, raw: &str) -> RuleDefinition {
1055 let safety_knowledge =
1056 builtin_safety_knowledge().expect("built-in safety catalog should load");
1057 let rules = parse_rule_file(path, raw, &safety_knowledge).expect("test rule should parse");
1058 assert_eq!(rules.len(), 1);
1059 rules.into_iter().next().unwrap()
1060 }
1061
1062 #[test]
1063 fn builtin_rule_ids_are_unique() {
1064 let rules = builtin_rules().expect("built-in rules should load");
1065 let ids = rules
1066 .iter()
1067 .map(|rule| rule.id.as_str())
1068 .collect::<HashSet<_>>();
1069
1070 assert_eq!(ids.len(), rules.len());
1071 }
1072
1073 #[test]
1074 fn builtin_rules_have_required_metadata() {
1075 super::validate_builtin_rules().expect("built-in rules should be valid");
1076 }
1077
1078 #[test]
1079 fn builtin_safety_catalog_exposes_warning_and_category_knowledge() {
1080 let catalog = super::builtin_safety_catalog().expect("built-in safety catalog should load");
1081 let knowledge = catalog
1082 .default_knowledge()
1083 .expect("built-in safety catalog should have default knowledge");
1084
1085 assert!(
1086 knowledge
1087 .warning_kinds()
1088 .iter()
1089 .any(|warning| warning.id() == "active-process")
1090 );
1091 assert!(
1092 knowledge
1093 .categories()
1094 .iter()
1095 .any(|category| category.id().label() == "application-durable-data")
1096 );
1097 assert!(knowledge.is_allowed_steam_install_target("appcache/httpcache"));
1098 assert!(knowledge.is_allowed_steam_library_target("steamapps/downloading"));
1099 assert!(catalog.knowledge_for_platform(Platform::Linux).is_some());
1100 assert!(catalog.knowledge_for_platform(Platform::Macos).is_some());
1101 }
1102
1103 #[test]
1104 fn builtin_safety_knowledge_for_platform_selects_requested_platform() {
1105 let linux = builtin_safety_knowledge_for_platform(Platform::Linux)
1106 .expect("Linux safety knowledge should load");
1107 let macos = builtin_safety_knowledge_for_platform(Platform::Macos)
1108 .expect("macOS safety knowledge should load");
1109
1110 assert_eq!(linux.platform(), Platform::Linux);
1111 assert_eq!(macos.platform(), Platform::Macos);
1112 assert_ne!(
1113 linux.critical_path_prefixes(),
1114 macos.critical_path_prefixes()
1115 );
1116 }
1117
1118 #[test]
1119 fn builtin_safety_catalog_matches_core_default_catalog() {
1120 let builtin = builtin_safety_knowledge().expect("built-in safety catalog should load");
1121 let builtin_catalog =
1122 super::builtin_safety_catalog().expect("built-in safety catalog should load");
1123 let core_catalog = default_safety_catalog();
1124 let core_default = default_safety_knowledge();
1125
1126 assert_eq!(
1127 builtin_catalog
1128 .platform_knowledge()
1129 .iter()
1130 .map(|knowledge| knowledge.platform().label())
1131 .collect::<Vec<_>>(),
1132 core_catalog
1133 .platform_knowledge()
1134 .iter()
1135 .map(|knowledge| knowledge.platform().label())
1136 .collect::<Vec<_>>()
1137 );
1138 assert_eq!(builtin.platform(), core_default.platform());
1139 assert_eq!(
1140 builtin
1141 .warning_kinds()
1142 .iter()
1143 .map(|warning| warning.id())
1144 .collect::<Vec<_>>(),
1145 core_default
1146 .warning_kinds()
1147 .iter()
1148 .map(|warning| warning.id())
1149 .collect::<Vec<_>>()
1150 );
1151 assert_eq!(
1152 builtin.critical_path_prefixes(),
1153 core_default.critical_path_prefixes()
1154 );
1155 }
1156
1157 #[test]
1158 fn builtin_rules_use_owned_provenance_sources() {
1159 let rules = builtin_rules().expect("built-in rules should load");
1160
1161 assert!(
1162 rules
1163 .iter()
1164 .all(|rule| rule.provenance.source == rebecca_core::RuleSource::Owned)
1165 );
1166 }
1167
1168 #[test]
1169 fn builtin_rules_have_restore_hints_and_project_owned_provenance() {
1170 let rules = builtin_rules().expect("built-in rules should load");
1171
1172 assert!(rules.iter().all(|rule| {
1173 rule.restore_hint
1174 .as_deref()
1175 .map(str::trim)
1176 .is_some_and(|hint| !hint.is_empty())
1177 && rule.provenance.license == "project-owned"
1178 }));
1179 }
1180
1181 #[test]
1182 fn builtin_rules_are_loaded_from_toml_catalog_files() {
1183 let rules = builtin_rules().expect("built-in rules should load");
1184 let user_temp = rules
1185 .iter()
1186 .find(|rule| rule.id == "windows.user-temp")
1187 .expect("Windows user temp rule should exist");
1188
1189 assert_eq!(user_temp.platform, rebecca_core::Platform::Windows);
1190 assert_eq!(user_temp.category, "system");
1191 assert_eq!(user_temp.path_templates.len(), 2);
1192 assert_eq!(user_temp.provenance.source, RuleSource::Owned);
1193
1194 let linux_user_temp = rules
1195 .iter()
1196 .find(|rule| rule.id == "linux.user-temp")
1197 .expect("Linux user temp rule should exist");
1198
1199 assert_eq!(linux_user_temp.platform, rebecca_core::Platform::Linux);
1200 assert_eq!(linux_user_temp.category, "system");
1201 assert_eq!(linux_user_temp.path_templates.len(), 2);
1202 assert_eq!(linux_user_temp.provenance.source, RuleSource::Owned);
1203 }
1204
1205 #[test]
1206 fn builtin_rule_files_match_rule_directory() {
1207 let rules_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("rules/cleanup");
1208 let mut discovered = Vec::new();
1209
1210 for rule_entry in
1211 fs::read_dir(rules_dir).expect("cleanup rule directory should be readable")
1212 {
1213 let rule_path = rule_entry
1214 .expect("rule directory entry should be readable")
1215 .path();
1216 if rule_path
1217 .extension()
1218 .is_some_and(|extension| extension == "toml")
1219 {
1220 discovered.push(format!(
1221 "rules/cleanup/{}",
1222 rule_path
1223 .file_name()
1224 .expect("rule file should have a file name")
1225 .to_string_lossy()
1226 ));
1227 }
1228 }
1229 discovered.sort();
1230
1231 let mut embedded = super::BUILTIN_RULE_FILES
1232 .iter()
1233 .map(|(path, _)| path.to_string())
1234 .collect::<Vec<_>>();
1235 embedded.sort();
1236
1237 assert_eq!(embedded, discovered);
1238 }
1239
1240 #[test]
1241 fn builtin_catalog_rejects_non_owned_provenance_sources() {
1242 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1243 id: "windows.test".to_string(),
1244 platform: Platform::Windows,
1245 category: "system".to_string(),
1246 name: "Test".to_string(),
1247 safety_level: SafetyLevel::Safe,
1248 path_templates: vec![RuleTargetSpec::template("%TEMP%")],
1249 restore_hint: Some("Regenerated automatically.".to_string()),
1250 warnings: Vec::new(),
1251 provenance: RuleProvenance {
1252 source: RuleSource::ReferenceOnly,
1253 license: "project-owned".to_string(),
1254 notes: "test".to_string(),
1255 },
1256 }])
1257 .unwrap_err();
1258
1259 assert!(err.to_string().contains("owned provenance source"));
1260 }
1261
1262 #[test]
1263 fn builtin_rule_file_rejects_rule_ids_that_drift_from_file_name() {
1264 let err = super::validate_builtin_rule_file(
1265 "rules/cleanup/user-temp.toml",
1266 &[rule_with_target(RuleTargetSpec::template("%TEMP%"))],
1267 )
1268 .expect_err("file name should constrain the produced rule id");
1269
1270 assert!(err.to_string().contains("produced rule windows.test"));
1271
1272 let option_rule = RuleDefinition {
1273 id: "windows.user-temp.option".to_string(),
1274 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1275 };
1276 super::validate_builtin_rule_file("rules/cleanup/user-temp.toml", &[option_rule])
1277 .expect("option rule ids should be allowed under the file id prefix");
1278
1279 let backslash_path_rule = RuleDefinition {
1280 id: "windows.user-temp".to_string(),
1281 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1282 };
1283 super::validate_builtin_rule_file(
1284 "rules\\cleanup\\user-temp.toml",
1285 std::slice::from_ref(&backslash_path_rule),
1286 )
1287 .expect("catalog path validation should accept Windows separators");
1288
1289 let linux_rule = RuleDefinition {
1290 id: "linux.user-temp".to_string(),
1291 platform: Platform::Linux,
1292 ..rule_with_target(RuleTargetSpec::template("%TMPDIR%"))
1293 };
1294 super::validate_builtin_rule_file(
1295 "rules/cleanup/user-temp.toml",
1296 &[backslash_path_rule, linux_rule],
1297 )
1298 .expect("shared catalog files should accept multiple supported platforms");
1299
1300 let id_mismatch_rule = RuleDefinition {
1301 id: "linux.other-temp".to_string(),
1302 platform: Platform::Linux,
1303 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1304 };
1305 let err =
1306 super::validate_builtin_rule_file("rules/cleanup/user-temp.toml", &[id_mismatch_rule])
1307 .expect_err("file family id should constrain generated rule ids");
1308 assert!(err.to_string().contains("expected linux.user-temp"));
1309 }
1310
1311 #[test]
1312 fn builtin_catalog_rejects_unsupported_categories() {
1313 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1314 category: "messaging".to_string(),
1315 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1316 }])
1317 .expect_err("unknown built-in categories should be rejected");
1318
1319 assert!(err.to_string().contains("unsupported category"));
1320 }
1321
1322 #[test]
1323 fn builtin_catalog_rejects_non_canonical_rule_ids() {
1324 for id in [
1325 "windows.Chrome_Cache",
1326 "windows.chrome--cache",
1327 "windows.chrome-",
1328 ] {
1329 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1330 id: id.to_string(),
1331 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1332 }])
1333 .expect_err("non-canonical rule id should be rejected");
1334
1335 assert!(
1336 err.to_string()
1337 .contains("canonical lowercase windows.<slug> rule id syntax"),
1338 "{err}"
1339 );
1340 }
1341 }
1342
1343 #[test]
1344 fn builtin_catalog_rejects_untrimmed_metadata() {
1345 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1346 name: " Test".to_string(),
1347 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1348 }])
1349 .expect_err("built-in metadata should be canonicalized before shipping");
1350
1351 assert!(
1352 err.to_string()
1353 .contains("must not contain leading or trailing whitespace")
1354 );
1355 }
1356
1357 #[test]
1358 fn builtin_catalog_rejects_copied_or_derived_reference_provenance() {
1359 for notes in [
1360 "Derived from BleachBit cleaner data.",
1361 "Copied from upstream cleaner data.",
1362 "Imported from Winapp2.",
1363 "Ported from Mole.",
1364 ] {
1365 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1366 provenance: RuleProvenance {
1367 notes: notes.to_string(),
1368 ..owned_provenance()
1369 },
1370 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1371 }])
1372 .expect_err("built-in rules must not claim copied reference rule data");
1373
1374 assert!(
1375 err.to_string()
1376 .contains("must not claim copied or derived rule data"),
1377 "{err}"
1378 );
1379 }
1380 }
1381
1382 #[test]
1383 fn builtin_catalog_requires_reference_only_provenance_for_restricted_sources() {
1384 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1385 provenance: RuleProvenance {
1386 notes: "Cross-checked against BleachBit cleaner behavior.".to_string(),
1387 ..owned_provenance()
1388 },
1389 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1390 }])
1391 .expect_err("restricted sources need explicit reference-only provenance");
1392
1393 assert!(err.to_string().contains("reference-only"), "{err}");
1394
1395 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1396 provenance: RuleProvenance {
1397 notes: "Cross-checked against BleachBit as behavior reference only.".to_string(),
1398 ..owned_provenance()
1399 },
1400 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1401 }])
1402 .expect_err("restricted sources need no-copy provenance");
1403
1404 assert!(err.to_string().contains("no bleachbit rule data"), "{err}");
1405
1406 super::validate_builtin_rule_catalog(&[RuleDefinition {
1407 provenance: RuleProvenance {
1408 notes: "Cross-checked against BleachBit as behavior reference only, no rule data copied.".to_string(),
1409 ..owned_provenance()
1410 },
1411 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1412 }])
1413 .expect("reference-only provenance should pass");
1414 }
1415
1416 #[test]
1417 fn builtin_catalog_rejects_risky_and_dangerous_safety_levels() {
1418 for safety_level in [SafetyLevel::Risky, SafetyLevel::Dangerous] {
1419 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1420 safety_level,
1421 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1422 }])
1423 .expect_err("built-in rules should not require risky opt-in levels");
1424
1425 assert!(err.to_string().contains("must not use"), "{err}");
1426 }
1427 }
1428
1429 #[test]
1430 fn builtin_catalog_rejects_non_canonical_or_duplicate_warnings() {
1431 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1432 warnings: vec!["ACTIVE-PROCESS".to_string()],
1433 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1434 }])
1435 .expect_err("built-in warnings should use canonical ids");
1436
1437 assert!(
1438 err.to_string()
1439 .contains("canonical safety catalog warning id")
1440 );
1441
1442 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
1443 warnings: vec!["active-process".to_string(), "active-process".to_string()],
1444 ..rule_with_target(RuleTargetSpec::template("%TEMP%"))
1445 }])
1446 .expect_err("duplicate warning ids should be rejected");
1447
1448 assert!(err.to_string().contains("duplicate warning kind"));
1449 }
1450
1451 #[test]
1452 fn builtin_catalog_rejects_targets_without_positive_cleanup_basis() {
1453 let err = super::validate_builtin_rule_catalog(&[rule_with_target(
1454 RuleTargetSpec::template("%USERPROFILE%\\Downloads"),
1455 )])
1456 .expect_err("built-in targets need a positive cleanup basis");
1457
1458 assert!(err.to_string().contains("positive cleanup basis"), "{err}");
1459 }
1460
1461 #[test]
1462 fn builtin_catalog_rejects_wide_profile_root_globs() {
1463 let err = super::validate_builtin_rule_catalog(&[rule_with_target(
1464 RuleTargetSpec::glob_template("%USERPROFILE%\\*\\Cache"),
1465 )])
1466 .expect_err("profile-root wildcard discovery should be rejected");
1467
1468 assert!(
1469 err.to_string()
1470 .contains("starts discovery from a profile or drive root"),
1471 "{err}"
1472 );
1473 }
1474
1475 #[test]
1476 fn builtin_catalog_requires_shape_implied_warnings() {
1477 let broad = super::validate_builtin_rule_catalog(&[rule_with_target(
1478 RuleTargetSpec::glob_template("%APPDATA%\\Vendor\\*\\Cache\\*\\file.tmp"),
1479 )])
1480 .expect_err("multi-wildcard glob should require broad-discovery");
1481 assert!(broad.to_string().contains("broad-discovery"), "{broad}");
1482
1483 let mut broad_rule = rule_with_target(RuleTargetSpec::glob_template(
1484 "%APPDATA%\\Vendor\\*\\Cache\\*\\file.tmp",
1485 ));
1486 broad_rule.warnings = vec!["broad-discovery".to_string()];
1487 super::validate_builtin_rule_catalog(&[broad_rule])
1488 .expect("broad-discovery warning should satisfy the shape gate");
1489
1490 let source = super::validate_builtin_rule_catalog(&[rule_with_target(
1491 RuleTargetSpec::steam_install_template("appcache\\httpcache"),
1492 )])
1493 .expect_err("Steam discovery should require source-boundary");
1494 assert!(source.to_string().contains("source-boundary"), "{source}");
1495
1496 let mut source_rule = rule_with_target(RuleTargetSpec::steam_install_template(
1497 "appcache\\httpcache",
1498 ));
1499 source_rule.warnings = vec!["source-boundary".to_string()];
1500 super::validate_builtin_rule_catalog(&[source_rule])
1501 .expect("source-boundary warning should satisfy Steam discovery");
1502
1503 let privileged = super::validate_builtin_rule_catalog(&[rule_with_target(
1504 RuleTargetSpec::template("%WINDIR%\\Temp"),
1505 )])
1506 .expect_err("Windows root maintenance targets should require privileged-location");
1507 assert!(
1508 privileged.to_string().contains("privileged-location"),
1509 "{privileged}"
1510 );
1511
1512 let mut privileged_rule = rule_with_target(RuleTargetSpec::template("%WINDIR%\\Temp"));
1513 privileged_rule.warnings = vec!["privileged-location".to_string()];
1514 super::validate_builtin_rule_catalog(&[privileged_rule])
1515 .expect("privileged-location warning should satisfy Windows maintenance targets");
1516
1517 let package_cache = super::validate_builtin_rule_catalog(&[linux_rule_with_target(
1518 RuleTargetSpec::template("/var/cache/apt/archives"),
1519 )])
1520 .expect_err("Linux package caches should require permission-sensitive");
1521 assert!(
1522 package_cache.to_string().contains("permission-sensitive"),
1523 "{package_cache}"
1524 );
1525
1526 let mut package_cache_rule =
1527 linux_rule_with_target(RuleTargetSpec::template("/var/cache/apt/archives"));
1528 package_cache_rule.warnings = vec!["permission-sensitive".to_string()];
1529 super::validate_builtin_rule_catalog(&[package_cache_rule])
1530 .expect("permission-sensitive warning should satisfy Linux package caches");
1531 }
1532
1533 #[test]
1534 fn builtin_catalog_rejects_linux_package_manager_state_and_broad_cache_roots() {
1535 for target in [
1536 "/var/cache/apt",
1537 "/var/cache/apt/lists",
1538 "/var/cache/dnf",
1539 "/var/cache/dnf/*/repodata",
1540 "/var/cache/pacman",
1541 "/var/cache/zypp",
1542 "/var/lib/apt/lists",
1543 "/var/log/pacman.log",
1544 "/etc/zypp/repos.d",
1545 ] {
1546 let spec = if target.contains('*') {
1547 RuleTargetSpec::glob_template(target)
1548 } else {
1549 RuleTargetSpec::template(target)
1550 };
1551 let mut rule = linux_rule_with_target(spec);
1552 rule.warnings = vec!["permission-sensitive".to_string()];
1553 let err = super::validate_builtin_rule_catalog(&[rule])
1554 .expect_err("package-manager state or broad roots should be rejected");
1555 let message = err.to_string();
1556 assert!(
1557 message.contains("package-manager cache namespace")
1558 || message.contains("critical-path")
1559 || message.contains("positive cleanup basis"),
1560 "{target}: {err}"
1561 );
1562 }
1563 }
1564
1565 #[test]
1566 fn builtin_catalog_rejects_linux_root_globs() {
1567 for target in [
1568 "%HOME%\\*\\Cache",
1569 "%XDG_CACHE_HOME%\\*\\Cache",
1570 "%XDG_DATA_HOME%\\*\\Cache",
1571 "%HOME%\\.var\\app\\*\\cache",
1572 "%HOME%\\snap\\*\\common\\.cache",
1573 ] {
1574 let err = super::validate_builtin_rule_catalog(&[linux_rule_with_target(
1575 RuleTargetSpec::glob_template(target),
1576 )])
1577 .expect_err("Linux root-level discovery should be rejected");
1578
1579 let message = err.to_string();
1580 assert!(
1581 message.contains("starts discovery from a protected platform root")
1582 || message.contains("application-durable-data"),
1583 "{target}: {err}"
1584 );
1585 }
1586 }
1587
1588 #[test]
1589 fn builtin_catalog_rejects_macos_root_globs_and_durable_roots() {
1590 for target in [
1591 "%HOME%/Library/*/Cache",
1592 "%MACOS_CACHE_HOME%/*/Cache",
1593 "%MACOS_APPLICATION_SUPPORT_HOME%/*/Cache",
1594 "%MACOS_CONTAINER_HOME%/*/Data/Library/Caches",
1595 "%MACOS_GROUP_CONTAINER_HOME%/*/Library/Caches",
1596 ] {
1597 let err = super::validate_builtin_rule_catalog(&[macos_rule_with_target(
1598 RuleTargetSpec::glob_template(target),
1599 )])
1600 .expect_err("macOS root-level discovery should be rejected");
1601
1602 let message = err.to_string();
1603 assert!(
1604 message.contains("starts discovery from a protected platform root")
1605 || message.contains("application-durable-data"),
1606 "{target}: {err}"
1607 );
1608 }
1609
1610 for target in [
1611 "%MACOS_CACHE_HOME%",
1612 "%MACOS_APPLICATION_SUPPORT_HOME%",
1613 "%MACOS_CONTAINER_HOME%/com.example.App/Data",
1614 "%MACOS_GROUP_CONTAINER_HOME%/group.example.App/Data",
1615 ] {
1616 let err = super::validate_builtin_rule_catalog(&[macos_rule_with_target(
1617 RuleTargetSpec::template(target),
1618 )])
1619 .expect_err("macOS broad durable roots should be rejected");
1620 assert!(
1621 err.to_string().contains("application-durable-data"),
1622 "{err}"
1623 );
1624 }
1625 }
1626
1627 #[test]
1628 fn builtin_catalog_accepts_macos_cache_leaves_and_rejects_private_data() {
1629 for target in [
1630 RuleTargetSpec::template("%MACOS_CACHE_HOME%/pip"),
1631 RuleTargetSpec::template("%HOME%/Library/Caches/Homebrew/downloads"),
1632 RuleTargetSpec::template("%MACOS_CACHE_HOME%/CocoaPods"),
1633 RuleTargetSpec::template("%HOME%/Library/Developer/Xcode/DerivedData"),
1634 RuleTargetSpec::template("%MACOS_CACHE_HOME%/com.apple.dt.Xcode"),
1635 RuleTargetSpec::template("%MACOS_CACHE_HOME%/org.swift.swiftpm"),
1636 RuleTargetSpec::template("%MACOS_APPLICATION_SUPPORT_HOME%/Slack/Cache"),
1637 RuleTargetSpec::template(
1638 "%MACOS_APPLICATION_SUPPORT_HOME%/Google/Chrome/Default/Cache",
1639 ),
1640 RuleTargetSpec::glob_template("%MACOS_CACHE_HOME%/Firefox/Profiles/*/cache2"),
1641 RuleTargetSpec::template("%MACOS_LOG_HOME%/Zoom/logs"),
1642 ] {
1643 super::validate_builtin_rule_catalog(&[macos_rule_with_target(target)])
1644 .expect("macOS cache or log leaf should be accepted");
1645 }
1646
1647 for target in [
1648 RuleTargetSpec::template(
1649 "%MACOS_APPLICATION_SUPPORT_HOME%/Google/Chrome/Default/History",
1650 ),
1651 RuleTargetSpec::template("%MACOS_APPLICATION_SUPPORT_HOME%/Slack/Local Storage"),
1652 RuleTargetSpec::template("%HOME%/Library/Developer/Xcode/Archives"),
1653 RuleTargetSpec::template("%HOME%/Library/Developer/Xcode/iOS DeviceSupport"),
1654 RuleTargetSpec::template("%HOME%/Library/MobileDevice/Provisioning Profiles"),
1655 RuleTargetSpec::template("%HOME%/Library/Safari/History.db"),
1656 RuleTargetSpec::template("%HOME%/Library/Keychains/login.keychain-db"),
1657 ] {
1658 let err = super::validate_builtin_rule_catalog(&[macos_rule_with_target(target)])
1659 .expect_err("macOS private or durable data should be rejected");
1660 let message = err.to_string();
1661 assert!(
1662 message.contains("browser-private-data")
1663 || message.contains("application-durable-data")
1664 || message.contains("credentials"),
1665 "{err}"
1666 );
1667 }
1668 }
1669
1670 #[test]
1671 fn builtin_rule_fixture_matrix_catches_positive_and_near_miss_shapes() {
1672 super::validate_builtin_rule_catalog(&[rule_with_target(RuleTargetSpec::template(
1673 "%APPDATA%\\Slack\\Cache",
1674 ))])
1675 .expect("positive cache target should pass");
1676
1677 let durable = super::validate_builtin_rule_catalog(&[rule_with_target(
1678 RuleTargetSpec::template("%APPDATA%\\Slack\\Local Storage"),
1679 )])
1680 .expect_err("durable app state near a cache should be blocked");
1681 assert!(durable.to_string().contains("application-durable-data"));
1682
1683 let protected = super::validate_builtin_rule_catalog(&[rule_with_target(
1684 RuleTargetSpec::template("%USERPROFILE%\\.ssh"),
1685 )])
1686 .expect_err("protected user credential path should be blocked");
1687 assert!(protected.to_string().contains("credentials"));
1688 }
1689
1690 #[test]
1691 fn builtin_rules_include_first_expansion_batch() {
1692 let rules = builtin_rules().expect("built-in rules should load");
1693 let ids = rules
1694 .iter()
1695 .map(|rule| rule.id.as_str())
1696 .collect::<HashSet<_>>();
1697
1698 for expected in [
1699 "linux.user-temp",
1700 "linux.android-cache",
1701 "linux.apt-cache",
1702 "linux.brave-cache",
1703 "linux.bun-cache",
1704 "linux.cargo-cache",
1705 "linux.ccache-cache",
1706 "linux.chrome-cache",
1707 "linux.chromium-cache",
1708 "linux.conda-cache",
1709 "linux.corepack-cache",
1710 "linux.dnf-cache",
1711 "linux.edge-cache",
1712 "linux.firefox-profile-cache",
1713 "linux.go-build-cache",
1714 "linux.go-module-cache",
1715 "linux.gradle-cache",
1716 "linux.huggingface-cache",
1717 "linux.discord-cache",
1718 "linux.figma-cache",
1719 "linux.jetbrains-cache",
1720 "linux.maven-cache",
1721 "linux.npm-cache",
1722 "linux.nuget-cache",
1723 "linux.pip-cache",
1724 "linux.pnpm-cache",
1725 "linux.poetry-cache",
1726 "linux.pacman-cache",
1727 "linux.postman-cache",
1728 "linux.pytorch-cache",
1729 "linux.rustup-cache",
1730 "linux.sccache-cache",
1731 "linux.slack-cache",
1732 "linux.steam-cache",
1733 "linux.steam-install-cache",
1734 "linux.steam-install-download-cache",
1735 "linux.steam-library-shader-cache",
1736 "linux.thumbnail-cache",
1737 "linux.thunderbird-cache",
1738 "linux.uv-cache",
1739 "linux.vlc-cache",
1740 "linux.waterfox-cache",
1741 "linux.vscode-cache",
1742 "linux.yarn-cache",
1743 "linux.zen-browser-cache",
1744 "linux.zoom-logs",
1745 "linux.zypper-cache",
1746 "macos.android-cache",
1747 "macos.brave-cache",
1748 "macos.bun-cache",
1749 "macos.cargo-cache",
1750 "macos.ccache-cache",
1751 "macos.chrome-cache",
1752 "macos.chromium-cache",
1753 "macos.cocoapods-cache",
1754 "macos.conda-cache",
1755 "macos.corepack-cache",
1756 "macos.discord-cache",
1757 "macos.edge-cache",
1758 "macos.figma-cache",
1759 "macos.firefox-profile-cache",
1760 "macos.go-build-cache",
1761 "macos.go-module-cache",
1762 "macos.gradle-cache",
1763 "macos.homebrew-cache",
1764 "macos.huggingface-cache",
1765 "macos.jetbrains-cache",
1766 "macos.maven-cache",
1767 "macos.notion-cache",
1768 "macos.npm-cache",
1769 "macos.nuget-cache",
1770 "macos.pip-cache",
1771 "macos.pnpm-cache",
1772 "macos.poetry-cache",
1773 "macos.postman-cache",
1774 "macos.pytorch-cache",
1775 "macos.rustup-cache",
1776 "macos.sccache-cache",
1777 "macos.slack-cache",
1778 "macos.thumbnail-cache",
1779 "macos.thunderbird-cache",
1780 "macos.user-temp",
1781 "macos.uv-cache",
1782 "macos.vlc-cache",
1783 "macos.vscode-cache",
1784 "macos.waterfox-cache",
1785 "macos.xcode-cache",
1786 "macos.yarn-cache",
1787 "macos.zen-browser-cache",
1788 "macos.zoom-logs",
1789 "windows.chrome-cache",
1790 "windows.chromium-cache",
1791 "windows.android-cache",
1792 "windows.adobe-reader-cache",
1793 "windows.brave-cache",
1794 "windows.bun-cache",
1795 "windows.cargo-cache",
1796 "windows.ccache-cache",
1797 "windows.corepack-cache",
1798 "windows.gradle-cache",
1799 "windows.directx-shader-cache",
1800 "windows.discord-cache",
1801 "windows.wechat-cache",
1802 "windows.wxwork-cache",
1803 "windows.qq-cache",
1804 "windows.feishu-cache",
1805 "windows.dingtalk-cache",
1806 "windows.wps-cache",
1807 "windows.baidunetdisk-cache",
1808 "windows.tencent-meeting-cache",
1809 "windows.qqmusic-cache",
1810 "windows.tencent-video-cache",
1811 "windows.figma-cache",
1812 "windows.firefox-profile-cache",
1813 "windows.waterfox-cache",
1814 "windows.zen-browser-cache",
1815 "windows.go-build-cache",
1816 "windows.go-module-cache",
1817 "windows.huggingface-cache",
1818 "windows.maven-cache",
1819 "windows.jetbrains-cache",
1820 "windows.nuget-cache",
1821 "windows.notion-cache",
1822 "windows.pip-cache",
1823 "windows.poetry-cache",
1824 "windows.conda-cache",
1825 "windows.postman-cache",
1826 "windows.pytorch-cache",
1827 "windows.pnpm-cache",
1828 "windows.rustup-cache",
1829 "windows.sccache-cache",
1830 "windows.slack-cache",
1831 "windows.zoom-logs",
1832 "windows.teamviewer-logs",
1833 "windows.thunderbird-cache",
1834 "windows.vlc-cache",
1835 "windows.steam-cache",
1836 "windows.steam-install-cache",
1837 "windows.steam-install-download-cache",
1838 "windows.steam-install-library-cache",
1839 "windows.steam-install-shader-cache",
1840 "windows.steam-install-logs",
1841 "windows.steam-install-avatar-cache",
1842 "windows.steam-install-stats-cache",
1843 "windows.steam-install-appinfo-cache",
1844 "windows.steam-install-localization-cache",
1845 "windows.steam-install-packageinfo-cache",
1846 "windows.steam-library-downloading-cache",
1847 "windows.steam-library-shader-cache",
1848 "windows.steam-library-temp-cache",
1849 "windows.thumbnail-cache",
1850 "windows.uv-cache",
1851 "windows.vscode-cache",
1852 "windows.wer-reports",
1853 "windows.yarn-cache",
1854 "windows.system-temp",
1855 "windows.prefetch",
1856 "windows.update-download-cache",
1857 "windows.media-player-cache",
1858 ] {
1859 assert!(ids.contains(expected), "missing built-in rule: {expected}");
1860 }
1861 }
1862
1863 #[test]
1864 fn catalog_parser_rejects_unknown_fields() {
1865 let safety_knowledge =
1866 builtin_safety_knowledge().expect("built-in safety catalog should load");
1867 let err = parse_rule_file(
1868 "test.toml",
1869 r#"
1870manifest_version = 1
1871id = "test"
1872category = "system"
1873name = "Test"
1874safety_level = "safe"
1875unexpected = "field"
1876
1877[[platforms]]
1878platform = "windows"
1879
1880[[platforms.targets]]
1881kind = "template"
1882value = "%TEMP%"
1883
1884[provenance]
1885source = "owned"
1886license = "project-owned"
1887notes = "test"
1888"#,
1889 &safety_knowledge,
1890 )
1891 .unwrap_err();
1892
1893 assert!(err.to_string().contains("unknown field"));
1894 }
1895
1896 #[test]
1897 fn catalog_parser_supports_exact_path_targets() {
1898 let rule = parse_single_rule_file(
1899 "test.toml",
1900 r#"
1901manifest_version = 1
1902id = "exact"
1903category = "system"
1904name = "Exact"
1905safety_level = "safe"
1906
1907[[platforms]]
1908platform = "windows"
1909
1910[[platforms.targets]]
1911kind = "exact-path"
1912value = "C:\\Users\\Example\\Cache"
1913
1914[provenance]
1915source = "owned"
1916license = "project-owned"
1917notes = "test"
1918"#,
1919 );
1920
1921 assert_eq!(rule.path_templates.len(), 1);
1922 assert!(matches!(
1923 rule.path_templates[0],
1924 rebecca_core::RuleTargetSpec::ExactPath(_)
1925 ));
1926 }
1927
1928 #[test]
1929 fn catalog_parser_supports_glob_template_targets() {
1930 let rule = parse_single_rule_file(
1931 "test.toml",
1932 r#"
1933manifest_version = 1
1934id = "glob"
1935category = "browser"
1936name = "Glob"
1937safety_level = "safe"
1938
1939[[platforms]]
1940platform = "windows"
1941
1942[[platforms.targets]]
1943kind = "glob-template"
1944value = "%APPDATA%\\Mozilla\\Firefox\\Profiles\\*\\cache2"
1945
1946[provenance]
1947source = "owned"
1948license = "project-owned"
1949notes = "test"
1950"#,
1951 );
1952
1953 assert!(matches!(
1954 rule.path_templates[0],
1955 rebecca_core::RuleTargetSpec::GlobTemplate(_)
1956 ));
1957 }
1958
1959 #[test]
1960 fn catalog_parser_supports_steam_discovery_targets() {
1961 let rule = parse_single_rule_file(
1962 "test.toml",
1963 r#"
1964manifest_version = 1
1965id = "steam-test"
1966category = "application"
1967name = "Steam test"
1968safety_level = "safe"
1969
1970[[platforms]]
1971platform = "windows"
1972
1973[[platforms.targets]]
1974kind = "steam-install-template"
1975value = "appcache\\httpcache"
1976
1977[[platforms.targets]]
1978kind = "steam-library-template"
1979value = "steamapps\\shadercache"
1980
1981[provenance]
1982source = "owned"
1983license = "project-owned"
1984notes = "test"
1985"#,
1986 );
1987
1988 assert!(matches!(
1989 rule.path_templates[0],
1990 rebecca_core::RuleTargetSpec::SteamInstallTemplate(_)
1991 ));
1992 assert!(matches!(
1993 rule.path_templates[1],
1994 rebecca_core::RuleTargetSpec::SteamLibraryTemplate(_)
1995 ));
1996 }
1997
1998 #[test]
1999 fn builtin_catalog_rejects_missing_restore_hints() {
2000 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
2001 id: "windows.test".to_string(),
2002 platform: Platform::Windows,
2003 category: "system".to_string(),
2004 name: "Test".to_string(),
2005 safety_level: SafetyLevel::Safe,
2006 path_templates: vec![RuleTargetSpec::template("%TEMP%")],
2007 restore_hint: None,
2008 warnings: Vec::new(),
2009 provenance: RuleProvenance {
2010 source: RuleSource::Owned,
2011 license: "project-owned".to_string(),
2012 notes: "test".to_string(),
2013 },
2014 }])
2015 .unwrap_err();
2016
2017 assert!(err.to_string().contains("restore hint"));
2018 }
2019
2020 #[test]
2021 fn builtin_catalog_rejects_non_project_owned_licenses() {
2022 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
2023 id: "windows.test".to_string(),
2024 platform: Platform::Windows,
2025 category: "system".to_string(),
2026 name: "Test".to_string(),
2027 safety_level: SafetyLevel::Safe,
2028 path_templates: vec![RuleTargetSpec::template("%TEMP%")],
2029 restore_hint: Some("Regenerated automatically.".to_string()),
2030 warnings: Vec::new(),
2031 provenance: RuleProvenance {
2032 source: RuleSource::Owned,
2033 license: "reference-only".to_string(),
2034 notes: "test".to_string(),
2035 },
2036 }])
2037 .unwrap_err();
2038
2039 assert!(err.to_string().contains("project-owned provenance license"));
2040 }
2041
2042 #[test]
2043 fn catalog_parser_accepts_portable_platform_labels() {
2044 let safety_knowledge =
2045 builtin_safety_knowledge().expect("built-in safety catalog should load");
2046 let rules = parse_rule_file(
2047 "test.toml",
2048 r#"
2049manifest_version = 1
2050id = "test"
2051category = "system"
2052name = "Test"
2053safety_level = "safe"
2054
2055[[platforms]]
2056platform = "linux"
2057
2058[[platforms.targets]]
2059kind = "template"
2060value = "/tmp"
2061
2062[provenance]
2063source = "owned"
2064license = "project-owned"
2065notes = "test"
2066"#,
2067 &safety_knowledge,
2068 )
2069 .expect("portable platform labels should parse");
2070
2071 assert_eq!(rules.len(), 1);
2072 assert_eq!(rules[0].id, "linux.test");
2073 assert_eq!(rules[0].platform, Platform::Linux);
2074 }
2075
2076 #[test]
2077 fn builtin_catalog_rejects_platform_id_prefix_mismatches() {
2078 let err = super::validate_builtin_rule_catalog(&[RuleDefinition {
2079 id: "linux.test".to_string(),
2080 platform: Platform::Windows,
2081 category: "system".to_string(),
2082 name: "Test".to_string(),
2083 safety_level: SafetyLevel::Safe,
2084 path_templates: vec![RuleTargetSpec::template("%TEMP%")],
2085 restore_hint: Some("Regenerated automatically.".to_string()),
2086 warnings: Vec::new(),
2087 provenance: RuleProvenance {
2088 source: RuleSource::Owned,
2089 license: "project-owned".to_string(),
2090 notes: "test".to_string(),
2091 },
2092 }])
2093 .unwrap_err();
2094
2095 assert!(
2096 err.to_string()
2097 .contains("rule id prefix matching platform windows")
2098 );
2099 }
2100
2101 #[test]
2102 fn builtin_catalog_rejects_protected_target_shapes() {
2103 for (target, expected) in [
2104 (
2105 RuleTargetSpec::template("%USERPROFILE%\\.ssh"),
2106 "credentials",
2107 ),
2108 (
2109 RuleTargetSpec::template(
2110 "%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\History",
2111 ),
2112 "browser-private-data",
2113 ),
2114 (
2115 RuleTargetSpec::steam_install_template("userdata"),
2116 "application-durable-data",
2117 ),
2118 (
2119 RuleTargetSpec::steam_library_template("steamapps\\common"),
2120 "application-durable-data",
2121 ),
2122 ] {
2123 let err = super::validate_builtin_rule_catalog(&[rule_with_target(target)])
2124 .expect_err("protected target shape should be rejected");
2125 assert!(
2126 err.to_string().contains(expected),
2127 "{err} should mention {expected}"
2128 );
2129 }
2130 }
2131
2132 #[test]
2133 fn builtin_browser_catalog_accepts_regenerable_cache_boundary_shapes() {
2134 for target in [
2135 RuleTargetSpec::template("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Cache"),
2136 RuleTargetSpec::glob_template(
2137 "%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Profile *\\DawnCache",
2138 ),
2139 RuleTargetSpec::template("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\ShaderCache"),
2140 RuleTargetSpec::template(
2141 "%LOCALAPPDATA%\\Google\\Chrome\\User Data\\component_crx_cache",
2142 ),
2143 RuleTargetSpec::glob_template("%APPDATA%\\Mozilla\\Firefox\\Profiles\\*\\cache2"),
2144 RuleTargetSpec::glob_template("%LOCALAPPDATA%\\Waterfox\\Profiles\\*\\jumpListCache"),
2145 RuleTargetSpec::glob_template("%LOCALAPPDATA%\\Zen\\Profiles\\*\\OfflineCache"),
2146 ] {
2147 super::validate_builtin_rule_catalog(&[browser_rule_with_target(target)])
2148 .expect("browser cache target shape should be accepted");
2149 }
2150 }
2151
2152 #[test]
2153 fn builtin_browser_catalog_rejects_targets_outside_cache_boundary() {
2154 for target in [
2155 RuleTargetSpec::template("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\History"),
2156 RuleTargetSpec::template(
2157 "%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Preferences",
2158 ),
2159 RuleTargetSpec::template("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Default\\Storage"),
2160 RuleTargetSpec::template("%LOCALAPPDATA%\\Google\\Chrome\\User Data\\Local State"),
2161 RuleTargetSpec::glob_template(
2162 "%APPDATA%\\Mozilla\\Firefox\\Profiles\\*\\cookies.sqlite",
2163 ),
2164 RuleTargetSpec::glob_template("%APPDATA%\\Mozilla\\Firefox\\Profiles\\*\\storage"),
2165 ] {
2166 let err = super::validate_builtin_rule_catalog(&[browser_rule_with_target(target)])
2167 .expect_err("browser target outside the cache boundary should be rejected");
2168 assert!(
2169 err.to_string()
2170 .contains("regenerable browser cache boundary"),
2171 "{err}"
2172 );
2173 }
2174 }
2175
2176 fn browser_rule_with_target(target: RuleTargetSpec) -> RuleDefinition {
2177 RuleDefinition {
2178 category: "browser".to_string(),
2179 ..rule_with_target(target)
2180 }
2181 }
2182
2183 fn rule_with_target(target: RuleTargetSpec) -> RuleDefinition {
2184 RuleDefinition {
2185 id: "windows.test".to_string(),
2186 platform: Platform::Windows,
2187 category: "system".to_string(),
2188 name: "Test".to_string(),
2189 safety_level: SafetyLevel::Safe,
2190 path_templates: vec![target],
2191 restore_hint: Some("Regenerated automatically.".to_string()),
2192 warnings: Vec::new(),
2193 provenance: owned_provenance(),
2194 }
2195 }
2196
2197 fn linux_rule_with_target(target: RuleTargetSpec) -> RuleDefinition {
2198 RuleDefinition {
2199 id: "linux.test".to_string(),
2200 platform: Platform::Linux,
2201 ..rule_with_target(target)
2202 }
2203 }
2204
2205 fn macos_rule_with_target(target: RuleTargetSpec) -> RuleDefinition {
2206 RuleDefinition {
2207 id: "macos.test".to_string(),
2208 platform: Platform::Macos,
2209 ..rule_with_target(target)
2210 }
2211 }
2212
2213 fn owned_provenance() -> RuleProvenance {
2214 RuleProvenance {
2215 source: RuleSource::Owned,
2216 license: "project-owned".to_string(),
2217 notes: "test rule".to_string(),
2218 }
2219 }
2220}