Skip to main content

chaser_cf/models/
mod.rs

1//! Data models for chaser-cf
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Stealth profile for browser fingerprinting
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9#[repr(C)]
10pub enum Profile {
11    /// Windows fingerprint (most common, default)
12    #[default]
13    Windows,
14    /// Linux fingerprint
15    Linux,
16    /// macOS fingerprint
17    Macos,
18}
19
20impl Profile {
21    /// Parse profile from string (for FFI)
22    pub fn parse(s: &str) -> Option<Self> {
23        match s.to_lowercase().as_str() {
24            "windows" | "win" => Some(Profile::Windows),
25            "linux" => Some(Profile::Linux),
26            "macos" | "mac" | "darwin" => Some(Profile::Macos),
27            _ => None,
28        }
29    }
30}
31
32/// Proxy configuration
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ProxyConfig {
35    /// Proxy host
36    pub host: String,
37    /// Proxy port
38    pub port: u16,
39    /// Optional username for authentication
40    pub username: Option<String>,
41    /// Optional password for authentication
42    pub password: Option<String>,
43    /// Optional URL scheme. Defaults to "http" for backward compatibility.
44    /// Chrome's `--proxy-server` natively supports `http`, `https`,
45    /// `socks4`, `socks5`. Use `socks5h` for remote DNS resolution.
46    /// Set via [`ProxyConfig::with_scheme`].
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub scheme: Option<String>,
49}
50
51impl ProxyConfig {
52    /// Create new proxy config
53    pub fn new(host: impl Into<String>, port: u16) -> Self {
54        Self {
55            host: host.into(),
56            port,
57            username: None,
58            password: None,
59            scheme: None,
60        }
61    }
62
63    /// Add authentication credentials
64    pub fn with_auth(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
65        self.username = Some(username.into());
66        self.password = Some(password.into());
67        self
68    }
69
70    /// Override the URL scheme. Default is "http". Common values:
71    /// - "http" — HTTP CONNECT (default; Chrome's classic proxy mode)
72    /// - "https" — HTTPS CONNECT
73    /// - "socks5" — SOCKS5 with local DNS
74    /// - "socks5h" — SOCKS5 with remote (proxy-side) DNS
75    /// - "socks4" — SOCKS4
76    pub fn with_scheme(mut self, scheme: impl Into<String>) -> Self {
77        self.scheme = Some(scheme.into());
78        self
79    }
80
81    /// Get proxy URL in the form `<scheme>://host:port`. Defaults the
82    /// scheme to `http` when none has been explicitly set.
83    /// Note: Chrome/CDP doesn't support inline authentication in proxy URLs.
84    pub fn to_url(&self) -> String {
85        let scheme = self.scheme.as_deref().unwrap_or("http");
86        format!("{scheme}://{}:{}", self.host, self.port)
87    }
88}
89
90/// Browser cookie
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Cookie {
93    /// Cookie name
94    pub name: String,
95    /// Cookie value
96    pub value: String,
97    /// Cookie domain
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub domain: Option<String>,
100    /// Cookie path
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub path: Option<String>,
103    /// Expiration timestamp
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub expires: Option<f64>,
106    /// HTTP only flag
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub http_only: Option<bool>,
109    /// Secure flag
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub secure: Option<bool>,
112    /// SameSite attribute
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub same_site: Option<String>,
115}
116
117impl Cookie {
118    /// Create a simple cookie with name and value
119    pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
120        Self {
121            name: name.into(),
122            value: value.into(),
123            domain: None,
124            path: None,
125            expires: None,
126            http_only: None,
127            secure: None,
128            same_site: None,
129        }
130    }
131}
132
133/// WAF session data containing cookies and headers
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct WafSession {
136    /// Extracted cookies
137    pub cookies: Vec<Cookie>,
138    /// Extracted headers (cleaned)
139    pub headers: HashMap<String, String>,
140}
141
142impl WafSession {
143    /// Create new WAF session
144    pub fn new(cookies: Vec<Cookie>, headers: HashMap<String, String>) -> Self {
145        Self { cookies, headers }
146    }
147
148    /// Get cookies as a single cookie header string
149    pub fn cookies_string(&self) -> String {
150        self.cookies
151            .iter()
152            .map(|c| format!("{}={}", c.name, c.value))
153            .collect::<Vec<_>>()
154            .join("; ")
155    }
156}
157
158/// Result of a chaser-cf operation (for FFI serialization)
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(tag = "type", content = "data")]
161pub enum ChaserResult {
162    /// Page source HTML
163    Source(String),
164    /// Turnstile token
165    Token(String),
166    /// WAF session
167    WafSession(WafSession),
168    /// Error
169    Error { code: i32, message: String },
170}
171
172impl ChaserResult {
173    /// Create success result with source
174    pub fn source(html: String) -> Self {
175        ChaserResult::Source(html)
176    }
177
178    /// Create success result with token
179    pub fn token(token: String) -> Self {
180        ChaserResult::Token(token)
181    }
182
183    /// Create success result with WAF session
184    pub fn waf_session(session: WafSession) -> Self {
185        ChaserResult::WafSession(session)
186    }
187
188    /// Create error result
189    pub fn error(code: i32, message: impl Into<String>) -> Self {
190        ChaserResult::Error {
191            code,
192            message: message.into(),
193        }
194    }
195
196    /// Check if result is success
197    pub fn is_success(&self) -> bool {
198        !matches!(self, ChaserResult::Error { .. })
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_proxy_url_without_auth() {
208        let proxy = ProxyConfig::new("proxy.example.com", 8080);
209        assert_eq!(proxy.to_url(), "http://proxy.example.com:8080");
210    }
211
212    #[test]
213    fn test_proxy_url_with_auth() {
214        let proxy = ProxyConfig::new("proxy.example.com", 8080).with_auth("user", "pass");
215        assert_eq!(proxy.to_url(), "http://proxy.example.com:8080");
216    }
217
218    #[test]
219    fn test_proxy_url_with_socks5_scheme() {
220        let proxy = ProxyConfig::new("127.0.0.1", 1080).with_scheme("socks5");
221        assert_eq!(proxy.to_url(), "socks5://127.0.0.1:1080");
222    }
223
224    #[test]
225    fn test_proxy_url_with_socks5h_scheme_remote_dns() {
226        let proxy = ProxyConfig::new("127.0.0.1", 1080)
227            .with_scheme("socks5h")
228            .with_auth("u", "p");
229        assert_eq!(proxy.to_url(), "socks5h://127.0.0.1:1080");
230    }
231
232    #[test]
233    fn test_proxy_url_default_scheme_is_http() {
234        // Backward compat: ProxyConfig::new() with no with_scheme()
235        // must continue to emit http:// URLs, otherwise existing
236        // callers break silently.
237        let proxy = ProxyConfig::new("h", 1);
238        assert!(proxy.scheme.is_none());
239        assert_eq!(proxy.to_url(), "http://h:1");
240    }
241
242    #[test]
243    fn test_waf_session_cookies_string() {
244        let session = WafSession::new(
245            vec![
246                Cookie::new("cf_clearance", "abc123"),
247                Cookie::new("session", "xyz789"),
248            ],
249            HashMap::new(),
250        );
251        assert_eq!(
252            session.cookies_string(),
253            "cf_clearance=abc123; session=xyz789"
254        );
255    }
256
257    #[test]
258    fn test_profile_from_str() {
259        assert_eq!(Profile::parse("windows"), Some(Profile::Windows));
260        assert_eq!(Profile::parse("LINUX"), Some(Profile::Linux));
261        assert_eq!(Profile::parse("darwin"), Some(Profile::Macos));
262        assert_eq!(Profile::parse("invalid"), None);
263    }
264}