Skip to main content

browser_automation_cli/native/
state.rs

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