1use 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
28const STORAGE_TIMEOUT: Duration = Duration::from_secs(10);
32
33#[derive(Subcommand, Debug)]
34pub enum StorageCmd {
35 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 #[arg(long, default_value = freshness::DEFAULT_MAX_AGE_STR)]
50 max_age: String,
51 },
52 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 {
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 #[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
128async 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 let r = route::preamble(browser, target.as_deref(), trace).await?;
148 let resolved = &r.resolved;
149
150 match (r.tab_name.clone(), target) {
151 (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 (None, None) => {
174 if matches!(resolved.source, Source::External) {
175 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 (None, Some(regex)) => {
211 trace.route("target-regex");
212 let session =
213 PageSession::attach(&resolved.endpoint, resolved.engine, Some(®ex)).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 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}