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