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
70impl BrowserSession {
71    /// Read all browser cookies for the current page URL.
72    ///
73    /// Uses CDP `Network.getCookies`. Policy-gated: requires
74    /// `PersistentProfile` capability.
75    pub async fn cookies(&self) -> BrowserResult<Vec<Cookie>> {
76        self.policy.require(PolicyCapability::PersistentProfile)?;
77        self.cdp
78            .with_current_route(async {
79                let raw = self.cdp.get_cookies().await?;
80                let cookies: Vec<Cookie> = raw["cookies"]
81                    .as_array()
82                    .map(|arr| {
83                        arr.iter()
84                            .filter_map(|c| serde_json::from_value(c.clone()).ok())
85                            .collect()
86                    })
87                    .unwrap_or_default();
88                Ok(cookies)
89            })
90            .await
91    }
92
93    /// Set browser cookies.
94    ///
95    /// Each cookie must have at least `name`, `value`, and `domain`.
96    /// Uses CDP `Network.setCookies`. Requires `PersistentProfile`.
97    pub async fn set_cookies(&self, cookies: &[Cookie]) -> BrowserResult<()> {
98        self.policy.require(PolicyCapability::PersistentProfile)?;
99        if cookies.is_empty() {
100            return Ok(());
101        }
102        self.cdp
103            .with_current_route(async {
104                let value = serde_json::to_value(cookies)?;
105                self.cdp.set_cookies(value).await?;
106                Ok(())
107            })
108            .await
109    }
110
111    /// Clear all browser cookies.
112    ///
113    /// Uses CDP `Network.clearBrowserCookies`. Requires `PersistentProfile`.
114    pub async fn clear_cookies(&self) -> BrowserResult<()> {
115        self.policy.require(PolicyCapability::PersistentProfile)?;
116        self.cdp
117            .with_current_route(async {
118                self.cdp.clear_browser_cookies().await?;
119                Ok(())
120            })
121            .await
122    }
123
124    /// Read localStorage items for the current page.
125    ///
126    /// Bounded to 64 entries; each value capped at 1 KiB.
127    /// Requires `PersistentProfile`.
128    pub async fn local_storage(&self) -> BrowserResult<StorageItems> {
129        self.read_dom_storage("localStorage").await
130    }
131
132    /// Read sessionStorage items for the current page.
133    ///
134    /// Bounded to 64 entries; each value capped at 1 KiB.
135    /// Requires `PersistentProfile`.
136    pub async fn session_storage(&self) -> BrowserResult<StorageItems> {
137        self.read_dom_storage("sessionStorage").await
138    }
139
140    async fn read_dom_storage(&self, storage_type: &str) -> BrowserResult<StorageItems> {
141        self.policy.require(PolicyCapability::PersistentProfile)?;
142        let expression = format!(
143            r#"JSON.stringify((function() {{
144            const store = window.{storage_type};
145            if (!store) return JSON.stringify({{items:[], count:0}});
146            const keys = Object.keys(store).slice(0, {STORAGE_MAX_ENTRIES});
147            const items = keys.map(k => {{
148                let v = store.getItem(k) || '';
149                if (v.length > {STORAGE_VALUE_MAX_BYTES}) {{
150                    v = v.slice(0, {STORAGE_VALUE_MAX_BYTES}) + '…';
151                }}
152                return {{key: k, value: v}};
153            }});
154            return JSON.stringify({{
155                items: items,
156                truncated: Object.keys(store).length > {STORAGE_MAX_ENTRIES},
157                count: Object.keys(store).length
158            }});
159        }})())"#
160        );
161        self.cdp
162            .with_current_route(async {
163                let raw = self.cdp.evaluate(&expression).await?;
164                let value = runtime_value(&raw)?;
165                let json = value
166                    .as_str()
167                    .ok_or("DOM storage evaluation returned a non-string value")?;
168                Ok(serde_json::from_str(json)?)
169            })
170            .await
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn cookie_deserializes_from_cdp_json() {
180        let json = serde_json::json!({
181            "name": "session",
182            "value": "abc123",
183            "domain": ".example.com",
184            "path": "/",
185            "expires": 1735689600.0,
186            "http_only": true,
187            "secure": true,
188            "sameSite": "Lax",
189            "session": false,
190            "size": 64,
191            "priority": "Medium"
192        });
193        let cookie: Cookie = serde_json::from_value(json).unwrap();
194        assert_eq!(cookie.name, "session");
195        assert_eq!(cookie.value, "abc123");
196        assert_eq!(cookie.domain, ".example.com");
197        assert_eq!(cookie.path, "/");
198        assert!((cookie.expires - 1735689600.0).abs() < 1.0);
199        assert!(cookie.http_only);
200        assert!(cookie.secure);
201        assert_eq!(cookie.same_site, Some("Lax".to_string()));
202        assert!(!cookie.is_session);
203        assert_eq!(cookie.size, Some(64));
204        assert_eq!(cookie.priority, Some("Medium".to_string()));
205    }
206
207    #[test]
208    fn cookie_deserializes_with_minimal_fields() {
209        let json = serde_json::json!({
210            "name": "minimal",
211            "value": "val",
212            "domain": "example.com",
213            "path": "/"
214        });
215        let cookie: Cookie = serde_json::from_value(json).unwrap();
216        assert_eq!(cookie.name, "minimal");
217        assert_eq!(cookie.expires, 0.0);
218        assert!(!cookie.http_only);
219        assert!(!cookie.secure);
220        assert!(cookie.same_site.is_none());
221    }
222
223    #[test]
224    fn storage_items_deserializes_from_json() {
225        let json = serde_json::json!({
226            "items": [
227                {"key": "token", "value": "eyJhbGciOiJIUzI1NiJ9"},
228                {"key": "theme", "value": "dark"}
229            ],
230            "truncated": false,
231            "count": 2
232        });
233        let items: StorageItems = serde_json::from_value(json).unwrap();
234        assert_eq!(items.count, 2);
235        assert!(!items.truncated);
236        assert_eq!(items.items.len(), 2);
237        assert_eq!(items.items[0].key, "token");
238        assert_eq!(items.items[1].value, "dark");
239    }
240
241    #[test]
242    fn storage_items_defaults_truncated_to_false() {
243        let json = serde_json::json!({
244            "items": [],
245            "count": 0
246        });
247        let items: StorageItems = serde_json::from_value(json).unwrap();
248        assert!(!items.truncated);
249        assert_eq!(items.count, 0);
250        assert!(items.items.is_empty());
251    }
252
253    #[test]
254    fn storage_entry_serializes_to_json() {
255        let entry = StorageEntry {
256            key: "session_token".to_string(),
257            value: "abc.def.ghi".to_string(),
258        };
259        let json = serde_json::to_value(&entry).unwrap();
260        assert_eq!(json["key"], "session_token");
261        assert_eq!(json["value"], "abc.def.ghi");
262    }
263
264    #[test]
265    fn storage_entry_roundtrip_through_json() {
266        let original = StorageEntry {
267            key: "theme".to_string(),
268            value: "dark".to_string(),
269        };
270        let json = serde_json::to_string(&original).unwrap();
271        let parsed: StorageEntry = serde_json::from_str(&json).unwrap();
272        assert_eq!(parsed.key, "theme");
273        assert_eq!(parsed.value, "dark");
274    }
275
276    #[test]
277    fn storage_max_entries_is_64() {
278        assert_eq!(STORAGE_MAX_ENTRIES, 64);
279    }
280
281    #[test]
282    fn storage_max_entries_is_reasonable() {
283        // Must be at least 1
284        const { assert!(STORAGE_MAX_ENTRIES >= 1) };
285        // Must be at most 256
286        const { assert!(STORAGE_MAX_ENTRIES <= 256) };
287    }
288
289    #[test]
290    fn storage_max_entries_is_power_of_two() {
291        assert_eq!(STORAGE_MAX_ENTRIES.count_ones(), 1);
292    }
293
294    #[test]
295    fn storage_items_serializes_with_truncated_flag() {
296        let items = StorageItems {
297            items: vec![StorageEntry {
298                key: "k1".to_string(),
299                value: "v1".to_string(),
300            }],
301            truncated: true,
302            count: 100,
303        };
304        let json = serde_json::to_value(&items).unwrap();
305        assert_eq!(json["truncated"], true);
306        assert_eq!(json["count"], 100);
307        assert_eq!(json["items"].as_array().unwrap().len(), 1);
308    }
309
310    #[test]
311    fn cookie_roundtrip_through_json() {
312        let original = Cookie {
313            name: "sid".to_string(),
314            value: "secret".to_string(),
315            domain: "example.com".to_string(),
316            path: "/app".to_string(),
317            expires: 2000000000.0,
318            http_only: true,
319            secure: true,
320            same_site: Some("Strict".to_string()),
321            is_session: false,
322            size: Some(32),
323            priority: Some("High".to_string()),
324        };
325        let json = serde_json::to_string(&original).unwrap();
326        let parsed: Cookie = serde_json::from_str(&json).unwrap();
327        assert_eq!(parsed.name, "sid");
328        assert_eq!(parsed.value, "secret");
329        assert_eq!(parsed.same_site, Some("Strict".to_string()));
330        assert!(parsed.http_only);
331        assert_eq!(parsed.priority, Some("High".to_string()));
332    }
333}