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::Severity;
9use crate::config::glob_validation::compile_user_glob;
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}
31
32/// Internal side-effect taxonomy derived from security catalogue rows.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
34#[serde(rename_all = "kebab-case")]
35pub enum EffectKind {
36    Pure,
37    Read,
38    Write,
39    Network,
40    Storage,
41    Process,
42    Shell,
43    Crypto,
44    Randomness,
45    Dom,
46    Database,
47    FrameworkCallback,
48    Unknown,
49}
50
51impl EffectKind {
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Pure => "pure",
56            Self::Read => "read",
57            Self::Write => "write",
58            Self::Network => "network",
59            Self::Storage => "storage",
60            Self::Process => "process",
61            Self::Shell => "shell",
62            Self::Crypto => "crypto",
63            Self::Randomness => "randomness",
64            Self::Dom => "dom",
65            Self::Database => "database",
66            Self::FrameworkCallback => "framework-callback",
67            Self::Unknown => "unknown",
68        }
69    }
70}
71
72/// One declarative policy rule inside a rule pack.
73///
74/// `callees` applies only to `banned-call` rules; `specifiers` and
75/// `ignoreTypeOnly` apply only to `banned-import` rules; `effects` applies
76/// only to `banned-effect` rules. Setting a field on the wrong kind is a load
77/// error (fail loud, never silently ignore policy).
78#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
79#[serde(deny_unknown_fields, rename_all = "camelCase")]
80pub struct RulePackRule {
81    /// Rule id, unique within the pack. Must use only ASCII letters, digits,
82    /// `.`, `_`, and `-` so `"<pack>/<id>"` is unambiguous in output,
83    /// baselines, and scoped suppression comments.
84    pub id: String,
85    /// Which check this rule performs.
86    pub kind: RulePackRuleKind,
87    /// Callee patterns to ban (`banned-call` only). Matching is segment-aware
88    /// and import-resolved, identical to `boundaries.calls.forbidden`:
89    /// `child_process.*` covers `import { exec } from "node:child_process"`,
90    /// the bare specifier, and namespace/default imports; `fetch` matches only
91    /// the global `fetch`; a leading `*.member` matches any object.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub callees: Vec<String>,
94    /// Import specifiers to ban (`banned-import` only). Matched segment-aware
95    /// against the RAW specifier: `moment` covers `moment` and
96    /// `moment/locale/nl` but not `moment-timezone`. Aliased or rewritten
97    /// specifiers (e.g. `npm:moment`) are not matched.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub specifiers: Vec<String>,
100    /// Effect classes to ban (`banned-effect` only). Effects are derived from
101    /// `security_matchers.toml` catalogue rows and matched against captured
102    /// call sites after import-resolution canonicalization.
103    #[serde(default, skip_serializing_if = "Vec::is_empty")]
104    pub effects: Vec<EffectKind>,
105    /// When `true`, type-only imports (`import type ...` and type-only
106    /// re-exports) are ignored by this `banned-import` rule. Defaults to
107    /// `false`: type-only imports are flagged too.
108    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109    pub ignore_type_only: bool,
110    /// Optional include globs (project-root-relative). Empty or absent means
111    /// the rule applies to every analyzed file.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub files: Vec<String>,
114    /// Optional exclude globs (project-root-relative), applied after `files`.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub exclude: Vec<String>,
117    /// Author-provided message naming the sanctioned alternative. Rendered
118    /// next to each finding.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub message: Option<String>,
121    /// Per-rule severity overriding the `rules."policy-violation"` master.
122    /// `off` disables this rule. When the master itself is `off`, the whole
123    /// evaluator is disabled and per-rule severity cannot resurrect it.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub severity: Option<Severity>,
126}
127
128/// A declarative rule pack loaded from a standalone JSON or JSONC file listed
129/// in the `rulePacks` config key.
130///
131/// Rule packs are pure data: loading a pack never executes project code. They
132/// encode project-specific policy (banned calls, banned imports, and
133/// catalogue-backed banned effects) evaluated over fallow's static extraction
134/// data, reporting as `policy-violation`
135/// findings.
136///
137/// ```jsonc
138/// {
139///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/rule-pack-schema.json",
140///   "version": 1,
141///   "name": "team-policy",
142///   "description": "House rules for the platform team",
143///   "rules": [
144///     {
145///       "id": "no-child-process",
146///       "kind": "banned-call",
147///       "callees": ["child_process.*"],
148///       "message": "Use the sandboxed runner instead.",
149///       "severity": "error"
150///     },
151///     {
152///       "id": "no-network",
153///       "kind": "banned-effect",
154///       "effects": ["network"],
155///       "message": "Keep this package side-effect free."
156///     },
157///     {
158///       "id": "no-moment",
159///       "kind": "banned-import",
160///       "specifiers": ["moment"],
161///       "message": "Use date-fns."
162///     }
163///   ]
164/// }
165/// ```
166#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
167#[serde(deny_unknown_fields, rename_all = "camelCase")]
168pub struct RulePackDef {
169    /// JSON Schema reference (ignored during deserialization).
170    #[serde(rename = "$schema", default, skip_serializing)]
171    #[schemars(skip)]
172    pub schema: Option<String>,
173    /// Pack format version. Must be `1`; the field exists so future rule
174    /// kinds can be added without breaking older fallow builds silently.
175    pub version: u32,
176    /// Pack name, unique across all loaded packs. Must use only ASCII
177    /// letters, digits, `.`, `_`, and `-` so `"<pack>/<id>"` is unambiguous in
178    /// output, baselines, and scoped suppression comments.
179    pub name: String,
180    /// Optional human description of the pack's intent.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub description: Option<String>,
183    /// The policy rules this pack enforces. Must be non-empty: an empty pack
184    /// would silently enforce nothing.
185    pub rules: Vec<RulePackRule>,
186}
187
188impl RulePackDef {
189    /// Generate JSON Schema for the rule-pack format (consumed by
190    /// `fallow rule-pack-schema` for editor autocomplete).
191    #[must_use]
192    pub fn json_schema() -> serde_json::Value {
193        serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
194    }
195}
196
197/// One rule-pack load or validation failure, anchored at the offending pack
198/// file.
199#[derive(Debug, Clone)]
200pub struct RulePackError {
201    /// The pack file (as listed in `rulePacks`, root-joined).
202    pub path: PathBuf,
203    /// What went wrong, including the rule id when the error is rule-scoped.
204    pub message: String,
205}
206
207impl std::fmt::Display for RulePackError {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(f, "{}: {}", self.path.display(), self.message)
210    }
211}
212
213/// Load and validate every rule pack listed in the `rulePacks` config key.
214///
215/// Paths are project-root-relative. Every failure is collected (missing file,
216/// unsupported extension, parse error, schema violation) so the user sees all
217/// problems in one run. A pack that fails any check fails the whole load:
218/// silently skipping policy would be worse than failing.
219///
220/// # Errors
221///
222/// Returns the accumulated list of [`RulePackError`] entries when any listed
223/// pack is missing, unparsable, or invalid.
224pub fn load_rule_packs(
225    root: &Path,
226    pack_paths: &[String],
227) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
228    let mut packs = Vec::new();
229    let mut errors = Vec::new();
230    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
231
232    for path_str in pack_paths {
233        load_one_rule_pack(root, path_str, &canonical_root, &mut packs, &mut errors);
234    }
235
236    push_duplicate_pack_name_errors(root, &packs, &mut errors);
237
238    if errors.is_empty() {
239        Ok(packs)
240    } else {
241        Err(errors)
242    }
243}
244
245/// Load, validate, and stage a single listed rule pack, collecting any failure.
246fn load_one_rule_pack(
247    root: &Path,
248    path_str: &str,
249    canonical_root: &Path,
250    packs: &mut Vec<RulePackDef>,
251    errors: &mut Vec<RulePackError>,
252) {
253    let path = root.join(path_str);
254    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
255    if !RULE_PACK_EXTENSIONS.contains(&ext) {
256        errors.push(RulePackError {
257            path: path.clone(),
258            message: format!("unsupported rule pack extension '.{ext}'; expected .json or .jsonc"),
259        });
260        return;
261    }
262    let content = match std::fs::read_to_string(&path) {
263        Ok(content) => content,
264        Err(e) => {
265            errors.push(RulePackError {
266                path,
267                message: format!("failed to read rule pack: {e}"),
268            });
269            return;
270        }
271    };
272    // Checked after the read so a missing file reports as missing even on
273    // platforms where the project root itself sits behind a symlink.
274    if !crate::external_plugin::is_within_root(&path, canonical_root) {
275        errors.push(RulePackError {
276            path,
277            message: "resolves outside the project root".to_owned(),
278        });
279        return;
280    }
281    let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
282        crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
283    } else {
284        serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
285    };
286    match parsed {
287        Ok(pack) => {
288            let before = errors.len();
289            validate_pack(&pack, &path, errors);
290            if errors.len() == before {
291                packs.push(pack);
292            }
293        }
294        Err(message) => {
295            errors.push(RulePackError {
296                path,
297                message: format!("failed to parse rule pack: {message}"),
298            });
299        }
300    }
301}
302
303/// Push one error per pack name declared by more than one loaded pack.
304fn push_duplicate_pack_name_errors(
305    root: &Path,
306    packs: &[RulePackDef],
307    errors: &mut Vec<RulePackError>,
308) {
309    let mut seen_names: FxHashSet<&str> = FxHashSet::default();
310    for pack in packs {
311        if !seen_names.insert(pack.name.as_str()) {
312            errors.push(RulePackError {
313                path: root.to_path_buf(),
314                message: format!(
315                    "rule pack name '{}' is declared by more than one pack; pack names must be \
316                     unique because findings are identified as '<pack>/<rule-id>'",
317                    pack.name
318                ),
319            });
320        }
321    }
322}
323
324/// Validate a parsed pack. Pushes one error per problem so a pack with three
325/// bad rules reports all three.
326fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
327    let err = |message: String| RulePackError {
328        path: path.to_path_buf(),
329        message,
330    };
331
332    if pack.version != SUPPORTED_PACK_VERSION {
333        errors.push(err(format!(
334            "unsupported rule pack version {}; this fallow build supports version \
335             {SUPPORTED_PACK_VERSION}",
336            pack.version
337        )));
338    }
339    if pack.name.trim().is_empty() {
340        errors.push(err("pack `name` must not be empty".to_owned()));
341    } else if !is_valid_policy_identifier(&pack.name) {
342        errors.push(err(format!(
343            "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
344            pack.name
345        )));
346    }
347    if pack.rules.is_empty() {
348        errors.push(err(
349            "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
350        ));
351    }
352
353    let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
354    for rule in &pack.rules {
355        if rule.id.trim().is_empty() {
356            errors.push(err("rule `id` must not be empty".to_owned()));
357            continue;
358        }
359        if !is_valid_policy_identifier(&rule.id) {
360            errors.push(err(format!(
361                "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
362                rule.id
363            )));
364            continue;
365        }
366        if !seen_ids.insert(rule.id.as_str()) {
367            errors.push(err(format!(
368                "duplicate rule id '{}'; rule ids must be unique within a pack",
369                rule.id
370            )));
371        }
372        validate_rule(rule, path, errors);
373    }
374}
375
376/// Validate one rule's kind-specific fields and patterns.
377fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
378    let err = |message: String| RulePackError {
379        path: path.to_path_buf(),
380        message: format!("rule '{}': {message}", rule.id),
381    };
382
383    match rule.kind {
384        RulePackRuleKind::BannedCall => validate_banned_call_rule(rule, &err, errors),
385        RulePackRuleKind::BannedImport => validate_banned_import_rule(rule, &err, errors),
386        RulePackRuleKind::BannedEffect => validate_banned_effect_rule(rule, &err, errors),
387    }
388
389    validate_rule_file_globs(rule, &err, errors);
390}
391
392/// Validate a `banned-call` rule's required and cross-kind fields.
393fn validate_banned_call_rule(
394    rule: &RulePackRule,
395    err: &impl Fn(String) -> RulePackError,
396    errors: &mut Vec<RulePackError>,
397) {
398    if rule.callees.is_empty() {
399        errors.push(err(
400            "banned-call rules must list at least one `callees` pattern".to_owned(),
401        ));
402    }
403    if !rule.specifiers.is_empty() {
404        errors.push(err(
405            "`specifiers` applies only to banned-import rules".to_owned()
406        ));
407    }
408    if !rule.effects.is_empty() {
409        errors.push(err(
410            "`effects` applies only to banned-effect rules".to_owned()
411        ));
412    }
413    if rule.ignore_type_only {
414        errors.push(err(
415            "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
416        ));
417    }
418    for pattern in &rule.callees {
419        if let Some(reason) = callee_pattern_error(pattern) {
420            errors.push(err(format!("callee pattern `{pattern}` {reason}")));
421        }
422    }
423}
424
425/// Validate a `banned-import` rule's required and cross-kind fields.
426fn validate_banned_import_rule(
427    rule: &RulePackRule,
428    err: &impl Fn(String) -> RulePackError,
429    errors: &mut Vec<RulePackError>,
430) {
431    if rule.specifiers.is_empty() {
432        errors.push(err(
433            "banned-import rules must list at least one `specifiers` entry".to_owned(),
434        ));
435    }
436    if !rule.callees.is_empty() {
437        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
438    }
439    if !rule.effects.is_empty() {
440        errors.push(err(
441            "`effects` applies only to banned-effect rules".to_owned()
442        ));
443    }
444    for specifier in &rule.specifiers {
445        if specifier.trim().is_empty() {
446            errors.push(err("specifier must not be empty".to_owned()));
447        } else if specifier.contains('*') {
448            errors.push(err(format!(
449                "specifier `{specifier}` contains `*`; specifier matching is \
450                 segment-aware, not glob. List the package or path prefix; subpaths are \
451                 covered automatically"
452            )));
453        }
454    }
455}
456
457/// Validate a `banned-effect` rule's required and cross-kind fields.
458fn validate_banned_effect_rule(
459    rule: &RulePackRule,
460    err: &impl Fn(String) -> RulePackError,
461    errors: &mut Vec<RulePackError>,
462) {
463    if rule.effects.is_empty() {
464        errors.push(err(
465            "banned-effect rules must list at least one `effects` entry".to_owned(),
466        ));
467    }
468    if !rule.callees.is_empty() {
469        errors.push(err("`callees` applies only to banned-call rules".to_owned()));
470    }
471    if !rule.specifiers.is_empty() {
472        errors.push(err(
473            "`specifiers` applies only to banned-import rules".to_owned()
474        ));
475    }
476    if rule.ignore_type_only {
477        errors.push(err(
478            "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
479        ));
480    }
481}
482
483/// Validate a rule's `files` and `exclude` include/exclude globs.
484fn validate_rule_file_globs(
485    rule: &RulePackRule,
486    err: &impl Fn(String) -> RulePackError,
487    errors: &mut Vec<RulePackError>,
488) {
489    for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
490        for pattern in patterns {
491            if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
492                errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
493            }
494        }
495    }
496}
497
498/// Reject callee patterns the segment-aware matcher cannot honor, using the
499/// same rules as `boundaries.calls.forbidden` (`validate_call_rules`).
500fn callee_pattern_error(pattern: &str) -> Option<String> {
501    let trimmed = pattern.trim();
502    if trimmed.is_empty() {
503        return Some("must not be empty".to_owned());
504    }
505    if trimmed == "*" {
506        return Some(
507            "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
508             `console.*` or `child_process.exec`"
509                .to_owned(),
510        );
511    }
512    if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
513        return Some("contains an empty path segment".to_owned());
514    }
515    crate::config::wildcard_placement_error(trimmed)
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    fn write_pack(dir: &Path, name: &str, content: &str) -> String {
523        std::fs::write(dir.join(name), content).unwrap();
524        name.to_owned()
525    }
526
527    fn valid_pack_json() -> &'static str {
528        r#"{
529            "version": 1,
530            "name": "team-policy",
531            "description": "House rules",
532            "rules": [
533                {
534                    "id": "no-child-process",
535                    "kind": "banned-call",
536                    "callees": ["child_process.*", "execa"],
537                    "files": ["src/**"],
538                    "exclude": ["src/tooling/**"],
539                    "message": "Use the sandboxed runner instead.",
540                    "severity": "error"
541                },
542                {
543                    "id": "no-network",
544                    "kind": "banned-effect",
545                    "effects": ["network"],
546                    "message": "Keep this package side-effect free."
547                },
548                {
549                    "id": "no-moment",
550                    "kind": "banned-import",
551                    "specifiers": ["moment"],
552                    "ignoreTypeOnly": true,
553                    "message": "Use date-fns."
554                }
555            ]
556        }"#
557    }
558
559    #[test]
560    fn loads_valid_json_pack() {
561        let dir = tempfile::tempdir().unwrap();
562        let path = write_pack(dir.path(), "policy.json", valid_pack_json());
563        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
564        assert_eq!(packs.len(), 1);
565        assert_eq!(packs[0].name, "team-policy");
566        assert_eq!(packs[0].rules.len(), 3);
567        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
568        assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
569        assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
570        assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
571        assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
572        assert!(packs[0].rules[2].ignore_type_only);
573        assert_eq!(packs[0].rules[2].severity, None);
574    }
575
576    #[test]
577    fn loads_jsonc_pack_with_comments() {
578        let dir = tempfile::tempdir().unwrap();
579        let path = write_pack(
580            dir.path(),
581            "policy.jsonc",
582            r#"{
583                // why: keep the domain layer pure
584                "version": 1,
585                "name": "jsonc-policy",
586                "rules": [
587                    { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
588                ]
589            }"#,
590        );
591        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
592        assert_eq!(packs[0].name, "jsonc-policy");
593    }
594
595    #[test]
596    fn rejects_unsupported_version() {
597        let dir = tempfile::tempdir().unwrap();
598        let path = write_pack(
599            dir.path(),
600            "policy.json",
601            r#"{ "version": 2, "name": "p", "rules": [
602                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
603            ] }"#,
604        );
605        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
606        assert!(
607            errors[0]
608                .message
609                .contains("unsupported rule pack version 2")
610        );
611    }
612
613    #[test]
614    fn rejects_unknown_kind_with_expected_list() {
615        let dir = tempfile::tempdir().unwrap();
616        let path = write_pack(
617            dir.path(),
618            "policy.json",
619            r#"{ "version": 1, "name": "p", "rules": [
620                { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
621            ] }"#,
622        );
623        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
624        assert!(errors[0].message.contains("banned-thing"));
625        assert!(errors[0].message.contains("banned-effect"));
626        assert!(errors[0].message.contains("banned-call"));
627        assert!(errors[0].message.contains("banned-import"));
628    }
629
630    #[test]
631    fn rejects_unknown_field() {
632        let dir = tempfile::tempdir().unwrap();
633        let path = write_pack(
634            dir.path(),
635            "policy.json",
636            r#"{ "version": 1, "name": "p", "rules": [
637                { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
638            ] }"#,
639        );
640        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
641        assert!(errors[0].message.contains("file"));
642    }
643
644    #[test]
645    fn rejects_empty_rules_and_empty_pack_name() {
646        let dir = tempfile::tempdir().unwrap();
647        let path = write_pack(
648            dir.path(),
649            "policy.json",
650            r#"{ "version": 1, "name": " ", "rules": [] }"#,
651        );
652        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
653        let joined = errors
654            .iter()
655            .map(|e| e.message.clone())
656            .collect::<Vec<_>>()
657            .join("\n");
658        assert!(joined.contains("declares no rules"));
659        assert!(joined.contains("`name` must not be empty"));
660    }
661
662    #[test]
663    fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
664        let dir = tempfile::tempdir().unwrap();
665        let path = write_pack(
666            dir.path(),
667            "policy.json",
668            r#"{ "version": 1, "name": "team/policy", "rules": [
669                { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
670            ] }"#,
671        );
672        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
673        assert!(errors[0].message.contains("pack `name` 'team/policy'"));
674        assert!(errors[0].message.contains("ASCII letters"));
675    }
676
677    #[test]
678    fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
679        let dir = tempfile::tempdir().unwrap();
680        let path = write_pack(
681            dir.path(),
682            "policy.json",
683            r#"{ "version": 1, "name": "team-policy", "rules": [
684                { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
685            ] }"#,
686        );
687        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
688        assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
689        assert!(errors[0].message.contains("ASCII letters"));
690    }
691
692    #[test]
693    fn rejects_duplicate_rule_ids_within_pack() {
694        let dir = tempfile::tempdir().unwrap();
695        let path = write_pack(
696            dir.path(),
697            "policy.json",
698            r#"{ "version": 1, "name": "p", "rules": [
699                { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
700                { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
701            ] }"#,
702        );
703        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
704        assert!(errors[0].message.contains("duplicate rule id 'a'"));
705    }
706
707    #[test]
708    fn rejects_duplicate_pack_names() {
709        let dir = tempfile::tempdir().unwrap();
710        let a = write_pack(
711            dir.path(),
712            "a.json",
713            r#"{ "version": 1, "name": "p", "rules": [
714                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
715            ] }"#,
716        );
717        let b = write_pack(
718            dir.path(),
719            "b.json",
720            r#"{ "version": 1, "name": "p", "rules": [
721                { "id": "b", "kind": "banned-call", "callees": ["eval"] }
722            ] }"#,
723        );
724        let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
725        assert!(errors[0].message.contains("rule pack name 'p'"));
726    }
727
728    #[test]
729    fn rejects_cross_kind_fields() {
730        let dir = tempfile::tempdir().unwrap();
731        let path = write_pack(
732            dir.path(),
733            "policy.json",
734            r#"{ "version": 1, "name": "p", "rules": [
735                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
736                  "specifiers": ["moment"], "effects": ["network"], "ignoreTypeOnly": true },
737                { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
738                  "callees": ["fetch"], "effects": ["network"] },
739                { "id": "c", "kind": "banned-effect", "effects": ["network"],
740                  "callees": ["fetch"], "specifiers": ["moment"], "ignoreTypeOnly": true }
741            ] }"#,
742        );
743        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
744        let joined = errors
745            .iter()
746            .map(|e| e.message.clone())
747            .collect::<Vec<_>>()
748            .join("\n");
749        assert!(joined.contains("`specifiers` applies only to banned-import"));
750        assert!(joined.contains("`ignoreTypeOnly` applies only to banned-import"));
751        assert!(joined.contains("`callees` applies only to banned-call"));
752        assert!(joined.contains("`effects` applies only to banned-effect"));
753    }
754
755    #[test]
756    fn rejects_missing_kind_fields() {
757        let dir = tempfile::tempdir().unwrap();
758        let path = write_pack(
759            dir.path(),
760            "policy.json",
761            r#"{ "version": 1, "name": "p", "rules": [
762                { "id": "a", "kind": "banned-call" },
763                { "id": "b", "kind": "banned-import" },
764                { "id": "c", "kind": "banned-effect" }
765            ] }"#,
766        );
767        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
768        let joined = errors
769            .iter()
770            .map(|e| e.message.clone())
771            .collect::<Vec<_>>()
772            .join("\n");
773        assert!(joined.contains("must list at least one `callees` pattern"));
774        assert!(joined.contains("must list at least one `specifiers` entry"));
775        assert!(joined.contains("must list at least one `effects` entry"));
776    }
777
778    #[test]
779    fn rejects_inert_callee_patterns() {
780        let dir = tempfile::tempdir().unwrap();
781        let path = write_pack(
782            dir.path(),
783            "policy.json",
784            r#"{ "version": 1, "name": "p", "rules": [
785                { "id": "a", "kind": "banned-call",
786                  "callees": ["*", "a..b", "child*", "a.*.b"] }
787            ] }"#,
788        );
789        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
790        assert_eq!(errors.len(), 4);
791    }
792
793    #[test]
794    fn rejects_glob_specifiers() {
795        let dir = tempfile::tempdir().unwrap();
796        let path = write_pack(
797            dir.path(),
798            "policy.json",
799            r#"{ "version": 1, "name": "p", "rules": [
800                { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
801            ] }"#,
802        );
803        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
804        assert!(errors[0].message.contains("segment-aware, not glob"));
805    }
806
807    #[test]
808    fn rejects_traversal_globs() {
809        let dir = tempfile::tempdir().unwrap();
810        let path = write_pack(
811            dir.path(),
812            "policy.json",
813            r#"{ "version": 1, "name": "p", "rules": [
814                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
815                  "files": ["../outside/**"] }
816            ] }"#,
817        );
818        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
819        assert!(errors[0].message.contains("invalid `files` glob"));
820    }
821
822    #[test]
823    fn rejects_missing_pack_file_and_bad_extension() {
824        let dir = tempfile::tempdir().unwrap();
825        write_pack(dir.path(), "policy.toml", "version = 1");
826        let errors = load_rule_packs(
827            dir.path(),
828            &["missing.json".to_owned(), "policy.toml".to_owned()],
829        )
830        .unwrap_err();
831        assert_eq!(errors.len(), 2);
832        assert!(errors[0].message.contains("failed to read rule pack"));
833        assert!(
834            errors[1]
835                .message
836                .contains("unsupported rule pack extension")
837        );
838    }
839
840    #[test]
841    fn rejects_paths_outside_root() {
842        let dir = tempfile::tempdir().unwrap();
843        let inner = dir.path().join("project");
844        std::fs::create_dir_all(&inner).unwrap();
845        std::fs::write(
846            dir.path().join("outside.json"),
847            r#"{ "version": 1, "name": "p", "rules": [
848                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
849            ] }"#,
850        )
851        .unwrap();
852        let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
853        assert!(errors[0].message.contains("outside the project root"));
854    }
855
856    #[test]
857    fn schema_validates_doc_example_shape() {
858        let schema = RulePackDef::json_schema();
859        let properties = schema
860            .get("properties")
861            .and_then(|p| p.as_object())
862            .expect("schema should expose properties");
863        assert!(properties.contains_key("version"));
864        assert!(properties.contains_key("name"));
865        assert!(properties.contains_key("rules"));
866
867        // The doc-comment example must parse with the same serde shape the
868        // schema is generated from.
869        let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
870        assert_eq!(pack.version, 1);
871    }
872}