1use std::path::{Path, PathBuf};
23use std::sync::Arc;
24use std::time::Duration;
25
26use serde::{Deserialize, Serialize};
27use tokio::sync::RwLock;
28use tracing::{debug, info, warn};
29
30use crate::engine::resume_data::{ResumeData, ResumeDataExt};
31use crate::http::cookie_storage::CookieJar;
32use crate::request::request_group::{DownloadOptions, DownloadStatus, GroupId, RequestGroup};
33use crate::selector::server_stat_man::ServerStatMan;
34
35const SESSION_OPTIONS_FILENAME: &str = "session_options.json";
37
38const DEFAULT_AUTO_SAVE_INTERVAL_SECS: u64 = 60;
40
41pub struct SessionPersistence {
64 session_dir: PathBuf,
66 auto_save_interval: Duration,
68 auto_save_enabled: bool,
70 cookie_jar: Option<CookieJar>,
72}
73
74impl SessionPersistence {
75 pub fn new(session_dir: &Path) -> Self {
81 Self {
82 session_dir: session_dir.to_path_buf(),
83 auto_save_interval: Duration::from_secs(DEFAULT_AUTO_SAVE_INTERVAL_SECS),
84 auto_save_enabled: true,
85 cookie_jar: None,
86 }
87 }
88
89 pub fn with_interval(mut self, interval_secs: u64) -> Self {
91 self.auto_save_interval = Duration::from_secs(interval_secs.max(10));
92 self
93 }
94
95 pub fn without_auto_save(mut self) -> Self {
97 self.auto_save_enabled = false;
98 self
99 }
100
101 pub fn with_cookie_jar(mut self, jar: CookieJar) -> Self {
103 self.cookie_jar = Some(jar);
104 self
105 }
106
107 pub fn cookie_jar_mut(&mut self) -> Option<&mut CookieJar> {
109 self.cookie_jar.as_mut()
110 }
111
112 pub fn cookie_jar(&self) -> Option<&CookieJar> {
114 self.cookie_jar.as_ref()
115 }
116
117 pub fn session_dir(&self) -> &Path {
119 &self.session_dir
120 }
121
122 pub async fn save_state(&self, groups: &[Arc<RwLock<RequestGroup>>]) -> Result<usize, String> {
141 tokio::fs::create_dir_all(&self.session_dir)
143 .await
144 .map_err(|e| {
145 format!(
146 "Failed to create session dir {}: {}",
147 self.session_dir.display(),
148 e
149 )
150 })?;
151
152 let mut saved = 0usize;
153
154 for group_lock in groups.iter() {
155 let group = group_lock.read().await;
156
157 match ResumeData::from_request_group(&group).await {
159 Ok(resume_data) => {
160 let file_name = format!("{}.aria2", resume_data.gid);
161 let path = self.session_dir.join(&file_name);
162
163 if let Err(e) = resume_data.save_to_file(&path) {
164 warn!(
165 gid = %resume_data.gid,
166 error = %e,
167 "Failed to save resume data for GID"
168 );
169 continue;
170 }
171 saved += 1;
172 debug!(
173 gid = %resume_data.gid,
174 path = %path.display(),
175 "Saved resume data"
176 );
177 }
178 Err(e) => {
179 debug!(
180 gid = %group.gid().value(),
181 error = %e,
182 "Skipping command that cannot be serialized"
183 );
184 }
185 }
186 }
187
188 self.save_global_options(groups).await?;
190
191 if let Some(ref jar) = self.cookie_jar {
193 let cookie_path = self.session_dir.join("cookies.json");
194 if let Err(e) = Self::save_cookie_jar_to_file(jar, &cookie_path).await {
195 warn!("Failed to persist cookies: {}", e);
196 } else {
197 debug!(path = %cookie_path.display(), "Cookies persisted to session");
198 }
199 }
200
201 info!(
202 saved,
203 dir = %self.session_dir.display(),
204 "Session state saved"
205 );
206
207 Ok(saved)
208 }
209
210 pub async fn load_state(
230 &mut self,
231 groups: &mut Vec<Arc<RwLock<RequestGroup>>>,
232 ) -> Result<usize, String> {
233 if !self.session_dir.exists() {
234 debug!(
235 dir = %self.session_dir.display(),
236 "Session directory does not exist, nothing to load"
237 );
238 return Ok(0);
239 }
240
241 let mut loaded = 0usize;
242 let mut entries = tokio::fs::read_dir(&self.session_dir).await.map_err(|e| {
243 format!(
244 "Failed to read session dir {}: {}",
245 self.session_dir.display(),
246 e
247 )
248 })?;
249
250 while let Ok(Some(entry)) = entries.next_entry().await {
251 let path = entry.path();
252
253 let is_aria2 = path.extension().map(|e| e == "aria2").unwrap_or(false);
255
256 if !is_aria2 {
257 continue;
258 }
259
260 match ResumeData::load_from_file(&path) {
261 Ok(Some(resume_data)) => {
262 match Self::restore_command(&resume_data) {
264 Ok(group) => {
265 groups.push(Arc::new(RwLock::new(group)));
266 loaded += 1;
267 info!(
268 gid = %resume_data.gid,
269 status = %resume_data.status,
270 "Restored download from session"
271 );
272 }
273 Err(e) => {
274 warn!(
275 gid = %resume_data.gid,
276 error = %e,
277 "Failed to restore command from resume data"
278 );
279 }
280 }
281 }
282 Ok(None) => {
283 debug!(path = %path.display(), "Resume file was empty (skipped)");
284 }
285 Err(e) => {
286 warn!(
287 path = %path.display(),
288 error = %e,
289 "Corrupted or invalid .aria2 file, skipping gracefully"
290 );
291 }
293 }
294 }
295
296 let _ = self.load_global_options().await;
298
299 let cookie_path = self.session_dir.join("cookies.json");
301 if cookie_path.exists() {
302 match Self::load_cookie_jar_from_file(&cookie_path).await {
303 Ok(jar) => {
304 self.cookie_jar = Some(jar);
305 info!("Loaded cookies from session");
306 }
307 Err(e) => {
308 warn!("Failed to load cookies from session: {}", e);
309 }
310 }
311 }
312
313 info!(
314 loaded,
315 dir = %self.session_dir.display(),
316 "Session state loaded"
317 );
318
319 Ok(loaded)
320 }
321
322 fn restore_command(resume_data: &ResumeData) -> Result<RequestGroup, String> {
327 if resume_data.uris.is_empty() {
328 return Err("ResumeData has no URIs, cannot restore".to_string());
329 }
330
331 let uris: Vec<String> = resume_data.uris.iter().map(|u| u.uri.clone()).collect();
333
334 let mut options = DownloadOptions::default();
336
337 if let Some(ref output_path) = resume_data.output_path {
339 if let Some(parent) = Path::new(output_path).parent() {
340 options.dir = Some(parent.to_string_lossy().to_string());
341 }
342 if let Some(file_name) = Path::new(output_path).file_name() {
343 options.out = Some(file_name.to_string_lossy().to_string());
344 }
345 }
346
347 let gid = if !resume_data.gid.is_empty() {
349 GroupId::from_hex_string(&resume_data.gid).unwrap_or_else(GroupId::new_random)
350 } else {
351 GroupId::new_random()
352 };
353
354 let group = RequestGroup::new(gid, uris, options);
355
356 if resume_data.status == "paused" || resume_data.status == "waiting" {
358 }
361
362 if resume_data.completed_length > 0 {
364 group.set_resume_offset(resume_data.completed_length);
365 }
366
367 Ok(group)
368 }
369
370 pub fn start_auto_save(
383 &self,
384 groups: Arc<RwLock<Vec<Arc<RwLock<RequestGroup>>>>>,
385 ) -> Option<tokio::task::JoinHandle<()>> {
386 if !self.auto_save_enabled {
387 debug!("Auto-save is disabled");
388 return None;
389 }
390
391 let session_dir = self.session_dir.clone();
392 let interval = self.auto_save_interval;
393
394 info!(
395 interval_secs = interval.as_secs(),
396 dir = %session_dir.display(),
397 "Starting auto-save task"
398 );
399
400 Some(tokio::spawn(async move {
401 let mut ticker = tokio::time::interval(interval);
402
403 loop {
404 ticker.tick().await;
405
406 let groups_read = groups.read().await;
407 let persistence = SessionPersistence::new(&session_dir).without_auto_save();
408
409 match persistence.save_state(&groups_read).await {
410 Ok(count) => {
411 if count > 0 {
412 debug!(count, "Auto-save completed successfully");
413 }
414 }
415 Err(e) => {
416 warn!(error = %e, "Auto-save failed, will retry next interval");
417 }
418 }
419 }
420 }))
421 }
422
423 async fn save_global_options(
425 &self,
426 _groups: &[Arc<RwLock<RequestGroup>>],
427 ) -> Result<(), String> {
428 let opts_path = self.session_dir.join(SESSION_OPTIONS_FILENAME);
429
430 let options_summary = serde_json::json!({
432 "version": "1.0",
433 "saved_at": chrono_timestamp_or_fallback(),
434 "note": "Global session options summary"
435 });
436
437 let json = serde_json::to_string_pretty(&options_summary)
438 .map_err(|e| format!("Failed to serialize session options: {}", e))?;
439
440 tokio::fs::write(&opts_path, json).await.map_err(|e| {
441 format!(
442 "Failed to write session options {}: {}",
443 opts_path.display(),
444 e
445 )
446 })?;
447
448 Ok(())
449 }
450
451 async fn load_global_options(&self) -> Result<(), String> {
453 let opts_path = self.session_dir.join(SESSION_OPTIONS_FILENAME);
454
455 if !opts_path.exists() {
456 return Ok(());
457 }
458
459 let content = tokio::fs::read_to_string(&opts_path).await.map_err(|e| {
460 format!(
461 "Failed to read session options {}: {}",
462 opts_path.display(),
463 e
464 )
465 })?;
466
467 let _parsed: serde_json::Value = serde_json::from_str(&content)
469 .map_err(|e| format!("Invalid JSON in session options: {}", e))?;
470
471 debug!(path = %opts_path.display(), "Loaded session options");
472
473 Ok(())
474 }
475
476 pub async fn cleanup(&self) -> Result<(), String> {
478 if !self.session_dir.exists() {
479 return Ok(());
480 }
481
482 let mut entries = tokio::fs::read_dir(&self.session_dir)
483 .await
484 .map_err(|e| format!("Failed to read session dir: {}", e))?;
485
486 while let Ok(Some(entry)) = entries.next_entry().await {
487 let path = entry.path();
488 if let Err(e) = tokio::fs::remove_file(&path).await {
489 warn!(path = %path.display(), error = %e, "Failed to remove session file");
490 }
491 }
492
493 info!(dir = %self.session_dir.display(), "Session directory cleaned up");
494 Ok(())
495 }
496
497 pub async fn save_server_stats(&self, stat_man: &ServerStatMan) -> Result<usize, String> {
534 let stat_file = self.session_dir.join("server-stat.json");
535 let saved = stat_man.save_to_file_async(&stat_file).await?;
536
537 if saved > 0 {
538 debug!(
539 count = saved,
540 path = %stat_file.display(),
541 "Server statistics saved"
542 );
543 }
544
545 Ok(saved)
546 }
547
548 pub async fn load_server_stats(&self, stat_man: &ServerStatMan) -> Result<usize, String> {
582 let stat_file = self.session_dir.join("server-stat.json");
583
584 if !stat_file.exists() {
585 debug!("No server statistics file found, starting fresh");
586 return Ok(0);
587 }
588
589 let loaded = stat_man.load_from_file_async(&stat_file).await?;
590
591 if loaded > 0 {
592 info!(
593 count = loaded,
594 path = %stat_file.display(),
595 "Server statistics loaded from previous session"
596 );
597 }
598
599 Ok(loaded)
600 }
601
602 pub async fn save_active_only(
620 &self,
621 groups: &[Arc<RwLock<RequestGroup>>],
622 ) -> Result<usize, String> {
623 let mut count = 0;
624 for group in groups {
625 let g = group.read().await;
626 let status = g.status().await;
627
628 match status {
630 DownloadStatus::Active | DownloadStatus::Waiting => {
631 drop(g);
632 let group_read = group.read().await;
634 match ResumeData::from_request_group(&group_read).await {
635 Ok(resume_data) => {
636 drop(group_read);
637 let file_name = format!("{}.aria2", resume_data.gid);
638 let path = self.session_dir.join(&file_name);
639 if resume_data.save_to_file(&path).is_ok() {
640 count += 1;
641 debug!(gid = %resume_data.gid, "Saved active download");
642 } else {
643 warn!(gid = %resume_data.gid, "Failed to save active download");
644 }
645 }
646 Err(e) => {
647 debug!(error = %e, "Skipping active download that cannot be serialized");
648 }
649 }
650 }
651 _ => {} }
653 }
654 debug!(
655 saved = count,
656 total = groups.len(),
657 "save_active_only completed"
658 );
659 Ok(count)
660 }
661
662 pub async fn save_completed(
677 &self,
678 groups: &[Arc<RwLock<RequestGroup>>],
679 ) -> Result<usize, String> {
680 let mut count = 0;
681 for group in groups {
682 let g = group.read().await;
683 let status = g.status().await;
684
685 if status.is_completed() || matches!(status, DownloadStatus::Complete) {
686 drop(g);
687 let group_read = group.read().await;
689 match ResumeData::from_request_group(&group_read).await {
690 Ok(resume_data) => {
691 drop(group_read);
692 let file_name = format!("{}.aria2", resume_data.gid);
693 let path = self.session_dir.join(&file_name);
694 if resume_data.save_to_file(&path).is_ok() {
695 count += 1;
696 debug!(gid = %resume_data.gid, "Saved completed download");
697 }
698 }
699 Err(e) => {
700 debug!(error = %e, "Skipping completed download that cannot be serialized");
701 }
702 }
703 }
704 }
705 debug!(
706 saved = count,
707 total = groups.len(),
708 "save_completed completed"
709 );
710 Ok(count)
711 }
712
713 async fn save_cookie_jar_to_file(jar: &CookieJar, path: &Path) -> Result<(), String> {
723 #[derive(Serialize)]
725 struct SerializableJar<'a> {
726 cookies: &'a [crate::http::cookie_storage::JarCookie],
727 }
728
729 let serializable = SerializableJar {
730 cookies: &jar.cookies,
731 };
732
733 let json = serde_json::to_string_pretty(&serializable).map_err(|e| e.to_string())?;
734
735 tokio::fs::write(path, json)
736 .await
737 .map_err(|e| format!("Failed to write cookie file: {}", e))
738 }
739
740 async fn load_cookie_jar_from_file(path: &Path) -> Result<CookieJar, String> {
745 let content = tokio::fs::read_to_string(path)
746 .await
747 .map_err(|e| format!("Failed to read cookie file: {}", e))?;
748
749 #[derive(Deserialize)]
750 struct SerializableJar {
751 cookies: Vec<crate::http::cookie_storage::JarCookie>,
752 }
753
754 let parsed: SerializableJar =
755 serde_json::from_str(&content).map_err(|e| format!("Invalid cookie JSON: {}", e))?;
756
757 let mut jar = CookieJar::new();
758 for cookie in parsed.cookies {
759 jar.store(cookie);
760 }
761
762 Ok(jar)
763 }
764}
765
766fn chrono_timestamp_or_fallback() -> String {
768 use std::time::{SystemTime, UNIX_EPOCH};
769 SystemTime::now()
770 .duration_since(UNIX_EPOCH)
771 .map(|d| d.as_secs().to_string())
772 .unwrap_or_else(|_| "unknown".to_string())
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct DhtStateSnapshot {
790 pub nodes: Vec<DhtNodeInfo>,
792 pub token_secret: [u8; 20],
794 pub last_bootstrap_epoch_secs: Option<u64>,
796 pub total_nodes: usize,
798}
799
800#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct DhtNodeInfo {
803 pub id: [u8; 20],
805 pub addr: String,
807 pub last_seen_epoch_secs: u64,
809}
810
811impl DhtStateSnapshot {
812 pub fn empty() -> Self {
817 Self {
818 nodes: vec![],
819 token_secret: [0u8; 20],
820 last_bootstrap_epoch_secs: None,
821 total_nodes: 0,
822 }
823 }
824
825 pub fn new(
833 nodes: Vec<DhtNodeInfo>,
834 token_secret: [u8; 20],
835 last_bootstrap_epoch_secs: Option<u64>,
836 ) -> Self {
837 let total_nodes = nodes.len();
838 Self {
839 nodes,
840 token_secret,
841 last_bootstrap_epoch_secs,
842 total_nodes,
843 }
844 }
845
846 pub fn to_json_string(&self) -> Result<String, String> {
853 serde_json::to_string(self).map_err(|e| e.to_string())
854 }
855
856 pub fn from_json_string(json: &str) -> Result<Self, String> {
867 serde_json::from_str(json).map_err(|e| e.to_string())
868 }
869}
870
871#[cfg(test)]
876mod tests {
877 use super::*;
878 use std::fs;
879
880 fn create_test_session_dir() -> PathBuf {
882 let ts = std::time::SystemTime::now()
883 .duration_since(std::time::UNIX_EPOCH)
884 .unwrap_or_default()
885 .as_nanos()
886 % 1_000_000_000;
887 let dir =
888 std::env::temp_dir().join(format!("aria2_session_test_{}_{}", std::process::id(), ts));
889 let _ = fs::remove_dir_all(&dir);
890 fs::create_dir_all(&dir).expect("Failed to create test session directory");
891 dir
892 }
893
894 fn create_test_groups(count: usize) -> Vec<Arc<RwLock<RequestGroup>>> {
896 let mut groups = Vec::new();
897 for i in 0..count {
898 let gid = GroupId::new(i as u64 + 1000);
899 let uri = format!("http://example.com/file{}.bin", i);
900 let options = DownloadOptions {
901 dir: Some("/downloads".to_string()),
902 split: Some(4),
903 ..Default::default()
904 };
905 let group = Arc::new(RwLock::new(RequestGroup::new(gid, vec![uri], options)));
906 groups.push(group);
907 }
908 groups
909 }
910
911 #[tokio::test]
912 async fn test_session_save_creates_files() {
913 let session_dir = create_test_session_dir();
914 let persistence = SessionPersistence::new(&session_dir);
915
916 let groups = create_test_groups(3);
917
918 let saved_count = persistence
920 .save_state(&groups)
921 .await
922 .expect("Save should succeed");
923
924 assert_eq!(saved_count, 3, "Should save 3 commands");
925
926 let entries: Vec<_> = fs::read_dir(&session_dir)
928 .expect("Should read session dir")
929 .filter_map(|e| e.ok())
930 .collect();
931
932 let aria2_count = entries
934 .iter()
935 .filter(|e| {
936 e.path()
937 .extension()
938 .map(|ext| ext == "aria2")
939 .unwrap_or(false)
940 })
941 .count();
942
943 assert_eq!(aria2_count, 3, "Should have 3 .aria2 files");
944
945 for entry in entries.iter().filter(|e| {
947 e.path()
948 .extension()
949 .map(|ext| ext == "aria2")
950 .unwrap_or(false)
951 }) {
952 let content = fs::read_to_string(entry.path()).expect("Should read file");
953 let parsed: serde_json::Value =
954 serde_json::from_str(&content).expect("Should be valid JSON");
955 assert!(
956 parsed.get("gid").is_some(),
957 "Each .aria2 file should contain a GID field"
958 );
959 }
960
961 let _ = fs::remove_dir_all(&session_dir);
963 }
964
965 #[tokio::test]
966 async fn test_session_load_restores_commands() {
967 let session_dir = create_test_session_dir();
968 let mut persistence = SessionPersistence::new(&session_dir);
969
970 let original_groups = create_test_groups(2);
972 let saved = persistence
973 .save_state(&original_groups)
974 .await
975 .expect("Save should succeed");
976 assert_eq!(saved, 2, "Should save 2 commands");
977
978 let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
980 let loaded = persistence
981 .load_state(&mut loaded_groups)
982 .await
983 .expect("Load should succeed");
984
985 assert_eq!(loaded, 2, "Should restore 2 commands");
986
987 let mut found_uris: Vec<String> = Vec::new();
989 for group_lock in &loaded_groups {
990 let group = group_lock.read().await;
991 for uri in group.uris() {
992 found_uris.push(uri.clone());
993 }
994 }
995
996 assert!(
997 found_uris.iter().any(|u| u.contains("file0.bin")),
998 "Should restore first file URI"
999 );
1000 assert!(
1001 found_uris.iter().any(|u| u.contains("file1.bin")),
1002 "Should restore second file URI"
1003 );
1004
1005 let _ = fs::remove_dir_all(&session_dir);
1007 }
1008
1009 #[tokio::test]
1010 async fn test_session_save_empty_no_error() {
1011 let session_dir = create_test_session_dir();
1012 let persistence = SessionPersistence::new(&session_dir);
1013
1014 let empty_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1016 let result = persistence.save_state(&empty_groups).await;
1017
1018 assert!(result.is_ok(), "Saving empty session should not error");
1019 let saved_count = result.unwrap();
1020 assert_eq!(saved_count, 0, "Empty session should report 0 saved");
1021
1022 assert!(
1024 session_dir.exists(),
1025 "Session dir should be created even for empty save"
1026 );
1027
1028 let _ = fs::remove_dir_all(&session_dir);
1030 }
1031
1032 #[tokio::test]
1033 async fn test_session_corrupted_file_skipped_gracefully() {
1034 let session_dir = create_test_session_dir();
1035
1036 let corrupt_file = session_dir.join("corrupt-gid.aria2");
1038 fs::write(&corrupt_file, "THIS IS NOT VALID JSON {{{{").expect("Should write corrupt file");
1039
1040 let valid_file = session_dir.join("valid-gid.aria2");
1042 let valid_resume_data = ResumeData {
1043 gid: "valid-gid-12345".to_string(),
1044 uris: vec![crate::engine::resume_data::UriState {
1045 uri: "http://example.com/valid-file.bin".to_string(),
1046 tried: true,
1047 used: false,
1048 last_result: None,
1049 speed_bytes_per_sec: None,
1050 }],
1051 total_length: 1024,
1052 completed_length: 512,
1053 uploaded_length: 0,
1054 bitfield: vec![],
1055 num_pieces: None,
1056 piece_length: None,
1057 status: "paused".to_string(),
1058 error_message: None,
1059 last_download_time: 0,
1060 created_at: 0,
1061 output_path: Some("/downloads/valid-file.bin".to_string()),
1062 checksum: None,
1063 options: std::collections::HashMap::new(),
1064 resume_offset: Some(512),
1065 bt_info_hash: None,
1066 bt_saved_metadata_path: None,
1067 };
1068 valid_resume_data
1069 .save_to_file(&valid_file)
1070 .expect("Should write valid file");
1071
1072 let mut persistence = SessionPersistence::new(&session_dir);
1073 let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1074
1075 let result = persistence.load_state(&mut loaded_groups).await;
1077
1078 assert!(result.is_ok(), "Load should succeed despite corrupt file");
1079 let loaded_count = result.unwrap();
1080 assert_eq!(
1081 loaded_count, 1,
1082 "Should load 1 valid file (corrupt one skipped)"
1083 );
1084
1085 assert_eq!(loaded_groups.len(), 1, "Should have 1 restored group");
1087
1088 let _ = fs::remove_dir_all(&session_dir);
1090 }
1091
1092 #[tokio::test]
1093 async fn test_session_load_nonexistent_dir_returns_zero() {
1094 let nonexistent_dir =
1095 PathBuf::from("/tmp/aria2_nonexistent_test_dir_that_should_not_exist_12345");
1096 let mut persistence = SessionPersistence::new(&nonexistent_dir);
1097
1098 let mut groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1099 let result = persistence.load_state(&mut groups).await;
1100
1101 assert!(result.is_ok(), "Nonexistent dir should return Ok");
1102 assert_eq!(result.unwrap(), 0, "Nonexistent dir should return 0 loaded");
1103 assert!(groups.is_empty(), "No groups should be added");
1104 }
1105
1106 #[tokio::test]
1107 async fn test_session_cleanup_removes_all_files() {
1108 let session_dir = create_test_session_dir();
1109 let persistence = SessionPersistence::new(&session_dir);
1110
1111 let groups = create_test_groups(2);
1113 let _ = persistence.save_state(&groups).await.unwrap();
1114
1115 assert!(
1117 session_dir.exists(),
1118 "Session dir should exist before cleanup"
1119 );
1120
1121 persistence.cleanup().await.expect("Cleanup should succeed");
1123
1124 if session_dir.exists() {
1126 let remaining: Vec<_> = fs::read_dir(&session_dir)
1127 .expect("Should read dir")
1128 .filter_map(|e| e.ok())
1129 .collect();
1130 assert!(
1131 remaining.is_empty(),
1132 "All files should be removed after cleanup"
1133 );
1134 }
1135
1136 let _ = fs::remove_dir_all(&session_dir);
1138 }
1139
1140 #[tokio::test]
1141 async fn test_session_custom_interval() {
1142 let session_dir = create_test_session_dir();
1143
1144 let persistence = SessionPersistence::new(&session_dir).with_interval(30);
1145
1146 assert_eq!(
1147 persistence.auto_save_interval,
1148 Duration::from_secs(30),
1149 "Custom interval should be set"
1150 );
1151
1152 let short_interval = SessionPersistence::new(&session_dir).with_interval(1);
1154 assert!(
1155 short_interval.auto_save_interval >= Duration::from_secs(10),
1156 "Interval should be at least 10 seconds"
1157 );
1158
1159 let _ = fs::remove_dir_all(&session_dir);
1160 }
1161
1162 #[tokio::test]
1163 async fn test_resume_data_roundtrip_via_persistence() {
1164 let session_dir = create_test_session_dir();
1165 let mut persistence = SessionPersistence::new(&session_dir);
1166
1167 let gid = GroupId::new(0xDEADBEEF);
1169 let options = DownloadOptions {
1170 dir: Some("/test/downloads".to_string()),
1171 out: Some("special_file.iso".to_string()),
1172 split: Some(16),
1173 ..Default::default()
1174 };
1175 let group = Arc::new(RwLock::new(RequestGroup::new(
1176 gid,
1177 vec!["http://example.com/special_file.iso".to_string()],
1178 options,
1179 )));
1180
1181 {
1183 let g = group.write().await;
1184 g.set_total_length_atomic(10485760); g.set_completed_length(5242880); }
1187
1188 let saved = persistence.save_state(&[group]).await.unwrap();
1190 assert_eq!(saved, 1);
1191
1192 let mut loaded: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1194 let loaded_count = persistence.load_state(&mut loaded).await.unwrap();
1195 assert_eq!(loaded_count, 1);
1196
1197 let restored = loaded[0].read().await;
1199 let uris = restored.uris();
1200 assert_eq!(uris.len(), 1);
1201 assert!(uris[0].contains("special_file.iso"));
1202
1203 let _ = fs::remove_dir_all(&session_dir);
1205 }
1206
1207 #[tokio::test]
1216 async fn test_selective_save_active_only() {
1217 let session_dir = create_test_session_dir();
1218 let persistence = SessionPersistence::new(&session_dir);
1219
1220 let mut groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1222
1223 let active_gid = GroupId::new(1001);
1225 let active_group = Arc::new(RwLock::new(RequestGroup::new(
1226 active_gid,
1227 vec!["http://example.com/active.bin".to_string()],
1228 DownloadOptions::default(),
1229 )));
1230 {
1231 let mut g = active_group.write().await;
1232 g.start().await.unwrap(); }
1234 groups.push(active_group);
1235
1236 let waiting_gid = GroupId::new(1002);
1238 let waiting_group = Arc::new(RwLock::new(RequestGroup::new(
1239 waiting_gid,
1240 vec!["http://example.com/waiting.bin".to_string()],
1241 DownloadOptions::default(),
1242 )));
1243 groups.push(waiting_group);
1245
1246 let complete_gid = GroupId::new(1003);
1248 let complete_group = Arc::new(RwLock::new(RequestGroup::new(
1249 complete_gid,
1250 vec!["http://example.com/complete.bin".to_string()],
1251 DownloadOptions::default(),
1252 )));
1253 {
1254 let mut g = complete_group.write().await;
1255 g.complete().await.unwrap(); }
1257 groups.push(complete_group);
1258
1259 let saved_count = persistence.save_active_only(&groups).await.unwrap();
1261
1262 assert_eq!(
1264 saved_count, 2,
1265 "save_active_only should save only active and waiting downloads"
1266 );
1267
1268 let entries: Vec<_> = fs::read_dir(&session_dir)
1270 .expect("Should read session dir")
1271 .filter_map(|e| e.ok())
1272 .filter(|e| {
1273 e.path()
1274 .extension()
1275 .map(|ext| ext == "aria2")
1276 .unwrap_or(false)
1277 })
1278 .collect();
1279
1280 assert_eq!(
1281 entries.len(),
1282 2,
1283 "Should have exactly 2 .aria2 files for active+waiting"
1284 );
1285
1286 let _ = fs::remove_dir_all(&session_dir);
1288 }
1289
1290 #[test]
1295 fn test_dht_snapshot_roundtrip() {
1296 use crate::session::session_persistence::{DhtNodeInfo, DhtStateSnapshot};
1297
1298 let node1 = DhtNodeInfo {
1300 id: [1u8; 20],
1301 addr: "192.168.1.100:6881".to_string(),
1302 last_seen_epoch_secs: 1700000000,
1303 };
1304
1305 let node2 = DhtNodeInfo {
1306 id: [2u8; 20],
1307 addr: "10.0.0.5:6881".to_string(),
1308 last_seen_epoch_secs: 1700000100,
1309 };
1310
1311 let token_secret: [u8; 20] = [0xAB; 20];
1312
1313 let original = DhtStateSnapshot::new(vec![node1, node2], token_secret, Some(1699999000));
1314
1315 assert_eq!(original.total_nodes, 2);
1317 assert_eq!(original.nodes.len(), 2);
1318 assert!(original.last_bootstrap_epoch_secs.is_some());
1319
1320 let json = original
1322 .to_json_string()
1323 .expect("Serialization should succeed");
1324 assert!(!json.is_empty(), "JSON output should not be empty");
1325 assert!(
1326 json.contains("192.168.1.100"),
1327 "JSON should contain first node address"
1328 );
1329 assert!(
1330 json.contains("10.0.0.5"),
1331 "JSON should contain second node address"
1332 );
1333
1334 let restored =
1336 DhtStateSnapshot::from_json_string(&json).expect("Deserialization should succeed");
1337
1338 assert_eq!(restored.total_nodes, 2, "total_nodes should be preserved");
1340 assert_eq!(restored.nodes.len(), 2, "nodes count should be preserved");
1341 assert_eq!(
1342 restored.token_secret, token_secret,
1343 "token_secret should be preserved"
1344 );
1345 assert_eq!(
1346 restored.last_bootstrap_epoch_secs,
1347 Some(1699999000),
1348 "last_bootstrap_epoch_secs should be preserved"
1349 );
1350
1351 assert_eq!(
1353 restored.nodes[0].id, [1u8; 20],
1354 "First node ID should match"
1355 );
1356 assert_eq!(
1357 restored.nodes[0].addr, "192.168.1.100:6881",
1358 "First node address should match"
1359 );
1360 assert_eq!(
1361 restored.nodes[0].last_seen_epoch_secs, 1700000000,
1362 "First node timestamp should match"
1363 );
1364
1365 assert_eq!(
1366 restored.nodes[1].id, [2u8; 20],
1367 "Second node ID should match"
1368 );
1369 assert_eq!(
1370 restored.nodes[1].addr, "10.0.0.5:6881",
1371 "Second node address should match"
1372 );
1373
1374 let empty = DhtStateSnapshot::empty();
1376 assert_eq!(empty.total_nodes, 0, "Empty snapshot should have 0 nodes");
1377 assert!(
1378 empty.nodes.is_empty(),
1379 "Empty snapshot should have no nodes"
1380 );
1381
1382 let empty_json = empty
1383 .to_json_string()
1384 .expect("Empty serialization should succeed");
1385 let empty_restored = DhtStateSnapshot::from_json_string(&empty_json)
1386 .expect("Empty deserialization should work");
1387 assert_eq!(
1388 empty_restored.total_nodes, 0,
1389 "Restored empty should still be empty"
1390 );
1391 }
1392
1393 #[tokio::test]
1398 async fn test_cookie_persist_integration() {
1399 use crate::http::cookie_storage::{CookieJar, JarCookie};
1400
1401 let session_dir = create_test_session_dir();
1402
1403 let mut jar = CookieJar::new();
1405 jar.store(JarCookie::new("session_id", "abc123", "example.com"));
1406 jar.store(JarCookie::new("auth_token", "xyz789", "api.example.com"));
1407
1408 let persistence_with_cookies = SessionPersistence::new(&session_dir).with_cookie_jar(jar);
1409
1410 assert!(
1412 persistence_with_cookies.cookie_jar().is_some(),
1413 "Cookie jar should be set"
1414 );
1415 assert_eq!(
1416 persistence_with_cookies.cookie_jar().unwrap().len(),
1417 2,
1418 "Should have 2 cookies before save"
1419 );
1420
1421 let groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1423 let _saved = persistence_with_cookies.save_state(&groups).await.unwrap();
1424
1425 let cookie_path = session_dir.join("cookies.json");
1427 assert!(
1428 cookie_path.exists(),
1429 "cookies.json file should exist after save"
1430 );
1431
1432 let mut persistence_new = SessionPersistence::new(&session_dir);
1434 let mut loaded_groups: Vec<Arc<RwLock<RequestGroup>>> = Vec::new();
1435 let _loaded = persistence_new
1436 .load_state(&mut loaded_groups)
1437 .await
1438 .unwrap();
1439
1440 assert!(
1442 persistence_new.cookie_jar().is_some(),
1443 "Cookie jar should exist after load"
1444 );
1445 let loaded_jar = persistence_new.cookie_jar().unwrap();
1446 assert_eq!(
1447 loaded_jar.len(),
1448 2,
1449 "Should have loaded 2 cookies from file"
1450 );
1451
1452 let example_cookies = loaded_jar.get_cookies_for_url("http://example.com/", false);
1454 assert_eq!(
1455 example_cookies.len(),
1456 1,
1457 "Should find 1 cookie for example.com"
1458 );
1459 assert_eq!(example_cookies[0].name, "session_id");
1460 assert_eq!(example_cookies[0].value, "abc123");
1461
1462 let api_cookies = loaded_jar.get_cookies_for_url("http://api.example.com/api", false);
1463 assert_eq!(
1464 api_cookies.len(),
1465 2,
1466 "Should find 2 cookies for api.example.com (parent domain + exact)"
1467 );
1468 let auth_cookie = api_cookies
1469 .iter()
1470 .find(|c| c.name == "auth_token")
1471 .expect("Should find auth_token cookie");
1472 assert_eq!(auth_cookie.value, "xyz789");
1473 let session_cookie = api_cookies
1474 .iter()
1475 .find(|c| c.name == "session_id")
1476 .expect("Should find session_id cookie from parent domain");
1477 assert_eq!(session_cookie.value, "abc123");
1478
1479 let _ = fs::remove_dir_all(&session_dir);
1481 }
1482
1483 #[tokio::test]
1488 async fn test_auto_save_with_custom_interval() {
1489 let session_dir = create_test_session_dir();
1490
1491 let persistence_30s = SessionPersistence::new(&session_dir).with_interval(30);
1493 assert_eq!(
1494 persistence_30s.auto_save_interval,
1495 Duration::from_secs(30),
1496 "Custom 30s interval should be set"
1497 );
1498
1499 let persistence_too_short = SessionPersistence::new(&session_dir).with_interval(1);
1501 assert!(
1502 persistence_too_short.auto_save_interval >= Duration::from_secs(10),
1503 "Interval below minimum should be clamped to 10s"
1504 );
1505
1506 let persistence_exact_min = SessionPersistence::new(&session_dir).with_interval(10);
1508 assert_eq!(
1509 persistence_exact_min.auto_save_interval,
1510 Duration::from_secs(10),
1511 "Exact minimum interval (10s) should be accepted"
1512 );
1513
1514 let persistence_large = SessionPersistence::new(&session_dir).with_interval(300); assert_eq!(
1517 persistence_large.auto_save_interval,
1518 Duration::from_secs(300),
1519 "Large interval (300s) should be accepted"
1520 );
1521
1522 let persistence_default = SessionPersistence::new(&session_dir);
1524 assert!(
1525 persistence_default.auto_save_enabled,
1526 "Auto-save should be enabled by default"
1527 );
1528 assert_eq!(
1529 persistence_default.auto_save_interval,
1530 Duration::from_secs(DEFAULT_AUTO_SAVE_INTERVAL_SECS),
1531 "Default interval should be 60 seconds"
1532 );
1533
1534 let persistence_disabled = SessionPersistence::new(&session_dir).without_auto_save();
1536 assert!(
1537 !persistence_disabled.auto_save_enabled,
1538 "Auto-save should be disabled after without_auto_save()"
1539 );
1540
1541 let _ = fs::remove_dir_all(&session_dir);
1543 }
1544}