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