1use std::collections::{BTreeMap, BTreeSet};
34use std::net::IpAddr;
35
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38
39use crate::compiler::validate_pattern_safety;
40use crate::decorators::VALID_CLOUD_PROVIDERS;
41use crate::security_config::SecurityConfig;
42
43pub const LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION: u64 = 1;
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(default)]
53#[allow(clippy::derive_partial_eq_without_eq)]
54pub struct DynamicRules {
55 pub rule_id: String,
57 pub version: u64,
59 pub timestamp: Option<DateTime<Utc>>,
61 pub expires_at: Option<DateTime<Utc>>,
63 pub ttl: u64,
65 pub ip_blacklist: Vec<String>,
67 pub ip_whitelist: Vec<String>,
69 pub ip_ban_duration: u64,
71 pub blocked_countries: Vec<String>,
73 pub whitelist_countries: Vec<String>,
75 pub global_rate_limit: Option<u64>,
77 pub global_rate_window: Option<u64>,
79 pub endpoint_rate_limits: BTreeMap<String, (u64, u64)>,
81 pub blocked_cloud_providers: BTreeSet<String>,
83 pub blocked_user_agents: Vec<String>,
85 pub suspicious_patterns: Vec<String>,
87 pub enable_penetration_detection: Option<bool>,
89 pub enable_ip_banning: Option<bool>,
91 pub enable_rate_limiting: Option<bool>,
93 pub auto_ban_threshold: Option<u64>,
95 pub auto_ban_duration: Option<u64>,
97 pub enable_rate_limit_auto_ban: Option<bool>,
99 pub emergency_mode: bool,
101 pub emergency_whitelist: Vec<String>,
103}
104
105impl Default for DynamicRules {
106 fn default() -> Self {
107 Self {
108 rule_id: String::new(),
109 version: 0,
110 timestamp: None,
111 expires_at: None,
112 ttl: 300,
113 ip_blacklist: Vec::new(),
114 ip_whitelist: Vec::new(),
115 ip_ban_duration: 3600,
116 blocked_countries: Vec::new(),
117 whitelist_countries: Vec::new(),
118 global_rate_limit: None,
119 global_rate_window: None,
120 endpoint_rate_limits: BTreeMap::new(),
121 blocked_cloud_providers: BTreeSet::new(),
122 blocked_user_agents: Vec::new(),
123 suspicious_patterns: Vec::new(),
124 enable_penetration_detection: None,
125 enable_ip_banning: None,
126 enable_rate_limiting: None,
127 auto_ban_threshold: None,
128 auto_ban_duration: None,
129 enable_rate_limit_auto_ban: None,
130 emergency_mode: false,
131 emergency_whitelist: Vec::new(),
132 }
133 }
134}
135
136impl DynamicRules {
137 #[must_use]
139 pub fn identity(&self) -> (&str, u64) {
140 (&self.rule_id, self.version)
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct LastKnownRulesSnapshot {
148 pub schema_version: u64,
150 pub rules: DynamicRules,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct SnapshotError(pub String);
157
158impl core::fmt::Display for SnapshotError {
159 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160 write!(f, "last-known rules snapshot error: {}", self.0)
161 }
162}
163
164impl std::error::Error for SnapshotError {}
165
166pub fn dump_last_known_rules_snapshot(rules: &DynamicRules) -> Result<String, SnapshotError> {
172 let snapshot = LastKnownRulesSnapshot {
173 schema_version: LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION,
174 rules: rules.clone(),
175 };
176 serde_json::to_string(&snapshot)
177 .map_err(|error| SnapshotError(format!("snapshot serialization: {error}")))
178}
179
180pub fn load_last_known_rules_snapshot(payload: &str) -> Result<DynamicRules, SnapshotError> {
187 let snapshot: LastKnownRulesSnapshot = serde_json::from_str(payload)
188 .map_err(|error| SnapshotError(format!("snapshot decode: {error}")))?;
189 if snapshot.schema_version != LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION {
190 return Err(SnapshotError(format!(
191 "Unsupported last-known dynamic rules snapshot schema version: {}",
192 snapshot.schema_version
193 )));
194 }
195 if !snapshot.rules.auto_ban_overrides_valid() {
196 return Err(SnapshotError(String::from(
197 "snapshot decode: auto_ban_threshold and auto_ban_duration must be >= 1",
198 )));
199 }
200 Ok(snapshot.rules)
201}
202
203impl DynamicRules {
204 #[must_use]
207 pub const fn auto_ban_overrides_valid(&self) -> bool {
208 !matches!(self.auto_ban_threshold, Some(0)) && !matches!(self.auto_ban_duration, Some(0))
209 }
210}
211
212#[must_use]
215pub fn has_expired(rules: &DynamicRules, now: DateTime<Utc>) -> bool {
216 rules.expires_at.is_some_and(|expires_at| now > expires_at)
217}
218
219#[must_use]
222pub fn should_update(current: Option<&DynamicRules>, incoming: &DynamicRules) -> bool {
223 current.is_none_or(|current| {
224 !(incoming.rule_id == current.rule_id && incoming.version <= current.version)
225 })
226}
227
228#[must_use]
231pub fn expired_identity(
232 last_skipped: &mut Option<(String, u64)>,
233 rules: &DynamicRules,
234 now: DateTime<Utc>,
235) -> bool {
236 if !has_expired(rules, now) {
237 return false;
238 }
239 let key = (rules.rule_id.clone(), rules.version);
240 if last_skipped.as_ref() != Some(&key) {
241 *last_skipped = Some(key);
242 }
243 true
244}
245
246#[must_use]
254pub fn matches_event(
255 rules: &DynamicRules,
256 ip: Option<&str>,
257 country: Option<&str>,
258 event_type: &str,
259) -> Option<(String, u64)> {
260 if let Some(ip) = ip
261 && (rules.ip_blacklist.iter().any(|banned| banned == ip)
262 || rules.ip_whitelist.iter().any(|allowed| allowed == ip))
263 {
264 return Some((rules.rule_id.clone(), rules.version));
265 }
266 if let Some(country) = country
267 && rules
268 .blocked_countries
269 .iter()
270 .any(|blocked| blocked.eq_ignore_ascii_case(country))
271 {
272 return Some((rules.rule_id.clone(), rules.version));
273 }
274 let type_matches = match event_type {
275 "rate_limited" => {
276 rules.global_rate_limit.is_some() || !rules.endpoint_rate_limits.is_empty()
277 }
278 "cloud_blocked" => !rules.blocked_cloud_providers.is_empty(),
279 "user_agent_blocked" => !rules.blocked_user_agents.is_empty(),
280 _ => false,
281 };
282 if type_matches {
283 return Some((rules.rule_id.clone(), rules.version));
284 }
285 None
286}
287
288#[derive(Debug, Clone, Default, PartialEq, Eq)]
293pub struct AppliedDynamicRules {
294 pub bans: Vec<(IpAddr, u64)>,
296 pub unparseable_bans: Vec<String>,
298 pub unbans: Vec<IpAddr>,
300 pub unparseable_unbans: Vec<String>,
302 pub patterns: Vec<String>,
304 pub rejected_user_agents: Vec<(String, String)>,
307 pub emergency_activated: bool,
309}
310
311fn validate_user_agent_rules(
315 patterns: &[String],
316 cache: Option<&crate::redos::validation_cache::ValidationCache>,
317) -> (Vec<String>, Vec<(String, String)>) {
318 let mut valid = Vec::new();
319 let mut rejected = Vec::new();
320 for pattern in patterns {
321 let (is_safe, reason) = cache.map_or_else(
322 || validate_pattern_safety(pattern),
323 |cache| cache.validate_compat(pattern),
324 );
325 if is_safe {
326 valid.push(pattern.clone());
327 } else {
328 rejected.push((pattern.clone(), reason));
329 }
330 }
331 (valid, rejected)
332}
333
334pub fn apply_to_config(config: &mut SecurityConfig, rules: &DynamicRules) -> AppliedDynamicRules {
341 apply_to_config_with_validation_cache(config, rules, None)
342}
343
344pub fn apply_to_config_with_validation_cache(
352 config: &mut SecurityConfig,
353 rules: &DynamicRules,
354 cache: Option<&crate::redos::validation_cache::ValidationCache>,
355) -> AppliedDynamicRules {
356 let mut outcome = AppliedDynamicRules::default();
357
358 for ip in &rules.ip_blacklist {
361 match ip.parse::<IpAddr>() {
362 Ok(parsed) => outcome.bans.push((parsed, rules.ip_ban_duration)),
363 Err(_) => outcome.unparseable_bans.push(ip.clone()),
364 }
365 }
366 for ip in &rules.ip_whitelist {
367 match ip.parse::<IpAddr>() {
368 Ok(parsed) => outcome.unbans.push(parsed),
369 Err(_) => outcome.unparseable_unbans.push(ip.clone()),
370 }
371 }
372
373 let countries_resolvable = config.geo_ip_handler.is_some()
377 || config
378 .ipinfo_token
379 .as_deref()
380 .is_some_and(|token| !token.is_empty());
381 if countries_resolvable && !rules.blocked_countries.is_empty() {
382 config.blocked_countries = rules
383 .blocked_countries
384 .iter()
385 .map(|c| c.to_uppercase())
386 .collect::<BTreeSet<_>>();
387 }
388 if countries_resolvable && !rules.whitelist_countries.is_empty() {
389 config.whitelist_countries = rules
390 .whitelist_countries
391 .iter()
392 .map(|c| c.to_uppercase())
393 .collect::<BTreeSet<_>>();
394 }
395
396 if let Some(limit) = rules.global_rate_limit.filter(|limit| *limit > 0) {
402 config.rate_limit = u32::try_from(limit).unwrap_or(u32::MAX);
403 if let Some(window) = rules.global_rate_window.filter(|window| *window > 0) {
404 config.rate_limit_window = window;
405 }
406 }
407 if !rules.endpoint_rate_limits.is_empty() {
408 config.endpoint_rate_limits = rules
409 .endpoint_rate_limits
410 .iter()
411 .map(|(endpoint, (limit, window))| {
412 (
413 endpoint.clone(),
414 (u32::try_from(*limit).unwrap_or(u32::MAX), *window),
415 )
416 })
417 .collect();
418 }
419
420 if !rules.blocked_cloud_providers.is_empty() {
424 let valid: BTreeSet<String> = rules
425 .blocked_cloud_providers
426 .iter()
427 .filter(|provider| {
428 let base = provider.split(":!").next().unwrap_or(provider);
429 VALID_CLOUD_PROVIDERS.contains(&base)
430 })
431 .cloned()
432 .collect();
433 config.block_cloud_providers = Some(valid);
434 }
435
436 if !rules.blocked_user_agents.is_empty() {
439 let (valid, rejected) = validate_user_agent_rules(&rules.blocked_user_agents, cache);
440 outcome.rejected_user_agents = rejected;
441 config.blocked_user_agents = valid;
442 }
443
444 if let Some(flag) = rules.enable_penetration_detection {
446 config.enable_penetration_detection = flag;
447 }
448 if let Some(flag) = rules.enable_ip_banning {
449 config.enable_ip_banning = flag;
450 }
451 if let Some(flag) = rules.enable_rate_limiting {
452 config.enable_rate_limiting = flag;
453 }
454 if let Some(threshold) = rules.auto_ban_threshold {
455 config.auto_ban_threshold = u32::try_from(threshold).unwrap_or(u32::MAX);
456 }
457 if let Some(duration) = rules.auto_ban_duration {
458 config.auto_ban_duration = duration;
459 }
460 if let Some(flag) = rules.enable_rate_limit_auto_ban {
461 config.enable_rate_limit_auto_ban = flag;
462 }
463
464 if rules.emergency_mode {
467 config.emergency_mode = true;
468 config
469 .emergency_whitelist
470 .clone_from(&rules.emergency_whitelist);
471 let original = config.auto_ban_threshold;
472 config.auto_ban_threshold = (original / 2).max(1);
473 outcome.emergency_activated = true;
474 }
475
476 outcome.patterns.clone_from(&rules.suspicious_patterns);
479 outcome
480}
481
482#[cfg(test)]
483mod dynamic_rules_tests {
484 use super::*;
485 use chrono::TimeZone;
486
487 fn rules() -> DynamicRules {
488 DynamicRules {
489 rule_id: String::from("rule-1"),
490 version: 2,
491 ..DynamicRules::default()
492 }
493 }
494
495 fn at(secs: i64) -> DateTime<Utc> {
496 Utc.timestamp_opt(secs, 0).single().expect("valid time")
497 }
498
499 #[test]
500 fn the_model_defaults_carry_the_reference_values() {
501 let base = DynamicRules::default();
502 assert_eq!(base.ttl, 300);
503 assert_eq!(base.ip_ban_duration, 3600);
504 assert!(!base.emergency_mode);
505 assert!(base.ip_blacklist.is_empty());
506 assert_eq!(base.identity(), ("", 0));
507 }
508
509 #[test]
510 fn the_snapshot_envelope_round_trips_and_guards_the_version() {
511 let rules = rules();
512 let payload = dump_last_known_rules_snapshot(&rules).expect("dumps");
513 assert!(payload.contains("\"schema_version\":1"));
514 let loaded = load_last_known_rules_snapshot(&payload).expect("loads");
515 assert_eq!(loaded, rules);
516
517 let wrong_version = payload.replace("\"schema_version\":1", "\"schema_version\":9");
518 let error = load_last_known_rules_snapshot(&wrong_version).expect_err("rejects");
519 assert!(error.to_string().contains("Unsupported last-known"));
520
521 let decode_error = load_last_known_rules_snapshot("not json").expect_err("rejects");
522 assert!(decode_error.to_string().contains("snapshot decode"));
523 let error_shape = SnapshotError(String::from("boom"));
524 assert_eq!(
525 error_shape.to_string(),
526 "last-known rules snapshot error: boom"
527 );
528 }
529
530 #[test]
531 fn the_expiry_gate_reads_the_deadline() {
532 let rules = DynamicRules {
533 expires_at: Some(at(500)),
534 ..rules()
535 };
536 assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
537 assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
538 assert!(has_expired(&rules, at(501)));
539 }
540
541 #[test]
542 fn the_update_gate_advances_versions_never_replays() {
543 assert!(should_update(None, &rules()));
544 let incoming = rules();
545 assert!(!should_update(Some(&incoming), &incoming));
546
547 let mut newer = rules();
548 newer.version = 3;
549 assert!(should_update(Some(&incoming), &newer));
550
551 let mut stale = rules();
552 stale.version = 1;
553 assert!(!should_update(Some(&incoming), &stale));
554
555 let mut other_id = rules();
556 other_id.rule_id = String::from("rule-2");
557 other_id.version = 1;
558 assert!(
559 should_update(Some(&incoming), &other_id),
560 "a new id updates"
561 );
562 }
563
564 #[test]
565 fn the_expired_identity_deduplicates_its_warning() {
566 let mut last_skipped: Option<(String, u64)> = None;
567 let mut expiring = rules();
568 expiring.expires_at = Some(at(500));
569
570 assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
571 let first = last_skipped.clone();
572 assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
573 assert_eq!(first, last_skipped, "the same rule logs once");
574
575 let mut fresh = rules();
576 fresh.rule_id = String::from("rule-2");
577 fresh.version = 7;
578 fresh.expires_at = Some(at(100));
579 assert!(expired_identity(&mut last_skipped, &fresh, at(501)));
580 assert_ne!(first, last_skipped, "a new identity re-warns");
581
582 assert!(!expired_identity(&mut last_skipped, &rules(), at(501)));
583 }
584
585 #[test]
586 fn the_event_matcher_answers_the_three_arms() {
587 let mut r = DynamicRules {
588 ip_blacklist: vec![String::from("192.0.2.1")],
589 ip_whitelist: vec![String::from("192.0.2.2")],
590 blocked_countries: vec![String::from("CN")],
591 ..rules()
592 };
593
594 assert_eq!(
595 matches_event(&r, Some("192.0.2.1"), None, "other"),
596 Some((String::from("rule-1"), 2))
597 );
598 assert_eq!(
599 matches_event(&r, Some("192.0.2.2"), None, "other"),
600 Some((String::from("rule-1"), 2)),
601 "the whitelist arm correlates too"
602 );
603 assert_eq!(
604 matches_event(&r, None, Some("cn"), "other"),
605 Some((String::from("rule-1"), 2)),
606 "the country arm is case-insensitive"
607 );
608 assert_eq!(
609 matches_event(&r, Some("192.0.2.9"), Some("US"), "other"),
610 None
611 );
612 assert_eq!(matches_event(&r, None, None, "rate_limited"), None);
613
614 r.global_rate_limit = Some(10);
615 assert!(matches_event(&r, None, None, "rate_limited").is_some());
616 r.blocked_cloud_providers.insert(String::from("AWS"));
617 assert!(matches_event(&r, None, None, "cloud_blocked").is_some());
618 r.blocked_user_agents = vec![String::from("curl")];
619 assert!(matches_event(&r, None, None, "user_agent_blocked").is_some());
620 }
621
622 #[test]
623 fn country_rules_need_a_resolver_and_zero_rate_limits_are_ignored() {
624 let mut config = SecurityConfig {
625 rate_limit: 100,
626 rate_limit_window: 60,
627 ..SecurityConfig::default()
628 };
629 let mut r = rules();
630 r.blocked_countries = vec![String::from("cn")];
631 r.whitelist_countries = vec![String::from("us")];
632 r.global_rate_limit = Some(0);
633 apply_to_config(&mut config, &r);
634 assert!(config.blocked_countries.is_empty());
635 assert!(config.whitelist_countries.is_empty());
636 assert_eq!(config.rate_limit, 100);
637
638 config.ipinfo_token = Some(String::new());
639 apply_to_config(&mut config, &r);
640 assert!(
641 config.blocked_countries.is_empty(),
642 "an empty token is falsy"
643 );
644
645 config.ipinfo_token = Some(String::from("token"));
646 r.global_rate_limit = Some(5);
647 r.global_rate_window = Some(0);
648 apply_to_config(&mut config, &r);
649 assert_eq!(
650 config.blocked_countries,
651 BTreeSet::from([String::from("CN")])
652 );
653 assert_eq!((config.rate_limit, config.rate_limit_window), (5, 60));
654 }
655
656 #[test]
657 fn a_zero_auto_ban_override_is_an_invalid_delivery() {
658 let mut r = rules();
659 assert!(r.auto_ban_overrides_valid());
660 r.auto_ban_threshold = Some(0);
661 assert!(!r.auto_ban_overrides_valid());
662 r.auto_ban_threshold = None;
663 r.auto_ban_duration = Some(0);
664 assert!(!r.auto_ban_overrides_valid());
665 let payload = dump_last_known_rules_snapshot(&r).expect("dumps");
666 let error = load_last_known_rules_snapshot(&payload).expect_err("rejects");
667 assert!(error.to_string().contains(">= 1"));
668 }
669
670 #[test]
671 fn the_application_lands_every_config_surface() {
672 let mut config = SecurityConfig {
673 auto_ban_threshold: 10,
674 ipinfo_token: Some(String::from("token")),
675 ..SecurityConfig::default()
676 };
677
678 let mut r = rules();
679 r.ip_blacklist = vec![String::from("192.0.2.1"), String::from("not-an-ip")];
680 r.ip_whitelist = vec![String::from("192.0.2.2"), String::from("also-bad")];
681 r.blocked_countries = vec![String::from("cn"), String::from("ru")];
682 r.whitelist_countries = vec![String::from("us")];
683 r.global_rate_limit = Some(25);
684 r.global_rate_window = Some(45);
685 r.endpoint_rate_limits.insert(String::from("/api"), (7, 30));
686 r.blocked_cloud_providers = BTreeSet::from([
687 String::from("AWS"),
688 String::from("AWS:!prod"),
689 String::from("GCP"),
690 String::from("Mystery"),
691 ]);
692 r.blocked_user_agents = vec![String::from("evil-agent"), String::from("(")];
693 r.enable_penetration_detection = Some(false);
694 r.enable_ip_banning = Some(false);
695 r.enable_rate_limiting = Some(true);
696 r.auto_ban_threshold = Some(20);
697 r.auto_ban_duration = Some(9_000);
698 r.enable_rate_limit_auto_ban = Some(false);
699 r.emergency_mode = true;
700 r.emergency_whitelist = vec![String::from("10.0.0.1")];
701 r.suspicious_patterns = vec![String::from("union.*select")];
702
703 let outcome = apply_to_config(&mut config, &r);
704
705 assert_eq!(outcome.bans, vec![(ip("192.0.2.1"), 3_600)]);
706 assert_eq!(outcome.unparseable_bans, vec![String::from("not-an-ip")]);
707 assert_eq!(outcome.unbans, vec![ip("192.0.2.2")]);
708 assert_eq!(outcome.unparseable_unbans, vec![String::from("also-bad")]);
709 assert_eq!(
710 config.blocked_countries,
711 BTreeSet::from([String::from("CN"), String::from("RU")])
712 );
713 assert_eq!(
714 config.whitelist_countries,
715 BTreeSet::from([String::from("US")])
716 );
717 assert_eq!(config.rate_limit, 25);
718 assert_eq!(config.rate_limit_window, 45);
719 assert_eq!(config.endpoint_rate_limits["/api"], (7, 30));
720 assert_eq!(
721 config.block_cloud_providers,
722 Some(BTreeSet::from([
723 String::from("AWS"),
724 String::from("AWS:!prod"),
725 String::from("GCP"),
726 ]))
727 );
728 assert_eq!(config.blocked_user_agents, vec![String::from("evil-agent")]);
729 assert_eq!(
730 outcome.rejected_user_agents.len(),
731 1,
732 "the unsafe pattern rejects with its reason"
733 );
734 assert!(!config.enable_penetration_detection);
735 assert!(!config.enable_ip_banning);
736 assert!(config.enable_rate_limiting);
737 assert_eq!(
738 config.auto_ban_threshold, 10,
739 "the emergency halves 20 to 10"
740 );
741 assert_eq!(config.auto_ban_duration, 9_000);
742 assert!(!config.enable_rate_limit_auto_ban);
743 assert!(config.emergency_mode);
744 assert_eq!(config.emergency_whitelist, vec![String::from("10.0.0.1")]);
745 assert!(outcome.emergency_activated);
746 assert_eq!(outcome.patterns, vec![String::from("union.*select")]);
747 }
748
749 #[test]
750 fn the_application_without_rule_content_changes_nothing() {
751 let mut config = SecurityConfig::default();
752 let before = config.clone();
753 let outcome = apply_to_config(&mut config, &rules());
754 assert_eq!(config.blocked_countries, before.blocked_countries);
755 assert_eq!(config.rate_limit, before.rate_limit);
756 assert_eq!(config.emergency_mode, before.emergency_mode);
757 assert_eq!(config.auto_ban_threshold, before.auto_ban_threshold);
758 assert!(outcome.bans.is_empty());
759 assert!(outcome.unbans.is_empty());
760 assert!(outcome.patterns.is_empty());
761 assert!(outcome.rejected_user_agents.is_empty());
762 assert!(!outcome.emergency_activated);
763 }
764
765 #[test]
766 fn the_emergency_threshold_never_reaches_zero() {
767 let mut config = SecurityConfig {
768 auto_ban_threshold: 1,
769 ..SecurityConfig::default()
770 };
771 let mut r = rules();
772 r.emergency_mode = true;
773 let outcome = apply_to_config(&mut config, &r);
774 assert_eq!(config.auto_ban_threshold, 1, "max(1, 1/2)");
775 assert!(outcome.emergency_activated);
776 }
777
778 fn ip(text: &str) -> IpAddr {
779 text.parse().expect("valid ip")
780 }
781
782 #[test]
783 fn debug_roundtrip() {
784 let mut r = DynamicRules {
785 rule_id: String::from("rule-1"),
786 version: 2,
787 ..DynamicRules::default()
788 };
789 r.ip_blacklist = vec![String::from("192.0.2.1")];
790 let v = serde_json::to_value(&r).expect("serializes");
791 let back: DynamicRules = serde_json::from_value(v).expect("decodes");
792 assert_eq!(back.rule_id, String::from("rule-1"));
793 assert_eq!(back.ip_blacklist, vec![String::from("192.0.2.1")]);
794 }
795
796 #[test]
797 fn a_wrong_typed_field_fails_the_decode() {
798 let payload = serde_json::json!({ "version": "not-a-number" });
799 let back: Result<DynamicRules, _> = serde_json::from_value(payload);
800 assert!(back.is_err(), "a wrong-typed field is a decode error");
801 }
802}