Skip to main content

rebecca_rules/
lib.rs

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}