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 let should_route = match self.config.routing_strategy {
167 CanaryRoutingStrategy::ConsistentHash => {
168 self.consistent_hash_route(user_id, ip_address)
169 }
170 CanaryRoutingStrategy::Random => self.random_route(),
171 CanaryRoutingStrategy::RoundRobin => self.round_robin_route(),
172 };
173
174 should_route
175 }
176
177 fn matches_team_criteria(
179 &self,
180 user_agent: Option<&str>,
181 ip_address: Option<&str>,
182 headers: &HashMap<String, String>,
183 ) -> bool {
184 if let Some(user_agents) = &self.config.team_identifiers.user_agents {
186 if let Some(ua) = user_agent {
187 let matches = user_agents.iter().any(|pattern| {
188 if pattern == "*" {
189 true
190 } else {
191 ua.contains(pattern)
193 }
194 });
195 if !matches {
196 return false;
197 }
198 } else if !user_agents.contains(&"*".to_string()) {
199 return false;
200 }
201 }
202
203 if let Some(ip_ranges) = &self.config.team_identifiers.ip_ranges {
205 if let Some(ip) = ip_address {
206 let matches = ip_ranges.iter().any(|range| {
207 if range == "*" {
208 true
209 } else {
210 ip.starts_with(range) || range == ip
212 }
213 });
214 if !matches {
215 return false;
216 }
217 } else if !ip_ranges.contains(&"*".to_string()) {
218 return false;
219 }
220 }
221
222 if let Some(header_rules) = &self.config.team_identifiers.headers {
224 for (header_name, expected_value) in header_rules {
225 if let Some(actual_value) = headers.get(header_name) {
226 if actual_value != expected_value && expected_value != "*" {
227 return false;
228 }
229 } else if expected_value != "*" {
230 return false;
231 }
232 }
233 }
234
235 true
236 }
237
238 fn consistent_hash_route(&self, user_id: Option<&str>, ip_address: Option<&str>) -> bool {
240 let hash_input = user_id.unwrap_or_else(|| ip_address.unwrap_or("default"));
242
243 let mut hasher = DefaultHasher::new();
245 hash_input.hash(&mut hasher);
246 let hash = hasher.finish();
247
248 let percentage = (hash % 10000) as f64 / 10000.0;
250
251 percentage < self.config.traffic_percentage
252 }
253
254 fn random_route(&self) -> bool {
256 use rand::Rng;
257 let mut rng = rand::thread_rng();
258 let random_value: f64 = rng.gen();
259 random_value < self.config.traffic_percentage
260 }
261
262 fn round_robin_route(&self) -> bool {
264 let counter = self.round_robin_counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
265 let cycle_size = (1.0 / self.config.traffic_percentage) as u64;
266 (counter % cycle_size) == 0
267 }
268
269 pub fn config(&self) -> &DeceptiveCanaryConfig {
271 &self.config
272 }
273
274 pub fn update_config(&mut self, config: DeceptiveCanaryConfig) {
276 self.config = config;
277 }
278
279 pub fn stats(&self) -> Option<&CanaryStats> {
281 self.config.stats.as_ref()
282 }
283
284 pub fn record_request(&self, routed: bool, opted_out: bool, matched: bool) {
286 if let Some(stats) = &self.config.stats {
287 }
292 }
293}
294
295impl Default for DeceptiveCanaryRouter {
296 fn default() -> Self {
297 Self::new(DeceptiveCanaryConfig::default())
298 }
299}