mockforge_core/
deceptive_canary.rs1use serde::{Deserialize, Serialize};
7use std::collections::hash_map::DefaultHasher;
8use std::collections::HashMap;
9use std::hash::{Hash, Hasher};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14pub struct DeceptiveCanaryConfig {
15 pub enabled: bool,
17 pub traffic_percentage: f64,
19 pub team_identifiers: TeamIdentifiers,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub opt_out_header: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub opt_out_query_param: Option<String>,
27 pub deceptive_deploy_url: String,
29 pub routing_strategy: CanaryRoutingStrategy,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub stats: Option<CanaryStats>,
34}
35
36impl Default for DeceptiveCanaryConfig {
37 fn default() -> Self {
38 Self {
39 enabled: false,
40 traffic_percentage: 0.05, team_identifiers: TeamIdentifiers::default(),
42 opt_out_header: Some("X-Opt-Out-Canary".to_string()),
43 opt_out_query_param: Some("no-canary".to_string()),
44 deceptive_deploy_url: String::new(),
45 routing_strategy: CanaryRoutingStrategy::ConsistentHash,
46 stats: Some(CanaryStats::default()),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct TeamIdentifiers {
55 #[serde(default)]
57 pub user_agents: Option<Vec<String>>,
58 #[serde(default)]
60 pub ip_ranges: Option<Vec<String>>,
61 #[serde(default)]
63 pub headers: Option<HashMap<String, String>>,
64 #[serde(default)]
66 pub teams: Option<Vec<String>>,
67}
68
69#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
71#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
72#[serde(rename_all = "snake_case")]
73pub enum CanaryRoutingStrategy {
74 ConsistentHash,
76 Random,
78 RoundRobin,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, Default)]
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
85pub struct CanaryStats {
86 pub total_requests: u64,
88 pub canary_requests: u64,
90 pub opted_out_requests: u64,
92 pub matched_requests: u64,
94}
95
96impl CanaryStats {
97 pub fn canary_percentage(&self) -> f64 {
99 if self.matched_requests == 0 {
100 return 0.0;
101 }
102 (self.canary_requests as f64 / self.matched_requests as f64) * 100.0
103 }
104}
105
106pub struct DeceptiveCanaryRouter {
110 config: DeceptiveCanaryConfig,
111 round_robin_counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
112}
113
114impl DeceptiveCanaryRouter {
115 pub fn new(config: DeceptiveCanaryConfig) -> Self {
117 Self {
118 config,
119 round_robin_counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
120 }
121 }
122
123 pub fn should_route_to_canary(
135 &self,
136 user_agent: Option<&str>,
137 ip_address: Option<&str>,
138 headers: &HashMap<String, String>,
139 query_params: &HashMap<String, String>,
140 user_id: Option<&str>,
141 ) -> bool {
142 if !self.config.enabled {
144 return false;
145 }
146
147 if let Some(opt_out_header) = &self.config.opt_out_header {
149 if headers.get(opt_out_header).is_some() {
150 return false;
151 }
152 }
153
154 if let Some(opt_out_param) = &self.config.opt_out_query_param {
155 if query_params.get(opt_out_param).is_some() {
156 return false;
157 }
158 }
159
160 if !self.matches_team_criteria(user_agent, ip_address, headers) {
162 return false;
163 }
164
165 match self.config.routing_strategy {
168 CanaryRoutingStrategy::ConsistentHash => {
169 self.consistent_hash_route(user_id, ip_address)
170 }
171 CanaryRoutingStrategy::Random => self.random_route(),
172 CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
173 }
174 }
175
176 fn matches_team_criteria(
178 &self,
179 user_agent: Option<&str>,
180 ip_address: Option<&str>,
181 headers: &HashMap<String, String>,
182 ) -> bool {
183 if let Some(user_agents) = &self.config.team_identifiers.user_agents {
185 if let Some(ua) = user_agent {
186 let matches = user_agents.iter().any(|pattern| {
187 if pattern == "*" {
188 true
189 } else {
190 ua.contains(pattern)
192 }
193 });
194 if !matches {
195 return false;
196 }
197 } else if !user_agents.contains(&"*".to_string()) {
198 return false;
199 }
200 }
201
202 if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
204 if let Some(ip) = ip_address {
205 let matches = ip_ranges.iter().any(|range| {
206 if range == "*" {
207 true
208 } else {
209 ip.starts_with(range) || range == ip
211 }
212 });
213 if !matches {
214 return false;
215 }
216 } else if !ip_ranges.contains(&"*".to_string()) {
217 return false;
218 }
219 }
220
221 if let Some(header_rules) = &self.config.team_identifiers.headers {
223 for (header_name, expected_value) in header_rules {
224 if let Some(actual_value) = headers.get(header_name) {
225 if actual_value != expected_value && expected_value != "*" {
226 return false;
227 }
228 } else if expected_value != "*" {
229 return false;
230 }
231 }
232 }
233
234 true
235 }
236
237 fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
239 let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
241
242 let mut hasher = DefaultHasher::new();
244 hash_input.hash(&mut hasher);
245 let hash = hasher.finish();
246
247 let percentage = (hash % 10000) as f64 / 10000.0;
249
250 percentage < self.config.traffic_percentage
251 }
252
253 fn random_route(&self) -> bool {
255 use rand::Rng;
256 let mut rng = rand::thread_rng();
257 let random_value: f64 = rng.gen();
258 random_value < self.config.traffic_percentage
259 }
260
261 fn round_robin_route(&self) -> bool {
263 let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
264 let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
265 counter.is_multiple_of(cycle_size)
266 }
267
268 pub fn config(&self) -> &DeceptiveCanaryConfig {
270 &self.config
271 }
272
273 pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
275 self.config = config;
276 }
277
278 pub fn stats(&self) -> Option<&CanaryStats> {
280 self.config.stats.as_ref()
281 }
282
283 pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
285 if let Some(stats) = &self.config.stats {
286 }
291 }
292}
293
294impl Default for DeceptiveCanaryRouter {
295 fn default() -> Self {
296 Self::new(DeceptiveCanaryConfig::default())
297 }
298}