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
11const RULE_PACK_EXTENSIONS: &[&str] = &["json", "jsonc"];
15
16const SUPPORTED_PACK_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
21#[serde(rename_all = "kebab-case")]
22pub enum RulePackRuleKind {
23 BannedCall,
25 BannedImport,
28 BannedEffect,
30}
31
32#[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#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
79#[serde(deny_unknown_fields, rename_all = "camelCase")]
80pub struct RulePackRule {
81 pub id: String,
85 pub kind: RulePackRuleKind,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub callees: Vec<String>,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub specifiers: Vec<String>,
100 #[serde(default, skip_serializing_if = "Vec::is_empty")]
104 pub effects: Vec<EffectKind>,
105 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
109 pub ignore_type_only: bool,
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub files: Vec<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub exclude: Vec<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub message: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub severity: Option<Severity>,
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
167#[serde(deny_unknown_fields, rename_all = "camelCase")]
168pub struct RulePackDef {
169 #[serde(rename = "$schema", default, skip_serializing)]
171 #[schemars(skip)]
172 pub schema: Option<String>,
173 pub version: u32,
176 pub name: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub description: Option<String>,
183 pub rules: Vec<RulePackRule>,
186}
187
188impl RulePackDef {
189 #[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#[derive(Debug, Clone)]
200pub struct RulePackError {
201 pub path: PathBuf,
203 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
213pub 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
245fn 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 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
303fn 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
324fn 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
376fn 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
392fn 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
425fn 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
457fn 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
483fn 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
498fn 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 let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
870 assert_eq!(pack.version, 1);
871 }
872}