Skip to main content

browser_control/cli/
storage.rs

1//! `browser-control storage` — local/sessionStorage get/set/list.
2//!
3//! Routing mirrors `eval`/`fetch`. The `--browser` flag accepts the
4//! unified `<browser>[/<tab>]` syntax; three paths fall out of `(tab, --target)`:
5//!
6//! 1. `<browser>/<tab>` (no `--target`) — named-tab path via
7//!    [`with_named_tab_recovery`]. Engine-agnostic, recover-once on dead tabs.
8//! 2. `<browser>` (no `--target`) — scratch tab via [`with_scratch_recovery`].
9//!    Storage `Set` against a scratch (`about:blank`) writes to that origin —
10//!    symmetric with `eval`, where the same caveat applies.
11//! 3. `<browser> --target <regex>` — legacy `PageSession::attach`.
12//!
13//! `<browser>/<tab>` and `--target` are mutually exclusive.
14
15use std::time::Duration;
16
17use anyhow::{anyhow, bail, Result};
18use clap::Subcommand;
19use serde_json::Value;
20
21use crate::cli::env_resolver::Source;
22use crate::cli::route;
23use crate::cli::trace::CommandTrace;
24use crate::session::backend::open_backend;
25use crate::session::freshness;
26use crate::session::{with_scratch_recovery, PageSession};
27
28/// Per-call timeout for storage probes. The expressions injected here are
29/// trivial (`localStorage.getItem`, `JSON.stringify(Object.entries(...))`,
30/// etc.); 10 s is generous and bounds wedged renderers.
31const STORAGE_TIMEOUT: Duration = Duration::from_secs(10);
32
33#[derive(Subcommand, Debug)]
34pub enum StorageCmd {
35    /// Read a single storage entry. With --key-regex, returns the first match.
36    Get {
37        #[arg(long, short = 'b', env = "BROWSER_CONTROL")]
38        browser: Option<String>,
39        key: Option<String>,
40        #[arg(long)]
41        key_regex: Option<String>,
42        #[arg(long)]
43        target: Option<String>,
44        #[arg(long, default_value = "local")]
45        namespace: String,
46        #[arg(long)]
47        json: bool,
48        /// Reload the page first if its document is older than this duration.
49        #[arg(long, default_value = freshness::DEFAULT_MAX_AGE_STR)]
50        max_age: String,
51    },
52    /// Write a single storage entry.
53    Set {
54        #[arg(long, short = 'b', env = "BROWSER_CONTROL")]
55        browser: Option<String>,
56        key: String,
57        value: String,
58        #[arg(long)]
59        target: Option<String>,
60        #[arg(long, default_value = "local")]
61        namespace: String,
62    },
63    /// List all storage entries (optionally filtered by key regex).
64    List {
65        #[arg(long, short = 'b', env = "BROWSER_CONTROL")]
66        browser: Option<String>,
67        #[arg(long)]
68        key_regex: Option<String>,
69        #[arg(long)]
70        target: Option<String>,
71        #[arg(long, default_value = "local")]
72        namespace: String,
73        #[arg(long)]
74        json: bool,
75        /// Reload the page first if its document is older than this duration.
76        #[arg(long, default_value = freshness::DEFAULT_MAX_AGE_STR)]
77        max_age: String,
78    },
79}
80
81pub async fn run(cmd: StorageCmd) -> Result<()> {
82    match cmd {
83        StorageCmd::Get {
84            browser,
85            key,
86            key_regex,
87            target,
88            namespace,
89            json,
90            max_age,
91        } => {
92            let mut trace = CommandTrace::new("storage-get");
93            let result = run_get(
94                browser, key, key_regex, target, namespace, json, max_age, &mut trace,
95            )
96            .await;
97            trace.finish(result)
98        }
99        StorageCmd::Set {
100            browser,
101            key,
102            value,
103            target,
104            namespace,
105        } => {
106            let mut trace = CommandTrace::new("storage-set");
107            let result = run_set(browser, key, value, target, namespace, &mut trace).await;
108            trace.finish(result)
109        }
110        StorageCmd::List {
111            browser,
112            key_regex,
113            target,
114            namespace,
115            json,
116            max_age,
117        } => {
118            let mut trace = CommandTrace::new("storage-list");
119            let result = run_list(
120                browser, key_regex, target, namespace, json, max_age, &mut trace,
121            )
122            .await;
123            trace.finish(result)
124        }
125    }
126}
127
128/// Run a JS expression against the resolved browser using the same
129/// three-path routing (`named-tab` / `scratch` / `target-regex` / `direct`)
130/// that `eval` uses. Returns the evaluated value.
131///
132/// Routing decision rules:
133/// - `<browser>/<tab>` + no `--target` → named-tab path.
134/// - bare `<browser>` + no `--target` → scratch path (or `direct` for
135///   external URL endpoints — there's no registry row to key a scratch by).
136/// - `<browser>` + `--target <regex>` → legacy attach.
137/// - Both `<browser>/<tab>` and `--target` → error.
138async fn evaluate_routed(
139    browser: Option<String>,
140    target: Option<String>,
141    expr: &str,
142    max_age: Option<Duration>,
143    trace: &mut CommandTrace,
144) -> Result<Value> {
145    // Preamble (parse, mutual-exclusion, resolve, registry, BiDi lock) is
146    // shared with `eval`/`fetch`; see `crate::cli::route`.
147    let r = route::preamble(browser, target.as_deref(), trace).await?;
148    let resolved = &r.resolved;
149
150    match (r.tab_name.clone(), target) {
151        // Path 1: <browser>/<tab> — named tab with recover-once.
152        (Some(name), None) => {
153            trace.route("named-tab").tab_name(&name);
154            let expr = expr.to_string();
155            route::run_named_tab(
156                &r,
157                &name,
158                "named tabs (`<browser>/<name>`) require a registered browser; \
159                 external endpoints can't carry tab names",
160                move |b, target_id| {
161                    let expr = expr.clone();
162                    async move {
163                        if let Some(max_age) = max_age {
164                            b.ensure_fresh(&target_id, max_age).await?;
165                        }
166                        b.evaluate(&target_id, &expr, true, STORAGE_TIMEOUT).await
167                    }
168                },
169            )
170            .await
171        }
172        // Path 2: bare browser, no `--target` → scratch tab with recover-once.
173        (None, None) => {
174            if matches!(resolved.source, Source::External) {
175                // External URL endpoint — no registry row to key a scratch
176                // by. Fall back to the legacy direct attach so external
177                // endpoints still work.
178                trace.route("direct");
179                let session =
180                    PageSession::attach(&resolved.endpoint, resolved.engine, None).await?;
181                if let Some(max_age) = max_age {
182                    session.ensure_fresh(max_age).await?;
183                }
184                let value = session
185                    .evaluate_with_timeout(expr, true, Some(STORAGE_TIMEOUT))
186                    .await;
187                session.close().await;
188                value
189            } else {
190                trace.route("scratch");
191                let browser_name = match &resolved.source {
192                    Source::Registered { name } => name.clone(),
193                    _ => unreachable!("Source::External branch handled above"),
194                };
195                let backend = open_backend(&resolved.endpoint, resolved.engine).await?;
196                let expr = expr.to_string();
197                with_scratch_recovery(&backend, &r.registry, &browser_name, move |b, target_id| {
198                    let expr = expr.clone();
199                    async move {
200                        if let Some(max_age) = max_age {
201                            b.ensure_fresh(&target_id, max_age).await?;
202                        }
203                        b.evaluate(&target_id, &expr, true, STORAGE_TIMEOUT).await
204                    }
205                })
206                .await
207            }
208        }
209        // Path 3: bare browser, --target regex → legacy selector.
210        (None, Some(regex)) => {
211            trace.route("target-regex");
212            let session =
213                PageSession::attach(&resolved.endpoint, resolved.engine, Some(&regex)).await?;
214            if let Some(max_age) = max_age {
215                session.ensure_fresh(max_age).await?;
216            }
217            let value = session
218                .evaluate_with_timeout(expr, true, Some(STORAGE_TIMEOUT))
219                .await;
220            session.close().await;
221            value
222        }
223        _ => unreachable!("mutex was checked above"),
224    }
225}
226
227#[allow(clippy::too_many_arguments)]
228async fn run_get(
229    browser: Option<String>,
230    key: Option<String>,
231    key_regex: Option<String>,
232    target: Option<String>,
233    namespace: String,
234    json: bool,
235    max_age: String,
236    trace: &mut CommandTrace,
237) -> Result<()> {
238    let max_age = freshness::parse_max_age(&max_age)?;
239    let ns = ns_global(&namespace)?;
240    match (key.as_deref(), key_regex.as_deref()) {
241        (Some(_), Some(_)) => bail!("specify either KEY or --key-regex, not both"),
242        (None, None) => bail!("specify a KEY or --key-regex"),
243        (Some(k), None) => {
244            let expr = build_get_expr(ns, k);
245            let value = evaluate_routed(browser, target, &expr, Some(max_age), trace).await?;
246            if value.is_null() {
247                bail!("key not found: {k}");
248            }
249            if json {
250                println!("{}", serde_json::to_string_pretty(&value)?);
251            } else if let Some(s) = value.as_str() {
252                println!("{s}");
253            } else {
254                println!("{value}");
255            }
256            Ok(())
257        }
258        (None, Some(pat)) => {
259            let expr = build_get_by_regex_expr(ns, pat);
260            let value = evaluate_routed(browser, target, &expr, Some(max_age), trace).await?;
261            if value.is_null() {
262                bail!("no key matches regex");
263            }
264            if json {
265                println!("{}", serde_json::to_string_pretty(&value)?);
266            } else {
267                let v = value.get("value").unwrap_or(&Value::Null);
268                if let Some(s) = v.as_str() {
269                    println!("{s}");
270                } else {
271                    println!("{v}");
272                }
273            }
274            Ok(())
275        }
276    }
277}
278
279async fn run_set(
280    browser: Option<String>,
281    key: String,
282    value: String,
283    target: Option<String>,
284    namespace: String,
285    trace: &mut CommandTrace,
286) -> Result<()> {
287    let ns = ns_global(&namespace)?;
288    let expr = build_set_expr(ns, &key, &value);
289    evaluate_routed(browser, target, &expr, None, trace).await?;
290    Ok(())
291}
292
293async fn run_list(
294    browser: Option<String>,
295    key_regex: Option<String>,
296    target: Option<String>,
297    namespace: String,
298    json: bool,
299    max_age: String,
300    trace: &mut CommandTrace,
301) -> Result<()> {
302    let max_age = freshness::parse_max_age(&max_age)?;
303    let ns = ns_global(&namespace)?;
304    let expr = build_list_expr(ns, key_regex.as_deref());
305    let value = evaluate_routed(browser, target, &expr, Some(max_age), trace).await?;
306    if json {
307        println!("{}", serde_json::to_string_pretty(&value)?);
308        return Ok(());
309    }
310    let arr = value.as_array().ok_or_else(|| anyhow!("expected array"))?;
311    for entry in arr {
312        let k = entry.get("key").and_then(|v| v.as_str()).unwrap_or("");
313        let v_val = entry.get("value").unwrap_or(&Value::Null);
314        let v_str = match v_val {
315            Value::String(s) => {
316                if s.contains('\t') || s.contains('\n') || s.contains('\r') {
317                    serde_json::to_string(s)?
318                } else {
319                    s.clone()
320                }
321            }
322            other => serde_json::to_string(other)?,
323        };
324        println!("{k}\t{v_str}");
325    }
326    Ok(())
327}
328
329pub(crate) fn ns_global(namespace: &str) -> Result<&'static str> {
330    match namespace {
331        "local" => Ok("localStorage"),
332        "session" => Ok("sessionStorage"),
333        other => bail!("invalid namespace `{other}`: expected `local` or `session`"),
334    }
335}
336
337pub(crate) fn build_get_expr(namespace_js: &str, key: &str) -> String {
338    let key_lit = serde_json::to_string(key).expect("string serialization is infallible");
339    format!("JSON.stringify({namespace_js}.getItem({key_lit}))")
340}
341
342fn build_get_by_regex_expr(namespace_js: &str, pattern: &str) -> String {
343    let pat_lit = serde_json::to_string(pattern).expect("string serialization is infallible");
344    format!(
345        "(() => {{ \
346const re = new RegExp({pat_lit}); \
347const k = Object.keys({namespace_js}).find(k => re.test(k)); \
348return k ? {{key: k, value: {namespace_js}.getItem(k)}} : null; \
349}})()"
350    )
351}
352
353pub(crate) fn build_set_expr(namespace_js: &str, key: &str, value: &str) -> String {
354    let key_lit = serde_json::to_string(key).expect("string serialization is infallible");
355    let val_lit = serde_json::to_string(value).expect("string serialization is infallible");
356    format!("{namespace_js}.setItem({key_lit}, {val_lit})")
357}
358
359fn build_list_expr(namespace_js: &str, pattern: Option<&str>) -> String {
360    let re_expr = match pattern {
361        Some(p) => {
362            let pat_lit = serde_json::to_string(p).expect("string serialization is infallible");
363            format!("new RegExp({pat_lit})")
364        }
365        None => "null".to_string(),
366    };
367    format!(
368        "(() => {{ \
369const ns = {namespace_js}; \
370const re = {re_expr}; \
371const out = []; \
372for (let i = 0; i < ns.length; i++) {{ \
373const k = ns.key(i); \
374if (!re || re.test(k)) out.push({{key: k, value: ns.getItem(k)}}); \
375}} \
376return out; \
377}})()"
378    )
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn ns_global_maps_known() {
387        assert_eq!(ns_global("local").unwrap(), "localStorage");
388        assert_eq!(ns_global("session").unwrap(), "sessionStorage");
389    }
390
391    #[test]
392    fn ns_global_rejects_unknown() {
393        let err = ns_global("cookies").unwrap_err().to_string();
394        assert!(err.contains("invalid namespace"), "got: {err}");
395        assert!(err.contains("cookies"));
396    }
397
398    #[test]
399    fn build_get_expr_escapes_single_quote() {
400        let expr = build_get_expr("localStorage", "it's");
401        assert_eq!(expr, "JSON.stringify(localStorage.getItem(\"it's\"))");
402    }
403
404    #[test]
405    fn build_get_expr_escapes_quote_and_backslash() {
406        let expr = build_get_expr("sessionStorage", "a\"b\\c");
407        assert_eq!(
408            expr,
409            "JSON.stringify(sessionStorage.getItem(\"a\\\"b\\\\c\"))"
410        );
411    }
412
413    #[test]
414    fn build_set_expr_escapes_both() {
415        let expr = build_set_expr("localStorage", "k\"1", "v\\n");
416        assert_eq!(expr, "localStorage.setItem(\"k\\\"1\", \"v\\\\n\")");
417    }
418
419    #[test]
420    fn build_get_by_regex_expr_escapes_quotes() {
421        let expr = build_get_by_regex_expr("localStorage", "^foo\".*$");
422        assert!(
423            expr.contains("new RegExp(\"^foo\\\".*$\")"),
424            "expr was: {expr}"
425        );
426        assert!(expr.contains("Object.keys(localStorage)"));
427    }
428
429    #[test]
430    fn build_list_expr_none_uses_null_regex() {
431        let expr = build_list_expr("localStorage", None);
432        assert!(expr.contains("const re = null;"), "expr: {expr}");
433        assert!(expr.contains("const ns = localStorage;"));
434    }
435
436    #[test]
437    fn build_list_expr_some_escapes_pattern() {
438        let expr = build_list_expr("sessionStorage", Some("a\"b"));
439        assert!(expr.contains("new RegExp(\"a\\\"b\")"), "expr: {expr}");
440        assert!(expr.contains("const ns = sessionStorage;"));
441    }
442
443    #[test]
444    fn strip_tab_removes_suffix_when_present() {
445        use crate::cli::routing::strip_tab;
446        assert_eq!(strip_tab("brave/cart", Some("cart")), "brave");
447        assert_eq!(strip_tab("brave", None), "brave");
448        // If `tab` is `Some` but does not in fact match the suffix, the
449        // original raw is returned unchanged (defensive — shouldn't happen
450        // in practice because the caller derives `tab` from the same raw).
451        assert_eq!(strip_tab("brave/cart", Some("other")), "brave/cart");
452    }
453
454    #[tokio::test]
455    async fn evaluate_routed_rejects_tab_and_target_together() {
456        let mut trace = CommandTrace::new("storage-get");
457        let err = evaluate_routed(
458            Some("brave/cart".to_string()),
459            Some(".*".to_string()),
460            "1",
461            None,
462            &mut trace,
463        )
464        .await
465        .unwrap_err();
466        assert!(
467            err.to_string().contains("either"),
468            "unexpected error: {err}"
469        );
470    }
471}