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, 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/// Validate that rule-pack `zones` references point at configured boundary
279/// zones.
280///
281/// Zone names come from the boundary config after preset and auto-discover
282/// expansion, which is the same zone set that analysis resolves. The zone
283/// globs are not compiled, and the expansion is skipped when no rule has a
284/// `zones` scope.
285#[must_use]
286pub fn validate_rule_pack_zones(
287    root: &Path,
288    boundaries: &BoundaryConfig,
289    pack_paths: &[String],
290    packs: &[RulePackDef],
291) -> Vec<RulePackError> {
292    if packs
293        .iter()
294        .all(|pack| pack.rules.iter().all(|rule| rule.zones.is_empty()))
295    {
296        return Vec::new();
297    }
298    let zone_names = expanded_zone_names(boundaries.clone(), root);
299    zone_reference_errors(root, pack_paths, packs, &zone_names)
300}
301
302fn expanded_zone_names(mut boundaries: BoundaryConfig, root: &Path) -> Vec<String> {
303    if boundaries.preset.is_some() {
304        let source_root = crate::workspace::parse_tsconfig_root_dir(root)
305            .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
306            .unwrap_or_else(|| "src".to_owned());
307        boundaries.expand(&source_root);
308    }
309    let _logical_groups = boundaries.expand_auto_discover(root);
310    boundaries.zones.into_iter().map(|zone| zone.name).collect()
311}
312
313fn zone_reference_errors(
314    root: &Path,
315    pack_paths: &[String],
316    packs: &[RulePackDef],
317    zone_names: &[String],
318) -> Vec<RulePackError> {
319    let configured_zones: FxHashSet<&str> = zone_names.iter().map(String::as_str).collect();
320    let configured_zone_list = if configured_zones.is_empty() {
321        "none".to_owned()
322    } else {
323        let mut zones: Vec<&str> = configured_zones.iter().copied().collect();
324        zones.sort_unstable();
325        zones.join(", ")
326    };
327
328    let mut errors = Vec::new();
329    for (pack_index, pack) in packs.iter().enumerate() {
330        let path = pack_paths
331            .get(pack_index)
332            .map_or_else(|| root.to_path_buf(), |path| root.join(path));
333        for rule in &pack.rules {
334            if rule.zones.is_empty() {
335                continue;
336            }
337            if configured_zones.is_empty() {
338                errors.push(RulePackError {
339                    path: path.clone(),
340                    message: format!(
341                        "rule '{}': `zones` requires configured boundary zones, but none are configured",
342                        rule.id
343                    ),
344                });
345                continue;
346            }
347            for zone in &rule.zones {
348                if !configured_zones.contains(zone.as_str()) {
349                    errors.push(RulePackError {
350                        path: path.clone(),
351                        message: format!(
352                            "rule '{}': unknown zone '{}' in `zones`; configured zones: {}",
353                            rule.id, zone, configured_zone_list
354                        ),
355                    });
356                }
357            }
358        }
359    }
360    errors
361}
362
363/// Load, validate, and stage a single listed rule pack, collecting any failure.
364fn load_one_rule_pack(
365    root: &Path,
366    path_str: &str,
367    canonical_root: &Path,
368    packs: &mut Vec<RulePackDef>,
369    errors: &mut Vec<RulePackError>,
370) {
371    let path = root.join(path_str);
372    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
373    if !RULE_PACK_EXTENSIONS.contains(&ext) {
374        errors.push(RulePackError {
375            path: path.clone(),
376            message: format!("unsupported rule pack extension '.{ext}'; expected .json or .jsonc"),
377        });
378        return;
379    }
380    let content = match std::fs::read_to_string(&path) {
381        Ok(content) => content,
382        Err(e) => {
383            errors.push(RulePackError {
384                path,
385                message: format!("failed to read rule pack: {e}"),
386            });
387            return;
388        }
389    };
390    // Checked after the read so a missing file reports as missing even on
391    // platforms where the project root itself sits behind a symlink.
392    if !crate::external_plugin::is_within_root(&path, canonical_root) {
393        errors.push(RulePackError {
394            path,
395            message: "resolves outside the project root".to_owned(),
396        });
397        return;
398    }
399    let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
400        crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
401    } else {
402        serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
403    };
404    match parsed {
405        Ok(pack) => {
406            let before = errors.len();
407            validate_pack(&pack, &path, errors);
408            if errors.len() == before {
409                packs.push(pack);
410            }
411        }
412        Err(message) => {
413            errors.push(RulePackError {
414                path,
415                message: format!("failed to parse rule pack: {message}"),
416            });
417        }
418    }
419}
420
421/// Push one error per pack name declared by more than one loaded pack.
422fn push_duplicate_pack_name_errors(
423    root: &Path,
424    packs: &[RulePackDef],
425    errors: &mut Vec<RulePackError>,
426) {
427    let mut seen_names: FxHashSet<&str> = FxHashSet::default();
428    for pack in packs {
429        if !seen_names.insert(pack.name.as_str()) {
430            errors.push(RulePackError {
431                path: root.to_path_buf(),
432                message: format!(
433                    "rule pack name '{}' is declared by more than one pack; pack names must be \
434                     unique because findings are identified as '<pack>/<rule-id>'",
435                    pack.name
436                ),
437            });
438        }
439    }
440}
441
442/// Validate a parsed pack. Pushes one error per problem so a pack with three
443/// bad rules reports all three.
444fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
445    let err = |message: String| RulePackError {
446        path: path.to_path_buf(),
447        message,
448    };
449
450    if pack.version != SUPPORTED_PACK_VERSION {
451        errors.push(err(format!(
452            "unsupported rule pack version {}; this fallow build supports version \
453             {SUPPORTED_PACK_VERSION}",
454            pack.version
455        )));
456    }
457    if pack.name.trim().is_empty() {
458        errors.push(err("pack `name` must not be empty".to_owned()));
459    } else if !is_valid_policy_identifier(&pack.name) {
460        errors.push(err(format!(
461            "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
462            pack.name
463        )));
464    }
465    if pack.rules.is_empty() {
466        errors.push(err(
467            "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
468        ));
469    }
470
471    let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
472    for rule in &pack.rules {
473        if rule.id.trim().is_empty() {
474            errors.push(err("rule `id` must not be empty".to_owned()));
475            continue;
476        }
477        if !is_valid_policy_identifier(&rule.id) {
478            errors.push(err(format!(
479                "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
480                rule.id
481            )));
482            continue;
483        }
484        if !seen_ids.insert(rule.id.as_str()) {
485            errors.push(err(format!(
486                "duplicate rule id '{}'; rule ids must be unique within a pack",
487                rule.id
488            )));
489        }
490        validate_rule(rule, path, errors);
491    }
492}
493
494/// Validate one rule's kind-specific fields and patterns.
495fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
496    let err = |message: String| RulePackError {
497        path: path.to_path_buf(),
498        message: format!("rule '{}': {message}", rule.id),
499    };
500
501    match rule.kind {
502        RulePackRuleKind::BannedCall => validate_banned_call_rule(rule, &err, errors),
503        RulePackRuleKind::BannedImport => validate_banned_import_rule(rule, &err, errors),
504        RulePackRuleKind::BannedEffect => validate_banned_effect_rule(rule, &err, errors),
505        RulePackRuleKind::BannedExport => validate_banned_export_rule(rule, &err, errors),
506    }
507
508    validate_rule_file_globs(rule, &err, errors);
509}
510
511/// Validate a `banned-call` rule's required and cross-kind fields.
512fn validate_banned_call_rule(
513    rule: &RulePackRule,
514    err: &impl Fn(String) -> RulePackError,
515    errors: &mut Vec<RulePackError>,
516) {
517    if rule.callees.is_empty() {
518        errors.push(err(
519            "banned-call rules must list at least one `callees` pattern".to_owned(),
520        ));
521    }
522    if !rule.specifiers.is_empty() {
523        errors.push(err(
524            "`specifiers` applies only to banned-import rules".to_owned()
525        ));
526    }
527    if !rule.effects.is_empty() {
528        errors.push(err(
529            "`effects` applies only to banned-effect rules".to_owned()
530        ));
531    }
532    if !rule.exports.is_empty() {
533        errors.push(err(
534            "`exports` applies only to banned-export rules".to_owned()
535        ));
536    }
537    if rule.ignore_type_only {
538        errors.push(err(
539            "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
540        ));
541    }
542    for pattern in &rule.callees {
543        if let Some(reason) = callee_pattern_error(pattern) {
544            errors.push(err(format!("callee pattern `{pattern}` {reason}")));
545        }
546    }
547}
548
549/// Validate a `banned-import` rule's required and cross-kind fields.
550fn validate_banned_import_rule(
551    rule: &RulePackRule,
552    err: &impl Fn(String) -> RulePackError,
553    errors: &mut Vec<RulePackError>,
554) {
555    if rule.specifiers.is_empty() {
556        errors.push(err(
557            "banned-import rules must list at least one `specifiers` entry".to_owned(),
558        ));
559    }
560    if !rule.callees.is_empty() {
561        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
562    }
563    if !rule.effects.is_empty() {
564        errors.push(err(
565            "`effects` applies only to banned-effect rules".to_owned()
566        ));
567    }
568    if !rule.exports.is_empty() {
569        errors.push(err(
570            "`exports` applies only to banned-export rules".to_owned()
571        ));
572    }
573    for specifier in &rule.specifiers {
574        if specifier.trim().is_empty() {
575            errors.push(err("specifier must not be empty".to_owned()));
576        } else if let Some(prefix) = specifier.strip_suffix("/*") {
577            if prefix.is_empty() || prefix.contains('*') {
578                errors.push(err(format!(
579                    "specifier `{specifier}` contains `*`; specifier matching is segment-aware, \
580                     not glob. Only a single trailing `/*` deep-import form is allowed"
581                )));
582            }
583        } else if specifier.contains('*') {
584            errors.push(err(format!(
585                "specifier `{specifier}` contains `*`; specifier matching is \
586                 segment-aware, not glob. List the package or path prefix; subpaths are \
587                 covered automatically, or use a single trailing `/*` to match subpaths only"
588            )));
589        }
590    }
591}
592
593/// Validate a `banned-effect` rule's required and cross-kind fields.
594fn validate_banned_effect_rule(
595    rule: &RulePackRule,
596    err: &impl Fn(String) -> RulePackError,
597    errors: &mut Vec<RulePackError>,
598) {
599    if rule.effects.is_empty() {
600        errors.push(err(
601            "banned-effect rules must list at least one `effects` entry".to_owned(),
602        ));
603    }
604    if !rule.callees.is_empty() {
605        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
606    }
607    if !rule.specifiers.is_empty() {
608        errors.push(err(
609            "`specifiers` applies only to banned-import rules".to_owned()
610        ));
611    }
612    if !rule.exports.is_empty() {
613        errors.push(err(
614            "`exports` applies only to banned-export rules".to_owned()
615        ));
616    }
617    if rule.ignore_type_only {
618        errors.push(err(
619            "`ignoreTypeOnly` applies only to banned-import and banned-export rules".to_owned(),
620        ));
621    }
622}
623
624/// Validate a `banned-export` rule's required and cross-kind fields.
625fn validate_banned_export_rule(
626    rule: &RulePackRule,
627    err: &impl Fn(String) -> RulePackError,
628    errors: &mut Vec<RulePackError>,
629) {
630    if rule.exports.is_empty() {
631        errors.push(err(
632            "banned-export rules must list at least one `exports` entry".to_owned(),
633        ));
634    }
635    if !rule.callees.is_empty() {
636        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
637    }
638    if !rule.specifiers.is_empty() {
639        errors.push(err(
640            "`specifiers` applies only to banned-import rules".to_owned()
641        ));
642    }
643    if !rule.effects.is_empty() {
644        errors.push(err(
645            "`effects` applies only to banned-effect rules".to_owned()
646        ));
647    }
648    for export in &rule.exports {
649        if export.trim().is_empty() {
650            errors.push(err("export pattern must not be empty".to_owned()));
651        } else if let Some(stripped) = export.strip_suffix('*') {
652            if stripped.is_empty() || stripped.contains('*') {
653                errors.push(err(format!(
654                    "export pattern `{export}` may only use a single trailing `*` after a prefix"
655                )));
656            }
657        } else if export.contains('*') {
658            errors.push(err(format!(
659                "export pattern `{export}` may only use `*` as a single trailing prefix wildcard"
660            )));
661        }
662    }
663}
664
665/// Validate a rule's `files` and `exclude` include/exclude globs.
666fn validate_rule_file_globs(
667    rule: &RulePackRule,
668    err: &impl Fn(String) -> RulePackError,
669    errors: &mut Vec<RulePackError>,
670) {
671    for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
672        for pattern in patterns {
673            if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
674                errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
675            }
676        }
677    }
678}
679
680/// Reject callee patterns the segment-aware matcher cannot honor, using the
681/// same rules as `boundaries.calls.forbidden` (`validate_call_rules`).
682fn callee_pattern_error(pattern: &str) -> Option<String> {
683    let trimmed = pattern.trim();
684    if trimmed.is_empty() {
685        return Some("must not be empty".to_owned());
686    }
687    if trimmed == "*" {
688        return Some(
689            "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
690             `console.*` or `child_process.exec`"
691                .to_owned(),
692        );
693    }
694    if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
695        return Some("contains an empty path segment".to_owned());
696    }
697    crate::config::wildcard_placement_error(trimmed)
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    fn write_pack(dir: &Path, name: &str, content: &str) -> String {
705        std::fs::write(dir.join(name), content).unwrap();
706        name.to_owned()
707    }
708
709    fn valid_pack_json() -> &'static str {
710        r#"{
711            "version": 1,
712            "name": "team-policy",
713            "description": "House rules",
714            "rules": [
715                {
716                    "id": "no-child-process",
717                    "kind": "banned-call",
718                    "callees": ["child_process.*", "execa"],
719                    "files": ["src/**"],
720                    "exclude": ["src/tooling/**"],
721                    "message": "Use the sandboxed runner instead.",
722                    "severity": "error"
723                },
724                {
725                    "id": "no-network",
726                    "kind": "banned-effect",
727                    "effects": ["network"],
728                    "message": "Keep this package side-effect free."
729                },
730                {
731                    "id": "no-moment",
732                    "kind": "banned-import",
733                    "specifiers": ["moment"],
734                    "ignoreTypeOnly": true,
735                    "message": "Use date-fns."
736                }
737            ]
738        }"#
739    }
740
741    #[test]
742    fn loads_valid_json_pack() {
743        let dir = tempfile::tempdir().unwrap();
744        let path = write_pack(dir.path(), "policy.json", valid_pack_json());
745        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
746        assert_eq!(packs.len(), 1);
747        assert_eq!(packs[0].name, "team-policy");
748        assert_eq!(packs[0].rules.len(), 3);
749        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
750        assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
751        assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
752        assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
753        assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
754        assert!(packs[0].rules[2].ignore_type_only);
755        assert_eq!(packs[0].rules[2].severity, None);
756    }
757
758    #[test]
759    fn loads_jsonc_pack_with_comments() {
760        let dir = tempfile::tempdir().unwrap();
761        let path = write_pack(
762            dir.path(),
763            "policy.jsonc",
764            r#"{
765                // why: keep the domain layer pure
766                "version": 1,
767                "name": "jsonc-policy",
768                "rules": [
769                    { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
770                ]
771            }"#,
772        );
773        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
774        assert_eq!(packs[0].name, "jsonc-policy");
775    }
776
777    #[test]
778    fn parses_zone_scoped_rules() {
779        let dir = tempfile::tempdir().unwrap();
780        let path = write_pack(
781            dir.path(),
782            "policy.json",
783            r#"{ "version": 1, "name": "p", "rules": [
784                { "id": "domain-network", "kind": "banned-effect",
785                  "effects": ["network"], "zones": ["domain"] }
786            ] }"#,
787        );
788        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
789        assert_eq!(packs[0].rules[0].zones, vec!["domain"]);
790    }
791
792    #[test]
793    fn validates_rule_pack_zones_against_resolved_boundaries() {
794        let dir = tempfile::tempdir().unwrap();
795        let path = write_pack(
796            dir.path(),
797            "policy.json",
798            r#"{ "version": 1, "name": "p", "rules": [
799                { "id": "domain-network", "kind": "banned-effect",
800                  "effects": ["network"], "zones": ["unknown"] }
801            ] }"#,
802        );
803        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
804        let boundaries = BoundaryConfig {
805            zones: vec![crate::config::BoundaryZone {
806                name: "domain".to_owned(),
807                patterns: vec!["src/domain/**".to_owned()],
808                auto_discover: Vec::new(),
809                root: None,
810            }],
811            ..BoundaryConfig::default()
812        };
813
814        let errors = validate_rule_pack_zones(dir.path(), &boundaries, &[path], &packs);
815        assert_eq!(errors.len(), 1);
816        assert!(errors[0].message.contains("unknown zone 'unknown'"));
817        assert!(errors[0].message.contains("configured zones: domain"));
818    }
819
820    #[test]
821    fn accepts_rule_pack_zones_that_match_configured_zones() {
822        let dir = tempfile::tempdir().unwrap();
823        let path = write_pack(
824            dir.path(),
825            "policy.json",
826            r#"{ "version": 1, "name": "p", "rules": [
827                { "id": "domain-network", "kind": "banned-effect",
828                  "effects": ["network"], "zones": ["domain"] }
829            ] }"#,
830        );
831        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
832        let boundaries = BoundaryConfig {
833            zones: vec![crate::config::BoundaryZone {
834                name: "domain".to_owned(),
835                patterns: vec!["src/domain/**".to_owned()],
836                auto_discover: Vec::new(),
837                root: None,
838            }],
839            ..BoundaryConfig::default()
840        };
841
842        assert!(validate_rule_pack_zones(dir.path(), &boundaries, &[path], &packs).is_empty());
843    }
844
845    #[test]
846    fn rule_packs_without_zone_scopes_need_no_boundaries() {
847        let dir = tempfile::tempdir().unwrap();
848        let path = write_pack(
849            dir.path(),
850            "policy.json",
851            r#"{ "version": 1, "name": "p", "rules": [
852                { "id": "no-network", "kind": "banned-effect", "effects": ["network"] }
853            ] }"#,
854        );
855        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
856
857        assert!(
858            validate_rule_pack_zones(dir.path(), &BoundaryConfig::default(), &[path], &packs)
859                .is_empty()
860        );
861    }
862
863    #[test]
864    fn rejects_rule_pack_zones_when_boundaries_are_empty() {
865        let dir = tempfile::tempdir().unwrap();
866        let path = write_pack(
867            dir.path(),
868            "policy.json",
869            r#"{ "version": 1, "name": "p", "rules": [
870                { "id": "domain-network", "kind": "banned-effect",
871                  "effects": ["network"], "zones": ["domain"] }
872            ] }"#,
873        );
874        let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
875        let errors =
876            validate_rule_pack_zones(dir.path(), &BoundaryConfig::default(), &[path], &packs);
877        assert_eq!(errors.len(), 1);
878        assert!(
879            errors[0]
880                .message
881                .contains("`zones` requires configured boundary zones")
882        );
883    }
884
885    #[test]
886    fn rejects_unsupported_version() {
887        let dir = tempfile::tempdir().unwrap();
888        let path = write_pack(
889            dir.path(),
890            "policy.json",
891            r#"{ "version": 2, "name": "p", "rules": [
892                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
893            ] }"#,
894        );
895        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
896        assert!(
897            errors[0]
898                .message
899                .contains("unsupported rule pack version 2")
900        );
901    }
902
903    #[test]
904    fn rejects_unknown_kind_with_expected_list() {
905        let dir = tempfile::tempdir().unwrap();
906        let path = write_pack(
907            dir.path(),
908            "policy.json",
909            r#"{ "version": 1, "name": "p", "rules": [
910                { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
911            ] }"#,
912        );
913        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
914        assert!(errors[0].message.contains("banned-thing"));
915        assert!(errors[0].message.contains("banned-effect"));
916        assert!(errors[0].message.contains("banned-call"));
917        assert!(errors[0].message.contains("banned-import"));
918        assert!(errors[0].message.contains("banned-export"));
919    }
920
921    #[test]
922    fn rejects_unknown_field() {
923        let dir = tempfile::tempdir().unwrap();
924        let path = write_pack(
925            dir.path(),
926            "policy.json",
927            r#"{ "version": 1, "name": "p", "rules": [
928                { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
929            ] }"#,
930        );
931        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
932        assert!(errors[0].message.contains("file"));
933    }
934
935    #[test]
936    fn rejects_empty_rules_and_empty_pack_name() {
937        let dir = tempfile::tempdir().unwrap();
938        let path = write_pack(
939            dir.path(),
940            "policy.json",
941            r#"{ "version": 1, "name": " ", "rules": [] }"#,
942        );
943        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
944        let joined = errors
945            .iter()
946            .map(|e| e.message.clone())
947            .collect::<Vec<_>>()
948            .join("\n");
949        assert!(joined.contains("declares no rules"));
950        assert!(joined.contains("`name` must not be empty"));
951    }
952
953    #[test]
954    fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
955        let dir = tempfile::tempdir().unwrap();
956        let path = write_pack(
957            dir.path(),
958            "policy.json",
959            r#"{ "version": 1, "name": "team/policy", "rules": [
960                { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
961            ] }"#,
962        );
963        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
964        assert!(errors[0].message.contains("pack `name` 'team/policy'"));
965        assert!(errors[0].message.contains("ASCII letters"));
966    }
967
968    #[test]
969    fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
970        let dir = tempfile::tempdir().unwrap();
971        let path = write_pack(
972            dir.path(),
973            "policy.json",
974            r#"{ "version": 1, "name": "team-policy", "rules": [
975                { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
976            ] }"#,
977        );
978        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
979        assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
980        assert!(errors[0].message.contains("ASCII letters"));
981    }
982
983    #[test]
984    fn rejects_duplicate_rule_ids_within_pack() {
985        let dir = tempfile::tempdir().unwrap();
986        let path = write_pack(
987            dir.path(),
988            "policy.json",
989            r#"{ "version": 1, "name": "p", "rules": [
990                { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
991                { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
992            ] }"#,
993        );
994        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
995        assert!(errors[0].message.contains("duplicate rule id 'a'"));
996    }
997
998    #[test]
999    fn rejects_duplicate_pack_names() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let a = write_pack(
1002            dir.path(),
1003            "a.json",
1004            r#"{ "version": 1, "name": "p", "rules": [
1005                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1006            ] }"#,
1007        );
1008        let b = write_pack(
1009            dir.path(),
1010            "b.json",
1011            r#"{ "version": 1, "name": "p", "rules": [
1012                { "id": "b", "kind": "banned-call", "callees": ["eval"] }
1013            ] }"#,
1014        );
1015        let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
1016        assert!(errors[0].message.contains("rule pack name 'p'"));
1017    }
1018
1019    #[test]
1020    fn rejects_cross_kind_fields() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let path = write_pack(
1023            dir.path(),
1024            "policy.json",
1025            r#"{ "version": 1, "name": "p", "rules": [
1026                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1027                  "specifiers": ["moment"], "effects": ["network"], "exports": ["default"],
1028                  "ignoreTypeOnly": true },
1029                { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
1030                  "callees": ["fetch"], "effects": ["network"], "exports": ["default"] },
1031                { "id": "c", "kind": "banned-effect", "effects": ["network"],
1032                  "callees": ["fetch"], "specifiers": ["moment"], "exports": ["default"],
1033                  "ignoreTypeOnly": true },
1034                { "id": "d", "kind": "banned-export", "exports": ["default"],
1035                  "callees": ["fetch"], "specifiers": ["moment"], "effects": ["network"] }
1036            ] }"#,
1037        );
1038        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1039        let joined = errors
1040            .iter()
1041            .map(|e| e.message.clone())
1042            .collect::<Vec<_>>()
1043            .join("\n");
1044        assert!(joined.contains("`specifiers` applies only to banned-import"));
1045        assert!(
1046            joined.contains("`ignoreTypeOnly` applies only to banned-import and banned-export")
1047        );
1048        assert!(joined.contains("`callees` applies only to banned-call"));
1049        assert!(joined.contains("`effects` applies only to banned-effect"));
1050        assert!(joined.contains("`exports` applies only to banned-export"));
1051    }
1052
1053    #[test]
1054    fn rejects_missing_kind_fields() {
1055        let dir = tempfile::tempdir().unwrap();
1056        let path = write_pack(
1057            dir.path(),
1058            "policy.json",
1059            r#"{ "version": 1, "name": "p", "rules": [
1060                { "id": "a", "kind": "banned-call" },
1061                { "id": "b", "kind": "banned-import" },
1062                { "id": "c", "kind": "banned-effect" },
1063                { "id": "d", "kind": "banned-export" }
1064            ] }"#,
1065        );
1066        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1067        let joined = errors
1068            .iter()
1069            .map(|e| e.message.clone())
1070            .collect::<Vec<_>>()
1071            .join("\n");
1072        assert!(joined.contains("must list at least one `callees` pattern"));
1073        assert!(joined.contains("must list at least one `specifiers` entry"));
1074        assert!(joined.contains("must list at least one `effects` entry"));
1075        assert!(joined.contains("must list at least one `exports` entry"));
1076    }
1077
1078    #[test]
1079    fn loads_banned_export_rule() {
1080        let dir = tempfile::tempdir().unwrap();
1081        let path = write_pack(
1082            dir.path(),
1083            "policy.json",
1084            r#"{ "version": 1, "name": "p", "rules": [
1085                { "id": "no-default", "kind": "banned-export",
1086                  "exports": ["default", "internal*"], "ignoreTypeOnly": true }
1087            ] }"#,
1088        );
1089        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1090        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedExport);
1091        assert_eq!(packs[0].rules[0].exports, vec!["default", "internal*"]);
1092        assert!(packs[0].rules[0].ignore_type_only);
1093    }
1094
1095    #[test]
1096    fn rejects_invalid_banned_export_patterns() {
1097        let dir = tempfile::tempdir().unwrap();
1098        let path = write_pack(
1099            dir.path(),
1100            "policy.json",
1101            r#"{ "version": 1, "name": "p", "rules": [
1102                { "id": "bad", "kind": "banned-export",
1103                  "exports": ["", "*", "a*b"] }
1104            ] }"#,
1105        );
1106        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1107        let joined = errors
1108            .iter()
1109            .map(|e| e.message.clone())
1110            .collect::<Vec<_>>()
1111            .join("\n");
1112        assert!(joined.contains("export pattern must not be empty"));
1113        assert!(joined.contains("may only use a single trailing `*` after a prefix"));
1114        assert!(joined.contains("may only use `*` as a single trailing prefix wildcard"));
1115    }
1116
1117    #[test]
1118    fn rejects_inert_callee_patterns() {
1119        let dir = tempfile::tempdir().unwrap();
1120        let path = write_pack(
1121            dir.path(),
1122            "policy.json",
1123            r#"{ "version": 1, "name": "p", "rules": [
1124                { "id": "a", "kind": "banned-call",
1125                  "callees": ["*", "a..b", "child*", "a.*.b"] }
1126            ] }"#,
1127        );
1128        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1129        assert_eq!(errors.len(), 4);
1130    }
1131
1132    #[test]
1133    fn rejects_glob_specifiers() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let path = write_pack(
1136            dir.path(),
1137            "policy.json",
1138            r#"{ "version": 1, "name": "p", "rules": [
1139                { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
1140            ] }"#,
1141        );
1142        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1143        assert!(errors[0].message.contains("segment-aware, not glob"));
1144    }
1145
1146    #[test]
1147    fn accepts_trailing_star_deep_import_specifier() {
1148        let dir = tempfile::tempdir().unwrap();
1149        let path = write_pack(
1150            dir.path(),
1151            "policy.json",
1152            r#"{ "version": 1, "name": "p", "rules": [
1153                { "id": "no-ui-deep-imports", "kind": "banned-import",
1154                  "specifiers": ["@org/ui/*"] }
1155            ] }"#,
1156        );
1157        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1158        assert_eq!(packs[0].rules[0].specifiers, vec!["@org/ui/*"]);
1159    }
1160
1161    #[test]
1162    fn rejects_non_trailing_star_import_specifier() {
1163        let dir = tempfile::tempdir().unwrap();
1164        let path = write_pack(
1165            dir.path(),
1166            "policy.json",
1167            r#"{ "version": 1, "name": "p", "rules": [
1168                { "id": "bad-deep-imports", "kind": "banned-import",
1169                  "specifiers": ["@org/*/x"] }
1170            ] }"#,
1171        );
1172        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1173        assert!(errors[0].message.contains("single trailing `/*`"));
1174    }
1175
1176    #[test]
1177    fn rejects_traversal_globs() {
1178        let dir = tempfile::tempdir().unwrap();
1179        let path = write_pack(
1180            dir.path(),
1181            "policy.json",
1182            r#"{ "version": 1, "name": "p", "rules": [
1183                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1184                  "files": ["../outside/**"] }
1185            ] }"#,
1186        );
1187        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1188        assert!(errors[0].message.contains("invalid `files` glob"));
1189    }
1190
1191    #[test]
1192    fn rejects_missing_pack_file_and_bad_extension() {
1193        let dir = tempfile::tempdir().unwrap();
1194        write_pack(dir.path(), "policy.toml", "version = 1");
1195        let errors = load_rule_packs(
1196            dir.path(),
1197            &["missing.json".to_owned(), "policy.toml".to_owned()],
1198        )
1199        .unwrap_err();
1200        assert_eq!(errors.len(), 2);
1201        assert!(errors[0].message.contains("failed to read rule pack"));
1202        assert!(
1203            errors[1]
1204                .message
1205                .contains("unsupported rule pack extension")
1206        );
1207    }
1208
1209    #[test]
1210    fn rejects_paths_outside_root() {
1211        let dir = tempfile::tempdir().unwrap();
1212        let inner = dir.path().join("project");
1213        std::fs::create_dir_all(&inner).unwrap();
1214        std::fs::write(
1215            dir.path().join("outside.json"),
1216            r#"{ "version": 1, "name": "p", "rules": [
1217                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1218            ] }"#,
1219        )
1220        .unwrap();
1221        let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
1222        assert!(errors[0].message.contains("outside the project root"));
1223    }
1224
1225    #[test]
1226    fn schema_validates_doc_example_shape() {
1227        let schema = RulePackDef::json_schema();
1228        let properties = schema
1229            .get("properties")
1230            .and_then(|p| p.as_object())
1231            .expect("schema should expose properties");
1232        assert!(properties.contains_key("version"));
1233        assert!(properties.contains_key("name"));
1234        assert!(properties.contains_key("rules"));
1235
1236        // The doc-comment example must parse with the same serde shape the
1237        // schema is generated from.
1238        let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
1239        assert_eq!(pack.version, 1);
1240    }
1241}