mod diagnostics;
mod elements;
mod evaluate;
mod frames;
mod input;
mod navigation;
mod network;
mod wait;
use std::collections::HashMap;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use crate::cdp::Session;
use crate::error::{Error, Result};
use crate::StealthConfig;
pub(crate) use input::{
coordinated_key_char, coordinated_key_down, coordinated_key_up, coordinated_mouse_down,
coordinated_mouse_move, coordinated_mouse_up, coordinated_mouse_wheel, HeldInputState,
};
pub use crate::element::{BoundingBox, Element};
const SETTLE_MS: u64 = 100;
pub(crate) const INTERACTION_DELAY_MS: u64 = 50;
pub(crate) async fn sleep_ms(ms: u64) {
tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
}
pub(crate) fn escape_js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'"' => out.push_str("\\\""),
'`' => out.push_str("\\`"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\0' => out.push_str("\\x00"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
'$' if chars.peek() == Some(&'{') => {
out.push_str("\\${");
chars.next();
}
_ => out.push(ch),
}
}
out
}
fn is_missing_node_message(message: &str) -> bool {
message.contains("Could not find node") || message.contains("No node with given id")
}
fn is_stale_node_error(e: &Error) -> bool {
matches!(e, Error::Cdp { message, .. } if is_missing_node_message(message))
}
fn is_element_cdp_error(e: &Error) -> bool {
match e {
Error::ElementNotFound(_) | Error::ElementNotVisible { .. } => true,
Error::Cdp { message, .. } => {
message.contains("box model")
|| is_missing_node_message(message)
|| message.contains("Node is not an element")
}
Error::Launch(_)
| Error::Transport { .. }
| Error::Navigation(_)
| Error::Timeout(_)
| Error::InputState(_)
| Error::ValueMismatch { .. }
| Error::Serialization(_)
| Error::Decode(_)
| Error::Io(_)
| Error::ChromeNotFound
| Error::Patching { .. }
| Error::RetryExhausted { .. } => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextMatch {
Exact,
#[default]
Contains,
StartsWith,
EndsWith,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MouseButton {
Left,
Middle,
Right,
Back,
Forward,
}
#[derive(Clone)]
pub struct Page {
pub(crate) session: Session,
config: Arc<StealthConfig>,
root_node: Arc<AtomicI32>,
net_idle_key: String,
held_input: Arc<tokio::sync::Mutex<HeldInputState>>,
}
impl Page {
pub(crate) fn new(session: Session, config: Arc<StealthConfig>) -> Self {
let net_idle_key = format!("_{:012x}", fastrand::u64(..) & 0xffff_ffff_ffff);
let held_input = session.held_input();
Self {
session,
config,
root_node: Arc::new(AtomicI32::new(0)),
net_idle_key,
held_input,
}
}
async fn document_node(&self) -> Result<i32> {
let id = self.root_node.load(Ordering::Acquire);
if id != 0 {
return Ok(id);
}
let doc = self.session.get_document(Some(0)).await?;
self.root_node.store(doc.node_id, Ordering::Release);
Ok(doc.node_id)
}
fn invalidate_document(&self) {
self.root_node.store(0, Ordering::Release);
}
async fn with_document<T, F, Fut>(&self, op: F) -> Result<T>
where
F: Fn(i32) -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let doc = self.document_node().await?;
match op(doc).await {
Err(ref e) if is_stale_node_error(e) => {
self.invalidate_document();
let doc = self.document_node().await?;
op(doc).await
}
other => other,
}
}
pub fn session(&self) -> &Session {
&self.session
}
pub fn target_id(&self) -> &str {
self.session.target_id()
}
}
#[derive(Debug, Clone)]
pub struct CapturedRequest {
pub request_id: String,
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
pub post_data: Option<String>,
pub resource_type: Option<String>,
pub status: Option<i32>,
pub status_text: Option<String>,
pub response_headers: Option<HashMap<String, String>>,
pub mime_type: Option<String>,
pub timestamp: f64,
pub complete: bool,
}
#[derive(Debug)]
pub enum ResponseBody {
Text(String),
Binary(Vec<u8>),
}
impl ResponseBody {
pub fn as_text(&self) -> Option<&str> {
match self {
ResponseBody::Text(s) => Some(s),
ResponseBody::Binary(_) => None,
}
}
pub fn as_bytes(&self) -> &[u8] {
match self {
ResponseBody::Text(s) => s.as_bytes(),
ResponseBody::Binary(b) => b,
}
}
}
#[derive(Debug, Clone)]
pub struct FrameInfo {
pub id: String,
pub url: String,
pub name: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PageState {
pub url: String,
pub title: String,
pub input_count: u32,
pub button_count: u32,
pub link_count: u32,
pub form_count: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_js_string() {
assert_eq!(escape_js_string("hello"), "hello");
assert_eq!(escape_js_string("it's"), "it\\'s");
assert_eq!(escape_js_string("line1\nline2"), "line1\\nline2");
assert_eq!(escape_js_string("back\\slash"), "back\\\\slash");
assert_eq!(escape_js_string("${var}"), "\\${var}");
assert_eq!(escape_js_string("a\0b"), "a\\x00b");
assert_eq!(escape_js_string("a\0"), "a\\x00");
assert_eq!(escape_js_string("a\u{0}1"), "a\\x001");
assert_eq!(escape_js_string("a\u{2028}b"), "a\\u2028b");
assert_eq!(escape_js_string("a\u{2029}b"), "a\\u2029b");
}
#[test]
fn test_escape_js_string_null_is_hex_not_octal() {
assert_eq!(escape_js_string("\0"), "\\x00");
assert_ne!(escape_js_string("\0"), "\\0");
assert_eq!(escape_js_string("a\u{0}1"), "a\\x001");
}
#[test]
fn test_escape_js_string_all_special_chars() {
assert_eq!(escape_js_string("'"), "\\'");
assert_eq!(escape_js_string("\""), "\\\"");
assert_eq!(escape_js_string("`"), "\\`");
assert_eq!(escape_js_string("\\"), "\\\\");
assert_eq!(escape_js_string("\n"), "\\n");
assert_eq!(escape_js_string("\r"), "\\r");
assert_eq!(escape_js_string("${"), "\\${");
assert_eq!(escape_js_string("\u{2028}"), "\\u2028");
assert_eq!(escape_js_string("\u{2029}"), "\\u2029");
}
#[test]
fn test_stale_node_error_detection() {
let stale = Error::cdp(
"DOM.resolveNode",
-32000,
"Could not find node with given id",
);
assert!(is_stale_node_error(&stale));
assert!(is_element_cdp_error(&stale));
}
#[test]
fn test_box_model_error_is_element_but_not_stale() {
let box_model = Error::cdp("DOM.getBoxModel", -32000, "box model could not be computed");
assert!(is_element_cdp_error(&box_model));
assert!(!is_stale_node_error(&box_model));
}
#[test]
fn test_element_not_found_is_element_but_not_stale() {
let not_found = Error::ElementNotFound("x".into());
assert!(is_element_cdp_error(¬_found));
assert!(!is_stale_node_error(¬_found));
}
#[test]
fn test_missing_node_message_variants() {
assert!(is_missing_node_message("Could not find node with given id"));
assert!(is_missing_node_message("No node with given id found"));
assert!(!is_missing_node_message("box model could not be computed"));
}
}