1#![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
71fn 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
100async 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
122async 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 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 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 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, Ok(Err(_)) => continue, Err(_) => break, }
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 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 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 all_origins.remove(¤t_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 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 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 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
492async 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 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 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 for origin in &state.origins {
520 if origin.local_storage.is_empty() && origin.session_storage.is_empty() {
521 continue;
522 }
523
524 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 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 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
841pub 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
876pub 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 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}