1use serde::{Deserialize, Serialize};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct CapturedCookie {
24 pub name: String,
25 pub value: String,
26 pub domain: String,
28 pub path: String,
29 pub expires: Option<i64>,
32 pub secure: bool,
33 pub http_only: bool,
34 pub same_site: Option<String>,
37}
38
39impl CapturedCookie {
40 pub fn is_expired_now(&self) -> bool {
45 let Some(exp) = self.expires else {
46 return false;
47 };
48 let now = SystemTime::now()
49 .duration_since(UNIX_EPOCH)
50 .map(|d| d.as_secs() as i64)
51 .unwrap_or(0);
52 exp <= now
53 }
54
55 pub fn keep_persistent_alive(input: &[CapturedCookie]) -> Vec<CapturedCookie> {
59 input
60 .iter()
61 .filter(|c| c.expires.is_some() && !c.is_expired_now())
62 .cloned()
63 .collect()
64 }
65}
66
67pub async fn capture_from_page(
70 page: &crate::browser::Page,
71) -> anyhow::Result<Vec<CapturedCookie>> {
72 page.get_cookies().await
73}
74
75pub async fn apply_to_page(
80 page: &crate::browser::Page,
81 cookies: &[CapturedCookie],
82) -> anyhow::Result<usize> {
83 let now = SystemTime::now()
84 .duration_since(UNIX_EPOCH)
85 .map(|d| d.as_secs() as i64)
86 .unwrap_or(0);
87
88 let mut installed = 0usize;
89 for c in cookies {
90 if let Some(exp) = c.expires {
91 if exp <= now {
92 continue; }
94 }
95 let same_site = c.same_site.as_ref().and_then(|s| match s.as_str() {
96 "strict" => Some(rustenium_bidi_definitions::network::types::SameSite::Strict),
97 "lax" => Some(rustenium_bidi_definitions::network::types::SameSite::Lax),
98 "none" => Some(rustenium_bidi_definitions::network::types::SameSite::None),
99 _ => None,
100 });
101 page.set_cookie(
102 &c.name,
103 &c.value,
104 &c.domain,
105 Some(&c.path),
106 c.expires.map(|e| e as u64),
107 Some(c.secure),
108 Some(c.http_only),
109 same_site,
110 )
111 .await?;
112 installed += 1;
113 }
114 Ok(installed)
115}
116
117pub fn vendor_cookies(input: &[CapturedCookie], vendor: &str) -> Vec<CapturedCookie> {
126 let names: &[&str] = match vendor.to_lowercase().as_str() {
127 "cloudflare" | "cf" => &["__cf_bm", "cf_chl_2", "cf_clearance"],
128 "akamai" => &["_abck", "bm_sz", "ak_bmsc"],
129 "datadome" => &["datadome", "_dd_s"],
130 "perimeterx" | "human" => &["_px2", "_pxhd", "_px3", "_pxvid"],
131 "incapsula" | "imperva" => &["visid_incap", "incap_ses"],
132 "kasada" => &["KP_UIDz", "x-kpsdk-cd", "x-kpsdk-ct"],
133 "fastly" => &["_fastly_ngwaf"],
134 "sucuri" => &["sucuri_cloudproxy_uuid"],
135 "anubis" => &["anubis-auth"],
136 _ => return Vec::new(),
137 };
138 input
139 .iter()
140 .filter(|c| names.iter().any(|n| c.name.starts_with(*n) || c.name == *n))
141 .cloned()
142 .collect()
143}
144
145#[cfg(test)]
146mod vendor_cookie_tests {
147 use super::*;
148
149 fn ck(name: &str) -> CapturedCookie {
150 CapturedCookie {
151 name: name.into(),
152 value: "test".into(),
153 domain: ".example.com".into(),
154 path: "/".into(),
155 expires: None,
156 secure: false,
157 http_only: false,
158 same_site: None,
159 }
160 }
161
162 #[test]
163 fn vendor_cookies_filters_cloudflare_set() {
164 let all = vec![
165 ck("__cf_bm"),
166 ck("cf_clearance"),
167 ck("session_id"),
168 ck("_ga"),
169 ];
170 let filtered = vendor_cookies(&all, "cloudflare");
171 let names: Vec<&str> = filtered.iter().map(|c| c.name.as_str()).collect();
172 assert!(names.contains(&"__cf_bm"));
173 assert!(names.contains(&"cf_clearance"));
174 assert!(!names.contains(&"_ga"));
175 }
176
177 #[test]
178 fn vendor_cookies_matches_prefixed_cookie_names() {
179 let all = vec![ck("visid_incap_12345"), ck("incap_ses_99_99")];
181 let filtered = vendor_cookies(&all, "imperva");
182 assert_eq!(filtered.len(), 2);
183 }
184
185 #[test]
186 fn vendor_cookies_unknown_vendor_returns_empty() {
187 let all = vec![ck("__cf_bm")];
188 let filtered = vendor_cookies(&all, "totally-not-a-vendor");
189 assert!(filtered.is_empty());
190 }
191
192 #[test]
193 fn vendor_cookies_is_case_insensitive_on_vendor() {
194 let all = vec![ck("__cf_bm")];
195 assert_eq!(vendor_cookies(&all, "Cloudflare").len(), 1);
196 assert_eq!(vendor_cookies(&all, "CLOUDFLARE").len(), 1);
197 assert_eq!(vendor_cookies(&all, "cf").len(), 1);
198 }
199
200 #[test]
201 fn vendor_cookies_akamai_set() {
202 let all = vec![ck("_abck"), ck("bm_sz"), ck("ak_bmsc"), ck("other")];
203 let filtered = vendor_cookies(&all, "akamai");
204 assert_eq!(filtered.len(), 3);
205 }
206
207 #[test]
208 fn vendor_cookies_datadome_set() {
209 let all = vec![ck("datadome"), ck("_dd_s"), ck("session")];
210 let filtered = vendor_cookies(&all, "datadome");
211 assert_eq!(filtered.len(), 2);
212 }
213
214 #[test]
215 fn vendor_cookies_perimeterx_aliases() {
216 let all = vec![ck("_px2"), ck("_pxhd"), ck("_px3")];
217 assert_eq!(vendor_cookies(&all, "perimeterx").len(), 3);
218 assert_eq!(vendor_cookies(&all, "human").len(), 3);
219 }
220
221 #[test]
222 fn vendor_cookies_kasada_set() {
223 let all = vec![ck("KP_UIDz"), ck("x-kpsdk-cd")];
224 assert_eq!(vendor_cookies(&all, "kasada").len(), 2);
225 }
226
227 #[test]
228 fn vendor_cookies_fastly_set() {
229 let all = vec![ck("_fastly_ngwaf"), ck("other")];
230 assert_eq!(vendor_cookies(&all, "fastly").len(), 1);
231 }
232
233 #[test]
234 fn vendor_cookies_sucuri_set() {
235 let all = vec![ck("sucuri_cloudproxy_uuid")];
236 assert_eq!(vendor_cookies(&all, "sucuri").len(), 1);
237 }
238
239 #[test]
240 fn vendor_cookies_anubis_set() {
241 let all = vec![ck("anubis-auth")];
242 assert_eq!(vendor_cookies(&all, "anubis").len(), 1);
243 }
244
245 #[test]
246 fn vendor_cookies_empty_input_returns_empty() {
247 assert!(vendor_cookies(&[], "cloudflare").is_empty());
248 }
249
250 #[test]
251 fn vendor_cookies_prefix_match_dynamic_suffix() {
252 let all = vec![ck("visid_incap_12345"), ck("visid_incap_99999")];
255 let filtered = vendor_cookies(&all, "imperva");
256 assert_eq!(filtered.len(), 2);
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn cookie(name: &str, expires: Option<i64>) -> CapturedCookie {
265 CapturedCookie {
266 name: name.into(),
267 value: "v".into(),
268 domain: ".example.com".into(),
269 path: "/".into(),
270 expires,
271 secure: true,
272 http_only: false,
273 same_site: None,
274 }
275 }
276
277 #[test]
278 fn is_expired_now_true_for_past_expires() {
279 let c = cookie("c", Some(1)); assert!(c.is_expired_now());
281 }
282
283 #[test]
284 fn is_expired_now_false_for_far_future_expires() {
285 let c = cookie("c", Some(i64::MAX));
286 assert!(!c.is_expired_now());
287 }
288
289 #[test]
290 fn is_expired_now_false_for_session_cookie() {
291 let c = cookie("c", None);
292 assert!(!c.is_expired_now());
293 }
294
295 #[test]
296 fn keep_persistent_alive_drops_session_cookies() {
297 let cookies = vec![
298 cookie("session", None),
299 cookie("persistent", Some(i64::MAX)),
300 ];
301 let kept = CapturedCookie::keep_persistent_alive(&cookies);
302 assert_eq!(kept.len(), 1);
303 assert_eq!(kept[0].name, "persistent");
304 }
305
306 #[test]
307 fn keep_persistent_alive_drops_expired_cookies() {
308 let cookies = vec![cookie("expired", Some(1)), cookie("alive", Some(i64::MAX))];
309 let kept = CapturedCookie::keep_persistent_alive(&cookies);
310 assert_eq!(kept.len(), 1);
311 assert_eq!(kept[0].name, "alive");
312 }
313
314 #[test]
315 fn captured_cookie_serde_roundtrip() {
316 let c = cookie("cf_clearance", Some(1234567890));
317 let json = serde_json::to_string(&c).unwrap();
318 let back: CapturedCookie = serde_json::from_str(&json).unwrap();
319 assert_eq!(c, back);
320 }
321
322 #[test]
323 fn is_expired_now_false_at_exact_boundary() {
324 let c = cookie("c", Some(0));
329 assert!(c.is_expired_now());
330 }
331
332 #[test]
333 fn is_expired_now_negative_expiry_treated_as_expired() {
334 let c = cookie("c", Some(-1));
336 assert!(c.is_expired_now());
337 }
338
339 #[test]
340 fn keep_persistent_alive_empty_input() {
341 let kept = CapturedCookie::keep_persistent_alive(&[]);
342 assert!(kept.is_empty());
343 }
344
345 #[test]
346 fn keep_persistent_alive_all_expired_returns_empty() {
347 let cookies = vec![cookie("a", Some(1)), cookie("b", Some(2))];
348 let kept = CapturedCookie::keep_persistent_alive(&cookies);
349 assert!(kept.is_empty());
350 }
351
352 #[test]
353 fn keep_persistent_alive_all_session_returns_empty() {
354 let cookies = vec![cookie("a", None), cookie("b", None)];
355 let kept = CapturedCookie::keep_persistent_alive(&cookies);
356 assert!(kept.is_empty());
357 }
358
359 #[test]
360 fn keep_persistent_alive_preserves_order() {
361 let cookies = vec![
362 cookie("first", Some(i64::MAX)),
363 cookie("second", Some(i64::MAX - 1)),
364 ];
365 let kept = CapturedCookie::keep_persistent_alive(&cookies);
366 assert_eq!(kept.len(), 2);
367 assert_eq!(kept[0].name, "first");
368 assert_eq!(kept[1].name, "second");
369 }
370
371 #[test]
372 fn captured_cookie_equality() {
373 let a = cookie("a", Some(100));
374 let b = cookie("a", Some(100));
375 let c = cookie("a", Some(200));
376 assert_eq!(a, b);
377 assert_ne!(a, c);
378 }
379
380 #[test]
381 fn captured_cookie_serde_with_all_fields() {
382 let c = CapturedCookie {
383 name: "session".into(),
384 value: "abc123".into(),
385 domain: ".example.com".into(),
386 path: "/api".into(),
387 expires: Some(1893456000),
388 secure: true,
389 http_only: true,
390 same_site: Some("strict".into()),
391 };
392 let json = serde_json::to_string(&c).unwrap();
393 let back: CapturedCookie = serde_json::from_str(&json).unwrap();
394 assert_eq!(c, back);
395 assert!(json.contains("same_site"));
396 assert!(json.contains("http_only"));
397 }
398}