1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::time::Duration;
4
5#[derive(Clone, Debug, PartialEq)]
6pub struct ResponseDiff {
7 pub status_changed: bool,
8 pub old_status: u16,
9 pub new_status: u16,
10 pub new_headers: Vec<(String, String)>,
11 pub missing_headers: Vec<String>,
12 pub changed_headers: Vec<(String, String, String)>,
13 pub body_size_delta: i64,
14 pub timing_delta_ms: i64,
15 pub body_similarity: f64,
16}
17
18impl ResponseDiff {
19 pub fn has_differences(&self) -> bool {
20 crate::diff::is_differential_match(self)
21 }
22}
23
24#[derive(Clone, Debug, PartialEq)]
25pub struct DiffPolicy {
26 pub timing_threshold_ms: i64,
27 pub similarity_threshold: f64,
28}
29
30impl Default for DiffPolicy {
31 fn default() -> Self {
32 Self {
33 timing_threshold_ms: 100,
34 similarity_threshold: 0.95,
35 }
36 }
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
40pub enum ObservationOutcome {
41 Match,
42 Error,
43 Silent,
44 Timeout,
45 Crash,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
49pub struct ProbeObservation {
50 pub outcome: ObservationOutcome,
51 pub error: Option<String>,
52 pub categories: Vec<String>,
53 pub elapsed: Duration,
54 pub return_value: Option<String>,
55 pub confirmed: bool,
56}
57
58impl ProbeObservation {
59 pub fn silent(elapsed: Duration) -> Self {
60 Self {
61 outcome: ObservationOutcome::Silent,
62 error: None,
63 categories: Vec::new(),
64 elapsed,
65 return_value: None,
66 confirmed: false,
67 }
68 }
69
70 pub fn matched(
71 elapsed: Duration,
72 categories: impl IntoIterator<Item = impl Into<String>>,
73 ) -> Self {
74 let mut categories: Vec<String> = categories.into_iter().map(|item| item.into()).collect();
75 categories.sort();
76 categories.dedup();
77 Self {
78 outcome: ObservationOutcome::Match,
79 error: None,
80 categories,
81 elapsed,
82 return_value: None,
83 confirmed: true,
84 }
85 }
86
87 pub fn error(elapsed: Duration, error: impl Into<String>) -> Self {
88 Self {
89 outcome: ObservationOutcome::Error,
90 error: Some(error.into()),
91 categories: Vec::new(),
92 elapsed,
93 return_value: None,
94 confirmed: false,
95 }
96 }
97}
98
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct ProbeVariant {
101 pub properties: BTreeMap<String, String>,
102 pub reason: String,
103}
104
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub enum PropertyRole {
107 Gate(Vec<String>),
108 Injectable,
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 fn baseline_diff() -> ResponseDiff {
116 ResponseDiff {
117 status_changed: false,
118 old_status: 200,
119 new_status: 200,
120 new_headers: Vec::new(),
121 missing_headers: Vec::new(),
122 changed_headers: Vec::new(),
123 body_size_delta: 0,
124 timing_delta_ms: 0,
125 body_similarity: 1.0,
126 }
127 }
128
129 #[test]
130 fn response_diff_has_no_differences_when_empty() {
131 assert!(!baseline_diff().has_differences());
132 }
133
134 #[test]
135 fn response_diff_detects_status_difference() {
136 let mut diff = baseline_diff();
137 diff.status_changed = true;
138 assert!(diff.has_differences());
139 }
140
141 #[test]
142 fn diff_policy_default_values_are_stable() {
143 let policy = DiffPolicy::default();
144 assert_eq!(policy.timing_threshold_ms, 100);
145 assert_eq!(policy.similarity_threshold, 0.95);
146 }
147
148 #[test]
149 fn silent_observation_sets_expected_fields() {
150 let observation = ProbeObservation::silent(Duration::from_millis(5));
151 assert_eq!(observation.outcome, ObservationOutcome::Silent);
152 assert_eq!(observation.error, None);
153 assert!(observation.categories.is_empty());
154 assert_eq!(observation.return_value, None);
155 assert!(!observation.confirmed);
156 }
157
158 #[test]
159 fn matched_observation_sorts_and_deduplicates_categories() {
160 let observation =
161 ProbeObservation::matched(Duration::from_millis(10), ["sql", "xss", "sql"]);
162 assert_eq!(observation.outcome, ObservationOutcome::Match);
163 assert_eq!(
164 observation.categories,
165 vec!["sql".to_string(), "xss".to_string()]
166 );
167 assert!(observation.confirmed);
168 }
169
170 #[test]
171 fn error_observation_sets_error_text() {
172 let observation = ProbeObservation::error(Duration::from_millis(2), "boom");
173 assert_eq!(observation.outcome, ObservationOutcome::Error);
174 assert_eq!(observation.error.as_deref(), Some("boom"));
175 assert!(!observation.confirmed);
176 }
177
178 #[test]
179 fn probe_variant_supports_value_comparison() {
180 let mut properties = BTreeMap::new();
181 properties.insert("role".to_string(), "admin".to_string());
182 let left = ProbeVariant {
183 properties: properties.clone(),
184 reason: "same".to_string(),
185 };
186 let right = ProbeVariant {
187 properties,
188 reason: "same".to_string(),
189 };
190 assert_eq!(left, right);
191 }
192
193 #[test]
194 fn property_role_gate_preserves_values() {
195 let role = PropertyRole::Gate(vec!["admin".to_string(), "user".to_string()]);
196 assert_eq!(
197 role,
198 PropertyRole::Gate(vec!["admin".to_string(), "user".to_string()])
199 );
200 }
201}