Skip to main content

fallow_config/
rule_pack.rs

1use std::path::{Path, PathBuf};
2
3use fallow_types::suppress::is_valid_policy_identifier;
4use rustc_hash::FxHashSet;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::config::glob_validation::compile_user_glob;
9use crate::config::{BoundaryConfig, ResolvedBoundaryConfig, Severity};
10
11/// Supported rule-pack file extensions. TOML is intentionally not supported:
12/// JSON Schema autocomplete is the headline authoring feature and TOML
13/// editors do not consume it.
14const RULE_PACK_EXTENSIONS: &[&str] = &["json", "jsonc"];
15
16/// The rule-pack format version this fallow build understands.
17const SUPPORTED_PACK_VERSION: u32 = 1;
18
19/// Which check a rule-pack rule performs.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
21#[serde(rename_all = "kebab-case")]
22pub enum RulePackRuleKind {
23    /// Ban call sites whose callee path matches one of `callees`.
24    BannedCall,
25    /// Ban imports and re-exports whose raw specifier matches one of
26    /// `specifiers`.
27    BannedImport,
28    /// Ban call sites whose catalogue-derived effect matches one of `effects`.
29    BannedEffect,
30    /// Ban exported names that match one of `exports`.
31    BannedExport,
32}
33
34/// Internal side-effect taxonomy derived from security catalogue rows.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
36#[serde(rename_all = "kebab-case")]
37pub enum EffectKind {
38    Pure,
39    Read,
40    Write,
41    Network,
42    Storage,
43    Process,
44    Shell,
45    Crypto,
46    Randomness,
47    Dom,
48    Database,
49    FrameworkCallback,
50    Unknown,
51}
52
53impl EffectKind {
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::Pure => "pure",
58            Self::Read => "read",
59            Self::Write => "write",
60            Self::Network => "network",
61            Self::Storage => "storage",
62            Self::Process => "process",
63            Self::Shell => "shell",
64            Self::Crypto => "crypto",
65            Self::Randomness => "randomness",
66            Self::Dom => "dom",
67            Self::Database => "database",
68            Self::FrameworkCallback => "framework-callback",
69            Self::Unknown => "unknown",
70        }
71    }
72}
73
74/// One declarative policy rule inside a rule pack.
75///
76/// `callees` applies only to `banned-call` rules; `specifiers` and
77/// `ignoreTypeOnly` apply only to `banned-import` rules; `effects` applies
78/// only to `banned-effect` rules; `exports` applies only to `banned-export`
79/// rules. `zones` can scope any rule kind to files classified into one of the
80/// named boundary zones. Setting a field on the wrong kind is a load error
81/// (fail loud, never silently ignore policy).
82#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
83#[serde(deny_unknown_fields, rename_all = "camelCase")]
84pub struct RulePackRule {
85    /// Rule id, unique within the pack. Must use only ASCII letters, digits,
86    /// `.`, `_`, and `-` so `"<pack>/<id>"` is unambiguous in output,
87    /// baselines, and scoped suppression comments.
88    pub id: String,
89    /// Which check this rule performs.
90    pub kind: RulePackRuleKind,
91    /// Callee patterns to ban (`banned-call` only). Matching is segment-aware
92    /// and import-resolved, identical to `boundaries.calls.forbidden`:
93    /// `child_process.*` covers `import { exec } from "node:child_process"`,
94    /// the bare specifier, and namespace/default imports; `fetch` matches only
95    /// the global `fetch`; a leading `*.member` matches any object.
96    #[serde(default, skip_serializing_if = "Vec::is_empty")]
97    pub callees: Vec<String>,
98    /// Import specifiers to ban (`banned-import` only). Matched segment-aware
99    /// against the RAW specifier: `moment` covers `moment` and
100    /// `moment/locale/nl` but not `moment-timezone`. A trailing `/*` form,
101    /// such as `@org/ui/*`, matches subpaths only (`@org/ui/internal`) and
102    /// not the package root (`@org/ui`). Aliased or rewritten specifiers
103    /// (e.g. `npm:moment`) are not matched.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub specifiers: Vec<String>,
106    /// Effect classes to ban (`banned-effect` only). Effects are derived from
107    /// `security_matchers.toml` catalogue rows and matched against captured
108    /// call sites after import-resolution canonicalization.
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub effects: Vec<EffectKind>,
111    /// Export names to ban (`banned-export` only). `"default"` matches the
112    /// default export; any other entry matches an exported name exactly; a
113    /// single trailing `*` makes it a prefix match (`internal*`). No other
114    /// glob syntax is supported. Re-exports are out of scope for this rule.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub exports: Vec<String>,
117    /// When `true`, type-only imports (`import type ...` and type-only
118    /// re-exports) are ignored by `banned-import`; type-only exports are
119    /// ignored by `banned-export`. Defaults to `false`: type-only sites are
120    /// flagged too.
121    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122    pub ignore_type_only: bool,
123    /// Optional include globs (project-root-relative). Empty or absent means
124    /// the rule applies to every analyzed file.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub files: Vec<String>,
127    /// Optional exclude globs (project-root-relative), applied after `files`.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub exclude: Vec<String>,
130    /// Optional boundary zones this rule applies to. Empty or absent means the
131    /// rule applies regardless of zone; non-empty values require matching
132    /// configured boundaries and combine with `files`/`exclude` as AND.
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub zones: Vec<String>,
135    /// Author-provided message naming the sanctioned alternative. Rendered
136    /// next to each finding.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub message: Option<String>,
139    /// Per-rule severity overriding the `rules."policy-violation"` master.
140    /// `off` disables this rule. When the master itself is `off`, the whole
141    /// evaluator is disabled and per-rule severity cannot resurrect it.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub severity: Option<Severity>,
144}
145
146/// A declarative rule pack loaded from a standalone JSON or JSONC file listed
147/// in the `rulePacks` config key.
148///
149/// Rule packs are pure data: loading a pack never executes project code. They
150/// encode project-specific policy (banned calls, banned imports, and
151/// catalogue-backed banned effects) evaluated over fallow's static extraction
152/// data, reporting as `policy-violation`
153/// findings.
154///
155/// ```jsonc
156/// {
157///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/rule-pack-schema.json",
158///   "version": 1,
159///   "name": "team-policy",
160///   "description": "House rules for the platform team",
161///   "rules": [
162///     {
163///       "id": "no-child-process",
164///       "kind": "banned-call",
165///       "callees": ["child_process.*"],
166///       "message": "Use the sandboxed runner instead.",
167///       "severity": "error"
168///     },
169///     {
170///       "id": "no-network",
171///       "kind": "banned-effect",
172///       "effects": ["network"],
173///       "message": "Keep this package side-effect free."
174///     },
175///     {
176///       "id": "no-moment",
177///       "kind": "banned-import",
178///       "specifiers": ["moment"],
179///       "message": "Use date-fns."
180///     }
181///   ]
182/// }
183/// ```
184#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
185#[serde(deny_unknown_fields, rename_all = "camelCase")]
186pub struct RulePackDef {
187    /// JSON Schema reference (ignored during deserialization).
188    #[serde(rename = "$schema", default, skip_serializing)]
189    #[schemars(skip)]
190    pub schema: Option<String>,
191    /// Pack format version. Must be `1`; the field exists so future rule
192    /// kinds can be added without breaking older fallow builds silently.
193    pub version: u32,
194    /// Pack name, unique across all loaded packs. Must use only ASCII
195    /// letters, digits, `.`, `_`, and `-` so `"<pack>/<id>"` is unambiguous in
196    /// output, baselines, and scoped suppression comments.
197    pub name: String,
198    /// Optional human description of the pack's intent.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub description: Option<String>,
201    /// The policy rules this pack enforces. Must be non-empty: an empty pack
202    /// would silently enforce nothing.
203    pub rules: Vec<RulePackRule>,
204}
205
206impl RulePackDef {
207    /// Generate JSON Schema for the rule-pack format (consumed by
208    /// `fallow rule-pack-schema` for editor autocomplete).
209    #[must_use]
210    pub fn json_schema() -> serde_json::Value {
211        serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
212    }
213}
214
215/// One rule-pack load or validation failure, anchored at the offending pack
216/// file.
217#[derive(Debug, Clone)]
218pub struct RulePackError {
219    /// The pack file (as listed in `rulePacks`, root-joined).
220    pub path: PathBuf,
221    /// What went wrong, including the rule id when the error is rule-scoped.
222    pub message: String,
223}
224
225impl std::fmt::Display for RulePackError {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        write!(f, "{}: {}", self.path.display(), self.message)
228    }
229}
230
231/// Load and validate every rule pack listed in the `rulePacks` config key.
232///
233/// Paths are project-root-relative. Every failure is collected (missing file,
234/// unsupported extension, parse error, schema violation) so the user sees all
235/// problems in one run. A pack that fails any check fails the whole load:
236/// silently skipping policy would be worse than failing.
237///
238/// # Errors
239///
240/// Returns the accumulated list of [`RulePackError`] entries when any listed
241/// pack is missing, unparsable, or invalid.
242pub fn load_rule_packs(
243    root: &Path,
244    pack_paths: &[String],
245) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
246    let mut packs = Vec::new();
247    let mut errors = Vec::new();
248    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
249
250    for path_str in pack_paths {
251        load_one_rule_pack(root, path_str, &canonical_root, &mut packs, &mut errors);
252    }
253
254    push_duplicate_pack_name_errors(root, &packs, &mut errors);
255
256    if errors.is_empty() {
257        Ok(packs)
258    } else {
259        Err(errors)
260    }
261}
262
263/// Resolve boundaries in the same shape used by analysis, without loading
264/// rule packs or running discovery.
265#[must_use]
266pub fn resolve_boundaries_for_rule_pack_validation(
267    mut boundaries: BoundaryConfig,
268    root: &Path,
269) -> ResolvedBoundaryConfig {
270    if boundaries.preset.is_some() {
271        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
272            .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
273            .unwrap_or_else(|| "src".to_owned());
274        boundaries.expand(&source_root);
275    }
276    let logical_groups = boundaries.expand_auto_discover(root);
277    let mut resolved = boundaries.resolve();
278    resolved.logical_groups = logical_groups;
279    resolved
280}
281
282/// Validate that rule-pack `zones` references point at resolved boundary zones.
283#[must_use]
284pub fn validate_rule_pack_zone_references(
285    root: &Path,
286    pack_paths: &[String],
287    packs: &[RulePackDef],
288    boundaries: &ResolvedBoundaryConfig,
289) -> Vec<RulePackError> {
290    let configured_zones: FxHashSet<&str> = boundaries
291        .zones
292        .iter()
293        .map(|zone| zone.name.as_str())
294        .collect();
295    let configured_zone_list = if configured_zones.is_empty() {
296        "none".to_owned()
297    } else {
298        let mut zones: Vec<&str> = configured_zones.iter().copied().collect();
299        zones.sort_unstable();
300        zones.join(", ")
301    };
302
303    let mut errors = Vec::new();
304    for (pack_index, pack) in packs.iter().enumerate() {
305        let path = pack_paths
306            .get(pack_index)
307            .map_or_else(|| root.to_path_buf(), |path| root.join(path));
308        for rule in &pack.rules {
309            if rule.zones.is_empty() {
310                continue;
311            }
312            if configured_zones.is_empty() {
313                errors.push(RulePackError {
314                    path: path.clone(),
315                    message: format!(
316                        "rule '{}': `zones` requires configured boundary zones, but none are configured",
317                        rule.id
318                    ),
319                });
320                continue;
321            }
322            for zone in &rule.zones {
323                if !configured_zones.contains(zone.as_str()) {
324                    errors.push(RulePackError {
325                        path: path.clone(),
326                        message: format!(
327                            "rule '{}': unknown zone '{}' in `zones`; configured zones: {}",
328                            rule.id, zone, configured_zone_list
329                        ),
330                    });
331                }
332            }
333        }
334    }
335    errors
336}
337
338/// Load, validate, and stage a single listed rule pack, collecting any failure.
339fn load_one_rule_pack(
340    root: &Path,
341    path_str: &str,
342    canonical_root: &Path,
343    packs: &mut Vec<RulePackDef>,
344    errors: &mut Vec<RulePackError>,
345) {
346    let path = root.join(path_str);
347    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
348    if !RULE_PACK_EXTENSIONS.contains(&ext) {
349        errors.push(RulePackError {
350            path: path.clone(),
351            message: format!("unsupported rule pack extension '.{ext}'; expected .json or .jsonc"),
352        });
353        return;
354    }
355    let content = match std::fs::read_to_string(&path) {
356        Ok(content) => content,
357        Err(e) => {
358            errors.push(RulePackError {
359                path,
360                message: format!("failed to read rule pack: {e}"),
361            });
362            return;
363        }
364    };
365    // Checked after the read so a missing file reports as missing even on
366    // platforms where the project root itself sits behind a symlink.
367    if !crate::external_plugin::is_within_root(&path, canonical_root) {
368        errors.push(RulePackError {
369            path,
370            message: "resolves outside the project root".to_owned(),
371        });
372        return;
373    }
374    let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
375        crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
376    } else {
377        serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
378    };
379    match parsed {
380        Ok(pack) => {
381            let before = errors.len();
382            validate_pack(&pack, &path, errors);
383            if errors.len() == before {
384                packs.push(pack);
385            }
386        }
387        Err(message) => {
388            errors.push(RulePackError {
389                path,
390                message: format!("failed to parse rule pack: {message}"),
391            });
392        }
393    }
394}
395
396/// Push one error per pack name declared by more than one loaded pack.
397fn push_duplicate_pack_name_errors(
398    root: &Path,
399    packs: &[RulePackDef],
400    errors: &mut Vec<RulePackError>,
401) {
402    let mut seen_names: FxHashSet<&str> = FxHashSet::default();
403    for pack in packs {
404        if !seen_names.insert(pack.name.as_str()) {
405            errors.push(RulePackError {
406                path: root.to_path_buf(),
407                message: format!(
408                    "rule pack name '{}' is declared by more than one pack; pack names must be \
409                     unique because findings are identified as '<pack>/<rule-id>'",
410                    pack.name
411                ),
412            });
413        }
414    }
415}
416
417/// Validate a parsed pack. Pushes one error per problem so a pack with three
418/// bad rules reports all three.
419fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
420    let err = |message: String| RulePackError {
421        path: path.to_path_buf(),
422        message,
423    };
424
425    if pack.version != SUPPORTED_PACK_VERSION {
426        errors.push(err(format!(
427            "unsupported rule pack version {}; this fallow build supports version \
428             {SUPPORTED_PACK_VERSION}",
429            pack.version
430        )));
431    }
432    if pack.name.trim().is_empty() {
433        errors.push(err("pack `name` must not be empty".to_owned()));
434    } else if !is_valid_policy_identifier(&pack.name) {
435        errors.push(err(format!(
436            "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
437            pack.name
438        )));
439    }
440    if pack.rules.is_empty() {
441        errors.push(err(
442            "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
443        ));
444    }
445
446    let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
447    for rule in &pack.rules {
448        if rule.id.trim().is_empty() {
449            errors.push(err("rule `id` must not be empty".to_owned()));
450            continue;
451        }
452        if !is_valid_policy_identifier(&rule.id) {
453            errors.push(err(format!(
454                "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
455                rule.id
456            )));
457            continue;
458        }
459        if !seen_ids.insert(rule.id.as_str()) {
460            errors.push(err(format!(
461                "duplicate rule id '{}'; rule ids must be unique within a pack",
462                rule.id
463            )));
464        }
465        validate_rule(rule, path, errors);
466    }
467}
468
469/// Validate one rule's kind-specific fields and patterns.
470fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
471    let err = |message: String| RulePackError {
472        path: path.to_path_buf(),
473        message: format!("rule '{}': {message}", rule.id),
474    };
475
476    match rule.kind {
477        RulePackRuleKind::BannedCall => validate_banned_call_rule(rule, &err, errors),
478        RulePackRuleKind::BannedImport => validate_banned_import_rule(rule, &err, errors),
479        RulePackRuleKind::BannedEffect => validate_banned_effect_rule(rule, &err, errors),
480        RulePackRuleKind::BannedExport => validate_banned_export_rule(rule, &err, errors),
481    }
482
483    validate_rule_file_globs(rule, &err, errors);
484}
485
486/// Validate a `banned-call` rule's required and cross-kind fields.
487fn validate_banned_call_rule(
488    rule: &RulePackRule,
489    err: &impl Fn(String) -> RulePackError,
490    errors: &mut Vec<RulePackError>,
491) {
492    if rule.callees.is_empty() {
493        errors.push(err(
494            "banned-call rules must list at least one `callees` pattern".to_owned(),
495        ));
496    }
497    if !rule.specifiers.is_empty() {
498        errors.push(err(
499            "`specifiers` applies only to banned-import rules".to_owned()
500        ));
501    }
502    if !rule.effects.is_empty() {
503        errors.push(err(
504            "`effects` applies only to banned-effect rules".to_owned()
505        ));
506    }
507    if !rule.exports.is_empty() {
508        errors.push(err(
509            "`exports` applies only to banned-export rules".to_owned()
510        ));
511    }
512    if rule.ignore_type_only {
513        errors.push(err(
514            "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
515        ));
516    }
517    for pattern in &rule.callees {
518        if let Some(reason) = callee_pattern_error(pattern) {
519            errors.push(err(format!("callee pattern `{pattern}` {reason}")));
520        }
521    }
522}
523
524/// Validate a `banned-import` rule's required and cross-kind fields.
525fn validate_banned_import_rule(
526    rule: &RulePackRule,
527    err: &impl Fn(String) -> RulePackError,
528    errors: &mut Vec<RulePackError>,
529) {
530    if rule.specifiers.is_empty() {
531        errors.push(err(
532            "banned-import rules must list at least one `specifiers` entry".to_owned(),
533        ));
534    }
535    if !rule.callees.is_empty() {
536        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
537    }
538    if !rule.effects.is_empty() {
539        errors.push(err(
540            "`effects` applies only to banned-effect rules".to_owned()
541        ));
542    }
543    if !rule.exports.is_empty() {
544        errors.push(err(
545            "`exports` applies only to banned-export rules".to_owned()
546        ));
547    }
548    for specifier in &rule.specifiers {
549        if specifier.trim().is_empty() {
550            errors.push(err("specifier must not be empty".to_owned()));
551        } else if let Some(prefix) = specifier.strip_suffix("/*") {
552            if prefix.is_empty() || prefix.contains('*') {
553                errors.push(err(format!(
554                    "specifier `{specifier}` contains `*`; specifier matching is segment-aware, \
555                     not glob. Only a single trailing `/*` deep-import form is allowed"
556                )));
557            }
558        } else if specifier.contains('*') {
559            errors.push(err(format!(
560                "specifier `{specifier}` contains `*`; specifier matching is \
561                 segment-aware, not glob. List the package or path prefix; subpaths are \
562                 covered automatically, or use a single trailing `/*` to match subpaths only"
563            )));
564        }
565    }
566}
567
568/// Validate a `banned-effect` rule's required and cross-kind fields.
569fn validate_banned_effect_rule(
570    rule: &RulePackRule,
571    err: &impl Fn(String) -> RulePackError,
572    errors: &mut Vec<RulePackError>,
573) {
574    if rule.effects.is_empty() {
575        errors.push(err(
576            "banned-effect rules must list at least one `effects` entry".to_owned(),
577        ));
578    }
579    if !rule.callees.is_empty() {
580        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
581    }
582    if !rule.specifiers.is_empty() {
583        errors.push(err(
584            "`specifiers` applies only to banned-import rules".to_owned()
585        ));
586    }
587    if !rule.exports.is_empty() {
588        errors.push(err(
589            "`exports` applies only to banned-export rules".to_owned()
590        ));
591    }
592    if rule.ignore_type_only {
593        errors.push(err(
594            "`ignoreTypeOnly` applies only to banned-import and banned-export rules".to_owned(),
595        ));
596    }
597}
598
599/// Validate a `banned-export` rule's required and cross-kind fields.
600fn validate_banned_export_rule(
601    rule: &RulePackRule,
602    err: &impl Fn(String) -> RulePackError,
603    errors: &mut Vec<RulePackError>,
604) {
605    if rule.exports.is_empty() {
606        errors.push(err(
607            "banned-export rules must list at least one `exports` entry".to_owned(),
608        ));
609    }
610    if !rule.callees.is_empty() {
611        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
612    }
613    if !rule.specifiers.is_empty() {
614        errors.push(err(
615            "`specifiers` applies only to banned-import rules".to_owned()
616        ));
617    }
618    if !rule.effects.is_empty() {
619        errors.push(err(
620            "`effects` applies only to banned-effect rules".to_owned()
621        ));
622    }
623    for export in &rule.exports {
624        if export.trim().is_empty() {
625            errors.push(err("export pattern must not be empty".to_owned()));
626        } else if let Some(stripped) = export.strip_suffix('*') {
627            if stripped.is_empty() || stripped.contains('*') {
628                errors.push(err(format!(
629                    "export pattern `{export}` may only use a single trailing `*` after a prefix"
630                )));
631            }
632        } else if export.contains('*') {
633            errors.push(err(format!(
634                "export pattern `{export}` may only use `*` as a single trailing prefix wildcard"
635            )));
636        }
637    }
638}
639
640/// Validate a rule's `files` and `exclude` include/exclude globs.
641fn validate_rule_file_globs(
642    rule: &RulePackRule,
643    err: &impl Fn(String) -> RulePackError,
644    errors: &mut Vec<RulePackError>,
645) {
646    for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
647        for pattern in patterns {
648            if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
649                errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
650            }
651        }
652    }
653}
654
655/// Reject callee patterns the segment-aware matcher cannot honor, using the
656/// same rules as `boundaries.calls.forbidden` (`validate_call_rules`).
657fn callee_pattern_error(pattern: &str) -> Option<String> {
658    let trimmed = pattern.trim();
659    if trimmed.is_empty() {
660        return Some("must not be empty".to_owned());
661    }
662    if trimmed == "*" {
663        return Some(
664            "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
665             `console.*` or `child_process.exec`"
666                .to_owned(),
667        );
668    }
669    if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
670        return Some("contains an empty path segment".to_owned());
671    }
672    crate::config::wildcard_placement_error(trimmed)
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    fn write_pack(dir: &Path, name: &str, content: &str) -> String {
680        std::fs::write(dir.join(name), content).unwrap();
681        name.to_owned()
682    }
683
684    fn valid_pack_json() -> &'static str {
685        r#"{
686            "version": 1,
687            "name": "team-policy",
688            "description": "House rules",
689            "rules": [
690                {
691                    "id": "no-child-process",
692                    "kind": "banned-call",
693                    "callees": ["child_process.*", "execa"],
694                    "files": ["src/**"],
695                    "exclude": ["src/tooling/**"],
696                    "message": "Use the sandboxed runner instead.",
697                    "severity": "error"
698                },
699                {
700                    "id": "no-network",
701                    "kind": "banned-effect",
702                    "effects": ["network"],
703                    "message": "Keep this package side-effect free."
704                },
705                {
706                    "id": "no-moment",
707                    "kind": "banned-import",
708                    "specifiers": ["moment"],
709                    "ignoreTypeOnly": true,
710                    "message": "Use date-fns."
711                }
712            ]
713        }"#
714    }
715
716    #[test]
717    fn loads_valid_json_pack() {
718        let dir = tempfile::tempdir().unwrap();
719        let path = write_pack(dir.path(), "policy.json", valid_pack_json());
720        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
721        assert_eq!(packs.len(), 1);
722        assert_eq!(packs[0].name, "team-policy");
723        assert_eq!(packs[0].rules.len(), 3);
724        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
725        assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
726        assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
727        assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
728        assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
729        assert!(packs[0].rules[2].ignore_type_only);
730        assert_eq!(packs[0].rules[2].severity, None);
731    }
732
733    #[test]
734    fn loads_jsonc_pack_with_comments() {
735        let dir = tempfile::tempdir().unwrap();
736        let path = write_pack(
737            dir.path(),
738            "policy.jsonc",
739            r#"{
740                // why: keep the domain layer pure
741                "version": 1,
742                "name": "jsonc-policy",
743                "rules": [
744                    { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
745                ]
746            }"#,
747        );
748        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
749        assert_eq!(packs[0].name, "jsonc-policy");
750    }
751
752    #[test]
753    fn parses_zone_scoped_rules() {
754        let dir = tempfile::tempdir().unwrap();
755        let path = write_pack(
756            dir.path(),
757            "policy.json",
758            r#"{ "version": 1, "name": "p", "rules": [
759                { "id": "domain-network", "kind": "banned-effect",
760                  "effects": ["network"], "zones": ["domain"] }
761            ] }"#,
762        );
763        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
764        assert_eq!(packs[0].rules[0].zones, vec!["domain"]);
765    }
766
767    #[test]
768    fn validates_rule_pack_zones_against_resolved_boundaries() {
769        let dir = tempfile::tempdir().unwrap();
770        let path = write_pack(
771            dir.path(),
772            "policy.json",
773            r#"{ "version": 1, "name": "p", "rules": [
774                { "id": "domain-network", "kind": "banned-effect",
775                  "effects": ["network"], "zones": ["unknown"] }
776            ] }"#,
777        );
778        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
779        let boundaries = BoundaryConfig {
780            zones: vec![crate::config::BoundaryZone {
781                name: "domain".to_owned(),
782                patterns: vec!["src/domain/**".to_owned()],
783                auto_discover: Vec::new(),
784                root: None,
785            }],
786            ..BoundaryConfig::default()
787        }
788        .resolve();
789
790        let errors = validate_rule_pack_zone_references(dir.path(), &[path], &packs, &boundaries);
791        assert_eq!(errors.len(), 1);
792        assert!(errors[0].message.contains("unknown zone 'unknown'"));
793        assert!(errors[0].message.contains("configured zones: domain"));
794    }
795
796    #[test]
797    fn rejects_rule_pack_zones_when_boundaries_are_empty() {
798        let dir = tempfile::tempdir().unwrap();
799        let path = write_pack(
800            dir.path(),
801            "policy.json",
802            r#"{ "version": 1, "name": "p", "rules": [
803                { "id": "domain-network", "kind": "banned-effect",
804                  "effects": ["network"], "zones": ["domain"] }
805            ] }"#,
806        );
807        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
808        let errors = validate_rule_pack_zone_references(
809            dir.path(),
810            &[path],
811            &packs,
812            &ResolvedBoundaryConfig::default(),
813        );
814        assert_eq!(errors.len(), 1);
815        assert!(
816            errors[0]
817                .message
818                .contains("`zones` requires configured boundary zones")
819        );
820    }
821
822    #[test]
823    fn rejects_unsupported_version() {
824        let dir = tempfile::tempdir().unwrap();
825        let path = write_pack(
826            dir.path(),
827            "policy.json",
828            r#"{ "version": 2, "name": "p", "rules": [
829                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
830            ] }"#,
831        );
832        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
833        assert!(
834            errors[0]
835                .message
836                .contains("unsupported rule pack version 2")
837        );
838    }
839
840    #[test]
841    fn rejects_unknown_kind_with_expected_list() {
842        let dir = tempfile::tempdir().unwrap();
843        let path = write_pack(
844            dir.path(),
845            "policy.json",
846            r#"{ "version": 1, "name": "p", "rules": [
847                { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
848            ] }"#,
849        );
850        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
851        assert!(errors[0].message.contains("banned-thing"));
852        assert!(errors[0].message.contains("banned-effect"));
853        assert!(errors[0].message.contains("banned-call"));
854        assert!(errors[0].message.contains("banned-import"));
855        assert!(errors[0].message.contains("banned-export"));
856    }
857
858    #[test]
859    fn rejects_unknown_field() {
860        let dir = tempfile::tempdir().unwrap();
861        let path = write_pack(
862            dir.path(),
863            "policy.json",
864            r#"{ "version": 1, "name": "p", "rules": [
865                { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
866            ] }"#,
867        );
868        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
869        assert!(errors[0].message.contains("file"));
870    }
871
872    #[test]
873    fn rejects_empty_rules_and_empty_pack_name() {
874        let dir = tempfile::tempdir().unwrap();
875        let path = write_pack(
876            dir.path(),
877            "policy.json",
878            r#"{ "version": 1, "name": " ", "rules": [] }"#,
879        );
880        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
881        let joined = errors
882            .iter()
883            .map(|e| e.message.clone())
884            .collect::<Vec<_>>()
885            .join("\n");
886        assert!(joined.contains("declares no rules"));
887        assert!(joined.contains("`name` must not be empty"));
888    }
889
890    #[test]
891    fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
892        let dir = tempfile::tempdir().unwrap();
893        let path = write_pack(
894            dir.path(),
895            "policy.json",
896            r#"{ "version": 1, "name": "team/policy", "rules": [
897                { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
898            ] }"#,
899        );
900        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
901        assert!(errors[0].message.contains("pack `name` 'team/policy'"));
902        assert!(errors[0].message.contains("ASCII letters"));
903    }
904
905    #[test]
906    fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
907        let dir = tempfile::tempdir().unwrap();
908        let path = write_pack(
909            dir.path(),
910            "policy.json",
911            r#"{ "version": 1, "name": "team-policy", "rules": [
912                { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
913            ] }"#,
914        );
915        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
916        assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
917        assert!(errors[0].message.contains("ASCII letters"));
918    }
919
920    #[test]
921    fn rejects_duplicate_rule_ids_within_pack() {
922        let dir = tempfile::tempdir().unwrap();
923        let path = write_pack(
924            dir.path(),
925            "policy.json",
926            r#"{ "version": 1, "name": "p", "rules": [
927                { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
928                { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
929            ] }"#,
930        );
931        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
932        assert!(errors[0].message.contains("duplicate rule id 'a'"));
933    }
934
935    #[test]
936    fn rejects_duplicate_pack_names() {
937        let dir = tempfile::tempdir().unwrap();
938        let a = write_pack(
939            dir.path(),
940            "a.json",
941            r#"{ "version": 1, "name": "p", "rules": [
942                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
943            ] }"#,
944        );
945        let b = write_pack(
946            dir.path(),
947            "b.json",
948            r#"{ "version": 1, "name": "p", "rules": [
949                { "id": "b", "kind": "banned-call", "callees": ["eval"] }
950            ] }"#,
951        );
952        let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
953        assert!(errors[0].message.contains("rule pack name 'p'"));
954    }
955
956    #[test]
957    fn rejects_cross_kind_fields() {
958        let dir = tempfile::tempdir().unwrap();
959        let path = write_pack(
960            dir.path(),
961            "policy.json",
962            r#"{ "version": 1, "name": "p", "rules": [
963                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
964                  "specifiers": ["moment"], "effects": ["network"], "exports": ["default"],
965                  "ignoreTypeOnly": true },
966                { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
967                  "callees": ["fetch"], "effects": ["network"], "exports": ["default"] },
968                { "id": "c", "kind": "banned-effect", "effects": ["network"],
969                  "callees": ["fetch"], "specifiers": ["moment"], "exports": ["default"],
970                  "ignoreTypeOnly": true },
971                { "id": "d", "kind": "banned-export", "exports": ["default"],
972                  "callees": ["fetch"], "specifiers": ["moment"], "effects": ["network"] }
973            ] }"#,
974        );
975        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
976        let joined = errors
977            .iter()
978            .map(|e| e.message.clone())
979            .collect::<Vec<_>>()
980            .join("\n");
981        assert!(joined.contains("`specifiers` applies only to banned-import"));
982        assert!(
983            joined.contains("`ignoreTypeOnly` applies only to banned-import and banned-export")
984        );
985        assert!(joined.contains("`callees` applies only to banned-call"));
986        assert!(joined.contains("`effects` applies only to banned-effect"));
987        assert!(joined.contains("`exports` applies only to banned-export"));
988    }
989
990    #[test]
991    fn rejects_missing_kind_fields() {
992        let dir = tempfile::tempdir().unwrap();
993        let path = write_pack(
994            dir.path(),
995            "policy.json",
996            r#"{ "version": 1, "name": "p", "rules": [
997                { "id": "a", "kind": "banned-call" },
998                { "id": "b", "kind": "banned-import" },
999                { "id": "c", "kind": "banned-effect" },
1000                { "id": "d", "kind": "banned-export" }
1001            ] }"#,
1002        );
1003        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1004        let joined = errors
1005            .iter()
1006            .map(|e| e.message.clone())
1007            .collect::<Vec<_>>()
1008            .join("\n");
1009        assert!(joined.contains("must list at least one `callees` pattern"));
1010        assert!(joined.contains("must list at least one `specifiers` entry"));
1011        assert!(joined.contains("must list at least one `effects` entry"));
1012        assert!(joined.contains("must list at least one `exports` entry"));
1013    }
1014
1015    #[test]
1016    fn loads_banned_export_rule() {
1017        let dir = tempfile::tempdir().unwrap();
1018        let path = write_pack(
1019            dir.path(),
1020            "policy.json",
1021            r#"{ "version": 1, "name": "p", "rules": [
1022                { "id": "no-default", "kind": "banned-export",
1023                  "exports": ["default", "internal*"], "ignoreTypeOnly": true }
1024            ] }"#,
1025        );
1026        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1027        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedExport);
1028        assert_eq!(packs[0].rules[0].exports, vec!["default", "internal*"]);
1029        assert!(packs[0].rules[0].ignore_type_only);
1030    }
1031
1032    #[test]
1033    fn rejects_invalid_banned_export_patterns() {
1034        let dir = tempfile::tempdir().unwrap();
1035        let path = write_pack(
1036            dir.path(),
1037            "policy.json",
1038            r#"{ "version": 1, "name": "p", "rules": [
1039                { "id": "bad", "kind": "banned-export",
1040                  "exports": ["", "*", "a*b"] }
1041            ] }"#,
1042        );
1043        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1044        let joined = errors
1045            .iter()
1046            .map(|e| e.message.clone())
1047            .collect::<Vec<_>>()
1048            .join("\n");
1049        assert!(joined.contains("export pattern must not be empty"));
1050        assert!(joined.contains("may only use a single trailing `*` after a prefix"));
1051        assert!(joined.contains("may only use `*` as a single trailing prefix wildcard"));
1052    }
1053
1054    #[test]
1055    fn rejects_inert_callee_patterns() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let path = write_pack(
1058            dir.path(),
1059            "policy.json",
1060            r#"{ "version": 1, "name": "p", "rules": [
1061                { "id": "a", "kind": "banned-call",
1062                  "callees": ["*", "a..b", "child*", "a.*.b"] }
1063            ] }"#,
1064        );
1065        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1066        assert_eq!(errors.len(), 4);
1067    }
1068
1069    #[test]
1070    fn rejects_glob_specifiers() {
1071        let dir = tempfile::tempdir().unwrap();
1072        let path = write_pack(
1073            dir.path(),
1074            "policy.json",
1075            r#"{ "version": 1, "name": "p", "rules": [
1076                { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
1077            ] }"#,
1078        );
1079        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1080        assert!(errors[0].message.contains("segment-aware, not glob"));
1081    }
1082
1083    #[test]
1084    fn accepts_trailing_star_deep_import_specifier() {
1085        let dir = tempfile::tempdir().unwrap();
1086        let path = write_pack(
1087            dir.path(),
1088            "policy.json",
1089            r#"{ "version": 1, "name": "p", "rules": [
1090                { "id": "no-ui-deep-imports", "kind": "banned-import",
1091                  "specifiers": ["@org/ui/*"] }
1092            ] }"#,
1093        );
1094        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1095        assert_eq!(packs[0].rules[0].specifiers, vec!["@org/ui/*"]);
1096    }
1097
1098    #[test]
1099    fn rejects_non_trailing_star_import_specifier() {
1100        let dir = tempfile::tempdir().unwrap();
1101        let path = write_pack(
1102            dir.path(),
1103            "policy.json",
1104            r#"{ "version": 1, "name": "p", "rules": [
1105                { "id": "bad-deep-imports", "kind": "banned-import",
1106                  "specifiers": ["@org/*/x"] }
1107            ] }"#,
1108        );
1109        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1110        assert!(errors[0].message.contains("single trailing `/*`"));
1111    }
1112
1113    #[test]
1114    fn rejects_traversal_globs() {
1115        let dir = tempfile::tempdir().unwrap();
1116        let path = write_pack(
1117            dir.path(),
1118            "policy.json",
1119            r#"{ "version": 1, "name": "p", "rules": [
1120                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1121                  "files": ["../outside/**"] }
1122            ] }"#,
1123        );
1124        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1125        assert!(errors[0].message.contains("invalid `files` glob"));
1126    }
1127
1128    #[test]
1129    fn rejects_missing_pack_file_and_bad_extension() {
1130        let dir = tempfile::tempdir().unwrap();
1131        write_pack(dir.path(), "policy.toml", "version = 1");
1132        let errors = load_rule_packs(
1133            dir.path(),
1134            &["missing.json".to_owned(), "policy.toml".to_owned()],
1135        )
1136        .unwrap_err();
1137        assert_eq!(errors.len(), 2);
1138        assert!(errors[0].message.contains("failed to read rule pack"));
1139        assert!(
1140            errors[1]
1141                .message
1142                .contains("unsupported rule pack extension")
1143        );
1144    }
1145
1146    #[test]
1147    fn rejects_paths_outside_root() {
1148        let dir = tempfile::tempdir().unwrap();
1149        let inner = dir.path().join("project");
1150        std::fs::create_dir_all(&inner).unwrap();
1151        std::fs::write(
1152            dir.path().join("outside.json"),
1153            r#"{ "version": 1, "name": "p", "rules": [
1154                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1155            ] }"#,
1156        )
1157        .unwrap();
1158        let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
1159        assert!(errors[0].message.contains("outside the project root"));
1160    }
1161
1162    #[test]
1163    fn schema_validates_doc_example_shape() {
1164        let schema = RulePackDef::json_schema();
1165        let properties = schema
1166            .get("properties")
1167            .and_then(|p| p.as_object())
1168            .expect("schema should expose properties");
1169        assert!(properties.contains_key("version"));
1170        assert!(properties.contains_key("name"));
1171        assert!(properties.contains_key("rules"));
1172
1173        // The doc-comment example must parse with the same serde shape the
1174        // schema is generated from.
1175        let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
1176        assert_eq!(pack.version, 1);
1177    }
1178}