1use std::collections::BTreeMap;
2
3use crate::har;
4use crate::investigation::{infer_requirements, investigate_har};
5use crate::types::{
6 AdapterStrategy, AntiBotProvider, ExecutionMode, IntegrationRecommendation,
7 InvestigationBundle, InvestigationReport, RequirementLevel, RequirementsProfile, RuntimePolicy,
8 SessionMode, TelemetryLevel,
9};
10
11#[must_use]
13pub fn build_runtime_policy(
14 report: &InvestigationReport,
15 requirements: &RequirementsProfile,
16) -> RuntimePolicy {
17 let blocked_ratio = if report.total_requests == 0 {
18 0.0
19 } else {
20 to_f64(report.blocked_requests) / to_f64(report.total_requests)
21 };
22
23 let mut policy = RuntimePolicy {
24 execution_mode: ExecutionMode::Http,
25 session_mode: SessionMode::Stateless,
26 telemetry_level: TelemetryLevel::Standard,
27 rate_limit_rps: 3.0,
28 max_retries: 2,
29 backoff_base_ms: 250,
30 enable_warmup: false,
31 enforce_webrtc_proxy_only: false,
32 sticky_session_ttl_secs: None,
33 required_stygian_features: requirements
34 .recommendation
35 .required_stygian_features
36 .clone(),
37 config_hints: requirements.recommendation.config_hints.clone(),
38 risk_score: blocked_ratio,
39 };
40
41 apply_strategy_defaults(&mut policy, &requirements.recommendation);
42
43 apply_slo_escalation(&mut policy, requirements);
45
46 let has_429 = report.status_histogram.get(&429).copied().unwrap_or(0) > 0;
47 if has_429 {
48 policy.rate_limit_rps = policy.rate_limit_rps.min(2.0);
49 policy.max_retries = policy.max_retries.max(3);
50 policy.backoff_base_ms = policy.backoff_base_ms.max(500);
51 policy
52 .config_hints
53 .insert("retry.respect_429".to_string(), "true".to_string());
54 }
55
56 if report.blocked_requests > 0 {
57 policy.telemetry_level = TelemetryLevel::Deep;
58 policy.config_hints.insert(
59 "charon.capture_block_template".to_string(),
60 "true".to_string(),
61 );
62 }
63
64 let provider_weight = match requirements.provider {
66 AntiBotProvider::Unknown => 0.1,
67 AntiBotProvider::Cloudflare => 0.25,
68 AntiBotProvider::DataDome => 0.35,
69 AntiBotProvider::Akamai
70 | AntiBotProvider::PerimeterX
71 | AntiBotProvider::Kasada
72 | AntiBotProvider::FingerprintCom => 0.3,
73 };
74
75 let mut risk = requirements
76 .confidence
77 .mul_add(provider_weight, blocked_ratio * 0.7);
78 if has_429 {
79 risk += 0.1;
80 }
81 policy.risk_score = risk.clamp(0.0, 1.0);
82
83 policy
84}
85
86pub fn analyze_and_plan(har_json: &str) -> Result<InvestigationBundle, har::HarError> {
92 let report = investigate_har(har_json)?;
93 Ok(plan_from_report(report))
94}
95
96#[must_use]
98pub fn plan_from_report(report: InvestigationReport) -> InvestigationBundle {
99 let requirements = infer_requirements(&report);
100 let policy = build_runtime_policy(&report, &requirements);
101
102 InvestigationBundle {
103 report,
104 requirements,
105 policy,
106 }
107}
108
109fn apply_strategy_defaults(policy: &mut RuntimePolicy, recommendation: &IntegrationRecommendation) {
110 match recommendation.strategy {
111 AdapterStrategy::DirectHttp => {
112 policy.execution_mode = ExecutionMode::Http;
113 policy.session_mode = SessionMode::Stateless;
114 policy.rate_limit_rps = 4.0;
115 }
116 AdapterStrategy::BrowserStealth => {
117 policy.execution_mode = ExecutionMode::Browser;
118 policy.session_mode = SessionMode::Stateless;
119 policy.rate_limit_rps = 2.0;
120 policy.max_retries = 3;
121 policy.enable_warmup = true;
122 policy.enforce_webrtc_proxy_only = true;
123 }
124 AdapterStrategy::StickyProxy => {
125 policy.execution_mode = ExecutionMode::Browser;
126 policy.session_mode = SessionMode::Sticky;
127 policy.rate_limit_rps = 1.5;
128 policy.max_retries = 3;
129 policy.backoff_base_ms = 600;
130 policy.enable_warmup = true;
131 policy.enforce_webrtc_proxy_only = true;
132 policy.sticky_session_ttl_secs = Some(600);
133 policy
134 .required_stygian_features
135 .push("stygian-proxy".to_string());
136 policy
137 .config_hints
138 .insert("proxy.rotation".to_string(), "per-domain".to_string());
139 }
140 AdapterStrategy::SessionWarmup => {
141 policy.execution_mode = ExecutionMode::Browser;
142 policy.enable_warmup = true;
143 policy.rate_limit_rps = 2.0;
144 policy.max_retries = 2;
145 }
146 AdapterStrategy::InvestigateOnly => {
147 policy.execution_mode = ExecutionMode::Http;
148 policy.telemetry_level = TelemetryLevel::Deep;
149 policy.rate_limit_rps = 2.0;
150 policy.max_retries = 1;
151 policy
152 .config_hints
153 .insert("charon.mode".to_string(), "investigation".to_string());
154 }
155 }
156
157 dedupe_required_features(&mut policy.required_stygian_features);
158}
159
160fn apply_slo_escalation(policy: &mut RuntimePolicy, requirements: &RequirementsProfile) {
167 let adaptive_req = requirements
169 .requirements
170 .iter()
171 .find(|r| r.id == "adaptive_rate_and_retry_budget");
172
173 if let Some(req) = adaptive_req {
174 match req.level {
175 RequirementLevel::Medium => {
176 policy.max_retries = policy.max_retries.max(4);
178 policy.backoff_base_ms = policy.backoff_base_ms.max(400);
179 if policy.execution_mode == ExecutionMode::Http {
180 policy.enable_warmup = true;
181 policy
182 .config_hints
183 .insert("slo.escalation".to_string(), "warning".to_string());
184 }
185 }
186 RequirementLevel::High => {
187 if policy.execution_mode != ExecutionMode::Browser
189 || policy.session_mode != SessionMode::Sticky
190 {
191 policy.execution_mode = ExecutionMode::Browser;
192 policy.session_mode = SessionMode::Sticky;
193 policy.rate_limit_rps = policy.rate_limit_rps.min(1.5);
194 policy.max_retries = policy.max_retries.max(5);
195 policy.backoff_base_ms = policy.backoff_base_ms.max(600);
196 policy.enable_warmup = true;
197 policy.enforce_webrtc_proxy_only = true;
198 policy.sticky_session_ttl_secs = Some(600);
199 policy
200 .required_stygian_features
201 .push("stygian-proxy".to_string());
202 policy
203 .config_hints
204 .insert("slo.escalation".to_string(), "critical".to_string());
205 dedupe_required_features(&mut policy.required_stygian_features);
206 }
207 }
208 RequirementLevel::Low => {
209 }
211 }
212 }
213}
214
215fn dedupe_required_features(features: &mut Vec<String>) {
216 let mut seen = BTreeMap::new();
217 features.retain(|feature| seen.insert(feature.clone(), true).is_none());
218}
219
220#[allow(clippy::cast_precision_loss)]
221const fn to_f64(value: u64) -> f64 {
222 value as f64
223}
224
225#[cfg(test)]
226#[allow(
227 clippy::unwrap_used,
228 clippy::expect_used,
229 clippy::panic,
230 clippy::indexing_slicing
231)]
232mod tests {
233 use std::collections::BTreeMap;
234
235 use crate::types::{Detection, InvestigationReport, MarkerCount, RequirementsProfile};
236
237 use super::*;
238
239 #[test]
240 fn sticky_proxy_strategy_enables_sticky_policy() {
241 let report = InvestigationReport {
242 page_title: Some("https://example.com".to_string()),
243 total_requests: 100,
244 blocked_requests: 30,
245 status_histogram: BTreeMap::from([(403, 30)]),
246 resource_type_histogram: BTreeMap::new(),
247 provider_histogram: BTreeMap::new(),
248 marker_histogram: BTreeMap::from([("x-datadome".to_string(), 30)]),
249 top_markers: vec![MarkerCount {
250 marker: "x-datadome".to_string(),
251 count: 30,
252 }],
253 hosts: Vec::new(),
254 suspicious_requests: Vec::new(),
255 aggregate: Detection {
256 provider: AntiBotProvider::DataDome,
257 confidence: 0.9,
258 markers: vec!["x-datadome".to_string()],
259 },
260 target_class: None,
261 };
262
263 let requirements = RequirementsProfile {
264 provider: AntiBotProvider::DataDome,
265 confidence: 0.9,
266 requirements: Vec::new(),
267 recommendation: IntegrationRecommendation {
268 strategy: AdapterStrategy::StickyProxy,
269 rationale: "test".to_string(),
270 required_stygian_features: vec!["stygian-browser".to_string()],
271 config_hints: BTreeMap::new(),
272 },
273 };
274
275 let policy = build_runtime_policy(&report, &requirements);
276 assert_eq!(policy.execution_mode, ExecutionMode::Browser);
277 assert_eq!(policy.session_mode, SessionMode::Sticky);
278 assert!(policy.sticky_session_ttl_secs.is_some());
279 assert!(policy.risk_score > 0.0);
280 }
281
282 #[test]
283 fn plan_from_report_preserves_report_and_builds_bundle() {
284 let report = InvestigationReport {
285 page_title: Some("https://example.com".to_string()),
286 total_requests: 10,
287 blocked_requests: 4,
288 status_histogram: BTreeMap::from([(200, 6), (403, 4)]),
289 resource_type_histogram: BTreeMap::from([("document".to_string(), 10)]),
290 provider_histogram: BTreeMap::from([(AntiBotProvider::Cloudflare, 4)]),
291 marker_histogram: BTreeMap::from([
292 ("cf-ray".to_string(), 4),
293 ("__cf_bm".to_string(), 4),
294 ]),
295 top_markers: vec![
296 MarkerCount {
297 marker: "cf-ray".to_string(),
298 count: 4,
299 },
300 MarkerCount {
301 marker: "__cf_bm".to_string(),
302 count: 4,
303 },
304 ],
305 hosts: Vec::new(),
306 suspicious_requests: Vec::new(),
307 aggregate: Detection {
308 provider: AntiBotProvider::Cloudflare,
309 confidence: 0.8,
310 markers: vec!["cf-ray".to_string(), "__cf_bm".to_string()],
311 },
312 target_class: None,
313 };
314
315 let bundle = plan_from_report(report.clone());
316 assert_eq!(bundle.report, report);
317 assert_eq!(bundle.requirements.provider, AntiBotProvider::Cloudflare);
318 assert_eq!(bundle.policy.execution_mode, ExecutionMode::Browser);
319 }
320
321 #[test]
322 fn slo_escalation_warning_increases_retries_and_warmup() {
323 use crate::types::{AntiBotRequirement, RequirementLevel};
325
326 let report = InvestigationReport {
327 page_title: None,
328 total_requests: 100,
329 blocked_requests: 20,
330 status_histogram: BTreeMap::new(),
331 resource_type_histogram: BTreeMap::new(),
332 provider_histogram: BTreeMap::new(),
333 marker_histogram: BTreeMap::new(),
334 top_markers: Vec::new(),
335 hosts: Vec::new(),
336 suspicious_requests: Vec::new(),
337 aggregate: Detection {
338 provider: AntiBotProvider::Unknown,
339 confidence: 0.0,
340 markers: Vec::new(),
341 },
342 target_class: None,
343 };
344
345 let requirements = RequirementsProfile {
346 provider: AntiBotProvider::Unknown,
347 confidence: 0.0,
348 requirements: vec![AntiBotRequirement {
349 id: "adaptive_rate_and_retry_budget".to_string(),
350 title: "Apply adaptive pacing".to_string(),
351 why: "Block ratio in warning zone".to_string(),
352 evidence: vec!["ratio=0.20".to_string()],
353 level: RequirementLevel::Medium,
354 }],
355 recommendation: IntegrationRecommendation {
356 strategy: AdapterStrategy::DirectHttp,
357 rationale: "test".to_string(),
358 required_stygian_features: Vec::new(),
359 config_hints: BTreeMap::new(),
360 },
361 };
362
363 let policy = build_runtime_policy(&report, &requirements);
364 assert!(policy.max_retries >= 4);
365 assert!(policy.backoff_base_ms >= 400);
366 assert!(policy.enable_warmup);
367 assert_eq!(
368 policy.config_hints.get("slo.escalation"),
369 Some(&"warning".to_string())
370 );
371 }
372
373 #[test]
374 fn slo_escalation_critical_escalates_to_browser_sticky() {
375 use crate::types::{AntiBotRequirement, RequirementLevel};
377
378 let report = InvestigationReport {
379 page_title: None,
380 total_requests: 100,
381 blocked_requests: 25,
382 status_histogram: BTreeMap::new(),
383 resource_type_histogram: BTreeMap::new(),
384 provider_histogram: BTreeMap::new(),
385 marker_histogram: BTreeMap::new(),
386 top_markers: Vec::new(),
387 hosts: Vec::new(),
388 suspicious_requests: Vec::new(),
389 aggregate: Detection {
390 provider: AntiBotProvider::Unknown,
391 confidence: 0.0,
392 markers: Vec::new(),
393 },
394 target_class: None,
395 };
396
397 let requirements = RequirementsProfile {
398 provider: AntiBotProvider::Unknown,
399 confidence: 0.0,
400 requirements: vec![AntiBotRequirement {
401 id: "adaptive_rate_and_retry_budget".to_string(),
402 title: "Apply adaptive pacing".to_string(),
403 why: "Block ratio in critical zone".to_string(),
404 evidence: vec!["ratio=0.25".to_string()],
405 level: RequirementLevel::High,
406 }],
407 recommendation: IntegrationRecommendation {
408 strategy: AdapterStrategy::DirectHttp,
409 rationale: "test".to_string(),
410 required_stygian_features: Vec::new(),
411 config_hints: BTreeMap::new(),
412 },
413 };
414
415 let policy = build_runtime_policy(&report, &requirements);
416 assert_eq!(policy.execution_mode, ExecutionMode::Browser);
417 assert_eq!(policy.session_mode, SessionMode::Sticky);
418 assert!(policy.max_retries >= 5);
419 assert!(policy.backoff_base_ms >= 600);
420 assert!(policy.enable_warmup);
421 assert!(policy.sticky_session_ttl_secs.is_some());
422 assert!(
423 policy
424 .required_stygian_features
425 .contains(&"stygian-proxy".to_string())
426 );
427 assert_eq!(
428 policy.config_hints.get("slo.escalation"),
429 Some(&"critical".to_string())
430 );
431 }
432
433 #[test]
434 fn slo_escalation_respects_already_escalated_policies() {
435 use crate::types::{AntiBotRequirement, RequirementLevel};
437
438 let report = InvestigationReport {
439 page_title: None,
440 total_requests: 100,
441 blocked_requests: 25,
442 status_histogram: BTreeMap::new(),
443 resource_type_histogram: BTreeMap::new(),
444 provider_histogram: BTreeMap::new(),
445 marker_histogram: BTreeMap::new(),
446 top_markers: Vec::new(),
447 hosts: Vec::new(),
448 suspicious_requests: Vec::new(),
449 aggregate: Detection {
450 provider: AntiBotProvider::Unknown,
451 confidence: 0.0,
452 markers: Vec::new(),
453 },
454 target_class: None,
455 };
456
457 let requirements = RequirementsProfile {
458 provider: AntiBotProvider::Unknown,
459 confidence: 0.0,
460 requirements: vec![AntiBotRequirement {
461 id: "adaptive_rate_and_retry_budget".to_string(),
462 title: "Apply adaptive pacing".to_string(),
463 why: "Block ratio in critical zone".to_string(),
464 evidence: vec!["ratio=0.25".to_string()],
465 level: RequirementLevel::High,
466 }],
467 recommendation: IntegrationRecommendation {
468 strategy: AdapterStrategy::StickyProxy,
469 rationale: "test".to_string(),
470 required_stygian_features: Vec::new(),
471 config_hints: BTreeMap::new(),
472 },
473 };
474
475 let policy = build_runtime_policy(&report, &requirements);
476 assert_eq!(policy.execution_mode, ExecutionMode::Browser);
477 assert_eq!(policy.session_mode, SessionMode::Sticky);
478 let proxy_count = policy
480 .required_stygian_features
481 .iter()
482 .filter(|f| f.as_str() == "stygian-proxy")
483 .count();
484 assert_eq!(proxy_count, 1);
485 }
486
487 #[test]
488 fn slo_escalation_no_requirement_means_no_escalation() {
489 let report = InvestigationReport {
491 page_title: None,
492 total_requests: 100,
493 blocked_requests: 3,
494 status_histogram: BTreeMap::new(),
495 resource_type_histogram: BTreeMap::new(),
496 provider_histogram: BTreeMap::new(),
497 marker_histogram: BTreeMap::new(),
498 top_markers: Vec::new(),
499 hosts: Vec::new(),
500 suspicious_requests: Vec::new(),
501 aggregate: Detection {
502 provider: AntiBotProvider::Unknown,
503 confidence: 0.0,
504 markers: Vec::new(),
505 },
506 target_class: None,
507 };
508
509 let requirements = RequirementsProfile {
510 provider: AntiBotProvider::Unknown,
511 confidence: 0.0,
512 requirements: Vec::new(), recommendation: IntegrationRecommendation {
514 strategy: AdapterStrategy::DirectHttp,
515 rationale: "test".to_string(),
516 required_stygian_features: Vec::new(),
517 config_hints: BTreeMap::new(),
518 },
519 };
520
521 let policy = build_runtime_policy(&report, &requirements);
522 assert_eq!(policy.execution_mode, ExecutionMode::Http);
523 assert_eq!(policy.session_mode, SessionMode::Stateless);
524 assert_eq!(policy.max_retries, 2);
525 assert!(!policy.enable_warmup);
526 assert_eq!(policy.config_hints.get("slo.escalation"), None);
527 }
528}