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
60fn 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
89async 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
111async 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 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 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 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, Ok(Err(_)) => continue, Err(_) => break, }
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 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 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 all_origins.remove(¤t_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 Some(key) = crate::xdg::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 crate::xdg::encryption_key().is_some() {
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 = crate::xdg::encryption_key().ok_or_else(|| {
430 "Encrypted state file requires config set encryption_key (XDG config)".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 Some(key) = crate::xdg::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 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 for origin in &state.origins {
483 if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
484 continue;
485 }
486
487 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 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 = crate::xdg::encryption_key().ok_or_else(|| {
604 "Encrypted state file requires config set encryption_key (XDG config)".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
792pub 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
827pub fn get_state_dir() -> PathBuf {
832 let base = crate::xdg::state_dir().unwrap_or_else(|_| {
833 std::env::temp_dir()
834 .join("browser-automation-cli")
835 .join("state")
836 });
837
838 if let Ok(cfg) = crate::xdg::load_config() {
839 if let Some(namespace) = cfg.namespace {
840 let namespace = sanitize_session_component(&namespace);
841 if !namespace.is_empty() {
842 return base.join("namespaces").join(namespace);
843 }
844 }
845 }
846
847 base
848}
849
850pub fn get_sessions_dir() -> PathBuf {
851 get_state_dir().join("sessions")
852}
853
854#[cfg(test)]
855mod tests {
856 use super::*;
857
858 #[test]
859 fn test_storage_state_serialization() {
860 let state = StorageState {
861 cookies: vec![Cookie {
862 name: "session".to_string(),
863 value: "abc123".to_string(),
864 domain: ".example.com".to_string(),
865 path: "/".to_string(),
866 expires: 0.0,
867 size: 0,
868 http_only: true,
869 secure: false,
870 session: true,
871 same_site: Some("Lax".to_string()),
872 }],
873 origins: vec![OriginStorage {
874 origin: "https://example.com".to_string(),
875 local_storage: vec![StorageEntry {
876 name: "key".to_string(),
877 value: "val".to_string(),
878 }],
879 session_storage: vec![],
880 }],
881 };
882
883 let json = serde_json::to_string_pretty(&state).unwrap();
884 let parsed: StorageState = serde_json::from_str(&json).unwrap();
885 assert_eq!(parsed.cookies.len(), 1);
886 assert_eq!(parsed.cookies[0].name, "session");
887 assert_eq!(parsed.origins.len(), 1);
888 assert_eq!(parsed.origins[0].local_storage.len(), 1);
889 }
890
891 #[test]
892 fn test_storage_state_empty() {
893 let state = StorageState {
894 cookies: vec![],
895 origins: vec![],
896 };
897 let json = serde_json::to_string(&state).unwrap();
898 let parsed: StorageState = serde_json::from_str(&json).unwrap();
899 assert!(parsed.cookies.is_empty());
900 assert!(parsed.origins.is_empty());
901 }
902
903 #[test]
904 fn test_state_show_nonexistent_file() {
905 let result = state_show("/tmp/nonexistent-browser-automation-cli-state-file.json");
906 assert!(result.is_err());
907 }
908
909 #[test]
910 fn test_state_clear_nonexistent_file() {
911 let result = state_clear(Some(
912 "/tmp/nonexistent-browser-automation-cli-state-file.json",
913 ));
914 assert!(result.is_err());
915 }
916
917 #[test]
918 fn test_state_file_matcher_includes_transactional_backups() {
919 assert!(is_state_file(std::path::Path::new("auth.json")));
920 assert!(is_state_file(std::path::Path::new("auth.json.enc")));
921 assert!(is_state_file(std::path::Path::new("auth.json.previous")));
922 assert!(is_state_file(std::path::Path::new(
923 "auth.json.enc.previous"
924 )));
925 assert!(is_encrypted_state(std::path::Path::new(
926 "auth.json.enc.previous"
927 )));
928 }
929
930 #[test]
931 fn test_state_clear_removes_transactional_backups() {
932 let guard = crate::test_utils::EnvGuard::new(&["HOME", "BROWSER_AUTOMATION_CLI_NAMESPACE"]);
933 let dir = tempfile::tempdir().unwrap();
934 guard.set("HOME", dir.path().to_str().unwrap());
935 guard.remove("BROWSER_AUTOMATION_CLI_NAMESPACE");
936
937 let sessions = get_sessions_dir();
938 fs::create_dir_all(&sessions).unwrap();
939 fs::write(sessions.join("auth-test.json"), "{}").unwrap();
940 fs::write(sessions.join("auth-test.json.previous"), "{}").unwrap();
941 fs::write(sessions.join("auth-test.json.enc.previous"), "encrypted").unwrap();
942
943 let result = state_clear(None).unwrap();
944
945 assert_eq!(result["deleted"], 3);
946 assert!(!sessions.join("auth-test.json").exists());
947 assert!(!sessions.join("auth-test.json.previous").exists());
948 assert!(!sessions.join("auth-test.json.enc.previous").exists());
949 }
950
951 #[test]
952 fn test_state_rename_nonexistent() {
953 let result = state_rename(
954 "/tmp/nonexistent-browser-automation-cli-state-file.json",
955 "new-name",
956 );
957 assert!(result.is_err());
958 assert!(result.unwrap_err().contains("not found"));
959 }
960
961 #[test]
962 fn test_state_list_returns_json() {
963 let result = state_list().unwrap();
964 assert!(result.get("files").is_some());
965 assert!(result.get("directory").is_some());
966 }
967
968 #[test]
969 fn test_sessions_dir_path() {
970 let dir = get_sessions_dir();
971 assert!(dir.to_string_lossy().contains("sessions"));
972 }
973
974 #[test]
975 fn test_get_state_dir_namespace_scopes_sessions() {
976 let dir = tempfile::tempdir().unwrap();
978 let home = dir.path();
979 let guard =
980 crate::test_utils::EnvGuard::new(&["HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME"]);
981 guard.set("HOME", home.to_str().unwrap());
982 guard.set("XDG_CONFIG_HOME", home.join("config").to_str().unwrap());
983 guard.set("XDG_STATE_HOME", home.join("state").to_str().unwrap());
984
985 let cfg = crate::xdg::ProductConfig {
986 namespace: Some("Worktree: One".into()),
987 ..Default::default()
988 };
989 crate::xdg::write_config(&cfg).expect("write config under temp XDG");
990
991 let state = get_state_dir();
992 assert!(
993 state.to_string_lossy().contains("namespaces")
994 && state.to_string_lossy().contains("worktree-one"),
995 "state dir should scope under namespaces/worktree-one, got {}",
996 state.display()
997 );
998 assert!(get_sessions_dir().ends_with("sessions"));
999 }
1000
1001 #[test]
1002 fn test_encrypt_decrypt_roundtrip() {
1003 let plain = b"hello world";
1004 let key = "test-secret-key";
1005 let encrypted = encrypt_data(plain, key).unwrap();
1006 assert!(encrypted.len() > 12);
1007 assert_ne!(&encrypted[12..], plain);
1008 let decrypted = decrypt_data(&encrypted, key).unwrap();
1009 assert_eq!(decrypted, plain);
1010 }
1011
1012 #[test]
1013 fn test_decrypt_wrong_key_fails() {
1014 let plain = b"secret data";
1015 let encrypted = encrypt_data(plain, "key1").unwrap();
1016 let result = decrypt_data(&encrypted, "key2");
1017 assert!(result.is_err());
1018 }
1019
1020 #[test]
1021 fn test_cookie_serde_roundtrip() {
1022 let cookie = Cookie {
1023 name: "test".to_string(),
1024 value: "123".to_string(),
1025 domain: ".test.com".to_string(),
1026 path: "/api".to_string(),
1027 expires: 1700000000.0,
1028 size: 7,
1029 http_only: false,
1030 secure: true,
1031 session: false,
1032 same_site: Some("Strict".to_string()),
1033 };
1034
1035 let json = serde_json::to_value(&cookie).unwrap();
1036 assert_eq!(json["name"], "test");
1037 assert_eq!(json["httpOnly"], false);
1038 assert_eq!(json["secure"], true);
1039 assert_eq!(json["sameSite"], "Strict");
1040 }
1041
1042 #[test]
1043 fn test_dispatch_state_command_routes_state_list() {
1044 let cmd = serde_json::json!({ "action": "state_list" });
1045 let result = dispatch_state_command(&cmd);
1046 assert!(result.is_some());
1047 assert!(result.unwrap().is_ok());
1048 }
1049
1050 #[test]
1051 fn test_dispatch_state_command_returns_none_for_unknown() {
1052 let cmd = serde_json::json!({ "action": "navigate" });
1053 assert!(dispatch_state_command(&cmd).is_none());
1054 }
1055
1056 #[test]
1057 fn test_dispatch_state_command_returns_none_for_missing_action() {
1058 let cmd = serde_json::json!({});
1059 assert!(dispatch_state_command(&cmd).is_none());
1060 }
1061
1062 #[test]
1063 fn test_dispatch_state_show_missing_path() {
1064 let cmd = serde_json::json!({ "action": "state_show" });
1065 let result = dispatch_state_command(&cmd).unwrap();
1066 assert!(result.is_err());
1067 assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
1068 }
1069
1070 #[test]
1071 fn test_dispatch_state_rename_missing_params() {
1072 let cmd = serde_json::json!({ "action": "state_rename" });
1073 let result = dispatch_state_command(&cmd).unwrap();
1074 assert!(result.is_err());
1075 assert_eq!(result.unwrap_err(), "Missing 'path' parameter");
1076
1077 let cmd = serde_json::json!({ "action": "state_rename", "path": "/tmp/test.json" });
1078 let result = dispatch_state_command(&cmd).unwrap();
1079 assert!(result.is_err());
1080 assert_eq!(result.unwrap_err(), "Missing 'name' parameter");
1081 }
1082}