Skip to main content

glass/browser/session/
storage.rs

1//! Browser storage inspection (cookies, localStorage, sessionStorage).
2//!
3//! Provides read/write access to browser cookies and read access to
4//! DOM storage via CDP and JavaScript evaluation. All storage
5//! operations require the `PersistentProfile` policy capability.
6
7use super::*;
8use serde::{Deserialize, Serialize};
9
10/// A browser cookie as returned by CDP `Network.getCookies`.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Cookie {
13    /// Cookie name.
14    pub name: String,
15    /// Cookie value.
16    pub value: String,
17    /// Domain the cookie belongs to.
18    pub domain: String,
19    /// Path the cookie is scoped to.
20    pub path: String,
21    /// Expiration time as a Unix timestamp in seconds.
22    #[serde(default)]
23    pub expires: f64,
24    /// Whether the cookie is HTTP-only (inaccessible to JavaScript).
25    #[serde(default)]
26    pub http_only: bool,
27    /// Whether the cookie is only sent over HTTPS.
28    #[serde(default)]
29    pub secure: bool,
30    /// SameSite attribute: `"Strict"`, `"Lax"`, or `"None"`.
31    #[serde(default, rename = "sameSite")]
32    pub same_site: Option<String>,
33    /// Whether this is a session cookie (deleted when the browser closes).
34    #[serde(default, rename = "session")]
35    pub is_session: bool,
36    /// Approximate size of the cookie in bytes.
37    #[serde(default)]
38    pub size: Option<i64>,
39    /// Cookie priority: `"Low"`, `"Medium"`, or `"High"`.
40    #[serde(default, rename = "priority")]
41    pub priority: Option<String>,
42}
43
44/// Key-value snapshot of DOM storage (localStorage or sessionStorage).
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct StorageItems {
47    /// The collected key-value entries.
48    pub items: Vec<StorageEntry>,
49    /// Whether the result was truncated (exceeded 64 entries).
50    #[serde(default)]
51    pub truncated: bool,
52    /// Total number of keys in the storage (may exceed `items.len()`).
53    pub count: usize,
54}
55
56/// A single key-value pair from DOM storage.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct StorageEntry {
59    /// Storage key.
60    pub key: String,
61    /// Storage value (truncated at 1 KiB).
62    pub value: String,
63}
64
65/// Maximum UTF-8 bytes of a single storage value before truncation.
66const STORAGE_VALUE_MAX_BYTES: usize = 1024;
67/// Maximum number of storage entries returned.
68const STORAGE_MAX_ENTRIES: usize = 64;
69/// Maximum number of cookies read or written by one operation.
70const COOKIE_MAX_ENTRIES: usize = 256;
71
72impl BrowserSession {
73    /// Read all browser cookies for the current page URL.
74    ///
75    /// Uses CDP `Network.getCookies`. Policy-gated: requires
76    /// `PersistentProfile` capability.
77    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    /// Set browser cookies.
99    ///
100    /// Each cookie must have at least `name`, `value`, and `domain`.
101    /// Uses CDP `Network.setCookies`. Requires `PersistentProfile`.
102    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    /// Clear all browser cookies.
122    ///
123    /// Uses CDP `Network.clearBrowserCookies`. Requires `PersistentProfile`.
124    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    /// Read localStorage items for the current page.
135    ///
136    /// Bounded to 64 entries; each value capped at 1 KiB.
137    /// Requires `PersistentProfile`.
138    pub async fn local_storage(&self) -> BrowserResult<StorageItems> {
139        self.read_dom_storage("localStorage").await
140    }
141
142    /// Read sessionStorage items for the current page.
143    ///
144    /// Bounded to 64 entries; each value capped at 1 KiB.
145    /// Requires `PersistentProfile`.
146    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        // Must be at least 1
294        const { assert!(STORAGE_MAX_ENTRIES >= 1) };
295        // Must be at most 256
296        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}