1use std::path::{Path, PathBuf};
2
3use fallow_types::suppress::is_valid_policy_identifier;
4use rustc_hash::FxHashSet;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8use crate::config::glob_validation::compile_user_glob;
9use crate::config::{BoundaryConfig, ResolvedBoundaryConfig, Severity};
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 BannedExport,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
36#[serde(rename_all = "kebab-case")]
37pub enum EffectKind {
38 Pure,
39 Read,
40 Write,
41 Network,
42 Storage,
43 Process,
44 Shell,
45 Crypto,
46 Randomness,
47 Dom,
48 Database,
49 FrameworkCallback,
50 Unknown,
51}
52
53impl EffectKind {
54 #[must_use]
55 pub const fn as_str(self) -> &'static str {
56 match self {
57 Self::Pure => "pure",
58 Self::Read => "read",
59 Self::Write => "write",
60 Self::Network => "network",
61 Self::Storage => "storage",
62 Self::Process => "process",
63 Self::Shell => "shell",
64 Self::Crypto => "crypto",
65 Self::Randomness => "randomness",
66 Self::Dom => "dom",
67 Self::Database => "database",
68 Self::FrameworkCallback => "framework-callback",
69 Self::Unknown => "unknown",
70 }
71 }
72}
73
74#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
83#[serde(deny_unknown_fields, rename_all = "camelCase")]
84pub struct RulePackRule {
85 pub id: String,
89 pub kind: RulePackRuleKind,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub callees: Vec<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub specifiers: Vec<String>,
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
110 pub effects: Vec<EffectKind>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub exports: Vec<String>,
117 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
122 pub ignore_type_only: bool,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub files: Vec<String>,
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub exclude: Vec<String>,
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub zones: Vec<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub message: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub severity: Option<Severity>,
144}
145
146#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
185#[serde(deny_unknown_fields, rename_all = "camelCase")]
186pub struct RulePackDef {
187 #[serde(rename = "$schema", default, skip_serializing)]
189 #[schemars(skip)]
190 pub schema: Option<String>,
191 pub version: u32,
194 pub name: String,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub description: Option<String>,
201 pub rules: Vec<RulePackRule>,
204}
205
206impl RulePackDef {
207 #[must_use]
210 pub fn json_schema() -> serde_json::Value {
211 serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
212 }
213}
214
215#[derive(Debug, Clone)]
218pub struct RulePackError {
219 pub path: PathBuf,
221 pub message: String,
223}
224
225impl std::fmt::Display for RulePackError {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 write!(f, "{}: {}", self.path.display(), self.message)
228 }
229}
230
231pub fn load_rule_packs(
243 root: &Path,
244 pack_paths: &[String],
245) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
246 let mut packs = Vec::new();
247 let mut errors = Vec::new();
248 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
249
250 for path_str in pack_paths {
251 load_one_rule_pack(root, path_str, &canonical_root, &mut packs, &mut errors);
252 }
253
254 push_duplicate_pack_name_errors(root, &packs, &mut errors);
255
256 if errors.is_empty() {
257 Ok(packs)
258 } else {
259 Err(errors)
260 }
261}
262
263#[must_use]
266pub fn resolve_boundaries_for_rule_pack_validation(
267 mut boundaries: BoundaryConfig,
268 root: &Path,
269) -> ResolvedBoundaryConfig {
270 if boundaries.preset.is_some() {
271 let source_root = crate::workspace::parse_tsconfig_root_dir(root)
272 .filter(|r| r != "." && !r.starts_with("..") && !Path::new(r).is_absolute())
273 .unwrap_or_else(|| "src".to_owned());
274 boundaries.expand(&source_root);
275 }
276 let logical_groups = boundaries.expand_auto_discover(root);
277 let mut resolved = boundaries.resolve();
278 resolved.logical_groups = logical_groups;
279 resolved
280}
281
282#[must_use]
284pub fn validate_rule_pack_zone_references(
285 root: &Path,
286 pack_paths: &[String],
287 packs: &[RulePackDef],
288 boundaries: &ResolvedBoundaryConfig,
289) -> Vec<RulePackError> {
290 let configured_zones: FxHashSet<&str> = boundaries
291 .zones
292 .iter()
293 .map(|zone| zone.name.as_str())
294 .collect();
295 let configured_zone_list = if configured_zones.is_empty() {
296 "none".to_owned()
297 } else {
298 let mut zones: Vec<&str> = configured_zones.iter().copied().collect();
299 zones.sort_unstable();
300 zones.join(", ")
301 };
302
303 let mut errors = Vec::new();
304 for (pack_index, pack) in packs.iter().enumerate() {
305 let path = pack_paths
306 .get(pack_index)
307 .map_or_else(|| root.to_path_buf(), |path| root.join(path));
308 for rule in &pack.rules {
309 if rule.zones.is_empty() {
310 continue;
311 }
312 if configured_zones.is_empty() {
313 errors.push(RulePackError {
314 path: path.clone(),
315 message: format!(
316 "rule '{}': `zones` requires configured boundary zones, but none are configured",
317 rule.id
318 ),
319 });
320 continue;
321 }
322 for zone in &rule.zones {
323 if !configured_zones.contains(zone.as_str()) {
324 errors.push(RulePackError {
325 path: path.clone(),
326 message: format!(
327 "rule '{}': unknown zone '{}' in `zones`; configured zones: {}",
328 rule.id, zone, configured_zone_list
329 ),
330 });
331 }
332 }
333 }
334 }
335 errors
336}
337
338fn load_one_rule_pack(
340 root: &Path,
341 path_str: &str,
342 canonical_root: &Path,
343 packs: &mut Vec<RulePackDef>,
344 errors: &mut Vec<RulePackError>,
345) {
346 let path = root.join(path_str);
347 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
348 if !RULE_PACK_EXTENSIONS.contains(&ext) {
349 errors.push(RulePackError {
350 path: path.clone(),
351 message: format!("unsupported rule pack extension '.{ext}'; expected .json or .jsonc"),
352 });
353 return;
354 }
355 let content = match std::fs::read_to_string(&path) {
356 Ok(content) => content,
357 Err(e) => {
358 errors.push(RulePackError {
359 path,
360 message: format!("failed to read rule pack: {e}"),
361 });
362 return;
363 }
364 };
365 if !crate::external_plugin::is_within_root(&path, canonical_root) {
368 errors.push(RulePackError {
369 path,
370 message: "resolves outside the project root".to_owned(),
371 });
372 return;
373 }
374 let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
375 crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
376 } else {
377 serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
378 };
379 match parsed {
380 Ok(pack) => {
381 let before = errors.len();
382 validate_pack(&pack, &path, errors);
383 if errors.len() == before {
384 packs.push(pack);
385 }
386 }
387 Err(message) => {
388 errors.push(RulePackError {
389 path,
390 message: format!("failed to parse rule pack: {message}"),
391 });
392 }
393 }
394}
395
396fn push_duplicate_pack_name_errors(
398 root: &Path,
399 packs: &[RulePackDef],
400 errors: &mut Vec<RulePackError>,
401) {
402 let mut seen_names: FxHashSet<&str> = FxHashSet::default();
403 for pack in packs {
404 if !seen_names.insert(pack.name.as_str()) {
405 errors.push(RulePackError {
406 path: root.to_path_buf(),
407 message: format!(
408 "rule pack name '{}' is declared by more than one pack; pack names must be \
409 unique because findings are identified as '<pack>/<rule-id>'",
410 pack.name
411 ),
412 });
413 }
414 }
415}
416
417fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
420 let err = |message: String| RulePackError {
421 path: path.to_path_buf(),
422 message,
423 };
424
425 if pack.version != SUPPORTED_PACK_VERSION {
426 errors.push(err(format!(
427 "unsupported rule pack version {}; this fallow build supports version \
428 {SUPPORTED_PACK_VERSION}",
429 pack.version
430 )));
431 }
432 if pack.name.trim().is_empty() {
433 errors.push(err("pack `name` must not be empty".to_owned()));
434 } else if !is_valid_policy_identifier(&pack.name) {
435 errors.push(err(format!(
436 "pack `name` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
437 pack.name
438 )));
439 }
440 if pack.rules.is_empty() {
441 errors.push(err(
442 "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
443 ));
444 }
445
446 let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
447 for rule in &pack.rules {
448 if rule.id.trim().is_empty() {
449 errors.push(err("rule `id` must not be empty".to_owned()));
450 continue;
451 }
452 if !is_valid_policy_identifier(&rule.id) {
453 errors.push(err(format!(
454 "rule `id` '{}' must use only ASCII letters, digits, '.', '_', and '-'",
455 rule.id
456 )));
457 continue;
458 }
459 if !seen_ids.insert(rule.id.as_str()) {
460 errors.push(err(format!(
461 "duplicate rule id '{}'; rule ids must be unique within a pack",
462 rule.id
463 )));
464 }
465 validate_rule(rule, path, errors);
466 }
467}
468
469fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
471 let err = |message: String| RulePackError {
472 path: path.to_path_buf(),
473 message: format!("rule '{}': {message}", rule.id),
474 };
475
476 match rule.kind {
477 RulePackRuleKind::BannedCall => validate_banned_call_rule(rule, &err, errors),
478 RulePackRuleKind::BannedImport => validate_banned_import_rule(rule, &err, errors),
479 RulePackRuleKind::BannedEffect => validate_banned_effect_rule(rule, &err, errors),
480 RulePackRuleKind::BannedExport => validate_banned_export_rule(rule, &err, errors),
481 }
482
483 validate_rule_file_globs(rule, &err, errors);
484}
485
486fn validate_banned_call_rule(
488 rule: &RulePackRule,
489 err: &impl Fn(String) -> RulePackError,
490 errors: &mut Vec<RulePackError>,
491) {
492 if rule.callees.is_empty() {
493 errors.push(err(
494 "banned-call rules must list at least one `callees` pattern".to_owned(),
495 ));
496 }
497 if !rule.specifiers.is_empty() {
498 errors.push(err(
499 "`specifiers` applies only to banned-import rules".to_owned()
500 ));
501 }
502 if !rule.effects.is_empty() {
503 errors.push(err(
504 "`effects` applies only to banned-effect rules".to_owned()
505 ));
506 }
507 if !rule.exports.is_empty() {
508 errors.push(err(
509 "`exports` applies only to banned-export rules".to_owned()
510 ));
511 }
512 if rule.ignore_type_only {
513 errors.push(err(
514 "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
515 ));
516 }
517 for pattern in &rule.callees {
518 if let Some(reason) = callee_pattern_error(pattern) {
519 errors.push(err(format!("callee pattern `{pattern}` {reason}")));
520 }
521 }
522}
523
524fn validate_banned_import_rule(
526 rule: &RulePackRule,
527 err: &impl Fn(String) -> RulePackError,
528 errors: &mut Vec<RulePackError>,
529) {
530 if rule.specifiers.is_empty() {
531 errors.push(err(
532 "banned-import rules must list at least one `specifiers` entry".to_owned(),
533 ));
534 }
535 if !rule.callees.is_empty() {
536 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
537 }
538 if !rule.effects.is_empty() {
539 errors.push(err(
540 "`effects` applies only to banned-effect rules".to_owned()
541 ));
542 }
543 if !rule.exports.is_empty() {
544 errors.push(err(
545 "`exports` applies only to banned-export rules".to_owned()
546 ));
547 }
548 for specifier in &rule.specifiers {
549 if specifier.trim().is_empty() {
550 errors.push(err("specifier must not be empty".to_owned()));
551 } else if let Some(prefix) = specifier.strip_suffix("/*") {
552 if prefix.is_empty() || prefix.contains('*') {
553 errors.push(err(format!(
554 "specifier `{specifier}` contains `*`; specifier matching is segment-aware, \
555 not glob. Only a single trailing `/*` deep-import form is allowed"
556 )));
557 }
558 } else if specifier.contains('*') {
559 errors.push(err(format!(
560 "specifier `{specifier}` contains `*`; specifier matching is \
561 segment-aware, not glob. List the package or path prefix; subpaths are \
562 covered automatically, or use a single trailing `/*` to match subpaths only"
563 )));
564 }
565 }
566}
567
568fn validate_banned_effect_rule(
570 rule: &RulePackRule,
571 err: &impl Fn(String) -> RulePackError,
572 errors: &mut Vec<RulePackError>,
573) {
574 if rule.effects.is_empty() {
575 errors.push(err(
576 "banned-effect rules must list at least one `effects` entry".to_owned(),
577 ));
578 }
579 if !rule.callees.is_empty() {
580 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
581 }
582 if !rule.specifiers.is_empty() {
583 errors.push(err(
584 "`specifiers` applies only to banned-import rules".to_owned()
585 ));
586 }
587 if !rule.exports.is_empty() {
588 errors.push(err(
589 "`exports` applies only to banned-export rules".to_owned()
590 ));
591 }
592 if rule.ignore_type_only {
593 errors.push(err(
594 "`ignoreTypeOnly` applies only to banned-import and banned-export rules".to_owned(),
595 ));
596 }
597}
598
599fn validate_banned_export_rule(
601 rule: &RulePackRule,
602 err: &impl Fn(String) -> RulePackError,
603 errors: &mut Vec<RulePackError>,
604) {
605 if rule.exports.is_empty() {
606 errors.push(err(
607 "banned-export rules must list at least one `exports` entry".to_owned(),
608 ));
609 }
610 if !rule.callees.is_empty() {
611 errors.push(err("`callees` applies only to banned-call rules".to_owned()));
612 }
613 if !rule.specifiers.is_empty() {
614 errors.push(err(
615 "`specifiers` applies only to banned-import rules".to_owned()
616 ));
617 }
618 if !rule.effects.is_empty() {
619 errors.push(err(
620 "`effects` applies only to banned-effect rules".to_owned()
621 ));
622 }
623 for export in &rule.exports {
624 if export.trim().is_empty() {
625 errors.push(err("export pattern must not be empty".to_owned()));
626 } else if let Some(stripped) = export.strip_suffix('*') {
627 if stripped.is_empty() || stripped.contains('*') {
628 errors.push(err(format!(
629 "export pattern `{export}` may only use a single trailing `*` after a prefix"
630 )));
631 }
632 } else if export.contains('*') {
633 errors.push(err(format!(
634 "export pattern `{export}` may only use `*` as a single trailing prefix wildcard"
635 )));
636 }
637 }
638}
639
640fn validate_rule_file_globs(
642 rule: &RulePackRule,
643 err: &impl Fn(String) -> RulePackError,
644 errors: &mut Vec<RulePackError>,
645) {
646 for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
647 for pattern in patterns {
648 if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
649 errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
650 }
651 }
652 }
653}
654
655fn callee_pattern_error(pattern: &str) -> Option<String> {
658 let trimmed = pattern.trim();
659 if trimmed.is_empty() {
660 return Some("must not be empty".to_owned());
661 }
662 if trimmed == "*" {
663 return Some(
664 "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
665 `console.*` or `child_process.exec`"
666 .to_owned(),
667 );
668 }
669 if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
670 return Some("contains an empty path segment".to_owned());
671 }
672 crate::config::wildcard_placement_error(trimmed)
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 fn write_pack(dir: &Path, name: &str, content: &str) -> String {
680 std::fs::write(dir.join(name), content).unwrap();
681 name.to_owned()
682 }
683
684 fn valid_pack_json() -> &'static str {
685 r#"{
686 "version": 1,
687 "name": "team-policy",
688 "description": "House rules",
689 "rules": [
690 {
691 "id": "no-child-process",
692 "kind": "banned-call",
693 "callees": ["child_process.*", "execa"],
694 "files": ["src/**"],
695 "exclude": ["src/tooling/**"],
696 "message": "Use the sandboxed runner instead.",
697 "severity": "error"
698 },
699 {
700 "id": "no-network",
701 "kind": "banned-effect",
702 "effects": ["network"],
703 "message": "Keep this package side-effect free."
704 },
705 {
706 "id": "no-moment",
707 "kind": "banned-import",
708 "specifiers": ["moment"],
709 "ignoreTypeOnly": true,
710 "message": "Use date-fns."
711 }
712 ]
713 }"#
714 }
715
716 #[test]
717 fn loads_valid_json_pack() {
718 let dir = tempfile::tempdir().unwrap();
719 let path = write_pack(dir.path(), "policy.json", valid_pack_json());
720 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
721 assert_eq!(packs.len(), 1);
722 assert_eq!(packs[0].name, "team-policy");
723 assert_eq!(packs[0].rules.len(), 3);
724 assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
725 assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
726 assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedEffect);
727 assert_eq!(packs[0].rules[1].effects, vec![EffectKind::Network]);
728 assert_eq!(packs[0].rules[2].kind, RulePackRuleKind::BannedImport);
729 assert!(packs[0].rules[2].ignore_type_only);
730 assert_eq!(packs[0].rules[2].severity, None);
731 }
732
733 #[test]
734 fn loads_jsonc_pack_with_comments() {
735 let dir = tempfile::tempdir().unwrap();
736 let path = write_pack(
737 dir.path(),
738 "policy.jsonc",
739 r#"{
740 // why: keep the domain layer pure
741 "version": 1,
742 "name": "jsonc-policy",
743 "rules": [
744 { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
745 ]
746 }"#,
747 );
748 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
749 assert_eq!(packs[0].name, "jsonc-policy");
750 }
751
752 #[test]
753 fn parses_zone_scoped_rules() {
754 let dir = tempfile::tempdir().unwrap();
755 let path = write_pack(
756 dir.path(),
757 "policy.json",
758 r#"{ "version": 1, "name": "p", "rules": [
759 { "id": "domain-network", "kind": "banned-effect",
760 "effects": ["network"], "zones": ["domain"] }
761 ] }"#,
762 );
763 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
764 assert_eq!(packs[0].rules[0].zones, vec!["domain"]);
765 }
766
767 #[test]
768 fn validates_rule_pack_zones_against_resolved_boundaries() {
769 let dir = tempfile::tempdir().unwrap();
770 let path = write_pack(
771 dir.path(),
772 "policy.json",
773 r#"{ "version": 1, "name": "p", "rules": [
774 { "id": "domain-network", "kind": "banned-effect",
775 "effects": ["network"], "zones": ["unknown"] }
776 ] }"#,
777 );
778 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
779 let boundaries = BoundaryConfig {
780 zones: vec![crate::config::BoundaryZone {
781 name: "domain".to_owned(),
782 patterns: vec!["src/domain/**".to_owned()],
783 auto_discover: Vec::new(),
784 root: None,
785 }],
786 ..BoundaryConfig::default()
787 }
788 .resolve();
789
790 let errors = validate_rule_pack_zone_references(dir.path(), &[path], &packs, &boundaries);
791 assert_eq!(errors.len(), 1);
792 assert!(errors[0].message.contains("unknown zone 'unknown'"));
793 assert!(errors[0].message.contains("configured zones: domain"));
794 }
795
796 #[test]
797 fn rejects_rule_pack_zones_when_boundaries_are_empty() {
798 let dir = tempfile::tempdir().unwrap();
799 let path = write_pack(
800 dir.path(),
801 "policy.json",
802 r#"{ "version": 1, "name": "p", "rules": [
803 { "id": "domain-network", "kind": "banned-effect",
804 "effects": ["network"], "zones": ["domain"] }
805 ] }"#,
806 );
807 let packs = load_rule_packs(dir.path(), std::slice::from_ref(&path)).unwrap();
808 let errors = validate_rule_pack_zone_references(
809 dir.path(),
810 &[path],
811 &packs,
812 &ResolvedBoundaryConfig::default(),
813 );
814 assert_eq!(errors.len(), 1);
815 assert!(
816 errors[0]
817 .message
818 .contains("`zones` requires configured boundary zones")
819 );
820 }
821
822 #[test]
823 fn rejects_unsupported_version() {
824 let dir = tempfile::tempdir().unwrap();
825 let path = write_pack(
826 dir.path(),
827 "policy.json",
828 r#"{ "version": 2, "name": "p", "rules": [
829 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
830 ] }"#,
831 );
832 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
833 assert!(
834 errors[0]
835 .message
836 .contains("unsupported rule pack version 2")
837 );
838 }
839
840 #[test]
841 fn rejects_unknown_kind_with_expected_list() {
842 let dir = tempfile::tempdir().unwrap();
843 let path = write_pack(
844 dir.path(),
845 "policy.json",
846 r#"{ "version": 1, "name": "p", "rules": [
847 { "id": "a", "kind": "banned-thing", "callees": ["fetch"] }
848 ] }"#,
849 );
850 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
851 assert!(errors[0].message.contains("banned-thing"));
852 assert!(errors[0].message.contains("banned-effect"));
853 assert!(errors[0].message.contains("banned-call"));
854 assert!(errors[0].message.contains("banned-import"));
855 assert!(errors[0].message.contains("banned-export"));
856 }
857
858 #[test]
859 fn rejects_unknown_field() {
860 let dir = tempfile::tempdir().unwrap();
861 let path = write_pack(
862 dir.path(),
863 "policy.json",
864 r#"{ "version": 1, "name": "p", "rules": [
865 { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
866 ] }"#,
867 );
868 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
869 assert!(errors[0].message.contains("file"));
870 }
871
872 #[test]
873 fn rejects_empty_rules_and_empty_pack_name() {
874 let dir = tempfile::tempdir().unwrap();
875 let path = write_pack(
876 dir.path(),
877 "policy.json",
878 r#"{ "version": 1, "name": " ", "rules": [] }"#,
879 );
880 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
881 let joined = errors
882 .iter()
883 .map(|e| e.message.clone())
884 .collect::<Vec<_>>()
885 .join("\n");
886 assert!(joined.contains("declares no rules"));
887 assert!(joined.contains("`name` must not be empty"));
888 }
889
890 #[test]
891 fn rejects_pack_names_that_cannot_be_scoped_suppression_tokens() {
892 let dir = tempfile::tempdir().unwrap();
893 let path = write_pack(
894 dir.path(),
895 "policy.json",
896 r#"{ "version": 1, "name": "team/policy", "rules": [
897 { "id": "no-child-process", "kind": "banned-call", "callees": ["fetch"] }
898 ] }"#,
899 );
900 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
901 assert!(errors[0].message.contains("pack `name` 'team/policy'"));
902 assert!(errors[0].message.contains("ASCII letters"));
903 }
904
905 #[test]
906 fn rejects_rule_ids_that_cannot_be_scoped_suppression_tokens() {
907 let dir = tempfile::tempdir().unwrap();
908 let path = write_pack(
909 dir.path(),
910 "policy.json",
911 r#"{ "version": 1, "name": "team-policy", "rules": [
912 { "id": "no:child-process", "kind": "banned-call", "callees": ["fetch"] }
913 ] }"#,
914 );
915 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
916 assert!(errors[0].message.contains("rule `id` 'no:child-process'"));
917 assert!(errors[0].message.contains("ASCII letters"));
918 }
919
920 #[test]
921 fn rejects_duplicate_rule_ids_within_pack() {
922 let dir = tempfile::tempdir().unwrap();
923 let path = write_pack(
924 dir.path(),
925 "policy.json",
926 r#"{ "version": 1, "name": "p", "rules": [
927 { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
928 { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
929 ] }"#,
930 );
931 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
932 assert!(errors[0].message.contains("duplicate rule id 'a'"));
933 }
934
935 #[test]
936 fn rejects_duplicate_pack_names() {
937 let dir = tempfile::tempdir().unwrap();
938 let a = write_pack(
939 dir.path(),
940 "a.json",
941 r#"{ "version": 1, "name": "p", "rules": [
942 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
943 ] }"#,
944 );
945 let b = write_pack(
946 dir.path(),
947 "b.json",
948 r#"{ "version": 1, "name": "p", "rules": [
949 { "id": "b", "kind": "banned-call", "callees": ["eval"] }
950 ] }"#,
951 );
952 let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
953 assert!(errors[0].message.contains("rule pack name 'p'"));
954 }
955
956 #[test]
957 fn rejects_cross_kind_fields() {
958 let dir = tempfile::tempdir().unwrap();
959 let path = write_pack(
960 dir.path(),
961 "policy.json",
962 r#"{ "version": 1, "name": "p", "rules": [
963 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
964 "specifiers": ["moment"], "effects": ["network"], "exports": ["default"],
965 "ignoreTypeOnly": true },
966 { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
967 "callees": ["fetch"], "effects": ["network"], "exports": ["default"] },
968 { "id": "c", "kind": "banned-effect", "effects": ["network"],
969 "callees": ["fetch"], "specifiers": ["moment"], "exports": ["default"],
970 "ignoreTypeOnly": true },
971 { "id": "d", "kind": "banned-export", "exports": ["default"],
972 "callees": ["fetch"], "specifiers": ["moment"], "effects": ["network"] }
973 ] }"#,
974 );
975 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
976 let joined = errors
977 .iter()
978 .map(|e| e.message.clone())
979 .collect::<Vec<_>>()
980 .join("\n");
981 assert!(joined.contains("`specifiers` applies only to banned-import"));
982 assert!(
983 joined.contains("`ignoreTypeOnly` applies only to banned-import and banned-export")
984 );
985 assert!(joined.contains("`callees` applies only to banned-call"));
986 assert!(joined.contains("`effects` applies only to banned-effect"));
987 assert!(joined.contains("`exports` applies only to banned-export"));
988 }
989
990 #[test]
991 fn rejects_missing_kind_fields() {
992 let dir = tempfile::tempdir().unwrap();
993 let path = write_pack(
994 dir.path(),
995 "policy.json",
996 r#"{ "version": 1, "name": "p", "rules": [
997 { "id": "a", "kind": "banned-call" },
998 { "id": "b", "kind": "banned-import" },
999 { "id": "c", "kind": "banned-effect" },
1000 { "id": "d", "kind": "banned-export" }
1001 ] }"#,
1002 );
1003 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1004 let joined = errors
1005 .iter()
1006 .map(|e| e.message.clone())
1007 .collect::<Vec<_>>()
1008 .join("\n");
1009 assert!(joined.contains("must list at least one `callees` pattern"));
1010 assert!(joined.contains("must list at least one `specifiers` entry"));
1011 assert!(joined.contains("must list at least one `effects` entry"));
1012 assert!(joined.contains("must list at least one `exports` entry"));
1013 }
1014
1015 #[test]
1016 fn loads_banned_export_rule() {
1017 let dir = tempfile::tempdir().unwrap();
1018 let path = write_pack(
1019 dir.path(),
1020 "policy.json",
1021 r#"{ "version": 1, "name": "p", "rules": [
1022 { "id": "no-default", "kind": "banned-export",
1023 "exports": ["default", "internal*"], "ignoreTypeOnly": true }
1024 ] }"#,
1025 );
1026 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1027 assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedExport);
1028 assert_eq!(packs[0].rules[0].exports, vec!["default", "internal*"]);
1029 assert!(packs[0].rules[0].ignore_type_only);
1030 }
1031
1032 #[test]
1033 fn rejects_invalid_banned_export_patterns() {
1034 let dir = tempfile::tempdir().unwrap();
1035 let path = write_pack(
1036 dir.path(),
1037 "policy.json",
1038 r#"{ "version": 1, "name": "p", "rules": [
1039 { "id": "bad", "kind": "banned-export",
1040 "exports": ["", "*", "a*b"] }
1041 ] }"#,
1042 );
1043 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1044 let joined = errors
1045 .iter()
1046 .map(|e| e.message.clone())
1047 .collect::<Vec<_>>()
1048 .join("\n");
1049 assert!(joined.contains("export pattern must not be empty"));
1050 assert!(joined.contains("may only use a single trailing `*` after a prefix"));
1051 assert!(joined.contains("may only use `*` as a single trailing prefix wildcard"));
1052 }
1053
1054 #[test]
1055 fn rejects_inert_callee_patterns() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let path = write_pack(
1058 dir.path(),
1059 "policy.json",
1060 r#"{ "version": 1, "name": "p", "rules": [
1061 { "id": "a", "kind": "banned-call",
1062 "callees": ["*", "a..b", "child*", "a.*.b"] }
1063 ] }"#,
1064 );
1065 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1066 assert_eq!(errors.len(), 4);
1067 }
1068
1069 #[test]
1070 fn rejects_glob_specifiers() {
1071 let dir = tempfile::tempdir().unwrap();
1072 let path = write_pack(
1073 dir.path(),
1074 "policy.json",
1075 r#"{ "version": 1, "name": "p", "rules": [
1076 { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
1077 ] }"#,
1078 );
1079 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1080 assert!(errors[0].message.contains("segment-aware, not glob"));
1081 }
1082
1083 #[test]
1084 fn accepts_trailing_star_deep_import_specifier() {
1085 let dir = tempfile::tempdir().unwrap();
1086 let path = write_pack(
1087 dir.path(),
1088 "policy.json",
1089 r#"{ "version": 1, "name": "p", "rules": [
1090 { "id": "no-ui-deep-imports", "kind": "banned-import",
1091 "specifiers": ["@org/ui/*"] }
1092 ] }"#,
1093 );
1094 let packs = load_rule_packs(dir.path(), &[path]).unwrap();
1095 assert_eq!(packs[0].rules[0].specifiers, vec!["@org/ui/*"]);
1096 }
1097
1098 #[test]
1099 fn rejects_non_trailing_star_import_specifier() {
1100 let dir = tempfile::tempdir().unwrap();
1101 let path = write_pack(
1102 dir.path(),
1103 "policy.json",
1104 r#"{ "version": 1, "name": "p", "rules": [
1105 { "id": "bad-deep-imports", "kind": "banned-import",
1106 "specifiers": ["@org/*/x"] }
1107 ] }"#,
1108 );
1109 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1110 assert!(errors[0].message.contains("single trailing `/*`"));
1111 }
1112
1113 #[test]
1114 fn rejects_traversal_globs() {
1115 let dir = tempfile::tempdir().unwrap();
1116 let path = write_pack(
1117 dir.path(),
1118 "policy.json",
1119 r#"{ "version": 1, "name": "p", "rules": [
1120 { "id": "a", "kind": "banned-call", "callees": ["fetch"],
1121 "files": ["../outside/**"] }
1122 ] }"#,
1123 );
1124 let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
1125 assert!(errors[0].message.contains("invalid `files` glob"));
1126 }
1127
1128 #[test]
1129 fn rejects_missing_pack_file_and_bad_extension() {
1130 let dir = tempfile::tempdir().unwrap();
1131 write_pack(dir.path(), "policy.toml", "version = 1");
1132 let errors = load_rule_packs(
1133 dir.path(),
1134 &["missing.json".to_owned(), "policy.toml".to_owned()],
1135 )
1136 .unwrap_err();
1137 assert_eq!(errors.len(), 2);
1138 assert!(errors[0].message.contains("failed to read rule pack"));
1139 assert!(
1140 errors[1]
1141 .message
1142 .contains("unsupported rule pack extension")
1143 );
1144 }
1145
1146 #[test]
1147 fn rejects_paths_outside_root() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let inner = dir.path().join("project");
1150 std::fs::create_dir_all(&inner).unwrap();
1151 std::fs::write(
1152 dir.path().join("outside.json"),
1153 r#"{ "version": 1, "name": "p", "rules": [
1154 { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
1155 ] }"#,
1156 )
1157 .unwrap();
1158 let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
1159 assert!(errors[0].message.contains("outside the project root"));
1160 }
1161
1162 #[test]
1163 fn schema_validates_doc_example_shape() {
1164 let schema = RulePackDef::json_schema();
1165 let properties = schema
1166 .get("properties")
1167 .and_then(|p| p.as_object())
1168 .expect("schema should expose properties");
1169 assert!(properties.contains_key("version"));
1170 assert!(properties.contains_key("name"));
1171 assert!(properties.contains_key("rules"));
1172
1173 let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
1176 assert_eq!(pack.version, 1);
1177 }
1178}