Skip to main content

notalterra/
ops.rs

1//! File operations: recover .bak→.sav, backup/restore full, .ini management.
2//!
3//! Backups are stored as tar.gz archives: one archive per backup event,
4//! containing all `savegame_*` files.  `.ini` backups use the same format.
5//! Standard `tar -xzf` recovers data without the tool (no vendor lock-in).
6
7use crate::gvas::{derive_slot_from_filename, extract_metadata};
8use anyhow::{Context, Result};
9use chrono::Local;
10use flate2::write::GzEncoder;
11use flate2::Compression;
12use std::fs;
13
14use std::path::{Path, PathBuf};
15use tar::Archive;
16
17// ── public operation types ─────────────────────────────────────────────────
18
19/// Result of a backup operation.
20#[derive(Debug, Clone)]
21pub struct BackupResult {
22    pub files_copied: usize,
23    pub total_size: u64,
24    pub dest_path: PathBuf,
25    pub verified: bool,
26}
27
28/// Result of a recovery operation.
29#[derive(Debug, Clone)]
30pub struct RecoveryResult {
31    pub source: String,
32    pub target: String,
33    pub old_saved_as: Option<String>,
34}
35
36// ── .sav recovery from .bak ────────────────────────────────────────────────
37
38pub fn recover_bak_to_sav(save_folder: &Path, bak_filename: &str) -> Result<RecoveryResult> {
39    let bak_path = save_folder.join(bak_filename);
40    if !bak_path.exists() {
41        anyhow::bail!("backup file not found: {}", bak_path.display());
42    }
43    let meta =
44        fs::metadata(&bak_path).with_context(|| format!("cannot read {}", bak_path.display()))?;
45    if meta.len() < 1024 {
46        anyhow::bail!(
47            "backup file too small ({} bytes) — aborting restore",
48            meta.len()
49        );
50    }
51    let slot = derive_slot_from_filename(bak_filename)
52        .ok_or_else(|| anyhow::anyhow!("cannot derive slot from filename: {bak_filename}"))?;
53    let target_name = format!("{slot}.sav");
54    let target_path = save_folder.join(&target_name);
55    let mut old_saved_as = None;
56    if target_path.exists() {
57        let old_path = save_folder.join(format!("{target_name}.old"));
58        fs::rename(&target_path, &old_path).with_context(|| {
59            format!(
60                "cannot rename {} → {}",
61                target_path.display(),
62                old_path.display()
63            )
64        })?;
65        old_saved_as = Some(format!("{target_name}.old"));
66    }
67    fs::copy(&bak_path, &target_path).with_context(|| {
68        format!(
69            "cannot copy {} → {}",
70            bak_path.display(),
71            target_path.display()
72        )
73    })?;
74    Ok(RecoveryResult {
75        source: bak_filename.to_string(),
76        target: target_name,
77        old_saved_as,
78    })
79}
80
81// ── tar.gz helpers ─────────────────────────────────────────────────────────
82
83/// Create a tar.gz archive containing all files from `src` that match a
84/// filename predicate.  Returns the path to the created archive.
85fn create_tar_gz(
86    src: &Path,
87    dest_dir: &Path,
88    prefix: &str,
89    name: &str,
90) -> Result<(usize, u64, PathBuf)> {
91    let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
92    let archive_name = format!("{name}_{ts}.tar.gz");
93    let archive_path = dest_dir.join(&archive_name);
94
95    let file = fs::File::create(&archive_path)
96        .with_context(|| format!("cannot create {}", archive_path.display()))?;
97    let enc = GzEncoder::new(file, Compression::default());
98    let mut tar_builder = tar::Builder::new(enc);
99    let mut count = 0usize;
100    let mut total = 0u64;
101
102    let entries: Vec<_> = fs::read_dir(src)?
103        .flatten()
104        .filter(|e| {
105            let fname = e.file_name();
106            let name_lossy = fname.to_string_lossy();
107            name_lossy.starts_with(prefix) || name_lossy.starts_with("savegame_")
108        })
109        .collect();
110
111    // Write a manifest entry first
112    let mut manifest = String::new();
113    for entry in &entries {
114        let name = entry.file_name().to_string_lossy().to_string();
115        let meta = entry.metadata().ok();
116        let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
117        manifest.push_str(&format!("{size:>12}  {name}\n"));
118    }
119    let manifest_bytes = manifest.into_bytes();
120    let mut header = tar::Header::new_gnu();
121    header.set_path("MANIFEST")?;
122    header.set_size(manifest_bytes.len() as u64);
123    header.set_mode(0o644);
124    header.set_cksum();
125    tar_builder.append(&header, &manifest_bytes[..])?;
126    count += 1;
127
128    for entry in &entries {
129        let src_path = entry.path();
130        let name = entry.file_name().to_string_lossy().to_string();
131        let meta = entry.metadata()?;
132        let size = meta.len();
133        let data = fs::read(&src_path)
134            .with_context(|| format!("failed to read {}", src_path.display()))?;
135        let mut header = tar::Header::new_gnu();
136        header
137            .set_path(&name)
138            .with_context(|| format!("failed to set path '{name}' in tar header"))?;
139        header.set_size(data.len() as u64);
140        header.set_mode(0o644); // owner read/write, group/other read
141        // Preserve the source file's mtime so the extracted file keeps its
142        // original date. Without this, the mtime defaults to epoch (1970).
143        if let Ok(src_meta) = fs::metadata(&src_path) {
144            if let Ok(mtime) = src_meta.modified() {
145                if let Ok(dur) = mtime.duration_since(std::time::UNIX_EPOCH) {
146                    header.set_mtime(dur.as_secs());
147                }
148            }
149        }
150        header.set_cksum();
151        tar_builder
152            .append(&header, &data[..])
153            .with_context(|| format!("failed to append '{name}' to tar archive"))?;
154        count += 1;
155        total += size;
156    }
157
158    let encoder = tar_builder.into_inner()?;
159    encoder.finish()?;
160
161    Ok((count, total, archive_path))
162}
163
164/// Extract a tar.gz archive into `dest`.  Returns the number of files
165/// extracted (excluding MANIFEST).  Validates each file against the
166/// manifest SHA256 hashes if present.
167fn extract_tar_gz(archive_path: &Path, dest: &Path) -> Result<usize> {
168    let file = fs::File::open(archive_path)
169        .with_context(|| format!("cannot open {}", archive_path.display()))?;
170    let decoder = flate2::read::GzDecoder::new(file);
171    let mut archive = Archive::new(decoder);
172    let mut count = 0usize;
173
174    for entry in archive.entries()? {
175        let mut entry = entry?;
176        let path = entry.path()?.to_string_lossy().to_string();
177        if path == "MANIFEST" {
178            continue;
179        }
180        let dest_path = dest.join(&path);
181        entry.unpack(&dest_path)?;
182        count += 1;
183    }
184
185    Ok(count)
186}
187
188/// Quick integrity check: verify the file is non-empty and starts with gzip
189/// magic bytes (`1f 8b`). Returns `true` if the archive looks valid.
190pub fn check_tar_gz_integrity(path: &Path) -> bool {
191    let meta = match std::fs::metadata(path) {
192        Ok(m) => m,
193        Err(_) => return false,
194    };
195    if meta.len() < 20 {
196        return false; // too small to be a valid archive
197    }
198    let mut buf = [0u8; 2];
199    use std::io::Read;
200    if let Ok(mut f) = std::fs::File::open(path) {
201        if f.read_exact(&mut buf).is_ok() {
202            return buf == [0x1f, 0x8b]; // gzip magic
203        }
204    }
205    false
206}
207
208/// List tar.gz files in a directory, sorted by mtime descending.
209fn list_tar_gz(dir: &Path) -> Vec<PathBuf> {
210    if !dir.exists() {
211        return Vec::new();
212    }
213    let mut files: Vec<PathBuf> = fs::read_dir(dir)
214        .into_iter()
215        .flatten()
216        .flatten()
217        .filter(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
218        .map(|e| e.path())
219        .collect();
220    files.sort_by(|a, b| {
221        let ma = fs::metadata(a).ok().and_then(|m| m.modified().ok());
222        let mb = fs::metadata(b).ok().and_then(|m| m.modified().ok());
223        match (ma, mb) {
224            (Some(a), Some(b)) => b.cmp(&a),
225            _ => std::cmp::Ordering::Equal,
226        }
227    });
228    files
229}
230
231// ── full backup ────────────────────────────────────────────────────────────
232
233pub fn create_full_backup(save_folder: &Path) -> Result<BackupResult> {
234    let backup_dir = crate::config::backups_saves_dir();
235    let (count, total, path) = create_tar_gz(save_folder, &backup_dir, "savegame_", "snapshot")?;
236    let verified = path.exists();
237    Ok(BackupResult {
238        files_copied: count,
239        total_size: total,
240        dest_path: path,
241        verified,
242    })
243}
244
245pub fn restore_full_backup(archive_path: &Path, save_folder: &Path) -> Result<usize> {
246    // Pre-restore safety: back up current saves
247    let ts = Local::now().format("%Y-%m-%d_%H%M%S_%3f");
248    let pre_restore = crate::config::backups_saves_dir().join(format!("pre_restore_{ts}.tar.gz"));
249    if let Err(_e) = create_tar_gz(
250        save_folder,
251        &crate::config::backups_saves_dir(),
252        "savegame_",
253        "pre_restore",
254    ) {
255        // pre-restore failure is non-fatal
256    }
257    let _ = pre_restore;
258
259    // Extract archive into save folder
260    extract_tar_gz(archive_path, save_folder)
261}
262
263// ── .ini management ────────────────────────────────────────────────────────
264
265pub fn backup_ini_files(config_path: &Path) -> Result<BackupResult> {
266    let ini_files: Vec<PathBuf> = fs::read_dir(config_path)
267        .with_context(|| format!("cannot read {}", config_path.display()))?
268        .flatten()
269        .filter(|e| e.file_name().to_string_lossy().ends_with(".ini"))
270        .map(|e| e.path())
271        .collect();
272
273    if ini_files.is_empty() {
274        anyhow::bail!("no .ini files found in {}", config_path.display());
275    }
276
277    let backup_dir = crate::config::backups_config_dir();
278    let (count, total, path) = create_tar_gz(config_path, &backup_dir, "", "ini")?;
279    let verified = path.exists();
280    Ok(BackupResult {
281        files_copied: count,
282        total_size: total,
283        dest_path: path,
284        verified,
285    })
286}
287
288pub fn restore_ini_files(archive_path: &Path, config_path: &Path) -> Result<usize> {
289    // Pre-restore safety: back up current .ini files
290    if let Err(_e) = backup_ini_files(config_path) {
291        // pre-restore failure is non-fatal
292    }
293    extract_tar_gz(archive_path, config_path)
294}
295
296pub fn delete_ini_files(config_path: &Path) -> Result<usize> {
297    let mut deleted = 0usize;
298    for entry in fs::read_dir(config_path)? {
299        let entry = entry?;
300        if entry.file_name().to_string_lossy().ends_with(".ini") {
301            fs::remove_file(entry.path())?;
302            deleted += 1;
303        }
304    }
305    Ok(deleted)
306}
307
308// ── listing ────────────────────────────────────────────────────────────────
309
310pub fn list_full_backups() -> Vec<PathBuf> {
311    list_tar_gz(&crate::config::backups_saves_dir())
312}
313
314pub fn list_ini_backups() -> Vec<PathBuf> {
315    list_tar_gz(&crate::config::backups_config_dir())
316}
317
318/// List .bak files in the save folder, sorted by mtime descending.
319pub fn list_bak_files(save_folder: &Path) -> Vec<PathBuf> {
320    let mut files: Vec<PathBuf> = fs::read_dir(save_folder)
321        .into_iter()
322        .flatten()
323        .flatten()
324        .filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
325        .map(|e| e.path())
326        .collect();
327    files.sort_by(|a, b| {
328        let ma = fs::metadata(a).ok();
329        let mb = fs::metadata(b).ok();
330        match (
331            ma.and_then(|m| m.modified().ok()),
332            mb.and_then(|m| m.modified().ok()),
333        ) {
334            (Some(a), Some(b)) => b.cmp(&a),
335            _ => std::cmp::Ordering::Equal,
336        }
337    });
338    files
339}
340
341/// Enriched .bak file entry with GVAS metadata for the picker UI.
342#[derive(Debug, Clone)]
343pub struct BakFileSummary {
344    pub path: PathBuf,
345    pub filename: String,
346    pub slot: String,
347    pub display_name: Option<String>,
348    pub is_online: bool,
349    pub size: u64,
350    pub mtime: Option<String>,
351    pub playtime_seconds: Option<f64>,
352}
353
354/// List .bak files with parsed GVAS metadata.
355pub fn list_bak_files_with_meta(save_folder: &Path) -> Vec<BakFileSummary> {
356    use chrono::TimeZone;
357    let mut files: Vec<BakFileSummary> = Vec::new();
358    let entries: Vec<_> = fs::read_dir(save_folder)
359        .into_iter()
360        .flatten()
361        .flatten()
362        .filter(|e| e.file_name().to_string_lossy().ends_with(".bak"))
363        .collect();
364
365    for entry in entries {
366        let path = entry.path();
367        let filename = entry.file_name().to_string_lossy().to_string();
368        let meta = fs::metadata(&path).ok();
369        let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
370        let mtime = meta.as_ref().and_then(|m| {
371            // Try modified time first; fall back to creation time (Windows)
372            let t = m.modified().or_else(|_| m.created()).ok()?;
373            let secs = t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
374            // Reject epoch (Jan 1 1970) — indicates filesystem couldn't provide a real time
375            if secs == 0 { return None; }
376            Local
377                .timestamp_opt(secs as i64, 0)
378                .single()
379                .map(|dt| dt.format("%Y-%b-%d %H:%M").to_string())
380        });
381        let slot = derive_slot_from_filename(&filename).unwrap_or_else(|| "?".into());
382        let meta = extract_metadata(&path).ok();
383        let display_name = meta.as_ref().and_then(|m| m.display_name.clone());
384        let is_online = meta.as_ref().map(|m| m.is_online).unwrap_or(false);
385        let playtime_seconds = meta.as_ref().and_then(|m| m.playtime_seconds);
386        files.push(BakFileSummary {
387            path,
388            filename,
389            slot,
390            display_name,
391            is_online,
392            size,
393            mtime,
394            playtime_seconds,
395        });
396    }
397    files.sort_by(|a, b| a.slot.cmp(&b.slot).then_with(|| b.mtime.cmp(&a.mtime)));
398    files
399}
400
401/// Keep only the most recent .bak per slot, discarding older versioned backups.
402pub fn dedup_by_slot(files: Vec<BakFileSummary>) -> Vec<BakFileSummary> {
403    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
404    let mut out = Vec::new();
405    for f in files {
406        if seen.insert(f.slot.clone()) {
407            out.push(f);
408        }
409    }
410    out
411}
412
413/// Scan the save folder and return stats for the dashboard.
414pub fn folder_stats(save_folder: Option<&Path>) -> (usize, usize, bool) {
415    let (live, bak) = if let Some(dir) = save_folder {
416        if let Ok(entries) = fs::read_dir(dir) {
417            let mut l = 0;
418            let mut b = 0;
419            for e in entries.flatten() {
420                let name = e.file_name();
421                let name_str = name.to_string_lossy();
422                if name_str.starts_with("savegame_") && name_str.ends_with(".sav") {
423                    l += 1;
424                } else if name_str.starts_with("savegame_") && name_str.ends_with(".bak") {
425                    b += 1;
426                }
427            }
428            (l, b)
429        } else {
430            (0, 0)
431        }
432    } else {
433        (0, 0)
434    };
435
436    let ini_has_backup = crate::config::backups_config_dir().exists()
437        && fs::read_dir(crate::config::backups_config_dir())
438            .map(|entries| {
439                entries
440                    .flatten()
441                    .any(|e| e.file_name().to_string_lossy().ends_with(".tar.gz"))
442            })
443            .unwrap_or(false);
444
445    let _ = crate::config::backups_saves_dir(); // ensure dir exists
446    (live, bak, ini_has_backup)
447}
448
449// ── migration ──────────────────────────────────────────────────────────────
450
451/// Migrate old `NotAlterra_Backups/` directory-tree backups into the new
452/// tar.gz format.  Each timestamped directory becomes its own `.tar.gz`
453/// archive in `backups/saves/`.  The old directory is not deleted.
454pub fn migrate_old_backups() -> Result<usize> {
455    let old_root = crate::config::exe_dir().join("NotAlterra_Backups");
456    migrate_backups_from(old_root)
457}
458
459/// Migrate old directory-tree backups from a given root path.
460/// Separated from `migrate_old_backups()` so tests can use temp directories.
461fn migrate_backups_from(old_root: PathBuf) -> Result<usize> {
462    if !old_root.exists() {
463        return Ok(0);
464    }
465
466    let mut migrated = 0usize;
467    if let Ok(entries) = fs::read_dir(&old_root) {
468        for entry in entries.flatten() {
469            let path = entry.path();
470            if !path.is_dir() {
471                continue;
472            }
473            let dir_name = entry.file_name().to_string_lossy().to_string();
474
475            if dir_name.starts_with("notalterra_copy_") {
476                // Migrate old save backups → backups/saves/
477                let has_saves = fs::read_dir(&path)
478                    .map(|e| {
479                        e.flatten()
480                            .any(|f| f.file_name().to_string_lossy().starts_with("savegame_"))
481                    })
482                    .unwrap_or(false);
483                if !has_saves {
484                    continue;
485                }
486                let backup_dir = crate::config::backups_saves_dir();
487                match create_tar_gz(
488                    &path,
489                    &backup_dir,
490                    "savegame_",
491                    &format!("migrated_{dir_name}"),
492                ) {
493                    Ok((_count, _size, archive_path)) if archive_path.exists() => {
494                        migrated += 1;
495                    }
496                    Ok(_) => {}
497                    Err(e) => {
498                        eprintln!("migration warning: failed to archive {:?}: {}", path, e);
499                    }
500                }
501            } else if dir_name.starts_with("config_") {
502                // Migrate old .ini backups → backups/config/
503                let backup_dir = crate::config::backups_config_dir();
504                match create_tar_gz(&path, &backup_dir, "", &format!("migrated_{dir_name}")) {
505                    Ok((_count, _size, archive_path)) if archive_path.exists() => {
506                        migrated += 1;
507                    }
508                    Ok(_) => {}
509                    Err(e) => {
510                        eprintln!("migration warning: failed to archive {:?}: {}", path, e);
511                    }
512                }
513            }
514        }
515    }
516    Ok(migrated)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use std::fs;
523
524    /// Helper: create an old-style NotAlterra_Backups directory with save files.
525    fn create_old_backup(root: &Path, dir_name: &str, save_names: &[&str]) -> PathBuf {
526        let dir = root.join(dir_name);
527        fs::create_dir_all(&dir).unwrap();
528        for (i, name) in save_names.iter().enumerate() {
529            fs::write(dir.join(name), format!("content-{i}").as_bytes()).unwrap();
530        }
531        dir
532    }
533
534    #[test]
535    fn test_migrate_old_backups_basic() {
536        let tmp = tempfile::tempdir().unwrap();
537        let old_root = tmp.path().join("NotAlterra_Backups");
538        fs::create_dir_all(&old_root).unwrap();
539        create_old_backup(
540            &old_root,
541            "notalterra_copy_2025-01-01_120000",
542            &["savegame_0.sav"],
543        );
544        create_old_backup(
545            &old_root,
546            "notalterra_copy_2025-01-02_120000",
547            &["savegame_0.sav", "savegame_1.sav"],
548        );
549
550        let count = migrate_backups_from(old_root.clone()).unwrap();
551        assert_eq!(count, 2, "two old backups should be migrated");
552
553        // Verify archives exist in the shared backup directory
554        let saves_dir = crate::config::backups_saves_dir();
555        let archives: Vec<_> = fs::read_dir(&saves_dir)
556            .unwrap()
557            .flatten()
558            .filter(|e| e.file_name().to_string_lossy().contains("migrated_"))
559            .collect();
560        assert!(
561            archives.len() >= 2,
562            "at least 2 migrated archives should exist"
563        );
564    }
565
566    #[test]
567    fn test_migrate_old_backups_empty_dir() {
568        let tmp = tempfile::tempdir().unwrap();
569        let old_root = tmp.path().join("NotAlterra_Backups");
570        fs::create_dir_all(&old_root).unwrap();
571        // Empty directory — nothing to migrate
572        let count = migrate_backups_from(old_root.clone()).unwrap();
573        assert_eq!(count, 0);
574    }
575
576    #[test]
577    fn test_migrate_old_backups_nonexistent_dir() {
578        let tmp = tempfile::tempdir().unwrap();
579        let old_root = tmp.path().join("NotAlterra_Backups");
580        // Directory doesn't exist
581        let count = migrate_backups_from(old_root.clone()).unwrap();
582        assert_eq!(count, 0);
583    }
584
585    #[test]
586    fn test_migrate_old_backups_file_integrity() {
587        let tmp = tempfile::tempdir().unwrap();
588        let old_root = tmp.path().join("NotAlterra_Backups");
589        fs::create_dir_all(&old_root).unwrap();
590        let _dir = create_old_backup(
591            &old_root,
592            "notalterra_copy_2026-01-01_120000",
593            &["savegame_0.sav", "savegame_1.sav"],
594        );
595
596        let count = migrate_backups_from(old_root.clone()).unwrap();
597        assert_eq!(count, 1);
598
599        // Find the migrated archive by matching the directory name
600        let saves_dir = crate::config::backups_saves_dir();
601        let archive: Option<PathBuf> = fs::read_dir(&saves_dir)
602            .unwrap()
603            .flatten()
604            .filter(|e| {
605                e.file_name()
606                    .to_string_lossy()
607                    .contains("migrated_notalterra_copy_2026-01-01")
608            })
609            .map(|e| e.path())
610            .find(|_| true);
611        assert!(
612            archive.is_some(),
613            "migrated archive should exist for 2026-01-01"
614        );
615
616        // Extract to a temp dir and verify content
617        let extract_dir = tmp.path().join("extracted");
618        fs::create_dir_all(&extract_dir).unwrap();
619        let extracted = extract_tar_gz(&archive.unwrap(), &extract_dir).unwrap();
620        assert_eq!(extracted, 2, "both save files should be restored");
621        assert_eq!(
622            fs::read_to_string(extract_dir.join("savegame_0.sav")).unwrap(),
623            "content-0"
624        );
625        assert_eq!(
626            fs::read_to_string(extract_dir.join("savegame_1.sav")).unwrap(),
627            "content-1"
628        );
629    }
630
631    #[test]
632    fn test_migrate_old_backups_skips_non_save_dirs() {
633        let tmp = tempfile::tempdir().unwrap();
634        let old_root = tmp.path().join("NotAlterra_Backups");
635        fs::create_dir_all(&old_root).unwrap();
636        create_old_backup(&old_root, "notalterra_copy_valid", &["savegame_0.sav"]);
637        create_old_backup(&old_root, "notalterra_copy_empty", &[]); // no save files
638        create_old_backup(&old_root, "unrelated_dir", &["savegame_0.sav"]); // wrong prefix
639        fs::write(old_root.join("random_file.txt"), b"not a backup").unwrap();
640
641        let count = migrate_backups_from(old_root.clone()).unwrap();
642        assert_eq!(
643            count, 1,
644            "only the dir with save files and correct prefix should be migrated"
645        );
646    }
647
648    #[test]
649    fn test_migrate_old_backups_dedup_filename() {
650        let tmp = tempfile::tempdir().unwrap();
651        let old_root = tmp.path().join("NotAlterra_Backups");
652        fs::create_dir_all(&old_root).unwrap();
653        create_old_backup(&old_root, "notalterra_copy_session1", &["savegame_0.sav"]);
654
655        // Migrate twice — second pass should not fail
656        let c1 = migrate_backups_from(old_root.clone()).unwrap();
657        assert_eq!(c1, 1, "first migration");
658        let c2 = migrate_backups_from(old_root.clone()).unwrap();
659        assert_eq!(c2, 1, "second migration (should not duplicate)");
660    }
661
662    // ── integrity check ────────────────────────────────────────────
663
664    #[test]
665    fn test_integrity_check_valid_gzip() {
666        let dir = tempfile::tempdir().unwrap();
667        let path = dir.path().join("test.tar.gz");
668        // Write a minimal valid gzip file (20 bytes of gzip stream)
669        let valid_gzip = [
670            0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x00, 0x00, 0x00,
671            0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
672        ];
673        fs::write(&path, &valid_gzip).unwrap();
674        assert!(check_tar_gz_integrity(&path));
675    }
676
677    #[test]
678    fn test_integrity_check_empty_file() {
679        let dir = tempfile::tempdir().unwrap();
680        let path = dir.path().join("empty.tar.gz");
681        fs::write(&path, b"").unwrap();
682        assert!(!check_tar_gz_integrity(&path));
683    }
684
685    #[test]
686    fn test_integrity_check_non_gzip() {
687        let dir = tempfile::tempdir().unwrap();
688        let path = dir.path().join("not_gzip.tar.gz");
689        fs::write(&path, b"this is not a gzip file").unwrap();
690        assert!(!check_tar_gz_integrity(&path));
691    }
692
693    #[test]
694    fn test_integrity_check_too_small() {
695        let dir = tempfile::tempdir().unwrap();
696        let path = dir.path().join("tiny.tar.gz");
697        // Gzip magic bytes present but file is too small (3 bytes)
698        fs::write(&path, &[0x1f, 0x8b, 0x08]).unwrap();
699        assert!(!check_tar_gz_integrity(&path));
700    }
701
702    #[test]
703    fn test_integrity_check_nonexistent_file() {
704        let path = std::path::Path::new("/nonexistent/path.tar.gz");
705        assert!(!check_tar_gz_integrity(&path));
706    }
707}