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 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 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
306fn 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
358fn 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
450fn 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 let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
822 assert_eq!(pack.version, 1);
823 }
824}