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        let path = root.join(path_str);
234        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
235        if !RULE_PACK_EXTENSIONS.contains(&ext) {
236            errors.push(RulePackError {
237                path: path.clone(),
238                message: format!(
239                    "unsupported rule pack extension '.{ext}'; expected .json or .jsonc"
240                ),
241            });
242            continue;
243        }
244        let content = match std::fs::read_to_string(&path) {
245            Ok(content) => content,
246            Err(e) => {
247                errors.push(RulePackError {
248                    path: path.clone(),
249                    message: format!("failed to read rule pack: {e}"),
250                });
251                continue;
252            }
253        };
254        // Checked after the read so a missing file reports as missing even on
255        // platforms where the project root itself sits behind a symlink.
256        if !crate::external_plugin::is_within_root(&path, &canonical_root) {
257            errors.push(RulePackError {
258                path: path.clone(),
259                message: "resolves outside the project root".to_owned(),
260            });
261            continue;
262        }
263        let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
264            crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
265        } else {
266            serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
267        };
268        match parsed {
269            Ok(pack) => {
270                let before = errors.len();
271                validate_pack(&pack, &path, &mut errors);
272                if errors.len() == before {
273                    packs.push(pack);
274                }
275            }
276            Err(message) => {
277                errors.push(RulePackError {
278                    path: path.clone(),
279                    message: format!("failed to parse rule pack: {message}"),
280                });
281            }
282        }
283    }
284
285    let mut seen_names: FxHashSet<&str> = FxHashSet::default();
286    for pack in &packs {
287        if !seen_names.insert(pack.name.as_str()) {
288            errors.push(RulePackError {
289                path: root.to_path_buf(),
290                message: format!(
291                    "rule pack name '{}' is declared by more than one pack; pack names must be \
292                     unique because findings are identified as '<pack>/<rule-id>'",
293                    pack.name
294                ),
295            });
296        }
297    }
298
299    if errors.is_empty() {
300        Ok(packs)
301    } else {
302        Err(errors)
303    }
304}
305
306/// Validate a parsed pack. Pushes one error per problem so a pack with three
307/// bad rules reports all three.
308fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
309    let err = |message: String| RulePackError {
310        path: path.to_path_buf(),
311        message,
312    };
313
314    if pack.version != SUPPORTED_PACK_VERSION {
315        errors.push(err(format!(
316            "unsupported rule pack version {}; this fallow build supports version \
317             {SUPPORTED_PACK_VERSION}",
318            pack.version
319        )));
320    }
321    if pack.name.trim().is_empty() {
322        errors.push(err("pack `name` must not be empty".to_owned()));
323    } else if !is_valid_policy_identifier(&pack.name) {
324        errors.push(err(format!(
325            "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
326            pack.name
327        )));
328    }
329    if pack.rules.is_empty() {
330        errors.push(err(
331            "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
332        ));
333    }
334
335    let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
336    for rule in &pack.rules {
337        if rule.id.trim().is_empty() {
338            errors.push(err("rule `id` must not be empty".to_owned()));
339            continue;
340        }
341        if !is_valid_policy_identifier(&rule.id) {
342            errors.push(err(format!(
343                "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
344                rule.id
345            )));
346            continue;
347        }
348        if !seen_ids.insert(rule.id.as_str()) {
349            errors.push(err(format!(
350                "duplicate rule id '{}'; rule ids must be unique within a pack",
351                rule.id
352            )));
353        }
354        validate_rule(rule, path, errors);
355    }
356}
357
358/// Validate one rule's kind-specific fields and patterns.
359fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
360    let err = |message: String| RulePackError {
361        path: path.to_path_buf(),
362        message: format!("rule '{}': {message}", rule.id),
363    };
364
365    match rule.kind {
366        RulePackRuleKind::BannedCall => {
367            if rule.callees.is_empty() {
368                errors.push(err(
369                    "banned-call rules must list at least one `callees` pattern".to_owned(),
370                ));
371            }
372            if !rule.specifiers.is_empty() {
373                errors.push(err(
374                    "`specifiers` applies only to banned-import rules".to_owned()
375                ));
376            }
377            if !rule.effects.is_empty() {
378                errors.push(err(
379                    "`effects` applies only to banned-effect rules".to_owned()
380                ));
381            }
382            if rule.ignore_type_only {
383                errors.push(err(
384                    "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
385                ));
386            }
387            for pattern in &rule.callees {
388                if let Some(reason) = callee_pattern_error(pattern) {
389                    errors.push(err(format!("callee pattern `{pattern}` {reason}")));
390                }
391            }
392        }
393        RulePackRuleKind::BannedImport => {
394            if rule.specifiers.is_empty() {
395                errors.push(err(
396                    "banned-import rules must list at least one `specifiers` entry".to_owned(),
397                ));
398            }
399            if !rule.callees.is_empty() {
400                errors.push(err("`callees` applies only to banned-call rules".to_owned()));
401            }
402            if !rule.effects.is_empty() {
403                errors.push(err(
404                    "`effects` applies only to banned-effect rules".to_owned()
405                ));
406            }
407            for specifier in &rule.specifiers {
408                if specifier.trim().is_empty() {
409                    errors.push(err("specifier must not be empty".to_owned()));
410                } else if specifier.contains('*') {
411                    errors.push(err(format!(
412                        "specifier `{specifier}` contains `*`; specifier matching is \
413                         segment-aware, not glob. List the package or path prefix; subpaths are \
414                         covered automatically"
415                    )));
416                }
417            }
418        }
419        RulePackRuleKind::BannedEffect => {
420            if rule.effects.is_empty() {
421                errors.push(err(
422                    "banned-effect rules must list at least one `effects` entry".to_owned(),
423                ));
424            }
425            if !rule.callees.is_empty() {
426                errors.push(err("`callees` applies only to banned-call rules".to_owned()));
427            }
428            if !rule.specifiers.is_empty() {
429                errors.push(err(
430                    "`specifiers` applies only to banned-import rules".to_owned()
431                ));
432            }
433            if rule.ignore_type_only {
434                errors.push(err(
435                    "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
436                ));
437            }
438        }
439    }
440
441    for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
442        for pattern in patterns {
443            if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
444                errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
445            }
446        }
447    }
448}
449
450/// Reject callee patterns the segment-aware matcher cannot honor, using the
451/// same rules as `boundaries.calls.forbidden` (`validate_call_rules`).
452fn callee_pattern_error(pattern: &str) -> Option<String> {
453    let trimmed = pattern.trim();
454    if trimmed.is_empty() {
455        return Some("must not be empty".to_owned());
456    }
457    if trimmed == "*" {
458        return Some(
459            "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
460             `console.*` or `child_process.exec`"
461                .to_owned(),
462        );
463    }
464    if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
465        return Some("contains an empty path segment".to_owned());
466    }
467    crate::config::wildcard_placement_error(trimmed)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn write_pack(dir: &Path, name: &str, content: &str) -> String {
475        std::fs::write(dir.join(name), content).unwrap();
476        name.to_owned()
477    }
478
479    fn valid_pack_json() -> &'static str {
480        r#"{
481            "version": 1,
482            "name": "team-policy",
483            "description": "House rules",
484            "rules": [
485                {
486                    "id": "no-child-process",
487                    "kind": "banned-call",
488                    "callees": ["child_process.*", "execa"],
489                    "files": ["src/**"],
490                    "exclude": ["src/tooling/**"],
491                    "message": "Use the sandboxed runner instead.",
492                    "severity": "error"
493                },
494                {
495                    "id": "no-network",
496                    "kind": "banned-effect",
497                    "effects": ["network"],
498                    "message": "Keep this package side-effect free."
499                },
500                {
501                    "id": "no-moment",
502                    "kind": "banned-import",
503                    "specifiers": ["moment"],
504                    "ignoreTypeOnly": true,
505                    "message": "Use date-fns."
506                }
507            ]
508        }"#
509    }
510
511    #[test]
512    fn loads_valid_json_pack() {
513        let dir = tempfile::tempdir().unwrap();
514        let path = write_pack(dir.path(), "policy.json", valid_pack_json());
515        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
516        assert_eq!(packs.len(), 1);
517        assert_eq!(packs[0].name, "team-policy");
518        assert_eq!(packs[0].rules.len(), 3);
519        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
520        assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
521        assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
522        assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
523        assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
524        assert!(packs[0].rules[2].ignore_type_only);
525        assert_eq!(packs[0].rules[2].severity, None);
526    }
527
528    #[test]
529    fn loads_jsonc_pack_with_comments() {
530        let dir = tempfile::tempdir().unwrap();
531        let path = write_pack(
532            dir.path(),
533            "policy.jsonc",
534            r#"{
535                // why: keep the domain layer pure
536                "version": 1,
537                "name": "jsonc-policy",
538                "rules": [
539                    { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
540                ]
541            }"#,
542        );
543        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
544        assert_eq!(packs[0].name, "jsonc-policy");
545    }
546
547    #[test]
548    fn rejects_unsupported_version() {
549        let dir = tempfile::tempdir().unwrap();
550        let path = write_pack(
551            dir.path(),
552            "policy.json",
553            r#"{ "version": 2, "name": "p", "rules": [
554                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
555            ] }"#,
556        );
557        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
558        assert!(
559            errors[0]
560                .message
561                .contains("unsupported rule pack version 2")
562        );
563    }
564
565    #[test]
566    fn rejects_unknown_kind_with_expected_list() {
567        let dir = tempfile::tempdir().unwrap();
568        let path = write_pack(
569            dir.path(),
570            "policy.json",
571            r#"{ "version": 1, "name": "p", "rules": [
572                { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
573            ] }"#,
574        );
575        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
576        assert!(errors[0].message.contains("banned-thing"));
577        assert!(errors[0].message.contains("banned-effect"));
578        assert!(errors[0].message.contains("banned-call"));
579        assert!(errors[0].message.contains("banned-import"));
580    }
581
582    #[test]
583    fn rejects_unknown_field() {
584        let dir = tempfile::tempdir().unwrap();
585        let path = write_pack(
586            dir.path(),
587            "policy.json",
588            r#"{ "version": 1, "name": "p", "rules": [
589                { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
590            ] }"#,
591        );
592        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
593        assert!(errors[0].message.contains("file"));
594    }
595
596    #[test]
597    fn rejects_empty_rules_and_empty_pack_name() {
598        let dir = tempfile::tempdir().unwrap();
599        let path = write_pack(
600            dir.path(),
601            "policy.json",
602            r#"{ "version": 1, "name": " ", "rules": [] }"#,
603        );
604        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
605        let joined = errors
606            .iter()
607            .map(|e| e.message.clone())
608            .collect::<Vec<_>>()
609            .join("\n");
610        assert!(joined.contains("declares no rules"));
611        assert!(joined.contains("`name` must not be empty"));
612    }
613
614    #[test]
615    fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
616        let dir = tempfile::tempdir().unwrap();
617        let path = write_pack(
618            dir.path(),
619            "policy.json",
620            r#"{ "version": 1, "name": "team/policy", "rules": [
621                { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
622            ] }"#,
623        );
624        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
625        assert!(errors[0].message.contains("pack `name` 'team/policy'"));
626        assert!(errors[0].message.contains("ASCII letters"));
627    }
628
629    #[test]
630    fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
631        let dir = tempfile::tempdir().unwrap();
632        let path = write_pack(
633            dir.path(),
634            "policy.json",
635            r#"{ "version": 1, "name": "team-policy", "rules": [
636                { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
637            ] }"#,
638        );
639        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
640        assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
641        assert!(errors[0].message.contains("ASCII letters"));
642    }
643
644    #[test]
645    fn rejects_duplicate_rule_ids_within_pack() {
646        let dir = tempfile::tempdir().unwrap();
647        let path = write_pack(
648            dir.path(),
649            "policy.json",
650            r#"{ "version": 1, "name": "p", "rules": [
651                { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
652                { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
653            ] }"#,
654        );
655        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
656        assert!(errors[0].message.contains("duplicate rule id 'a'"));
657    }
658
659    #[test]
660    fn rejects_duplicate_pack_names() {
661        let dir = tempfile::tempdir().unwrap();
662        let a = write_pack(
663            dir.path(),
664            "a.json",
665            r#"{ "version": 1, "name": "p", "rules": [
666                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
667            ] }"#,
668        );
669        let b = write_pack(
670            dir.path(),
671            "b.json",
672            r#"{ "version": 1, "name": "p", "rules": [
673                { "id": "b", "kind": "banned-call", "callees": ["eval"] }
674            ] }"#,
675        );
676        let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
677        assert!(errors[0].message.contains("rule pack name 'p'"));
678    }
679
680    #[test]
681    fn rejects_cross_kind_fields() {
682        let dir = tempfile::tempdir().unwrap();
683        let path = write_pack(
684            dir.path(),
685            "policy.json",
686            r#"{ "version": 1, "name": "p", "rules": [
687                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
688                  "specifiers": ["moment"], "effects": ["network"], "ignoreTypeOnly": true },
689                { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
690                  "callees": ["fetch"], "effects": ["network"] },
691                { "id": "c", "kind": "banned-effect", "effects": ["network"],
692                  "callees": ["fetch"], "specifiers": ["moment"], "ignoreTypeOnly": true }
693            ] }"#,
694        );
695        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
696        let joined = errors
697            .iter()
698            .map(|e| e.message.clone())
699            .collect::<Vec<_>>()
700            .join("\n");
701        assert!(joined.contains("`specifiers` applies only to banned-import"));
702        assert!(joined.contains("`ignoreTypeOnly` applies only to banned-import"));
703        assert!(joined.contains("`callees` applies only to banned-call"));
704        assert!(joined.contains("`effects` applies only to banned-effect"));
705    }
706
707    #[test]
708    fn rejects_missing_kind_fields() {
709        let dir = tempfile::tempdir().unwrap();
710        let path = write_pack(
711            dir.path(),
712            "policy.json",
713            r#"{ "version": 1, "name": "p", "rules": [
714                { "id": "a", "kind": "banned-call" },
715                { "id": "b", "kind": "banned-import" },
716                { "id": "c", "kind": "banned-effect" }
717            ] }"#,
718        );
719        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
720        let joined = errors
721            .iter()
722            .map(|e| e.message.clone())
723            .collect::<Vec<_>>()
724            .join("\n");
725        assert!(joined.contains("must list at least one `callees` pattern"));
726        assert!(joined.contains("must list at least one `specifiers` entry"));
727        assert!(joined.contains("must list at least one `effects` entry"));
728    }
729
730    #[test]
731    fn rejects_inert_callee_patterns() {
732        let dir = tempfile::tempdir().unwrap();
733        let path = write_pack(
734            dir.path(),
735            "policy.json",
736            r#"{ "version": 1, "name": "p", "rules": [
737                { "id": "a", "kind": "banned-call",
738                  "callees": ["*", "a..b", "child*", "a.*.b"] }
739            ] }"#,
740        );
741        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
742        assert_eq!(errors.len(), 4);
743    }
744
745    #[test]
746    fn rejects_glob_specifiers() {
747        let dir = tempfile::tempdir().unwrap();
748        let path = write_pack(
749            dir.path(),
750            "policy.json",
751            r#"{ "version": 1, "name": "p", "rules": [
752                { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
753            ] }"#,
754        );
755        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
756        assert!(errors[0].message.contains("segment-aware, not glob"));
757    }
758
759    #[test]
760    fn rejects_traversal_globs() {
761        let dir = tempfile::tempdir().unwrap();
762        let path = write_pack(
763            dir.path(),
764            "policy.json",
765            r#"{ "version": 1, "name": "p", "rules": [
766                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
767                  "files": ["../outside/**"] }
768            ] }"#,
769        );
770        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
771        assert!(errors[0].message.contains("invalid `files` glob"));
772    }
773
774    #[test]
775    fn rejects_missing_pack_file_and_bad_extension() {
776        let dir = tempfile::tempdir().unwrap();
777        write_pack(dir.path(), "policy.toml", "version = 1");
778        let errors = load_rule_packs(
779            dir.path(),
780            &["missing.json".to_owned(), "policy.toml".to_owned()],
781        )
782        .unwrap_err();
783        assert_eq!(errors.len(), 2);
784        assert!(errors[0].message.contains("failed to read rule pack"));
785        assert!(
786            errors[1]
787                .message
788                .contains("unsupported rule pack extension")
789        );
790    }
791
792    #[test]
793    fn rejects_paths_outside_root() {
794        let dir = tempfile::tempdir().unwrap();
795        let inner = dir.path().join("project");
796        std::fs::create_dir_all(&inner).unwrap();
797        std::fs::write(
798            dir.path().join("outside.json"),
799            r#"{ "version": 1, "name": "p", "rules": [
800                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
801            ] }"#,
802        )
803        .unwrap();
804        let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
805        assert!(errors[0].message.contains("outside the project root"));
806    }
807
808    #[test]
809    fn schema_validates_doc_example_shape() {
810        let schema = RulePackDef::json_schema();
811        let properties = schema
812            .get("properties")
813            .and_then(|p| p.as_object())
814            .expect("schema should expose properties");
815        assert!(properties.contains_key("version"));
816        assert!(properties.contains_key("name"));
817        assert!(properties.contains_key("rules"));
818
819        // The doc-comment example must parse with the same serde shape the
820        // schema is generated from.
821        let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
822        assert_eq!(pack.version, 1);
823    }
824}