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