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