mirror of
https://github.com/forkless/NotAlterra.git
synced 2026-08-20 10:03:35 +02:00
708 lines
25 KiB
Rust
708 lines
25 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
// ── .sav recovery from .bak ────────────────────────────────────────────────
|
|
|
|
pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result<RecoveryResult> {
|
|
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<usize> {
|
|
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<PathBuf> {
|
|
if !dir.exists() {
|
|
return Vec::new();
|
|
}
|
|
let mut files: Vec<PathBuf> = 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<BackupResult> {
|
|
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<usize> {
|
|
// 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<BackupResult> {
|
|
let ini_files: Vec<PathBuf> = 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<usize> {
|
|
// 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<usize> {
|
|
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<PathBuf> {
|
|
list_tar_gz(&crate::config::backups_saves_dir())
|
|
}
|
|
|
|
pub fn list_ini_backups() -> Vec<PathBuf> {
|
|
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<PathBuf> {
|
|
let mut files: Vec<PathBuf> = 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<String>,
|
|
pub is_online: bool,
|
|
pub size: u64,
|
|
pub mtime: Option<String>,
|
|
pub playtime_seconds: Option<f64>,
|
|
}
|
|
|
|
/// List .bak files with parsed GVAS metadata.
|
|
pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
|
|
use chrono::TimeZone;
|
|
let mut files: Vec<BakFileSummary> = 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<BakFileSummary>) -> Vec<BakFileSummary> {
|
|
let mut seen: std::collections::HashSet<String> = 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<usize> {
|
|
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<usize> {
|
|
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<PathBuf> = 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));
|
|
}
|
|
}
|