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