1use std::collections::BTreeMap;
85
86use serde::{Deserialize, Serialize};
87
88use crate::playbooks::{Playbook, PlaybookOverrides, PlaybookResolver, ResolvedPlaybook};
89use crate::types::TargetClass;
90use crate::vendor_classifier::{EvidenceBundle, VendorClassification, VendorId, VendorScore};
91use crate::vendor_resolver::error::VendorResolverError;
92use crate::vendor_resolver::rules::{MergeStrategy, ResolutionRule, VendorRuleMatch};
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum StrategyMarker {
105 Resolved {
107 playbook_id: String,
110 target_class: TargetClass,
112 },
113 Manual,
117}
118
119impl StrategyMarker {
120 #[must_use]
122 pub const fn is_resolved(&self) -> bool {
123 matches!(self, Self::Resolved { .. })
124 }
125
126 #[must_use]
128 pub const fn is_manual(&self) -> bool {
129 matches!(self, Self::Manual)
130 }
131
132 #[must_use]
135 pub fn playbook_id(&self) -> Option<&str> {
136 match self {
137 Self::Resolved { playbook_id, .. } => Some(playbook_id),
138 Self::Manual => None,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct AppliedRule {
150 pub rule_id: String,
152 pub fired: bool,
154 pub merge_strategy: MergeStrategy,
156 pub note: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
171pub struct ResolutionRationale {
172 pub summary: String,
174 pub applied_rules: Vec<AppliedRule>,
176 pub contributing_vendors: Vec<VendorScore>,
178 pub evidence: EvidenceBundle,
180 pub top_vendor: VendorId,
183 pub confidence: f64,
185 pub merge_strategy: MergeStrategy,
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196pub struct VendorResolution {
197 pub strategy: StrategyMarker,
199 pub rationale: ResolutionRationale,
201}
202
203impl VendorResolution {
204 #[must_use]
206 pub const fn is_resolved(&self) -> bool {
207 self.strategy.is_resolved()
208 }
209
210 #[must_use]
212 pub const fn is_manual(&self) -> bool {
213 self.strategy.is_manual()
214 }
215}
216
217#[derive(Debug, Clone)]
247pub struct VendorResolver {
248 rules: Vec<ResolutionRule>,
249}
250
251impl VendorResolver {
252 #[allow(clippy::missing_errors_doc)]
264 pub fn from_rules<I>(rules: I) -> Result<Self, VendorResolverError>
265 where
266 I: IntoIterator<Item = ResolutionRule>,
267 {
268 let mut by_id: BTreeMap<String, ResolutionRule> = BTreeMap::new();
269 for rule in rules {
270 rule.validate()?;
271 if by_id.contains_key(&rule.id) {
272 return Err(VendorResolverError::DuplicateId { rule_id: rule.id });
273 }
274 by_id.insert(rule.id.clone(), rule);
275 }
276 let mut sorted: Vec<ResolutionRule> = by_id.into_values().collect();
277 sorted.sort_by(|a, b| a.priority.cmp(&b.priority).then_with(|| a.id.cmp(&b.id)));
278 Ok(Self { rules: sorted })
279 }
280
281 #[must_use]
310 pub fn with_builtin_defaults() -> Self {
311 let rules = crate::vendor_resolver::builtins::builtin_resolution_rules();
312 #[allow(clippy::expect_used)]
318 Self::from_rules(rules).expect("builtin resolution rules are validated at compile time")
319 }
320
321 #[must_use]
323 pub fn contains(&self, id: &str) -> bool {
324 self.rules.iter().any(|r| r.id == id)
325 }
326
327 #[must_use]
329 pub const fn len(&self) -> usize {
330 self.rules.len()
331 }
332
333 #[must_use]
335 pub const fn is_empty(&self) -> bool {
336 self.rules.is_empty()
337 }
338
339 #[must_use]
341 pub fn rule_ids(&self) -> Vec<String> {
342 self.rules.iter().map(|r| r.id.clone()).collect()
343 }
344
345 #[must_use]
368 pub fn resolve(&self, classification: &VendorClassification) -> VendorResolution {
369 let top_score = classification.ranked.first().map_or(0, |s| s.score);
370 let mut applied: Vec<AppliedRule> = Vec::new();
371 let mut fired: Option<&ResolutionRule> = None;
372
373 for rule in &self.rules {
374 let note = evaluate_rule_note(rule, classification, top_score);
375 if rule_matches(rule, classification, top_score) {
376 applied.push(AppliedRule {
377 rule_id: rule.id.clone(),
378 fired: true,
379 merge_strategy: rule.merge_strategy,
380 note,
381 });
382 fired = Some(rule);
383 break;
384 }
385 applied.push(AppliedRule {
386 rule_id: rule.id.clone(),
387 fired: false,
388 merge_strategy: rule.merge_strategy,
389 note,
390 });
391 }
392
393 let chosen = fired.unwrap_or_else(|| {
394 self.rules.last().unwrap_or_else(|| manual_fallback_rule())
400 });
401
402 let strategy = strategy_from_rule(chosen, classification);
403 let summary = build_summary(&strategy, chosen, classification);
404 let merge_strategy = chosen.merge_strategy;
405 let rationale = ResolutionRationale {
406 summary,
407 applied_rules: applied,
408 contributing_vendors: classification.ranked.clone(),
409 evidence: classification.evidence.clone(),
410 top_vendor: classification.top_vendor,
411 confidence: classification.confidence,
412 merge_strategy,
413 };
414
415 VendorResolution {
416 strategy,
417 rationale,
418 }
419 }
420
421 pub fn resolve_with_playbooks(
437 &self,
438 classification: &VendorClassification,
439 playbook_resolver: &PlaybookResolver,
440 overrides: &PlaybookOverrides,
441 ) -> Result<Option<ResolvedPlaybook>, crate::playbooks::ValidationError> {
442 let resolution = self.resolve(classification);
443 let Some(playbook_id) = resolution.strategy.playbook_id() else {
444 return Ok(None);
445 };
446 let target_class = match &resolution.strategy {
447 StrategyMarker::Resolved { target_class, .. } => *target_class,
448 StrategyMarker::Manual => TargetClass::Unknown,
449 };
450 let resolved = playbook_resolver.resolve(target_class, playbook_id, overrides)?;
451 Ok(Some(resolved))
452 }
453}
454
455fn rule_matches(
456 rule: &ResolutionRule,
457 classification: &VendorClassification,
458 top_score: u32,
459) -> bool {
460 if classification.confidence < rule.min_confidence {
461 return false;
462 }
463 if top_score < rule.min_score {
464 return false;
465 }
466 if rule.require_unknown_vendor && classification.top_vendor != VendorId::Unknown {
467 return false;
468 }
469 if rule.vendors.is_empty() {
470 return true;
473 }
474 let classification_has_vendor = |v: &VendorId| {
484 if *v == VendorId::Unknown && classification.top_vendor == VendorId::Unknown {
485 return true;
486 }
487 classification
488 .ranked
489 .iter()
490 .any(|s| s.vendor == *v && s.score > 0)
491 };
492 rule.vendors
493 .iter()
494 .any(|v| classification_has_vendor(&v.vendor))
495}
496
497fn evaluate_rule_note(
498 rule: &ResolutionRule,
499 classification: &VendorClassification,
500 top_score: u32,
501) -> String {
502 if classification.confidence < rule.min_confidence {
503 format!(
504 "skipped: confidence {} < min_confidence {}",
505 classification.confidence, rule.min_confidence
506 )
507 } else if top_score < rule.min_score {
508 format!(
509 "skipped: top_score {top_score} < min_score {}",
510 rule.min_score
511 )
512 } else if rule.require_unknown_vendor && classification.top_vendor != VendorId::Unknown {
513 format!(
514 "skipped: top_vendor {} is not Unknown",
515 classification.top_vendor.label()
516 )
517 } else if rule.vendors.is_empty() {
518 "fired: catch-all rule (no vendor list, gates passed)".to_string()
519 } else if rule
520 .vendors
521 .iter()
522 .any(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
523 {
524 "fired: at least one listed vendor matched".to_string()
525 } else {
526 "skipped: no listed vendor matched".to_string()
527 }
528}
529
530fn strategy_from_rule(
531 rule: &ResolutionRule,
532 classification: &VendorClassification,
533) -> StrategyMarker {
534 match rule.merge_strategy {
535 MergeStrategy::Manual => StrategyMarker::Manual,
536 MergeStrategy::StrongestVendor => {
537 let winning = pick_strongest_vendor(rule, classification);
538 StrategyMarker::Resolved {
539 playbook_id: rule.playbook_id.clone(),
540 target_class: winning_target_class(rule, winning, classification),
541 }
542 }
543 MergeStrategy::Single => {
544 let winning = pick_single_vendor(rule, classification);
545 StrategyMarker::Resolved {
546 playbook_id: rule.playbook_id.clone(),
547 target_class: winning_target_class(rule, winning, classification),
548 }
549 }
550 }
551}
552
553fn pick_strongest_vendor<'a>(
554 rule: &'a ResolutionRule,
555 classification: &VendorClassification,
556) -> Option<&'a VendorRuleMatch> {
557 rule.vendors
558 .iter()
559 .filter(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
560 .max_by_key(|v| v.weight)
561}
562
563fn pick_single_vendor<'a>(
564 rule: &'a ResolutionRule,
565 classification: &VendorClassification,
566) -> Option<&'a VendorRuleMatch> {
567 rule.vendors
568 .iter()
569 .filter(|v| classification.ranked.iter().any(|s| s.vendor == v.vendor))
570 .min_by_key(|v| v.vendor)
571}
572
573fn manual_fallback_rule() -> &'static ResolutionRule {
580 static FALLBACK: std::sync::LazyLock<ResolutionRule> =
581 std::sync::LazyLock::new(|| ResolutionRule {
582 id: String::new(),
583 playbook_id: String::new(),
584 target_class: TargetClass::Unknown,
585 priority: u32::MAX,
586 merge_strategy: MergeStrategy::Manual,
587 description: String::new(),
588 min_confidence: 0.0,
589 min_score: 0,
590 require_unknown_vendor: false,
591 vendors: Vec::new(),
592 });
593 &FALLBACK
594}
595
596const fn winning_target_class(
597 rule: &ResolutionRule,
598 winning: Option<&VendorRuleMatch>,
599 _classification: &VendorClassification,
600) -> TargetClass {
601 let _ = winning;
605 rule.target_class
606}
607
608fn build_summary(
609 strategy: &StrategyMarker,
610 rule: &ResolutionRule,
611 classification: &VendorClassification,
612) -> String {
613 let vendor_label = classification.top_vendor.label();
614 let target_class_label = |tc: TargetClass| match tc {
615 TargetClass::Api => "api",
616 TargetClass::ContentSite => "content_site",
617 TargetClass::HighSecurity => "high_security",
618 TargetClass::Unknown => "unknown",
619 };
620 match strategy {
621 StrategyMarker::Resolved {
622 playbook_id,
623 target_class,
624 } => format!(
625 "rule '{}' fired for vendor {} (confidence {:.3}); resolved to playbook '{}' ({})",
626 rule.id,
627 vendor_label,
628 classification.confidence,
629 playbook_id,
630 target_class_label(*target_class),
631 ),
632 StrategyMarker::Manual => format!(
633 "rule '{}' fired for vendor {} (confidence {:.3}); deferring to manual mode",
634 rule.id, vendor_label, classification.confidence
635 ),
636 }
637}
638
639pub trait PlaybookResolverExt {
653 fn resolve_unsafe_playbook(
663 &self,
664 id: &str,
665 ) -> Result<Playbook, crate::playbooks::ValidationError>;
666}
667
668impl PlaybookResolverExt for PlaybookResolver {
669 fn resolve_unsafe_playbook(
670 &self,
671 id: &str,
672 ) -> Result<Playbook, crate::playbooks::ValidationError> {
673 let target_class = TargetClass::Unknown;
680 let overrides = PlaybookOverrides::default();
681 let resolved = self.resolve_optional(target_class, Some(id), &overrides)?;
682 Ok(Playbook {
683 id: resolved.playbook_id,
684 target_class: resolved.target_class,
685 description: String::new(),
686 acquisition: crate::playbooks::AcquisitionDefaults {
687 mode: resolved.acquisition.mode,
688 execution_mode: resolved.acquisition.execution_mode,
689 session_mode: resolved.acquisition.session_mode,
690 telemetry_level: resolved.acquisition.telemetry_level,
691 sticky_session_ttl_secs: resolved.acquisition.sticky_session_ttl_secs,
692 enable_warmup: resolved.acquisition.enable_warmup,
693 retry_budget: resolved.acquisition.retry_budget,
694 backoff_base_ms: resolved.acquisition.backoff_base_ms,
695 },
696 proxy_preference: resolved.proxy_preference,
697 pacing: resolved.pacing,
698 escalation: resolved.escalation,
699 })
700 }
701}
702
703#[cfg(test)]
704#[allow(
705 clippy::unwrap_used,
706 clippy::expect_used,
707 clippy::panic,
708 clippy::indexing_slicing,
709 clippy::similar_names
710)]
711mod tests {
712 use super::*;
713 use crate::vendor_classifier::{Evidence, EvidenceBundle, EvidenceSource};
714
715 fn approx_eq(a: f64, b: f64) -> bool {
716 (a - b).abs() < 1e-9
717 }
718
719 fn classification(
720 top_vendor: VendorId,
721 confidence: f64,
722 ranked: Vec<VendorScore>,
723 ) -> VendorClassification {
724 VendorClassification {
725 top_vendor,
726 confidence,
727 is_high_confidence: confidence >= 0.60,
728 ranked,
729 evidence: EvidenceBundle::default(),
730 threshold: 0.60,
731 }
732 }
733
734 fn datadome_score() -> VendorScore {
735 VendorScore {
736 vendor: VendorId::DataDome,
737 score: 15,
738 matched_sources: vec![(EvidenceSource::Header, 3), (EvidenceSource::Cookie, 1)]
739 .into_iter()
740 .collect(),
741 }
742 }
743
744 fn cloudflare_score() -> VendorScore {
745 VendorScore {
746 vendor: VendorId::Cloudflare,
747 score: 10,
748 matched_sources: vec![(EvidenceSource::Header, 2)].into_iter().collect(),
749 }
750 }
751
752 fn akamai_score() -> VendorScore {
753 VendorScore {
754 vendor: VendorId::Akamai,
755 score: 12,
756 matched_sources: vec![(EvidenceSource::Cookie, 2)].into_iter().collect(),
757 }
758 }
759
760 fn evidence() -> EvidenceBundle {
761 EvidenceBundle {
762 items: vec![Evidence {
763 signal: "x-datadome".to_string(),
764 source: EvidenceSource::Header,
765 weight: 5,
766 }],
767 source_summary: vec![(EvidenceSource::Header, 1)].into_iter().collect(),
768 }
769 }
770
771 #[test]
772 fn empty_resolver_returns_manual_marker() {
773 let resolver = VendorResolver::from_rules(Vec::new()).expect("empty resolver");
774 let c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
775 let r = resolver.resolve(&c);
776 assert!(r.is_manual());
779 assert!(r.rationale.applied_rules.is_empty());
780 }
781
782 #[test]
783 fn single_vendor_datadome_resolves_to_tier2_hostile() {
784 let resolver = VendorResolver::with_builtin_defaults();
785 let mut c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
786 c.evidence = evidence();
787 let r = resolver.resolve(&c);
788 match &r.strategy {
789 StrategyMarker::Resolved {
790 playbook_id,
791 target_class,
792 } => {
793 assert_eq!(playbook_id, "tier2-hostile");
794 assert_eq!(*target_class, TargetClass::HighSecurity);
795 }
796 StrategyMarker::Manual => panic!("DataDome should resolve, not defer"),
797 }
798 assert!(r.is_resolved());
799 assert_eq!(r.rationale.merge_strategy, MergeStrategy::StrongestVendor);
800 assert!(r.rationale.applied_rules.iter().any(|a| a.fired));
801 }
802
803 #[test]
804 fn single_vendor_cloudflare_resolves_to_tier1_js() {
805 let resolver = VendorResolver::with_builtin_defaults();
806 let c = classification(VendorId::Cloudflare, 0.9, vec![cloudflare_score()]);
807 let r = resolver.resolve(&c);
808 match &r.strategy {
809 StrategyMarker::Resolved {
810 playbook_id,
811 target_class,
812 } => {
813 assert_eq!(playbook_id, "tier1-js");
814 assert_eq!(*target_class, TargetClass::ContentSite);
815 }
816 StrategyMarker::Manual => panic!("Cloudflare should resolve, not defer"),
817 }
818 }
819
820 #[test]
821 fn multi_vendor_datadome_plus_cloudflare_picks_tier2_hostile() {
822 let resolver = VendorResolver::with_builtin_defaults();
823 let ranked = vec![
824 VendorScore {
825 vendor: VendorId::DataDome,
826 score: 15,
827 matched_sources: BTreeMap::new(),
828 },
829 VendorScore {
830 vendor: VendorId::Cloudflare,
831 score: 10,
832 matched_sources: BTreeMap::new(),
833 },
834 ];
835 let c = classification(VendorId::DataDome, 0.60, ranked);
836 let r = resolver.resolve(&c);
837 match &r.strategy {
840 StrategyMarker::Resolved { playbook_id, .. } => {
841 assert_eq!(playbook_id, "tier2-hostile");
842 }
843 StrategyMarker::Manual => panic!("multi-vendor should resolve"),
844 }
845 }
846
847 #[test]
848 fn low_confidence_datadome_falls_through_to_manual() {
849 let resolver = VendorResolver::with_builtin_defaults();
850 let c = classification(VendorId::DataDome, 0.30, vec![datadome_score()]);
855 let r = resolver.resolve(&c);
856 assert!(r.is_manual(), "expected Manual, got {:?}", r.strategy);
857 assert_eq!(r.rationale.top_vendor, VendorId::DataDome);
858 }
859
860 #[test]
861 fn unknown_with_no_evidence_picks_tier1_static() {
862 let resolver = VendorResolver::with_builtin_defaults();
863 let c = classification(VendorId::Unknown, 0.0, Vec::new());
864 let r = resolver.resolve(&c);
865 match &r.strategy {
866 StrategyMarker::Resolved {
867 playbook_id,
868 target_class,
869 } => {
870 assert_eq!(playbook_id, "tier1-static");
871 assert_eq!(*target_class, TargetClass::ContentSite);
872 }
873 StrategyMarker::Manual => panic!("clean Unknown should pick tier1-static"),
874 }
875 }
876
877 #[test]
878 fn unknown_vendor_with_some_evidence_falls_through_to_manual() {
879 let resolver = VendorResolver::with_builtin_defaults();
880 let c = classification(VendorId::DataDome, 0.0, vec![datadome_score()]);
886 let r = resolver.resolve(&c);
887 assert!(r.is_manual());
893 }
894
895 #[test]
896 fn akamai_vendor_resolves_to_tier2_hostile() {
897 let resolver = VendorResolver::with_builtin_defaults();
898 let c = classification(VendorId::Akamai, 0.85, vec![akamai_score()]);
899 let r = resolver.resolve(&c);
900 match &r.strategy {
901 StrategyMarker::Resolved { playbook_id, .. } => {
902 assert_eq!(playbook_id, "tier2-hostile");
903 }
904 StrategyMarker::Manual => panic!("Akamai should resolve"),
905 }
906 }
907
908 #[test]
909 fn perimeterx_vendor_resolves_to_tier2_hostile() {
910 let resolver = VendorResolver::with_builtin_defaults();
911 let c = classification(
912 VendorId::PerimeterX,
913 0.95,
914 vec![VendorScore {
915 vendor: VendorId::PerimeterX,
916 score: 18,
917 matched_sources: BTreeMap::new(),
918 }],
919 );
920 let r = resolver.resolve(&c);
921 match &r.strategy {
922 StrategyMarker::Resolved { playbook_id, .. } => {
923 assert_eq!(playbook_id, "tier2-hostile");
924 }
925 StrategyMarker::Manual => panic!("PerimeterX should resolve"),
926 }
927 }
928
929 #[test]
930 fn rationale_summary_mentions_top_vendor_and_confidence() {
931 let resolver = VendorResolver::with_builtin_defaults();
932 let c = classification(VendorId::DataDome, 0.9, vec![datadome_score()]);
933 let r = resolver.resolve(&c);
934 assert!(r.rationale.summary.contains("datadome"));
935 assert!(r.rationale.summary.contains("tier2-hostile"));
936 }
937
938 #[test]
939 fn rationale_records_every_evaluated_rule() {
940 let resolver = VendorResolver::with_builtin_defaults();
941 let c = classification(VendorId::DataDome, 1.0, vec![datadome_score()]);
942 let r = resolver.resolve(&c);
943 let rule_ids: Vec<&str> = r
944 .rationale
945 .applied_rules
946 .iter()
947 .map(|a| a.rule_id.as_str())
948 .collect();
949 assert_eq!(rule_ids, vec!["tier2-hostile"]);
953 }
954
955 #[test]
956 fn rule_ids_are_sorted_by_priority_then_id() {
957 let resolver = VendorResolver::with_builtin_defaults();
958 let ids = resolver.rule_ids();
959 assert_eq!(
960 ids,
961 vec![
962 "tier2-hostile".to_string(),
963 "tier1-js-cloudflare".to_string(),
964 "tier1-static".to_string(),
965 "default-manual".to_string(),
966 ]
967 );
968 }
969
970 #[test]
971 fn confidence_propagates_into_rationale() {
972 let resolver = VendorResolver::with_builtin_defaults();
973 let c = classification(VendorId::DataDome, 0.9, vec![datadome_score()]);
974 let r = resolver.resolve(&c);
975 assert!(approx_eq(r.rationale.confidence, 0.9));
976 assert_eq!(r.rationale.top_vendor, VendorId::DataDome);
977 }
978
979 #[test]
980 fn from_rules_rejects_duplicates() {
981 let rule = ResolutionRule {
982 id: "dup".to_string(),
983 playbook_id: "tier2-hostile".to_string(),
984 target_class: TargetClass::HighSecurity,
985 priority: 0,
986 merge_strategy: MergeStrategy::StrongestVendor,
987 description: String::new(),
988 min_confidence: 0.0,
989 min_score: 0,
990 require_unknown_vendor: false,
991 vendors: vec![VendorRuleMatch {
992 vendor: VendorId::DataDome,
993 weight: 5,
994 }],
995 };
996 let result = VendorResolver::from_rules(vec![rule.clone(), rule]);
997 assert!(matches!(
998 result,
999 Err(VendorResolverError::DuplicateId { .. })
1000 ));
1001 }
1002
1003 #[test]
1004 fn from_rules_rejects_invalid_rule() {
1005 let rule = ResolutionRule {
1006 id: "broken".to_string(),
1007 playbook_id: String::new(),
1008 target_class: TargetClass::HighSecurity,
1009 priority: 0,
1010 merge_strategy: MergeStrategy::StrongestVendor,
1011 description: String::new(),
1012 min_confidence: 0.0,
1013 min_score: 0,
1014 require_unknown_vendor: false,
1015 vendors: vec![VendorRuleMatch {
1016 vendor: VendorId::DataDome,
1017 weight: 5,
1018 }],
1019 };
1020 let result = VendorResolver::from_rules(vec![rule]);
1021 assert!(result.is_err());
1022 }
1023
1024 #[test]
1025 fn manual_strategy_marker_helpers() {
1026 let manual = StrategyMarker::Manual;
1027 assert!(manual.is_manual());
1028 assert!(!manual.is_resolved());
1029 assert!(manual.playbook_id().is_none());
1030
1031 let resolved = StrategyMarker::Resolved {
1032 playbook_id: "tier2-hostile".to_string(),
1033 target_class: TargetClass::HighSecurity,
1034 };
1035 assert!(resolved.is_resolved());
1036 assert!(!resolved.is_manual());
1037 assert_eq!(resolved.playbook_id(), Some("tier2-hostile"));
1038 }
1039}