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