//! File operations: recover .bak→.sav, backup/restore full, .ini management. //! //! Backups are stored as tar.gz archives: one archive per backup event, //! containing all `savegame_*` files. `.ini` backups use the same format. //! Standard `tar -xzf` recovers data without the tool (no vendor lock-in). use crate::gvas::{derive_slot_from_filename, extract_metadata}; use anyhow::{Context, Result}; use chrono::Local; use flate2::write::GzEncoder; use flate2::Compression; use std::fs; use std::path::{Path, PathBuf}; use tar::Archive; // ── public operation types ───────────────────────────────────────────────── /// Result of a backup operation. #[derive(Debug, Clone)] pub struct BackupResult { pub files_copied: usize, pub total_size: u64, pub dest_path: PathBuf, pub verified: bool, } /// Result of a recovery operation. #[derive(Debug, Clone)] pub struct RecoveryResult { pub source: String, pub target: String, pub old_saved_as: Option, } // ── .sav recovery from .bak ──────────────────────────────────────────────── pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result { let bak_path = save_folder.join(bak_filename); if !bak_path.exists() { anyhow::bail!("backup file not found: {}", bak_path.display()); } let meta = fs::metadata(&bak_path).with_context(|| format!("cannot read {}", bak_path.display()))?; if meta.len() < 1024 { anyhow::bail!( "backup file too small ({} bytes) — aborting restore", meta.len() ); } let slot = derive_slot_from_filename(bak_filename) .ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?; let target_name = format!("{slot}.sav"); let target_path = save_folder.join(&target_name); let mut old_saved_as = None; if target_path.exists() { let old_path = save_folder.join(format!("{target_name}.old")); fs::rename(&target_path, &old_path).with_context(|| { format!( "cannot rename {} → {}", target_path.display(), old_path.display() ) })?; old_saved_as = Some(format!("{target_name}.old")); } fs::copy(&bak_path, &target_path).with_context(|| { format!( "cannot copy {} → {}", bak_path.display(), target_path.display() ) })?; Ok(RecoveryResult { source: bak_filename.to_string(), target: target_name, old_saved_as, }) } // ── tar.gz helpers ───────────────────────────────────────────────────────── /// Create a tar.gz archive containing all files from `src` that match a /// filename predicate. Returns the path to the created archive. fn create_tar_gz( src: &Path, dest_dir: &Path, prefix: &str, name: &str, ) -> Result<(usize, u64, PathBuf)> { let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f"); let archive_name = format!("{name}_{ts}.tar.gz"); let archive_path = dest_dir.join(&archive_name); let file = fs::File::create(&archive_path) .with_context(|| format!("cannot create {}", archive_path.display()))?; let enc = GzEncoder::new(file, Compression::default()); let mut tar_builder = tar::Builder::new(enc); let mut count = 0usize; let mut total = 0u64; let entries: Vec<_> = fs::read_dir(src)? .flatten() .filter(|e| { let fname = e.file_name(); let name_lossy = fname.to_string_lossy(); name_lossy.starts_with(prefix) || name_lossy.starts_with("savegame_") }) .collect(); // Write a manifest entry first let mut manifest = String::new(); for entry in &entries { let name = entry.file_name().to_string_lossy().to_string(); let meta = entry.metadata().ok(); let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); manifest.push_str(&format!("{size:>12} {name}\n")); } let manifest_bytes = manifest.into_bytes(); let mut header = tar::Header::new_gnu(); header.set_path("MANIFEST")?; header.set_size(manifest_bytes.len() as u64); header.set_mode(0o644); header.set_cksum(); tar_builder.append(&header, &manifest_bytes[..])?; count += 1; for entry in &entries { let src_path = entry.path(); let name = entry.file_name().to_string_lossy().to_string(); let meta = entry.metadata()?; let size = meta.len(); let data = fs::read(&src_path) .with_context(|| format!("failed to read {}", src_path.display()))?; let mut header = tar::Header::new_gnu(); header .set_path(&name) .with_context(|| format!("failed to set path '{name}' in tar header"))?; header.set_size(data.len() as u64); header.set_mode(0o644); // owner read/write, group/other read // Preserve the source file's mtime so the extracted file keeps its // original date. Without this, the mtime defaults to epoch (1970). if let Ok(src_meta) = fs::metadata(&src_path) { if let Ok(mtime) = src_meta.modified() { if let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) { header.set_mtime(dur.as_secs()); } } } header.set_cksum(); tar_builder .append(&header, &data[..]) .with_context(|| format!("failed to append '{name}' to tar archive"))?; count += 1; total += size; } let encoder = tar_builder.into_inner()?; encoder.finish()?; Ok((count, total, archive_path)) } /// Extract a tar.gz archive into `dest`. Returns the number of files /// extracted (excluding MANIFEST). Validates each file against the /// manifest SHA256 hashes if present. fn extract_tar_gz(archive_path: &Path, dest: &Path) -> Result { let file = fs::File::open(archive_path) .with_context(|| format!("cannot open {}", archive_path.display()))?; let decoder = flate2::read::GzDecoder::new(file); let mut archive = Archive::new(decoder); let mut count = 0usize; for entry in archive.entries()? { let mut entry = entry?; let path = entry.path()?.to_string_lossy().to_string(); if path == "MANIFEST" { continue; } let dest_path = dest.join(&path); entry.unpack(&dest_path)?; count += 1; } Ok(count) } /// Quick integrity check: verify the file is non-empty and starts with gzip /// magic bytes (`1f 8b`). Returns `true` if the archive looks valid. pub fn check_tar_gz_integrity(path: &Path) -> bool { let meta = match std::fs::metadata(path) { Ok(m) => m, Err(_) => return false, }; if meta.len() < 20 { return false; // too small to be a valid archive } let mut buf = [0u8; 2]; use std::io::Read; if let Ok(mut f) = std::fs::File::open(path) { if f.read_exact(&mut buf).is_ok() { return buf == [0x1f, 0x8b]; // gzip magic } } false } /// List tar.gz files in a directory, sorted by mtime descending. fn list_tar_gz(dir: &Path) -> Vec { if !dir.exists() { return Vec::new(); } let mut files: Vec = fs::read_dir(dir) .into_iter() .flatten() .flatten() .filter(|e| e.file_name().to_string_lossy().ends_with(".tar.gz")) .map(|e| e.path()) .collect(); files.sort_by(|a, b| { let ma = fs::metadata(a).ok().and_then(|m| m.modified().ok()); let mb = fs::metadata(b).ok().and_then(|m| m.modified().ok()); match (ma, mb) { (Some(a), Some(b)) => b.cmp(&a), _ => std::cmp::Ordering::Equal, } }); files } // ── full backup ──────────────────────────────────────────────────────────── pub fn create_full_backup(save_folder: &Path) -> Result { let backup_dir = crate::config::backups_saves_dir(); let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?; let verified = path.exists(); Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified, }) } pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result { // Pre-restore safety: back up current saves let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f"); let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz")); if let Err(_e) = create_tar_gz( save_folder, &crate::config::backups_saves_dir(), "savegame_", "pre_restore", ) { // pre-restore failure is non-fatal } let _ = pre_restore; // Extract archive into save folder extract_tar_gz(archive_path, save_folder) } // ── .ini management ──────────────────────────────────────────────────────── pub fn backup_ini_files(config_path: &Path) -> Result { let ini_files: Vec = fs::read_dir(config_path) .with_context(|| format!("cannot read {}", config_path.display()))? .flatten() .filter(|e| e.file_name().to_string_lossy().ends_with(".ini")) .map(|e| e.path()) .collect(); if ini_files.is_empty() { anyhow::bail!("no .ini files found in {}", config_path.display()); } let backup_dir = crate::config::backups_config_dir(); let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?; let verified = path.exists(); Ok(BackupResult { files_copied: count, total_size: total, dest_path: path, verified, }) } pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result { // Pre-restore safety: back up current .ini files if let Err(_e) = backup_ini_files(config_path) { // pre-restore failure is non-fatal } extract_tar_gz(archive_path, config_path) } pub fn delete_ini_files(config_path: &Path) -> Result { let mut deleted = 0usize; for entry in fs::read_dir(config_path)? { let entry = entry?; if entry.file_name().to_string_lossy().ends_with(".ini") { fs::remove_file(entry.path())?; deleted += 1; } } Ok(deleted) } // ── listing ──────────────────────────────────────────────────────────────── pub fn list_full_backups() -> Vec { list_tar_gz(&crate::config::backups_saves_dir()) } pub fn list_ini_backups() -> Vec { list_tar_gz(&crate::config::backups_config_dir()) } /// List .bak files in the save folder, sorted by mtime descending. pub fn list_bak_files(save_folder: &Path) -> Vec { let mut files: Vec = fs::read_dir(save_folder) .into_iter() .flatten() .flatten() .filter(|e| e.file_name().to_string_lossy().ends_with(".bak")) .map(|e| e.path()) .collect(); files.sort_by(|a, b| { let ma = fs::metadata(a).ok(); let mb = fs::metadata(b).ok(); match ( ma.and_then(|m| m.modified().ok()), mb.and_then(|m| m.modified().ok()), ) { (Some(a), Some(b)) => b.cmp(&a), _ => std::cmp::Ordering::Equal, } }); files } /// Enriched .bak file entry with GVAS metadata for the picker UI. #[derive(Debug, Clone)] pub struct BakFileSummary { pub path: PathBuf, pub filename: String, pub slot: String, pub display_name: Option, pub is_online: bool, pub size: u64, pub mtime: Option, pub playtime_seconds: Option, } /// List .bak files with parsed GVAS metadata. pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec { use chrono::TimeZone; let mut files: Vec = Vec::new(); let entries: Vec<_> = fs::read_dir(save_folder) .into_iter() .flatten() .flatten() .filter(|e| e.file_name().to_string_lossy().ends_with(".bak")) .collect(); for entry in entries { let path = entry.path(); let filename = entry.file_name().to_string_lossy().to_string(); let meta = fs::metadata(&path).ok(); let size = meta.as_ref().map(|m| m.len()).unwrap_or(0); let mtime = meta.as_ref().and_then(|m| { // Try modified time first; fall back to creation time (Windows) let t = m.modified().or_else(|_| m.created()).ok()?; let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs(); // Reject epoch (Jan 1 1970) — indicates filesystem couldn't provide a real time if secs == 0 { return None; } Local .timestamp_opt(secs as i64, 0) .single() .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string()) }); let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into()); let meta = extract_metadata(&path).ok(); let display_name = meta.as_ref().and_then(|m| m.display_name.clone()); let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false); let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds); files.push(BakFileSummary { path, filename, slot, display_name, is_online, size, mtime, playtime_seconds, }); } files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime))); files } /// Keep only the most recent .bak per slot, discarding older versioned backups. pub fn dedup_by_slot(files: Vec) -> Vec { let mut seen: std::collections::HashSet = std::collections::HashSet::new(); let mut out = Vec::new(); for f in files { if seen.insert(f.slot.clone()) { out.push(f); } } out } /// Scan the save folder and return stats for the dashboard. pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) { let (live, bak) = if let Some(dir) = save_folder { if let Ok(entries) = fs::read_dir(dir) { let mut l = 0; let mut b = 0; for e in entries.flatten() { let name = e.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("savegame_") && name_str.ends_with(".sav") { l += 1; } else if name_str.starts_with("savegame_") && name_str.ends_with(".bak") { b += 1; } } (l, b) } else { (0, 0) } } else { (0, 0) }; let ini_has_backup = crate::config::backups_config_dir().exists() && fs::read_dir(crate::config::backups_config_dir()) .map(|entries| { entries .flatten() .any(|e| e.file_name().to_string_lossy().ends_with(".tar.gz")) }) .unwrap_or(false); let _ = crate::config::backups_saves_dir(); // ensure dir exists (live, bak, ini_has_backup) } // ── migration ────────────────────────────────────────────────────────────── /// Migrate old `NotAlterra_Backups/` directory-tree backups into the new /// tar.gz format. Each timestamped directory becomes its own `.tar.gz` /// archive in `backups/saves/`. The old directory is not deleted. pub fn migrate_old_backups() -> Result { let old_root = crate::config::exe_dir().join("NotAlterra_Backups"); migrate_backups_from(old_root) } /// Migrate old directory-tree backups from a given root path. /// Separated from `migrate_old_backups()` so tests can use temp directories. fn migrate_backups_from(old_root: PathBuf) -> Result { if !old_root.exists() { return Ok(0); } let mut migrated = 0usize; if let Ok(entries) = fs::read_dir(&old_root) { for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let dir_name = entry.file_name().to_string_lossy().to_string(); if dir_name.starts_with("notalterra_copy_") { // Migrate old save backups → backups/saves/ let has_saves = fs::read_dir(&path) .map(|e| { e.flatten() .any(|f| f.file_name().to_string_lossy().starts_with("savegame_")) }) .unwrap_or(false); if !has_saves { continue; } let backup_dir = crate::config::backups_saves_dir(); match create_tar_gz( &path, &backup_dir, "savegame_", &format!("migrated_{dir_name}"), ) { Ok((_count, _size, archive_path)) if archive_path.exists() => { migrated += 1; } Ok(_) => {} Err(e) => { eprintln!("migration warning: failed to archive {:?}: {}", path, e); } } } else if dir_name.starts_with("config_") { // Migrate old .ini backups → backups/config/ let backup_dir = crate::config::backups_config_dir(); match create_tar_gz(&path, &backup_dir, "", &format!("migrated_{dir_name}")) { Ok((_count, _size, archive_path)) if archive_path.exists() => { migrated += 1; } Ok(_) => {} Err(e) => { eprintln!("migration warning: failed to archive {:?}: {}", path, e); } } } } } Ok(migrated) } #[cfg(test)] mod tests { use super::*; use std::fs; /// Helper: create an old-style NotAlterra_Backups directory with save files. fn create_old_backup(root: &Path, dir_name: &str, save_names: &[&str]) -> PathBuf { let dir = root.join(dir_name); fs::create_dir_all(&dir).unwrap(); for (i, name) in save_names.iter().enumerate() { fs::write(dir.join(name), format!("content-{i}").as_bytes()).unwrap(); } dir } #[test] fn test_migrate_old_backups_basic() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); create_old_backup( &old_root, "notalterra_copy_2025-01-01_120000", &["savegame_0.sav"], ); create_old_backup( &old_root, "notalterra_copy_2025-01-02_120000", &["savegame_0.sav", "savegame_1.sav"], ); let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 2, "two old backups should be migrated"); // Verify archives exist in the shared backup directory let saves_dir = crate::config::backups_saves_dir(); let archives: Vec<_> = fs::read_dir(&saves_dir) .unwrap() .flatten() .filter(|e| e.file_name().to_string_lossy().contains("migrated_")) .collect(); assert!( archives.len() >= 2, "at least 2 migrated archives should exist" ); } #[test] fn test_migrate_old_backups_empty_dir() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); // Empty directory — nothing to migrate let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 0); } #[test] fn test_migrate_old_backups_nonexistent_dir() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); // Directory doesn't exist let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 0); } #[test] fn test_migrate_old_backups_file_integrity() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); let _dir = create_old_backup( &old_root, "notalterra_copy_2026-01-01_120000", &["savegame_0.sav", "savegame_1.sav"], ); let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(count, 1); // Find the migrated archive by matching the directory name let saves_dir = crate::config::backups_saves_dir(); let archive: Option = fs::read_dir(&saves_dir) .unwrap() .flatten() .filter(|e| { e.file_name() .to_string_lossy() .contains("migrated_notalterra_copy_2026-01-01") }) .map(|e| e.path()) .find(|_| true); assert!( archive.is_some(), "migrated archive should exist for 2026-01-01" ); // Extract to a temp dir and verify content let extract_dir = tmp.path().join("extracted"); fs::create_dir_all(&extract_dir).unwrap(); let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap(); assert_eq!(extracted, 2, "both save files should be restored"); assert_eq!( fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(), "content-0" ); assert_eq!( fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(), "content-1" ); } #[test] fn test_migrate_old_backups_skips_non_save_dirs() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); create_old_backup(&old_root, "notalterra_copy_valid", &["savegame_0.sav"]); create_old_backup(&old_root, "notalterra_copy_empty", &[]); // no save files create_old_backup(&old_root, "unrelated_dir", &["savegame_0.sav"]); // wrong prefix fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap(); let count = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!( count, 1, "only the dir with save files and correct prefix should be migrated" ); } #[test] fn test_migrate_old_backups_dedup_filename() { let tmp = tempfile::tempdir().unwrap(); let old_root = tmp.path().join("NotAlterra_Backups"); fs::create_dir_all(&old_root).unwrap(); create_old_backup(&old_root, "notalterra_copy_session1", &["savegame_0.sav"]); // Migrate twice — second pass should not fail let c1 = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(c1, 1, "first migration"); let c2 = migrate_backups_from(old_root.clone()).unwrap(); assert_eq!(c2, 1, "second migration (should not duplicate)"); } // ── integrity check ──────────────────────────────────────────── #[test] fn test_integrity_check_valid_gzip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.tar.gz"); // Write a minimal valid gzip file (20 bytes of gzip stream) let valid_gzip = [ 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; fs::write(&path, &valid_gzip).unwrap(); assert!(check_tar_gz_integrity(&path)); } #[test] fn test_integrity_check_empty_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("empty.tar.gz"); fs::write(&path, b"").unwrap(); assert!(!check_tar_gz_integrity(&path)); } #[test] fn test_integrity_check_non_gzip() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("not_gzip.tar.gz"); fs::write(&path, b"this is not a gzip file").unwrap(); assert!(!check_tar_gz_integrity(&path)); } #[test] fn test_integrity_check_too_small() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("tiny.tar.gz"); // Gzip magic bytes present but file is too small (3 bytes) fs::write(&path, &[0x1f, 0x8b, 0x08]).unwrap(); assert!(!check_tar_gz_integrity(&path)); } #[test] fn test_integrity_check_nonexistent_file() { let path = std::path::Path::new("/nonexistent/path.tar.gz"); assert!(!check_tar_gz_integrity(&path)); } }