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 #[must_use]
256 pub const fn kind(&self) -> AdapterKind {
257 self.kind
258 }
259
260 pub fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
262 self.inner.apply(config)
263 }
264}
265
266struct RuntimePolicyAdapter {
267 kind: AdapterKind,
268 policy: RuntimePolicyInput,
269}
270
271impl BrowserBehaviorAdapter for RuntimePolicyAdapter {
272 fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
273 let interaction = self.policy.telemetry_level.to_interaction_level();
274 let stealth_level = stealth_level_for_policy(&self.policy);
275
276 config.stealth_level = stealth_level;
277 config.cdp_fix_mode = if matches!(self.policy.execution_mode, ExecutionMode::Browser) {
278 CdpFixMode::AddBinding
279 } else {
280 CdpFixMode::None
281 };
282
283 #[cfg(feature = "stealth")]
284 {
285 if self.policy.enforce_webrtc_proxy_only {
286 config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
287 }
288 }
289
290 apply_config_hints(config, &self.policy.config_hints);
291
292 AppliedBehaviorPlan {
293 adapter_kind: self.kind,
294 execution_mode: self.policy.execution_mode,
295 session_mode: self.policy.session_mode,
296 interaction_level: interaction,
297 rate_limit_rps: self.policy.rate_limit_rps,
298 max_retries: self.policy.max_retries,
299 backoff_base_ms: self.policy.backoff_base_ms,
300 enable_warmup: self.policy.enable_warmup,
301 sticky_session_ttl_secs: self.policy.sticky_session_ttl_secs,
302 risk_score: clamp_unit(self.policy.risk_score),
303 required_stygian_features: self.policy.required_stygian_features.clone(),
304 config_hints: self.policy.config_hints.clone(),
305 }
306 }
307}
308
309struct DirectOverridesAdapter {
310 overrides: DirectOverridesInput,
311}
312
313impl BrowserBehaviorAdapter for DirectOverridesAdapter {
314 fn apply(&self, config: &mut BrowserConfig) -> AppliedBehaviorPlan {
315 if let Some(headless) = self.overrides.headless {
316 config.headless = headless;
317 }
318 if let Some(stealth) = self.overrides.stealth_level {
319 config.stealth_level = stealth;
320 }
321
322 #[cfg(feature = "stealth")]
323 {
324 if self.overrides.enforce_webrtc_proxy_only == Some(true) {
325 config.webrtc.policy = WebRtcPolicy::DisableNonProxied;
326 }
327 }
328
329 let hints = self.overrides.config_hints.clone().unwrap_or_default();
330 apply_config_hints(config, &hints);
331
332 let execution_mode = self
333 .overrides
334 .execution_mode
335 .unwrap_or(ExecutionMode::Browser);
336 let session_mode = self
337 .overrides
338 .session_mode
339 .unwrap_or(SessionMode::Stateless);
340 let telemetry = self
341 .overrides
342 .telemetry_level
343 .unwrap_or(TelemetryLevel::Standard);
344 let interaction = self
345 .overrides
346 .interaction_level
347 .unwrap_or_else(|| telemetry.to_interaction_level());
348
349 AppliedBehaviorPlan {
350 adapter_kind: AdapterKind::DirectOverrides,
351 execution_mode,
352 session_mode,
353 interaction_level: interaction,
354 rate_limit_rps: self.overrides.rate_limit_rps.unwrap_or(1.0),
355 max_retries: self.overrides.max_retries.unwrap_or(2),
356 backoff_base_ms: self.overrides.backoff_base_ms.unwrap_or(500),
357 enable_warmup: self.overrides.enable_warmup.unwrap_or(false),
358 sticky_session_ttl_secs: self.overrides.sticky_session_ttl_secs,
359 risk_score: clamp_unit(self.overrides.risk_score.unwrap_or(0.5)),
360 required_stygian_features: self
361 .overrides
362 .required_stygian_features
363 .clone()
364 .unwrap_or_default(),
365 config_hints: hints,
366 }
367 }
368}
369
370fn stealth_level_for_policy(policy: &RuntimePolicyInput) -> StealthLevel {
371 if matches!(policy.execution_mode, ExecutionMode::Http) {
372 return StealthLevel::None;
373 }
374
375 if policy
376 .required_stygian_features
377 .iter()
378 .any(|f| f.contains("stealth") || f.contains("browser"))
379 || policy.risk_score >= 0.65
380 {
381 StealthLevel::Advanced
382 } else if policy.risk_score >= 0.25 {
383 StealthLevel::Basic
384 } else {
385 StealthLevel::None
386 }
387}
388
389fn apply_config_hints(config: &mut BrowserConfig, hints: &BTreeMap<String, String>) {
390 if let Some(proxy) = hints.get("proxy_url").or_else(|| hints.get("proxy")) {
391 config.proxy = Some(proxy.clone());
392 }
393
394 if let Some(headless_raw) = hints.get("headless")
395 && let Ok(headless) = headless_raw.parse::<bool>()
396 {
397 config.headless = headless;
398 }
399
400 if let (Some(width_raw), Some(height_raw)) =
401 (hints.get("viewport_width"), hints.get("viewport_height"))
402 && let (Ok(width), Ok(height)) = (width_raw.parse::<u32>(), height_raw.parse::<u32>())
403 {
404 config.window_size = Some((width, height));
405 }
406
407 if let Some(mode_raw) = hints.get("cdp_fix_mode") {
408 config.cdp_fix_mode = parse_cdp_fix_mode(mode_raw);
409 }
410
411 if let Some(user_agent) = hints.get("user_agent") {
412 let arg = format!("--user-agent={user_agent}");
413 if !config.args.iter().any(|existing| existing == &arg) {
414 config.args.push(arg);
415 }
416 }
417}
418
419fn parse_cdp_fix_mode(raw: &str) -> CdpFixMode {
420 match raw.to_ascii_lowercase().as_str() {
421 "none" | "0" => CdpFixMode::None,
422 "isolatedworld" | "isolated_world" | "isolated" => CdpFixMode::IsolatedWorld,
423 "enabledisable" | "enable_disable" => CdpFixMode::EnableDisable,
424 _ => CdpFixMode::AddBinding,
425 }
426}
427
428const fn clamp_unit(value: f64) -> f64 {
429 if value < 0.0 {
430 0.0
431 } else if value > 1.0 {
432 1.0
433 } else {
434 value
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use serde_json::json;
442
443 #[test]
444 fn selects_runtime_policy_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
445 let value = json!({
446 "execution_mode": "Browser",
447 "session_mode": "Sticky",
448 "telemetry_level": "Deep",
449 "rate_limit_rps": 0.5,
450 "max_retries": 3,
451 "backoff_base_ms": 1200,
452 "enable_warmup": true,
453 "enforce_webrtc_proxy_only": true,
454 "sticky_session_ttl_secs": 1800,
455 "required_stygian_features": ["browser", "stealth"],
456 "config_hints": {"proxy_url": "http://127.0.0.1:8080"},
457 "risk_score": 0.9
458 });
459
460 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
461 .map_err(|e| format!("adapter parse failed: {e}"))?;
462 assert_eq!(adapter.kind(), AdapterKind::RuntimePolicy);
463
464 let mut cfg = BrowserConfig::default();
465 let plan = adapter.apply(&mut cfg);
466 assert_eq!(plan.interaction_level, BehaviorInteractionLevel::High);
467 assert_eq!(cfg.proxy.as_deref(), Some("http://127.0.0.1:8080"));
468 assert_eq!(cfg.stealth_level, StealthLevel::Advanced);
469 Ok(())
470 }
471
472 #[test]
473 fn selects_investigation_bundle_shape() -> std::result::Result<(), Box<dyn std::error::Error>> {
474 let value = json!({
475 "report": {},
476 "requirements": {},
477 "policy": {
478 "execution_mode": "Browser",
479 "session_mode": "Stateless",
480 "telemetry_level": "Standard",
481 "rate_limit_rps": 1.2,
482 "max_retries": 2,
483 "backoff_base_ms": 400,
484 "enable_warmup": false,
485 "enforce_webrtc_proxy_only": false,
486 "sticky_session_ttl_secs": null,
487 "required_stygian_features": [],
488 "config_hints": {},
489 "risk_score": 0.2
490 }
491 });
492
493 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
494 .map_err(|e| format!("adapter parse failed: {e}"))?;
495 assert_eq!(adapter.kind(), AdapterKind::InvestigationBundle);
496
497 let mut cfg = BrowserConfig::default();
498 let plan = adapter.apply(&mut cfg);
499 assert_eq!(plan.execution_mode, ExecutionMode::Browser);
500 assert_eq!(plan.session_mode, SessionMode::Stateless);
501 Ok(())
502 }
503
504 #[test]
505 fn direct_overrides_apply_to_config() -> std::result::Result<(), Box<dyn std::error::Error>> {
506 let value = json!({
507 "headless": false,
508 "stealth_level": "basic",
509 "interaction_level": "medium",
510 "config_hints": {
511 "viewport_width": "1366",
512 "viewport_height": "768",
513 "user_agent": "Mozilla/5.0 test"
514 }
515 });
516
517 let adapter = PolymorphicBehaviorAdapter::from_json_value(value)
518 .map_err(|e| format!("adapter parse failed: {e}"))?;
519 assert_eq!(adapter.kind(), AdapterKind::DirectOverrides);
520
521 let mut cfg = BrowserConfig::default();
522 let plan = adapter.apply(&mut cfg);
523
524 assert!(!cfg.headless);
525 assert_eq!(cfg.stealth_level, StealthLevel::Basic);
526 assert_eq!(plan.interaction_level, BehaviorInteractionLevel::Medium);
527 assert_eq!(cfg.window_size, Some((1366, 768)));
528 assert!(
529 cfg.args
530 .iter()
531 .any(|arg| arg.contains("--user-agent=Mozilla/5.0 test"))
532 );
533 Ok(())
534 }
535
536 #[test]
537 fn invalid_non_object_input_is_rejected() {
538 let err = PolymorphicBehaviorAdapter::from_json_value(json!("not-object"))
539 .err()
540 .map(|e| e.to_string())
541 .unwrap_or_default();
542 assert!(err.contains("must be a JSON object"));
543 }
544}