Skip to main content

browser_automation_cli/native/
state.rs

1#![allow(missing_docs)]
2use aes_gcm::{aead::Aead, aead::KeyInit, Aes256Gcm};
3use base64::Engine;
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use sha2::{Digest, Sha256};
7use std::collections::HashSet;
8use std::fs;
9use std::path::PathBuf;
10
11use super::cdp::client::CdpClient;
12use super::cdp::types::{
13    AttachToTargetParams, AttachToTargetResult, CloseTargetParams, CreateTargetParams,
14    CreateTargetResult, EvaluateParams,
15};
16use super::cookies::{self, Cookie};
17use crate::validation::{is_valid_session_name, sanitize_session_component, session_name_error};
18
19#[derive(Debug, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase")]
21pub struct StorageState {
22    pub cookies: Vec<Cookie>,
23    pub origins: Vec<OriginStorage>,
24}
25
26#[derive(Debug, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct OriginStorage {
29    pub origin: String,
30    pub local_storage: Vec<StorageEntry>,
31    #[serde(default)]
32    pub session_storage: Vec<StorageEntry>,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct StorageEntry {
38    pub name: String,
39    pub value: String,
40}
41
42fn collect_frame_origins(tree: &Value, origins: &mut HashSet<String>) {
43    if let Some(frame) = tree.get("frame") {
44        if let Some(url_str) = frame.get("url").and_then(|v| v.as_str()) {
45            if let Ok(parsed) = url::Url::parse(url_str) {
46                let origin = parsed.origin().ascii_serialization();
47                if origin != "null" && !origin.is_empty() {
48                    origins.insert(origin);
49                }
50            }
51        }
52    }
53    if let Some(children) = tree.get("childFrames").and_then(|v| v.as_array()) {
54        for child in children {
55            collect_frame_origins(child, origins);
56        }
57    }
58}
59
60/// Parse the JS-evaluated origin storage data into an OriginStorage struct.
61fn parse_origin_storage(data: &Value) -> Option<OriginStorage> {
62    if !data.is_object() {
63        return None;
64    }
65    let origin = data
66        .get("origin")
67        .and_then(|v| v.as_str())
68        .unwrap_or("")
69        .to_string();
70    if origin.is_empty() || origin == "null" {
71        return None;
72    }
73    let local_storage: Vec<StorageEntry> = data
74        .get("localStorage")
75        .and_then(|v| serde_json::from_value(v.clone()).ok())
76        .unwrap_or_default();
77    let session_storage: Vec<StorageEntry> = data
78        .get("sessionStorage")
79        .and_then(|v| serde_json::from_value(v.clone()).ok())
80        .unwrap_or_default();
81
82    Some(OriginStorage {
83        origin,
84        local_storage,
85        session_storage,
86    })
87}
88
89/// Evaluate the storage-collection JS snippet and parse the result.
90async fn eval_origin_storage(
91    client: &CdpClient,
92    session_id: &str,
93    origin_js: &str,
94) -> Option<OriginStorage> {
95    let result = client
96        .send_command_typed::<_, super::cdp::types::EvaluateResult>(
97            "Runtime.evaluate",
98            &EvaluateParams {
99                expression: origin_js.to_string(),
100                return_by_value: Some(true),
101                await_promise: Some(false),
102            },
103            Some(session_id),
104        )
105        .await
106        .ok()?;
107    let data = result.result.value.unwrap_or(Value::Null);
108    parse_origin_storage(&data)
109}
110
111/// Create a temporary CDP target, navigate it to each origin to collect localStorage,
112/// then close it. Uses Fetch interception to serve blank HTML instead of making real
113/// network requests.
114async fn collect_storage_via_temp_target(
115    client: &CdpClient,
116    origins: &[String],
117    origin_js: &str,
118) -> Result<Vec<OriginStorage>, String> {
119    let create_result: CreateTargetResult = client
120        .send_command_typed(
121            "Target.createTarget",
122            &CreateTargetParams {
123                url: "about:blank".to_string(),
124                browser_context_id: None,
125            },
126            None,
127        )
128        .await?;
129
130    let target_id = create_result.target_id;
131
132    // Ensure the target is closed even if attach or later steps fail
133    let result = collect_storage_in_target(client, &target_id, origins, origin_js).await;
134
135    let _ = client
136        .send_command_typed::<_, Value>(
137            "Target.closeTarget",
138            &CloseTargetParams { target_id },
139            None,
140        )
141        .await;
142
143    result
144}
145
146async fn collect_storage_in_target(
147    client: &CdpClient,
148    target_id: &str,
149    origins: &[String],
150    origin_js: &str,
151) -> Result<Vec<OriginStorage>, String> {
152    let attach_result: AttachToTargetResult = client
153        .send_command_typed(
154            "Target.attachToTarget",
155            &AttachToTargetParams {
156                target_id: target_id.to_string(),
157                flatten: true,
158            },
159            None,
160        )
161        .await?;
162
163    let temp_session = &attach_result.session_id;
164
165    client
166        .send_command_no_params("Page.enable", Some(temp_session))
167        .await?;
168    client
169        .send_command_no_params("Runtime.enable", Some(temp_session))
170        .await?;
171
172    // Blank HTML response body, pre-encoded to avoid repeated base64 work per request
173    let blank_html_b64 = base64::engine::general_purpose::STANDARD.encode("<html></html>");
174
175    let _ = client
176        .send_command(
177            "Fetch.enable",
178            Some(json!({ "patterns": [{ "urlPattern": "*" }] })),
179            Some(temp_session),
180        )
181        .await;
182
183    let mut event_rx = client.subscribe();
184    let mut results = Vec::new();
185
186    for target_origin in origins {
187        let nav_url = format!("{}/", target_origin.trim_end_matches('/'));
188        if client
189            .send_command(
190                "Page.navigate",
191                Some(json!({ "url": nav_url })),
192                Some(temp_session),
193            )
194            .await
195            .is_err()
196        {
197            continue;
198        }
199
200        // Fulfill intercepted requests with blank HTML until the page loads
201        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(5);
202        let mut page_loaded = false;
203        while tokio::time::Instant::now() < deadline {
204            match tokio::time::timeout(tokio::time::Duration::from_secs(2), event_rx.recv()).await {
205                Ok(Ok(evt)) if evt.session_id.as_deref() == Some(temp_session) => {
206                    if evt.method == "Fetch.requestPaused" {
207                        if let Some(request_id) =
208                            evt.params.get("requestId").and_then(|v| v.as_str())
209                        {
210                            let _ = client
211                                .send_command(
212                                    "Fetch.fulfillRequest",
213                                    Some(json!({
214                                        "requestId": request_id,
215                                        "responseCode": 200,
216                                        "responseHeaders": [
217                                            { "name": "Content-Type", "value": "text/html" }
218                                        ],
219                                        "body": &blank_html_b64
220                                    })),
221                                    Some(temp_session),
222                                )
223                                .await;
224                        }
225                    } else if evt.method == "Page.loadEventFired" {
226                        page_loaded = true;
227                        break;
228                    }
229                }
230                Ok(Ok(_)) => continue,  // event for a different session
231                Ok(Err(_)) => continue, // lagged or closed — retry within deadline
232                Err(_) => break,        // outer timeout elapsed
233            }
234        }
235
236        if !page_loaded {
237            continue;
238        }
239
240        if let Some(storage) = eval_origin_storage(client, temp_session, origin_js).await {
241            if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
242                results.push(storage);
243            }
244        }
245    }
246
247    Ok(results)
248}
249
250pub async fn save_state(
251    client: &CdpClient,
252    session_id: &str,
253    path: Option<&str>,
254    session_name: Option<&str>,
255    session_id_str: &str,
256    visited_origins: &HashSet<String>,
257) -> Result<String, String> {
258    let cookies = cookies::get_all_cookies(client, session_id).await?;
259
260    let origin_js = r#"(() => {
261        const result = { origin: location.origin, localStorage: [], sessionStorage: [] };
262        try {
263            for (let i = 0; i < localStorage.length; i++) {
264                const key = localStorage.key(i);
265                result.localStorage.push({ name: key, value: localStorage.getItem(key) });
266            }
267        } catch(e) {}
268        try {
269            for (let i = 0; i < sessionStorage.length; i++) {
270                const key = sessionStorage.key(i);
271                result.sessionStorage.push({ name: key, value: sessionStorage.getItem(key) });
272            }
273        } catch(e) {}
274        return result;
275    })()"#;
276
277    // Merge visited origins with current frame tree origins
278    let mut all_origins = visited_origins.clone();
279    if let Ok(tree_result) = client
280        .send_command_no_params("Page.getFrameTree", Some(session_id))
281        .await
282    {
283        if let Some(tree) = tree_result.get("frameTree") {
284            collect_frame_origins(tree, &mut all_origins);
285        }
286    }
287
288    // 1. Collect localStorage from the current page
289    let mut origins = Vec::new();
290    let mut current_origin = String::new();
291
292    if let Some(storage) = eval_origin_storage(client, session_id, origin_js).await {
293        current_origin = storage.origin.clone();
294        if !storage.local_storage.is_empty() || !storage.session_storage.is_empty() {
295            origins.push(storage);
296        }
297    }
298
299    // 2. Collect localStorage from remaining origins via a disposable temp target
300    all_origins.remove(&current_origin);
301    if !all_origins.is_empty() {
302        let remaining: Vec<String> = all_origins.into_iter().collect();
303        if let Ok(temp_origins) =
304            collect_storage_via_temp_target(client, &remaining, origin_js).await
305        {
306            origins.extend(temp_origins);
307        }
308    }
309
310    let state = StorageState { cookies, origins };
311    let json_str = serde_json::to_string_pretty(&state)
312        .map_err(|e| format!("Failed to serialize state: {}", e))?;
313
314    let mut save_path = match path {
315        Some(p) => p.to_string(),
316        None => {
317            let dir = get_sessions_dir();
318            let _ = fs::create_dir_all(&dir);
319            let name = session_name.unwrap_or("default");
320            if !is_valid_session_name(name) {
321                return Err(session_name_error(name));
322            }
323            dir.join(format!("{}-{}.json", name, session_id_str))
324                .to_string_lossy()
325                .to_string()
326        }
327    };
328
329    if let Ok(key) = std::env::var("BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY") {
330        let encrypted = encrypt_data(json_str.as_bytes(), &key)?;
331        save_path.push_str(".enc");
332        fs::write(&save_path, &encrypted)
333            .map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
334    } else {
335        fs::write(&save_path, &json_str)
336            .map_err(|e| format!("Failed to write state to {}: {}", save_path, e))?;
337    }
338
339    Ok(save_path)
340}
341
342pub async fn save_auto_state_transactional(
343    client: &CdpClient,
344    session_id: &str,
345    session_name: &str,
346    session_id_str: &str,
347    visited_origins: &HashSet<String>,
348) -> Result<String, String> {
349    if !is_valid_session_name(session_name) {
350        return Err(session_name_error(session_name));
351    }
352
353    let dir = get_sessions_dir();
354    fs::create_dir_all(&dir)
355        .map_err(|e| format!("Failed to create state directory {}: {}", dir.display(), e))?;
356
357    let tmp_dir = dir.join(".tmp");
358    fs::create_dir_all(&tmp_dir).map_err(|e| {
359        format!(
360            "Failed to create temporary state directory {}: {}",
361            tmp_dir.display(),
362            e
363        )
364    })?;
365
366    let base_name = format!("{}-{}", session_name, session_id_str);
367    let final_json_path = dir.join(format!("{}.json", base_name));
368    let final_path = if std::env::var("BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY").is_ok() {
369        PathBuf::from(format!("{}.enc", final_json_path.to_string_lossy()))
370    } else {
371        final_json_path
372    };
373    let candidate_json_path = tmp_dir.join(format!(
374        "{}-candidate-{}.json",
375        base_name,
376        std::process::id()
377    ));
378    let candidate_arg = candidate_json_path.to_string_lossy().to_string();
379
380    let candidate_path = save_state(
381        client,
382        session_id,
383        Some(&candidate_arg),
384        Some(session_name),
385        session_id_str,
386        visited_origins,
387    )
388    .await?;
389
390    if let Err(err) = validate_state_file(&candidate_path) {
391        let _ = fs::remove_file(&candidate_path);
392        return Err(err);
393    }
394
395    let previous_path = PathBuf::from(format!("{}.previous", final_path.to_string_lossy()));
396    if final_path.exists() {
397        let _ = fs::remove_file(&previous_path);
398        fs::rename(&final_path, &previous_path).map_err(|e| {
399            format!(
400                "Failed to rotate previous state {} to {}: {}",
401                final_path.display(),
402                previous_path.display(),
403                e
404            )
405        })?;
406    }
407
408    let candidate = PathBuf::from(&candidate_path);
409    if let Err(err) = fs::rename(&candidate, &final_path) {
410        if previous_path.exists() && !final_path.exists() {
411            let _ = fs::rename(&previous_path, &final_path);
412        }
413        return Err(format!(
414            "Failed to promote state {} to {}: {}",
415            candidate.display(),
416            final_path.display(),
417            err
418        ));
419    }
420    if previous_path.exists() {
421        let _ = fs::remove_file(&previous_path);
422    }
423
424    Ok(final_path.to_string_lossy().to_string())
425}
426
427fn read_state_json(path: &str) -> Result<String, String> {
428    if is_encrypted_state(std::path::Path::new(path)) {
429        let key = std::env::var("BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY").map_err(|_| {
430            "Encrypted state file requires BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY".to_string()
431        })?;
432        let data =
433            fs::read(path).map_err(|e| format!("Failed to read state from {}: {}", path, e))?;
434        let decrypted = decrypt_data(&data, &key)?;
435        Ok(String::from_utf8(decrypted)
436            .map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?)
437    } else {
438        match fs::read_to_string(path) {
439            Ok(s) => Ok(s),
440            Err(e) => {
441                if let Ok(key) = std::env::var("BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY") {
442                    let enc_path = format!("{}.enc", path);
443                    if let Ok(data) = fs::read(&enc_path) {
444                        let decrypted = decrypt_data(&data, &key)?;
445                        Ok(String::from_utf8(decrypted)
446                            .map_err(|de| format!("Decrypted state is not valid UTF-8: {}", de))?)
447                    } else {
448                        Err(format!("Failed to read state from {}: {}", path, e))
449                    }
450                } else {
451                    Err(format!("Failed to read state from {}: {}", path, e))
452                }
453            }
454        }
455    }
456}
457
458pub fn validate_state_file(path: &str) -> Result<(), String> {
459    let json_str = read_state_json(path)?;
460    let _: StorageState =
461        serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
462    Ok(())
463}
464
465pub async fn load_state(client: &CdpClient, session_id: &str, path: &str) -> Result<(), String> {
466    let json_str = read_state_json(path)?;
467
468    let state: StorageState =
469        serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
470
471    // Load cookies
472    if !state.cookies.is_empty() {
473        let cookie_values: Vec<Value> = state
474            .cookies
475            .iter()
476            .map(|c| serde_json::to_value(c).unwrap_or(Value::Null))
477            .collect();
478        cookies::set_cookies(client, session_id, cookie_values, None).await?;
479    }
480
481    // Load storage per origin
482    for origin in &state.origins {
483        if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
484            continue;
485        }
486
487        // Navigate to origin to set storage
488        let navigate_url = format!("{}/", origin.origin.trim_end_matches('/'));
489        client
490            .send_command(
491                "Page.navigate",
492                Some(json!({ "url": navigate_url })),
493                Some(session_id),
494            )
495            .await?;
496
497        // Brief wait for navigation
498        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
499
500        for entry in &origin.local_storage {
501            let js = format!(
502                "localStorage.setItem({}, {})",
503                serde_json::to_string(&entry.name).unwrap_or_default(),
504                serde_json::to_string(&entry.value).unwrap_or_default(),
505            );
506            let _ = client
507                .send_command_typed::<_, super::cdp::types::EvaluateResult>(
508                    "Runtime.evaluate",
509                    &EvaluateParams {
510                        expression: js,
511                        return_by_value: Some(true),
512                        await_promise: Some(false),
513                    },
514                    Some(session_id),
515                )
516                .await;
517        }
518
519        for entry in &origin.session_storage {
520            let js = format!(
521                "sessionStorage.setItem({}, {})",
522                serde_json::to_string(&entry.name).unwrap_or_default(),
523                serde_json::to_string(&entry.value).unwrap_or_default(),
524            );
525            let _ = client
526                .send_command_typed::<_, super::cdp::types::EvaluateResult>(
527                    "Runtime.evaluate",
528                    &EvaluateParams {
529                        expression: js,
530                        return_by_value: Some(true),
531                        await_promise: Some(false),
532                    },
533                    Some(session_id),
534                )
535                .await;
536        }
537    }
538
539    Ok(())
540}
541
542fn is_state_file(path: &std::path::Path) -> bool {
543    let fname = path
544        .file_name()
545        .unwrap_or_default()
546        .to_string_lossy()
547        .to_string();
548    fname.ends_with(".json")
549        || fname.ends_with(".json.enc")
550        || fname.ends_with(".json.previous")
551        || fname.ends_with(".json.enc.previous")
552}
553
554fn is_encrypted_state(path: &std::path::Path) -> bool {
555    let path = path.to_string_lossy();
556    path.ends_with(".json.enc") || path.ends_with(".json.enc.previous")
557}
558
559pub fn state_list() -> Result<Value, String> {
560    let dir = get_sessions_dir();
561    if !dir.exists() {
562        return Ok(json!({ "files": [], "directory": dir.to_string_lossy() }));
563    }
564
565    let mut files = Vec::new();
566
567    let entries = fs::read_dir(&dir).map_err(|e| format!("Failed to read sessions dir: {}", e))?;
568
569    for entry in entries.flatten() {
570        let path = entry.path();
571        if is_state_file(&path) {
572            let metadata = fs::metadata(&path).ok();
573            let filename = path
574                .file_name()
575                .unwrap_or_default()
576                .to_string_lossy()
577                .to_string();
578            let size = metadata.as_ref().map(|m| m.len()).unwrap_or(0);
579            let modified = metadata
580                .as_ref()
581                .and_then(|m| m.modified().ok())
582                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
583                .map(|d| d.as_secs())
584                .unwrap_or(0);
585            let encrypted = is_encrypted_state(&path);
586
587            files.push(json!({
588                "filename": filename,
589                "path": path.to_string_lossy(),
590                "size": size,
591                "modified": modified,
592                "encrypted": encrypted,
593            }));
594        }
595    }
596
597    Ok(json!({ "files": files, "directory": dir.to_string_lossy() }))
598}
599
600pub fn state_show(path: &str) -> Result<Value, String> {
601    let encrypted = is_encrypted_state(std::path::Path::new(path));
602    let json_str = if encrypted {
603        let key = std::env::var("BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY").map_err(|_| {
604            "Encrypted state file requires BROWSER_AUTOMATION_CLI_ENCRYPTION_KEY".to_string()
605        })?;
606        let data = fs::read(path).map_err(|e| format!("Failed to read state file: {}", e))?;
607        let decrypted = decrypt_data(&data, &key)?;
608        String::from_utf8(decrypted)
609            .map_err(|e| format!("Decrypted state is not valid UTF-8: {}", e))?
610    } else {
611        fs::read_to_string(path).map_err(|e| format!("Failed to read state file: {}", e))?
612    };
613
614    let state: StorageState =
615        serde_json::from_str(&json_str).map_err(|e| format!("Invalid state file: {}", e))?;
616
617    let metadata = fs::metadata(path).ok();
618    let filename = std::path::Path::new(path)
619        .file_name()
620        .unwrap_or_default()
621        .to_string_lossy()
622        .to_string();
623
624    Ok(json!({
625        "filename": filename,
626        "path": path,
627        "size": metadata.as_ref().map(|m| m.len()).unwrap_or(0),
628        "modified": metadata.as_ref()
629            .and_then(|m| m.modified().ok())
630            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
631            .map(|d| d.as_secs())
632            .unwrap_or(0),
633        "encrypted": encrypted,
634        "summary": format!("{} cookies, {} origins", state.cookies.len(), state.origins.len()),
635        "state": state,
636    }))
637}
638
639pub fn state_clear(path: Option<&str>) -> Result<Value, String> {
640    if let Some(p) = path {
641        fs::remove_file(p).map_err(|e| format!("Failed to delete state: {}", e))?;
642        return Ok(json!({ "deleted": p }));
643    }
644
645    let dir = get_sessions_dir();
646    if !dir.exists() {
647        return Ok(json!({ "deleted": 0 }));
648    }
649
650    let mut count = 0;
651    if let Ok(entries) = fs::read_dir(&dir) {
652        for entry in entries.flatten() {
653            let path = entry.path();
654            if is_state_file(&path) {
655                let _ = fs::remove_file(&path);
656                count += 1;
657            }
658        }
659    }
660
661    Ok(json!({ "deleted": count }))
662}
663
664pub fn state_clean(max_age_days: u64) -> Result<Value, String> {
665    let dir = get_sessions_dir();
666    if !dir.exists() {
667        return Ok(json!({ "cleaned": 0, "keptCount": 0, "days": max_age_days }));
668    }
669
670    let now = std::time::SystemTime::now();
671    let max_age = std::time::Duration::from_secs(max_age_days * 86400);
672    let mut deleted = 0;
673    let mut kept = 0;
674
675    if let Ok(entries) = fs::read_dir(&dir) {
676        for entry in entries.flatten() {
677            let path = entry.path();
678            if !is_state_file(&path) {
679                continue;
680            }
681
682            if let Ok(metadata) = fs::metadata(&path) {
683                if let Ok(modified) = metadata.modified() {
684                    if let Ok(age) = now.duration_since(modified) {
685                        if age > max_age {
686                            let _ = fs::remove_file(&path);
687                            deleted += 1;
688                            continue;
689                        }
690                    }
691                }
692            }
693            kept += 1;
694        }
695    }
696
697    Ok(json!({ "cleaned": deleted, "keptCount": kept, "days": max_age_days }))
698}
699
700pub fn state_rename(old_path: &str, new_name: &str) -> Result<Value, String> {
701    let old = PathBuf::from(old_path);
702    if !old.exists() {
703        return Err(format!("State file not found: {}", old_path));
704    }
705
706    let fallback = PathBuf::from(".");
707    let dir = old.parent().unwrap_or(&fallback);
708    let new_path = dir.join(format!("{}.json", new_name));
709
710    fs::rename(&old, &new_path).map_err(|e| format!("Failed to rename state: {}", e))?;
711
712    Ok(json!({
713        "renamed": true,
714        "from": old_path,
715        "to": new_path.to_string_lossy(),
716    }))
717}
718
719fn encrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
720    let mut hasher = Sha256::new();
721    hasher.update(key_str.as_bytes());
722    let key_bytes = hasher.finalize();
723    let cipher =
724        Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
725
726    let mut nonce = [0u8; 12];
727    getrandom::getrandom(&mut nonce).map_err(|e| format!("Failed to generate nonce: {}", e))?;
728    let ciphertext = cipher
729        .encrypt(aes_gcm::Nonce::from_slice(&nonce), data)
730        .map_err(|e| format!("Encryption failed: {}", e))?;
731
732    let mut result = Vec::with_capacity(12 + ciphertext.len());
733    result.extend_from_slice(&nonce);
734    result.extend_from_slice(&ciphertext);
735    Ok(result)
736}
737
738fn decrypt_data(data: &[u8], key_str: &str) -> Result<Vec<u8>, String> {
739    if data.len() < 13 {
740        return Err("Ciphertext too short".to_string());
741    }
742    let (nonce_bytes, ciphertext) = data.split_at(12);
743
744    let mut hasher = Sha256::new();
745    hasher.update(key_str.as_bytes());
746    let key_bytes = hasher.finalize();
747    let cipher =
748        Aes256Gcm::new_from_slice(&key_bytes).map_err(|e| format!("Invalid key: {}", e))?;
749    let plaintext = cipher
750        .decrypt(aes_gcm::Nonce::from_slice(nonce_bytes), ciphertext)
751        .map_err(|e| format!("Decryption failed: {}", e))?;
752    Ok(plaintext)
753}
754
755pub fn find_auto_state_file(session_name: &str) -> Option<String> {
756    if !is_valid_session_name(session_name) {
757        return None;
758    }
759
760    let dir = get_sessions_dir();
761    if !dir.exists() {
762        return None;
763    }
764    let prefix = format!("{}-", session_name);
765    let mut best_path: Option<(String, std::time::SystemTime)> = None;
766
767    if let Ok(entries) = fs::read_dir(&dir) {
768        for entry in entries.flatten() {
769            let path = entry.path();
770            let fname = path
771                .file_name()
772                .unwrap_or_default()
773                .to_string_lossy()
774                .to_string();
775            let is_match = fname.starts_with(&prefix)
776                && (fname.ends_with(".json") || fname.ends_with(".json.enc"));
777            if !is_match {
778                continue;
779            }
780            let modified = fs::metadata(&path)
781                .ok()
782                .and_then(|m| m.modified().ok())
783                .unwrap_or(std::time::UNIX_EPOCH);
784            if best_path.as_ref().is_none_or(|(_, t)| modified > *t) {
785                best_path = Some((path.to_string_lossy().to_string(), modified));
786            }
787        }
788    }
789    best_path.map(|(p, _)| p)
790}
791
792/// Dispatch a state management command from its JSON payload.
793/// Returns `Some(result)` for recognised state_* actions, `None` otherwise.
794pub fn dispatch_state_command(cmd: &Value) -> Option<Result<Value, String>> {
795    let action = cmd.get("action").and_then(|v| v.as_str())?;
796    match action {
797        "state_list" => Some(state_list()),
798        "state_show" => Some(
799            cmd.get("path")
800                .and_then(|v| v.as_str())
801                .ok_or_else(|| "Missing 'path' parameter".to_string())
802                .and_then(state_show),
803        ),
804        "state_clear" => {
805            let path = cmd.get("path").and_then(|v| v.as_str());
806            Some(state_clear(path))
807        }
808        "state_clean" => {
809            let days = cmd.get("days").and_then(|v| v.as_u64()).unwrap_or(30);
810            Some(state_clean(days))
811        }
812        "state_rename" => Some(
813            cmd.get("path")
814                .and_then(|v| v.as_str())
815                .ok_or_else(|| "Missing 'path' parameter".to_string())
816                .and_then(|path| {
817                    cmd.get("name")
818                        .and_then(|v| v.as_str())
819                        .ok_or_else(|| "Missing 'name' parameter".to_string())
820                        .and_then(|name| state_rename(path, name))
821                }),
822        ),
823        _ => None,
824    }
825}
826
827/// Return the browser-automation-cli state root (`~/.browser-automation-cli`, falling back to
828/// `<tempdir>/browser-automation-cli` when the home directory can't be resolved).
829/// This is the parent of `sessions/`, auth storage, and the encryption key.
830pub fn get_state_dir() -> PathBuf {
831    let base = if let Some(home) = dirs::home_dir() {
832        home.join(".browser-automation-cli")
833    } else {
834        std::env::temp_dir().join("browser-automation-cli")
835    };
836
837    if let Ok(namespace) = std::env::var("BROWSER_AUTOMATION_CLI_NAMESPACE") {
838        let namespace = sanitize_session_component(&namespace);
839        if !namespace.is_empty() {
840            return base.join("namespaces").join(namespace).join("state");
841        }
842    }
843
844    base
845}
846
847pub fn get_sessions_dir() -> PathBuf {
848    get_state_dir().join("sessions")
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_storage_state_serialization() {
857        let state = StorageState {
858            cookies: vec![Cookie {
859                name: "session".to_string(),
860                value: "abc123".to_string(),
861                domain: ".example.com".to_string(),
862                path: "/".to_string(),
863                expires: 0.0,
864                size: 0,
865                http_only: true,
866                secure: false,
867                session: true,
868                same_site: Some("Lax".to_string()),
869            }],
870            origins: vec![OriginStorage {
871                origin: "https://example.com".to_string(),
872                local_storage: vec![StorageEntry {
873                    name: "key".to_string(),
874                    value: "val".to_string(),
875                }],
876                session_storage: vec![],
877            }],
878        };
879
880        let json = serde_json::to_string_pretty(&state).unwrap();
881        let parsed: StorageState = serde_json::from_str(&json).unwrap();
882        assert_eq!(parsed.cookies.len(), 1);
883        assert_eq!(parsed.cookies[0].name, "session");
884        assert_eq!(parsed.origins.len(), 1);
885        assert_eq!(parsed.origins[0].local_storage.len(), 1);
886    }
887
888    #[test]
889    fn test_storage_state_empty() {
890        let state = StorageState {
891            cookies: vec![],
892            origins: vec![],
893        };
894        let json = serde_json::to_string(&state).unwrap();
895        let parsed: StorageState = serde_json::from_str(&json).unwrap();
896        assert!(parsed.cookies.is_empty());
897        assert!(parsed.origins.is_empty());
898    }
899
900    #[test]
901    fn test_state_show_nonexistent_file() {
902        let result = state_show("/tmp/nonexistent-browser-automation-cli-state-file.json");
903        assert!(result.is_err());
904    }
905
906    #[test]
907    fn test_state_clear_nonexistent_file() {
908        let result = state_clear(Some(
909            "/tmp/nonexistent-browser-automation-cli-state-file.json",
910        ));
911        assert!(result.is_err());
912    }
913
914    #[test]
915    fn test_state_file_matcher_includes_transactional_backups() {
916        assert!(is_state_file(std::path::Path::new("auth.json")));
917        assert!(is_state_file(std::path::Path::new("auth.json.enc")));
918        assert!(is_state_file(std::path::Path::new("auth.json.previous")));
919        assert!(is_state_file(std::path::Path::new(
920            "auth.json.enc.previous"
921        )));
922        assert!(is_encrypted_state(std::path::Path::new(
923            "auth.json.enc.previous"
924        )));
925    }
926
927    #[test]
928    fn test_state_clear_removes_transactional_backups() {
929        let guard = crate::test_utils::EnvGuard::new(&["HOME", "BROWSER_AUTOMATION_CLI_NAMESPACE"]);
930        let dir = tempfile::tempdir().unwrap();
931        guard.set("HOME", dir.path().to_str().unwrap());
932        guard.remove("BROWSER_AUTOMATION_CLI_NAMESPACE");
933
934        let sessions = get_sessions_dir();
935        fs::create_dir_all(&sessions).unwrap();
936        fs::write(sessions.join("auth-test.json"), "{}").unwrap();
937        fs::write(sessions.join("auth-test.json.previous"), "{}").unwrap();
938        fs::write(sessions.join("auth-test.json.enc.previous"), "encrypted").unwrap();
939
940        let result = state_clear(None).unwrap();
941
942        assert_eq!(result["deleted"], 3);
943        assert!(!sessions.join("auth-test.json").exists());
944        assert!(!sessions.join("auth-test.json.previous").exists());
945        assert!(!sessions.join("auth-test.json.enc.previous").exists());
946    }
947
948    #[test]
949    fn test_state_rename_nonexistent() {
950        let result = state_rename(
951            "/tmp/nonexistent-browser-automation-cli-state-file.json",
952            "new-name",
953        );
954        assert!(result.is_err());
955        assert!(result.unwrap_err().contains("not found"));
956    }
957
958    #[test]
959    fn test_state_list_returns_json() {
960        let result = state_list().unwrap();
961        assert!(result.get("files").is_some());
962        assert!(result.get("directory").is_some());
963    }
964
965    #[test]
966    fn test_sessions_dir_path() {
967        let dir = get_sessions_dir();
968        assert!(dir.to_string_lossy().contains("sessions"));
969    }
970
971    #[test]
972    fn test_get_state_dir_namespace_scopes_sessions() {
973        let _guard = crate::test_utils::EnvGuard::new(&["BROWSER_AUTOMATION_CLI_NAMESPACE"]);
974        _guard.set("BROWSER_AUTOMATION_CLI_NAMESPACE", "Worktree: One");
975
976        let dir = get_state_dir();
977        let expected_state_suffix = PathBuf::from(".browser-automation-cli")
978            .join("namespaces")
979            .join("worktree-one")
980            .join("state");
981        let expected_sessions_suffix = expected_state_suffix.join("sessions");
982
983        assert!(dir.ends_with(expected_state_suffix));
984        assert!(get_sessions_dir().ends_with(expected_sessions_suffix));
985    }
986
987    #[test]
988    fn test_encrypt_decrypt_roundtrip() {
989        let plain = b"hello world";
990        let key = "test-secret-key";
991        let encrypted = encrypt_data(plain, key).unwrap();
992        assert!(encrypted.len() > 12);
993        assert_ne!(&encrypted[12..], plain);
994        let decrypted = decrypt_data(&encrypted, key).unwrap();
995        assert_eq!(decrypted, plain);
996    }
997
998    #[test]
999    fn test_decrypt_wrong_key_fails() {
1000        let plain = b"secret data";
1001        let encrypted = encrypt_data(plain, "key1").unwrap();
1002        let result = decrypt_data(&encrypted, "key2");
1003        assert!(result.is_err());
1004    }
1005
1006    #[test]
1007    fn test_cookie_serde_roundtrip() {
1008        let cookie = Cookie {
1009            name: "test".to_string(),
1010            value: "123".to_string(),
1011            domain: ".test.com".to_string(),
1012            path: "/api".to_string(),
1013            expires: 1700000000.0,
1014            size: 7,
1015            http_only: false,
1016            secure: true,
1017            session: false,
1018            same_site: Some("Strict".to_string()),
1019        };
1020
1021        let json = serde_json::to_value(&cookie).unwrap();
1022        assert_eq!(json["name"], "test");
1023        assert_eq!(json["httpOnly"], false);
1024        assert_eq!(json["secure"], true);
1025        assert_eq!(json["sameSite"], "Strict");
1026    }
1027
1028    #[test]
1029    fn test_dispatch_state_command_routes_state_list() {
1030        let cmd = serde_json::json!({ "action": "state_list" });
1031        let result = dispatch_state_command(&cmd);
1032        assert!(result.is_some());
1033        assert!(result.unwrap().is_ok());
1034    }
1035
1036    #[test]
1037    fn test_dispatch_state_command_returns_none_for_unknown() {
1038        let cmd = serde_json::json!({ "action": "navigate" });
1039        assert!(dispatch_state_command(&cmd).is_none());
1040    }
1041
1042    #[test]
1043    fn test_dispatch_state_command_returns_none_for_missing_action() {
1044        let cmd = serde_json::json!({});
1045        assert!(dispatch_state_command(&cmd).is_none());
1046    }
1047
1048    #[test]
1049    fn test_dispatch_state_show_missing_path() {
1050        let cmd = serde_json::json!({ "action": "state_show" });
1051        let result = dispatch_state_command(&cmd).unwrap();
1052        assert!(result.is_err());
1053        assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
1054    }
1055
1056    #[test]
1057    fn test_dispatch_state_rename_missing_params() {
1058        let cmd = serde_json::json!({ "action": "state_rename" });
1059        let result = dispatch_state_command(&cmd).unwrap();
1060        assert!(result.is_err());
1061        assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
1062
1063        let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
1064        let result = dispatch_state_command(&cmd).unwrap();
1065        assert!(result.is_err());
1066        assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
1067    }
1068}