1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17use crate::{
18 BrowserConfig,
19 cdp_protection::CdpFixMode,
20 config::StealthLevel,
21 error::{BrowserError, Result},
22};
23
24#[cfg(feature = "stealth")]
25use crate::webrtc::WebRtcPolicy;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AdapterKind {
31 RuntimePolicy,
33 InvestigationBundle,
35 DirectOverrides,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ExecutionMode {
42 Http,
44 Browser,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum SessionMode {
51 Stateless,
53 Sticky,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59pub enum TelemetryLevel {
60 Basic,
62 Standard,
64 Deep,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum BehaviorInteractionLevel {
72 None,
74 Low,
76 Medium,
78 High,
80}
81
82impl TelemetryLevel {
83 const fn to_interaction_level(self) -> BehaviorInteractionLevel {
84 match self {
85 Self::Basic => BehaviorInteractionLevel::Low,
86 Self::Standard => BehaviorInteractionLevel::Medium,
87 Self::Deep => BehaviorInteractionLevel::High,
88 }
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct AppliedBehaviorPlan {
95 pub adapter_kind: AdapterKind,
97 pub execution_mode: ExecutionMode,
99 pub session_mode: SessionMode,
101 pub interaction_level: BehaviorInteractionLevel,
103 pub rate_limit_rps: f64,
105 pub max_retries: u32,
107 pub backoff_base_ms: u64,
109 pub enable_warmup: bool,
111 pub sticky_session_ttl_secs: Option<u64>,
113 pub risk_score: f64,
115 pub required_stygian_features: Vec<String>,
117 pub config_hints: BTreeMap<String, String>,
119}
120
121pub trait BrowserBehaviorAdapter {
123 fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan;
125}
126
127#[derive(Debug, Clone, Deserialize)]
129struct RuntimePolicyInput {
130 execution_mode: ExecutionMode,
131 session_mode: SessionMode,
132 telemetry_level: TelemetryLevel,
133 rate_limit_rps: f64,
134 max_retries: u32,
135 backoff_base_ms: u64,
136 enable_warmup: bool,
137 enforce_webrtc_proxy_only: bool,
138 sticky_session_ttl_secs: Option<u64>,
139 required_stygian_features: Vec<String>,
140 config_hints: BTreeMap<String, String>,
141 risk_score: f64,
142}
143
144#[derive(Debug, Clone, Deserialize)]
146struct InvestigationBundleInput {
147 policy: RuntimePolicyInput,
148}
149
150#[derive(Debug, Clone, Default, Deserialize)]
152struct DirectOverridesInput {
153 execution_mode: Option<ExecutionMode>,
154 session_mode: Option<SessionMode>,
155 telemetry_level: Option<TelemetryLevel>,
156 interaction_level: Option<BehaviorInteractionLevel>,
157 stealth_level: Option<StealthLevel>,
158 headless: Option<bool>,
159 rate_limit_rps: Option<f64>,
160 max_retries: Option<u32>,
161 backoff_base_ms: Option<u64>,
162 enable_warmup: Option<bool>,
163 enforce_webrtc_proxy_only: Option<bool>,
164 sticky_session_ttl_secs: Option<u64>,
165 required_stygian_features: Option<Vec<String>>,
166 config_hints: Option<BTreeMap<String, String>>,
167 risk_score: Option<f64>,
168}
169
170pub struct PolymorphicBehaviorAdapter {
172 kind: AdapterKind,
173 inner: Box<dyn BrowserBehaviorAdapter + Send + Sync>,
174}
175
176impl PolymorphicBehaviorAdapter {
177 pub fn from_json_str(json: &str) -> Result<Self> {
197 let value: Value = serde_json::from_str(json)
198 .map_err(|e| BrowserError::ConfigError(format!("Invalid behavior JSON: {e}")))?;
199 Self::from_json_value(value)
200 }
201
202 pub fn from_json_value(value: Value) -> Result<Self> {
209 let obj = value.as_object().ok_or_else(|| {
210 BrowserError::ConfigError("Behavior input must be a JSON object".to_string())
211 })?;
212
213 if obj.contains_key("policy") {
214 let parsed: InvestigationBundleInput = serde_json::from_value(value).map_err(|e| {
215 BrowserError::ConfigError(format!(
216 "Invalid investigation bundle behavior input: {e}"
217 ))
218 })?;
219 return Ok(Self {
220 kind: AdapterKind::InvestigationBundle,
221 inner: Box::new(RuntimePolicyAdapter {
222 kind: AdapterKind::InvestigationBundle,
223 policy: parsed.policy,
224 }),
225 });
226 }
227
228 if obj.contains_key("execution_mode")
229 && obj.contains_key("session_mode")
230 && obj.contains_key("telemetry_level")
231 {
232 let parsed: RuntimePolicyInput = serde_json::from_value(value).map_err(|e| {
233 BrowserError::ConfigError(format!("Invalid runtime policy behavior input: {e}"))
234 })?;
235 return Ok(Self {
236 kind: AdapterKind::RuntimePolicy,
237 inner: Box::new(RuntimePolicyAdapter {
238 kind: AdapterKind::RuntimePolicy,
239 policy: parsed,
240 }),
241 });
242 }
243
244 let parsed: DirectOverridesInput = serde_json::from_value(value).map_err(|e| {
245 BrowserError::ConfigError(format!("Invalid direct override behavior input: {e}"))
246 })?;
247
248 Ok(Self {
249 kind: AdapterKind::DirectOverrides,
250 inner: Box::new(DirectOverridesAdapter { overrides: parsed }),
251 })
252 }
253
254 pub const fn kind(&self) -> AdapterKind {
256 self.kind
257 }
258
259 pub fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
261 self.inner.apply(config)
262 }
263}
264
265struct RuntimePolicyAdapter {
266 kind: AdapterKind,
267 policy: RuntimePolicyInput,
268}
269
270impl BrowserBehaviorAdapter for RuntimePolicyAdapter {
271 fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
272 let interaction = self.policy.telemetry_level.to_interaction_level();
273 let stealth_level = stealth_level_for_policy(&self.policy);
274
275 config.stealth_level = stealth_level;
276 config.cdp_fix_mode = if matches!(self.policy.execution_mode, ExecutionMode::Browser) {
277 CdpFixMode::AddBinding
278 } else {
279 CdpFixMode::None
280 };
281
282 #[cfg(feature = "stealth")]
283 {
284 if self.policy.enforce_webrtc_proxy_only {
285 config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
286 }
287 }
288
289 apply_config_hints(config, &self.policy.config_hints);
290
291 AppliedBehaviorPlan {
292 adapter_kind: self.kind,
293 execution_mode: self.policy.execution_mode,
294 session_mode: self.policy.session_mode,
295 interaction_level: interaction,
296 rate_limit_rps: self.policy.rate_limit_rps,
297 max_retries: self.policy.max_retries,
298 backoff_base_ms: self.policy.backoff_base_ms,
299 enable_warmup: self.policy.enable_warmup,
300 sticky_session_ttl_secs: self.policy.sticky_session_ttl_secs,
301 risk_score: clamp_unit(self.policy.risk_score),
302 required_stygian_features: self.policy.required_stygian_features.clone(),
303 config_hints: self.policy.config_hints.clone(),
304 }
305 }
306}
307
308struct DirectOverridesAdapter {
309 overrides: DirectOverridesInput,
310}
311
312impl BrowserBehaviorAdapter for DirectOverridesAdapter {
313 fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
314 if let Some(headless) = self.overrides.headless {
315 config.headless = headless;
316 }
317 if let Some(stealth) = self.overrides.stealth_level {
318 config.stealth_level = stealth;
319 }
320
321 #[cfg(feature = "stealth")]
322 {
323 if self.overrides.enforce_webrtc_proxy_only == Some(true) {
324 config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
325 }
326 }
327
328 let hints = self.overrides.config_hints.clone().unwrap_or_default();
329 apply_config_hints(config, &hints);
330
331 let execution_mode = self
332 .overrides
333 .execution_mode
334 .unwrap_or(ExecutionMode::Browser);
335 let session_mode = self
336 .overrides
337 .session_mode
338 .unwrap_or(SessionMode::Stateless);
339 let telemetry = self
340 .overrides
341 .telemetry_level
342 .unwrap_or(TelemetryLevel::Standard);
343 let interaction = self
344 .overrides
345 .interaction_level
346 .unwrap_or_else(|| telemetry.to_interaction_level());
347
348 AppliedBehaviorPlan {
349 adapter_kind: AdapterKind::DirectOverrides,
350 execution_mode,
351 session_mode,
352 interaction_level: interaction,
353 rate_limit_rps: self.overrides.rate_limit_rps.unwrap_or(1.0),
354 max_retries: self.overrides.max_retries.unwrap_or(2),
355 backoff_base_ms: self.overrides.backoff_base_ms.unwrap_or(500),
356 enable_warmup: self.overrides.enable_warmup.unwrap_or(false),
357 sticky_session_ttl_secs: self.overrides.sticky_session_ttl_secs,
358 risk_score: clamp_unit(self.overrides.risk_score.unwrap_or(0.5)),
359 required_stygian_features: self
360 .overrides
361 .required_stygian_features
362 .clone()
363 .unwrap_or_default(),
364 config_hints: hints,
365 }
366 }
367}
368
369fn stealth_level_for_policy(policy: &RuntimePolicyInput) -> StealthLevel {
370 if matches!(policy.execution_mode, ExecutionMode::Http) {
371 return StealthLevel::None;
372 }
373
374 if policy
375 .required_stygian_features
376 .iter()
377 .any(|f| f.contains("stealth") || f.contains("browser"))
378 || policy.risk_score >= 0.65
379 {
380 StealthLevel::Advanced
381 } else if policy.risk_score >= 0.25 {
382 StealthLevel::Basic
383 } else {
384 StealthLevel::None
385 }
386}
387
388fn apply_config_hints(config: &mut BrowserConfig, hints: &BTreeMap<String, String>) {
389 if let Some(proxy) = hints.get("proxy_url").or_else(|| hints.get("proxy")) {
390 config.proxy = Some(proxy.clone());
391 }
392
393 if let Some(headless_raw) = hints.get("headless")
394 && let Ok(headless) = headless_raw.parse::<bool>()
395 {
396 config.headless = headless;
397 }
398
399 if let (Some(width_raw), Some(height_raw)) =
400 (hints.get("viewport_width"), hints.get("viewport_height"))
401 && let (Ok(width), Ok(height)) = (width_raw.parse::<u32>(), height_raw.parse::<u32>())
402 {
403 config.window_size = Some((width, height));
404 }
405
406 if let Some(mode_raw) = hints.get("cdp_fix_mode") {
407 config.cdp_fix_mode = parse_cdp_fix_mode(mode_raw);
408 }
409
410 if let Some(user_agent) = hints.get("user_agent") {
411 let arg = format!("--user-agent={user_agent}");
412 if !config.args.iter().any(|existing| existing == &arg) {
413 config.args.push(arg);
414 }
415 }
416}
417
418fn parse_cdp_fix_mode(raw: &str) -> CdpFixMode {
419 match raw.to_ascii_lowercase().as_str() {
420 "none" | "0" => CdpFixMode::None,
421 "isolatedworld" | "isolated_world" | "isolated" => CdpFixMode::IsolatedWorld,
422 "enabledisable" | "enable_disable" => CdpFixMode::EnableDisable,
423 _ => CdpFixMode::AddBinding,
424 }
425}
426
427const fn clamp_unit(value: f64) -> f64 {
428 if value < 0.0 {
429 0.0
430 } else if value > 1.0 {
431 1.0
432 } else {
433 value
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use serde_json::json;
441
442 #[test]
443 fn selects_runtime_policy_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
444 let value = json!({
445 "execution_mode": "Browser",
446 "session_mode": "Sticky",
447 "telemetry_level": "Deep",
448 "rate_limit_rps": 0.5,
449 "max_retries": 3,
450 "backoff_base_ms": 1200,
451 "enable_warmup": true,
452 "enforce_webrtc_proxy_only": true,
453 "sticky_session_ttl_secs": 1800,
454 "required_stygian_features": ["browser", "stealth"],
455 "config_hints": {"proxy_url": "http://127.0.0.1:8080"},
456 "risk_score": 0.9
457 });
458
459 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
460 .map_err(|e| format!("adapter parse failed: {e}"))?;
461 assert_eq!(adapter.kind(), AdapterKind::RuntimePolicy);
462
463 let mut cfg = BrowserConfig::default();
464 let plan = adapter.apply(&mut cfg);
465 assert_eq!(plan.interaction_level, BehaviorInteractionLevel::High);
466 assert_eq!(cfg.proxy.as_deref(), Some("http://127.0.0.1:8080"));
467 assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
468 Ok(())
469 }
470
471 #[test]
472 fn selects_investigation_bundle_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
473 let value = json!({
474 "report": {},
475 "requirements": {},
476 "policy": {
477 "execution_mode": "Browser",
478 "session_mode": "Stateless",
479 "telemetry_level": "Standard",
480 "rate_limit_rps": 1.2,
481 "max_retries": 2,
482 "backoff_base_ms": 400,
483 "enable_warmup": false,
484 "enforce_webrtc_proxy_only": false,
485 "sticky_session_ttl_secs": null,
486 "required_stygian_features": [],
487 "config_hints": {},
488 "risk_score": 0.2
489 }
490 });
491
492 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
493 .map_err(|e| format!("adapter parse failed: {e}"))?;
494 assert_eq!(adapter.kind(), AdapterKind::InvestigationBundle);
495
496 let mut cfg = BrowserConfig::default();
497 let plan = adapter.apply(&mut cfg);
498 assert_eq!(plan.execution_mode, ExecutionMode::Browser);
499 assert_eq!(plan.session_mode, SessionMode::Stateless);
500 Ok(())
501 }
502
503 #[test]
504 fn direct_overrides_apply_to_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
505 let value = json!({
506 "headless": false,
507 "stealth_level": "basic",
508 "interaction_level": "medium",
509 "config_hints": {
510 "viewport_width": "1366",
511 "viewport_height": "768",
512 "user_agent": "Mozilla/5.0 test"
513 }
514 });
515
516 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
517 .map_err(|e| format!("adapter parse failed: {e}"))?;
518 assert_eq!(adapter.kind(), AdapterKind::DirectOverrides);
519
520 let mut cfg = BrowserConfig::default();
521 let plan = adapter.apply(&mut cfg);
522
523 assert!(!cfg.headless);
524 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
525 assert_eq!(plan.interaction_level, BehaviorInteractionLevel::Medium);
526 assert_eq!(cfg.window_size, Some((1366, 768)));
527 assert!(
528 cfg.args
529 .iter()
530 .any(|arg| arg.contains("--user-agent=Mozilla/5.0 test"))
531 );
532 Ok(())
533 }
534
535 #[test]
536 fn invalid_non_object_input_is_rejected() {
537 let err = PolymorphicBehaviorAdapter::from_json_value(json!("not-object"))
538 .err()
539 .map(|e| e.to_string())
540 .unwrap_or_default();
541 assert!(err.contains("must be a JSON object"));
542 }
543}