1use super::*;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Cookie {
13 pub name: String,
15 pub value: String,
17 pub domain: String,
19 pub path: String,
21 #[serde(default)]
23 pub expires: f64,
24 #[serde(default)]
26 pub http_only: bool,
27 #[serde(default)]
29 pub secure: bool,
30 #[serde(default, rename = "sameSite")]
32 pub same_site: Option<String>,
33 #[serde(default, rename = "session")]
35 pub is_session: bool,
36 #[serde(default)]
38 pub size: Option<i64>,
39 #[serde(default, rename = "priority")]
41 pub priority: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct StorageItems {
47 pub items: Vec<StorageEntry>,
49 #[serde(default)]
51 pub truncated: bool,
52 pub count: usize,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct StorageEntry {
59 pub key: String,
61 pub value: String,
63}
64
65const STORAGE_VALUE_MAX_BYTES: usize = 1024;
67const STORAGE_MAX_ENTRIES: usize = 64;
69const COOKIE_MAX_ENTRIES: usize = 256;
71
72impl BrowserSession {
73 pub async fn cookies(&self) -> BrowserResult<Vec<Cookie>> {
78 self.policy.require(PolicyCapability::PersistentProfile)?;
79 self.cdp
80 .with_current_route(async {
81 let raw = self.cdp.get_cookies().await?;
82 let array = raw["cookies"].as_array().cloned().unwrap_or_default();
83 if array.len() > COOKIE_MAX_ENTRIES {
84 return Err(format!(
85 "browser returned more than {COOKIE_MAX_ENTRIES} cookies; narrow the profile before exporting"
86 )
87 .into());
88 }
89 let cookies: Vec<Cookie> = array
90 .into_iter()
91 .map(serde_json::from_value)
92 .collect::<Result<_, _>>()?;
93 Ok(cookies)
94 })
95 .await
96 }
97
98 pub async fn set_cookies(&self, cookies: &[Cookie]) -> BrowserResult<()> {
103 self.policy.require(PolicyCapability::PersistentProfile)?;
104 if cookies.is_empty() {
105 return Ok(());
106 }
107 if cookies.len() > COOKIE_MAX_ENTRIES {
108 return Err(
109 format!("cookie import exceeds the {COOKIE_MAX_ENTRIES}-cookie limit").into(),
110 );
111 }
112 self.cdp
113 .with_current_route(async {
114 let value = serde_json::to_value(cookies)?;
115 self.cdp.set_cookies(value).await?;
116 Ok(())
117 })
118 .await
119 }
120
121 pub async fn clear_cookies(&self) -> BrowserResult<()> {
125 self.policy.require(PolicyCapability::PersistentProfile)?;
126 self.cdp
127 .with_current_route(async {
128 self.cdp.clear_browser_cookies().await?;
129 Ok(())
130 })
131 .await
132 }
133
134 pub async fn local_storage(&self) -> BrowserResult<StorageItems> {
139 self.read_dom_storage("localStorage").await
140 }
141
142 pub async fn session_storage(&self) -> BrowserResult<StorageItems> {
147 self.read_dom_storage("sessionStorage").await
148 }
149
150 async fn read_dom_storage(&self, storage_type: &str) -> BrowserResult<StorageItems> {
151 self.policy.require(PolicyCapability::PersistentProfile)?;
152 let expression = format!(
153 r#"JSON.stringify((function() {{
154 const store = window.{storage_type};
155 if (!store) return JSON.stringify({{items:[], count:0}});
156 const keys = Object.keys(store).slice(0, {STORAGE_MAX_ENTRIES});
157 const items = keys.map(k => {{
158 let v = store.getItem(k) || '';
159 if (v.length > {STORAGE_VALUE_MAX_BYTES}) {{
160 v = v.slice(0, {STORAGE_VALUE_MAX_BYTES}) + '…';
161 }}
162 return {{key: k, value: v}};
163 }});
164 return JSON.stringify({{
165 items: items,
166 truncated: Object.keys(store).length > {STORAGE_MAX_ENTRIES},
167 count: Object.keys(store).length
168 }});
169 }})())"#
170 );
171 self.cdp
172 .with_current_route(async {
173 let raw = self.cdp.evaluate(&expression).await?;
174 let value = runtime_value(&raw)?;
175 let json = value
176 .as_str()
177 .ok_or("DOM storage evaluation returned a non-string value")?;
178 Ok(serde_json::from_str(json)?)
179 })
180 .await
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn cookie_deserializes_from_cdp_json() {
190 let json = serde_json::json!({
191 "name": "session",
192 "value": "abc123",
193 "domain": ".example.com",
194 "path": "/",
195 "expires": 1735689600.0,
196 "http_only": true,
197 "secure": true,
198 "sameSite": "Lax",
199 "session": false,
200 "size": 64,
201 "priority": "Medium"
202 });
203 let cookie: Cookie = serde_json::from_value(json).unwrap();
204 assert_eq!(cookie.name, "session");
205 assert_eq!(cookie.value, "abc123");
206 assert_eq!(cookie.domain, ".example.com");
207 assert_eq!(cookie.path, "/");
208 assert!((cookie.expires - 1735689600.0).abs() < 1.0);
209 assert!(cookie.http_only);
210 assert!(cookie.secure);
211 assert_eq!(cookie.same_site, Some("Lax".to_string()));
212 assert!(!cookie.is_session);
213 assert_eq!(cookie.size, Some(64));
214 assert_eq!(cookie.priority, Some("Medium".to_string()));
215 }
216
217 #[test]
218 fn cookie_deserializes_with_minimal_fields() {
219 let json = serde_json::json!({
220 "name": "minimal",
221 "value": "val",
222 "domain": "example.com",
223 "path": "/"
224 });
225 let cookie: Cookie = serde_json::from_value(json).unwrap();
226 assert_eq!(cookie.name, "minimal");
227 assert_eq!(cookie.expires, 0.0);
228 assert!(!cookie.http_only);
229 assert!(!cookie.secure);
230 assert!(cookie.same_site.is_none());
231 }
232
233 #[test]
234 fn storage_items_deserializes_from_json() {
235 let json = serde_json::json!({
236 "items": [
237 {"key": "token", "value": "eyJhbGciOiJIUzI1NiJ9"},
238 {"key": "theme", "value": "dark"}
239 ],
240 "truncated": false,
241 "count": 2
242 });
243 let items: StorageItems = serde_json::from_value(json).unwrap();
244 assert_eq!(items.count, 2);
245 assert!(!items.truncated);
246 assert_eq!(items.items.len(), 2);
247 assert_eq!(items.items[0].key, "token");
248 assert_eq!(items.items[1].value, "dark");
249 }
250
251 #[test]
252 fn storage_items_defaults_truncated_to_false() {
253 let json = serde_json::json!({
254 "items": [],
255 "count": 0
256 });
257 let items: StorageItems = serde_json::from_value(json).unwrap();
258 assert!(!items.truncated);
259 assert_eq!(items.count, 0);
260 assert!(items.items.is_empty());
261 }
262
263 #[test]
264 fn storage_entry_serializes_to_json() {
265 let entry = StorageEntry {
266 key: "session_token".to_string(),
267 value: "abc.def.ghi".to_string(),
268 };
269 let json = serde_json::to_value(&entry).unwrap();
270 assert_eq!(json["key"], "session_token");
271 assert_eq!(json["value"], "abc.def.ghi");
272 }
273
274 #[test]
275 fn storage_entry_roundtrip_through_json() {
276 let original = StorageEntry {
277 key: "theme".to_string(),
278 value: "dark".to_string(),
279 };
280 let json = serde_json::to_string(&original).unwrap();
281 let parsed: StorageEntry = serde_json::from_str(&json).unwrap();
282 assert_eq!(parsed.key, "theme");
283 assert_eq!(parsed.value, "dark");
284 }
285
286 #[test]
287 fn storage_max_entries_is_64() {
288 assert_eq!(STORAGE_MAX_ENTRIES, 64);
289 }
290
291 #[test]
292 fn storage_max_entries_is_reasonable() {
293 const { assert!(STORAGE_MAX_ENTRIES >= 1) };
295 const { assert!(STORAGE_MAX_ENTRIES <= 256) };
297 }
298
299 #[test]
300 fn storage_max_entries_is_power_of_two() {
301 assert_eq!(STORAGE_MAX_ENTRIES.count_ones(), 1);
302 }
303
304 #[test]
305 fn storage_items_serializes_with_truncated_flag() {
306 let items = StorageItems {
307 items: vec![StorageEntry {
308 key: "k1".to_string(),
309 value: "v1".to_string(),
310 }],
311 truncated: true,
312 count: 100,
313 };
314 let json = serde_json::to_value(&items).unwrap();
315 assert_eq!(json["truncated"], true);
316 assert_eq!(json["count"], 100);
317 assert_eq!(json["items"].as_array().unwrap().len(), 1);
318 }
319
320 #[test]
321 fn cookie_roundtrip_through_json() {
322 let original = Cookie {
323 name: "sid".to_string(),
324 value: "secret".to_string(),
325 domain: "example.com".to_string(),
326 path: "/app".to_string(),
327 expires: 2000000000.0,
328 http_only: true,
329 secure: true,
330 same_site: Some("Strict".to_string()),
331 is_session: false,
332 size: Some(32),
333 priority: Some("High".to_string()),
334 };
335 let json = serde_json::to_string(&original).unwrap();
336 let parsed: Cookie = serde_json::from_str(&json).unwrap();
337 assert_eq!(parsed.name, "sid");
338 assert_eq!(parsed.value, "secret");
339 assert_eq!(parsed.same_site, Some("Strict".to_string()));
340 assert!(parsed.http_only);
341 assert_eq!(parsed.priority, Some("High".to_string()));
342 }
343
344 #[test]
345 fn cookie_entry_budget_is_bounded_and_power_of_two() {
346 assert_eq!(COOKIE_MAX_ENTRIES, 256);
347 assert_eq!(COOKIE_MAX_ENTRIES.count_ones(), 1);
348 }
349}