🔒 Repository is read-only – file editing is disabled.

luna/apps/luna-files/src/archive.rs main

461 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
//! Archiwa: rozpoznawanie formatu, budowa poleceń i uruchamianie narzędzi systemowych.
//!
//! Świadomie korzystamy z `bsdtar`/`tar`/`zip`/`7z`/`unrar` zamiast wciągać do
//! aplikacji kilka ciężkich bibliotek: libarchive obsługuje ZIP, TAR (gz/xz/bz2),
//! 7z i RAR, a tworzenie archiwów robią natywne narzędzia. Dzięki temu menedżer
//! obsługuje też formaty, których czysto-rustowe biblioteki nie znają (RAR).

use std::ffi::OsString;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;

use libluna_i18n::tr;

/// Format archiwum rozpoznawany po nazwie.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
    Zip,
    Tar,
    TarGz,
    TarXz,
    TarBz2,
    SevenZ,
    Rar,
}

impl Format {
    /// Rozszerzenie używane przy tworzeniu archiwum (bez kropki).
    pub fn extension(self) -> &'static str {
        match self {
            Format::Zip => "zip",
            Format::Tar => "tar",
            Format::TarGz => "tar.gz",
            Format::TarXz => "tar.xz",
            Format::TarBz2 => "tar.bz2",
            Format::SevenZ => "7z",
            Format::Rar => "rar",
        }
    }
}

/// Czy plik wygląda na archiwum obsługiwanego formatu.
pub fn is_archive(path: &Path) -> bool {
    format_of(path).is_some()
}

/// Format archiwum na podstawie nazwy (małe/duże litery bez znaczenia).
pub fn format_of(path: &Path) -> Option<Format> {
    let name = path.file_name()?.to_string_lossy().to_ascii_lowercase();
    // Kolejność ma znaczenie: `.tar.gz` przed `.gz`.
    let suffixes: [(&str, Format); 7] = [
        (".tar.gz", Format::TarGz),
        (".tgz", Format::TarGz),
        (".tar.xz", Format::TarXz),
        (".txz", Format::TarXz),
        (".tar.bz2", Format::TarBz2),
        (".tbz2", Format::TarBz2),
        (".zip", Format::Zip),
    ];
    for (suffix, format) in suffixes {
        if name.ends_with(suffix) {
            return Some(format);
        }
    }
    if name.ends_with(".tar") {
        return Some(Format::Tar);
    }
    if name.ends_with(".7z") {
        return Some(Format::SevenZ);
    }
    if name.ends_with(".rar") {
        return Some(Format::Rar);
    }
    None
}

/// Nazwa archiwum bez rozszerzenia (`projekt.tar.gz` → `projekt`).
pub fn archive_stem(path: &Path) -> String {
    let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) else {
        return String::new();
    };
    let lower = name.to_ascii_lowercase();
    for suffix in [
        ".tar.gz", ".tar.xz", ".tar.bz2", ".tgz", ".txz", ".tbz2", ".zip", ".tar", ".7z", ".rar",
    ] {
        if lower.ends_with(suffix) {
            return name[..name.len() - suffix.len()].to_owned();
        }
    }
    name
}

/// Polecenie do uruchomienia.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Plan {
    pub program: String,
    pub args: Vec<OsString>,
    pub cwd: Option<PathBuf>,
}

impl Plan {
    fn new(program: impl Into<String>, args: Vec<OsString>) -> Self {
        Self {
            program: program.into(),
            args,
            cwd: None,
        }
    }
}

/// Buduje polecenie pakowania `items` do `dest` (format z rozszerzenia `dest`).
pub fn compress_plan(items: &[PathBuf], dest: &Path) -> io::Result<Plan> {
    let Some(format) = format_of(dest) else {
        return Err(invalid(tr("Unsupported archive format")));
    };
    if items.is_empty() {
        return Err(invalid(tr("Nothing selected")));
    }
    let parent = items[0]
        .parent()
        .ok_or_else(|| invalid(tr("Invalid path")))?
        .to_path_buf();
    if items.iter().any(|p| p.parent() != Some(parent.as_path())) {
        return Err(invalid(tr("Selected items must share one folder")));
    }
    // `zip`/`tar`/`7z` zapisują ścieżki względne — podajemy same nazwy, a katalog
    // roboczy ustawiamy na wspólny rodzic. Dzięki temu archiwum nie zawiera
    // `/home/u/...`, tylko wybrane pliki.
    let names: Vec<OsString> = items
        .iter()
        .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
        .collect();
    if names.len() != items.len() {
        return Err(invalid(tr("Invalid path")));
    }
    let dest_name = if dest.parent() == Some(parent.as_path()) {
        dest.file_name()
            .map(|n| n.to_os_string())
            .ok_or_else(|| invalid(tr("Invalid path")))?
    } else {
        dest.as_os_str().to_os_string()
    };

    let mut args: Vec<OsString> = Vec::new();
    let program = match format {
        Format::Zip => {
            args.push(OsString::from("-r"));
            args.push(OsString::from("-y")); // symlinki bez podążania
            args.push(OsString::from("-q"));
            args.push(dest_name);
            args.extend(names);
            "zip"
        }
        Format::SevenZ => {
            args.push(OsString::from("a"));
            args.push(OsString::from("-bd"));
            args.push(OsString::from("-y"));
            args.push(dest_name);
            args.push(OsString::from("--"));
            args.extend(names);
            "7z"
        }
        Format::Tar | Format::TarGz | Format::TarXz | Format::TarBz2 => {
            let flag = match format {
                Format::Tar => "-cf",
                Format::TarGz => "-czf",
                Format::TarXz => "-cJf",
                Format::TarBz2 => "-cjf",
                _ => unreachable!(),
            };
            args.push(OsString::from(flag));
            args.push(dest_name);
            args.push(OsString::from("--"));
            args.extend(names);
            "tar"
        }
        // RAR tworzymy tylko przez własnościowe `rar`; nasze archiwa tworzymy
        // w formatach otwartych — RAR obsługujemy wyłącznie do wypakowania.
        Format::Rar => return Err(invalid(tr("Unsupported archive format"))),
    };
    let mut plan = Plan::new(program, args);
    plan.cwd = Some(parent);
    Ok(plan)
}

/// Buduje polecenie wypakowania `archive` do katalogu `dest`.
pub fn extract_plan(archive: &Path, dest: &Path) -> io::Result<Plan> {
    let Some(format) = format_of(archive) else {
        return Err(invalid(tr("Unsupported archive format")));
    };
    let mut plan = if on_path("bsdtar") {
        // libarchive: ZIP, TAR (gz/xz/bz2), 7z, RAR — jedna ścieżka dla wszystkiego.
        Plan::new(
            "bsdtar",
            vec![
                OsString::from("-xf"),
                archive.as_os_str().to_os_string(),
                OsString::from("-C"),
                dest.as_os_str().to_os_string(),
            ],
        )
    } else {
        match format {
            Format::Zip => Plan::new(
                "unzip",
                vec![
                    OsString::from("-q"),
                    OsString::from("-o"),
                    archive.as_os_str().to_os_string(),
                    OsString::from("-d"),
                    dest.as_os_str().to_os_string(),
                ],
            ),
            Format::SevenZ => Plan::new(
                "7z",
                vec![
                    OsString::from("x"),
                    OsString::from("-y"),
                    OsString::from("-bd"),
                    OsString::from(format!("-o{}", dest.display())),
                    archive.as_os_str().to_os_string(),
                ],
            ),
            Format::Rar => Plan::new(
                "unrar",
                vec![
                    OsString::from("x"),
                    OsString::from("-o+"),
                    archive.as_os_str().to_os_string(),
                    dest.as_os_str().to_os_string(),
                ],
            ),
            Format::Tar | Format::TarGz | Format::TarXz | Format::TarBz2 => Plan::new(
                "tar",
                vec![
                    OsString::from("-xf"),
                    archive.as_os_str().to_os_string(),
                    OsString::from("-C"),
                    dest.as_os_str().to_os_string(),
                ],
            ),
        }
    };
    // `unrar`/`7z` przyjmują katalog docelowy jako argument; tar/bsdtar przez `-C`.
    plan.cwd = None;
    Ok(plan)
}

/// Pakuje `items` do `dest`. Blokuje wątek operacji; `cancel` zatrzymuje proces.
pub fn compress(items: &[PathBuf], dest: &Path, cancel: &AtomicBool) -> io::Result<()> {
    let plan = compress_plan(items, dest)?;
    run(&plan, cancel)
}

/// Wypakowuje `archive` do `dest` (katalog tworzony, jeśli nie istnieje).
pub fn extract(archive: &Path, dest: &Path, cancel: &AtomicBool) -> io::Result<()> {
    std::fs::create_dir_all(dest)?;
    let plan = extract_plan(archive, dest)?;
    run(&plan, cancel)
}

/// Wolna nazwa w `dir`: `base.zip`, `base (2).zip`, …
pub fn unique_path(dir: &Path, base: &str, extension: &str) -> PathBuf {
    for n in 1.. {
        let name = if n == 1 {
            format!("{base}.{extension}")
        } else {
            format!("{base} ({n}).{extension}")
        };
        let candidate = dir.join(name);
        if !candidate.exists() {
            return candidate;
        }
    }
    unreachable!()
}

fn run(plan: &Plan, cancel: &AtomicBool) -> io::Result<()> {
    let mut cmd = Command::new(&plan.program);
    cmd.args(&plan.args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());
    if let Some(cwd) = &plan.cwd {
        cmd.current_dir(cwd);
    }
    let mut child = cmd.spawn().map_err(|e| {
        io::Error::new(
            e.kind(),
            format!("{}: {}", tr("Archive tool not found"), plan.program),
        )
    })?;
    loop {
        if cancel.load(Ordering::SeqCst) {
            let _ = child.kill();
            let _ = child.wait();
            return Err(io::Error::new(io::ErrorKind::Interrupted, tr("Cancelled")));
        }
        match child.try_wait()? {
            Some(status) => {
                let mut stderr = String::new();
                if let Some(mut pipe) = child.stderr.take() {
                    let _ = pipe.read_to_string(&mut stderr);
                }
                if status.success() {
                    return Ok(());
                }
                let message = stderr.trim();
                return Err(io::Error::other(if message.is_empty() {
                    format!("{}: {status}", plan.program)
                } else {
                    message.to_owned()
                }));
            }
            None => std::thread::sleep(Duration::from_millis(60)),
        }
    }
}

/// Czy program jest w `PATH` (żeby wybrać `bsdtar` zamiast `unzip`/`tar`).
fn on_path(program: &str) -> bool {
    let Some(path) = std::env::var_os("PATH") else {
        return false;
    };
    std::env::split_paths(&path).any(|dir| {
        let candidate = dir.join(program);
        candidate.is_file()
    })
}

fn invalid(message: impl Into<String>) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidInput, message.into())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn p(s: &str) -> PathBuf {
        PathBuf::from(s)
    }

    fn temp_dir(name: &str) -> PathBuf {
        let dir = std::env::temp_dir().join(format!("luna-files-archive-{name}"));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn zip_roundtrip_with_real_tool() {
        if !on_path("zip") || !on_path("bsdtar") {
            return; // środowisko bez narzędzi — reszta testów pokrywa plany
        }
        let dir = temp_dir("zip-roundtrip");
        let src = dir.join("hello.txt");
        std::fs::write(&src, b"luna").unwrap();
        let dest = dir.join("pack.zip");
        let cancel = AtomicBool::new(false);
        compress(std::slice::from_ref(&src), &dest, &cancel).unwrap();
        assert!(dest.is_file());
        std::fs::remove_file(&src).unwrap();
        let out = dir.join("out");
        extract(&dest, &out, &cancel).unwrap();
        assert_eq!(std::fs::read(out.join("hello.txt")).unwrap(), b"luna");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn tar_gz_roundtrip_with_real_tool() {
        if !on_path("tar") || !on_path("bsdtar") {
            return;
        }
        let dir = temp_dir("tar-roundtrip");
        let src = dir.join("nested.txt");
        std::fs::write(&src, b"selene").unwrap();
        let dest = dir.join("pack.tar.gz");
        let cancel = AtomicBool::new(false);
        compress(std::slice::from_ref(&src), &dest, &cancel).unwrap();
        std::fs::remove_file(&src).unwrap();
        let out = dir.join("out");
        extract(&dest, &out, &cancel).unwrap();
        assert_eq!(std::fs::read(out.join("nested.txt")).unwrap(), b"selene");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn detects_archive_formats_case_insensitively() {
        assert_eq!(format_of(&p("a.zip")), Some(Format::Zip));
        assert_eq!(format_of(&p("A.TAR.GZ")), Some(Format::TarGz));
        assert_eq!(format_of(&p("x.tgz")), Some(Format::TarGz));
        assert_eq!(format_of(&p("x.tar.xz")), Some(Format::TarXz));
        assert_eq!(format_of(&p("x.tbz2")), Some(Format::TarBz2));
        assert_eq!(format_of(&p("x.7z")), Some(Format::SevenZ));
        assert_eq!(format_of(&p("x.rar")), Some(Format::Rar));
        assert_eq!(format_of(&p("x.txt")), None);
    }

    #[test]
    fn archive_stem_strips_double_extension() {
        assert_eq!(archive_stem(&p("projekt.tar.gz")), "projekt");
        assert_eq!(archive_stem(&p("projekt.ZIP")), "projekt");
        assert_eq!(archive_stem(&p("bez-rozszerzenia")), "bez-rozszerzenia");
    }

    #[test]
    fn zip_plan_uses_relative_names_and_parent_cwd() {
        let items = vec![p("/home/u/dir/a.txt"), p("/home/u/dir/b")];
        let plan = compress_plan(&items, &p("/home/u/dir/pack.zip")).unwrap();
        assert_eq!(plan.program, "zip");
        assert_eq!(plan.cwd.as_deref(), Some(Path::new("/home/u/dir")));
        let args: Vec<String> = plan
            .args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(args.contains(&"pack.zip".to_owned()));
        assert!(args.contains(&"a.txt".to_owned()));
        assert!(args.contains(&"b".to_owned()));
        assert!(!args.iter().any(|a| a.starts_with("/home/u")));
    }

    #[test]
    fn tar_plan_picks_compression_flag() {
        let items = vec![p("/tmp/x")];
        let plan = compress_plan(&items, &p("/tmp/x.tar.xz")).unwrap();
        assert_eq!(plan.program, "tar");
        assert!(plan.args[0].to_string_lossy().contains("J"));
    }

    #[test]
    fn extract_plan_contains_archive_and_destination() {
        let plan = extract_plan(&p("/tmp/a.zip"), &p("/tmp/out")).unwrap();
        let args: Vec<String> = plan
            .args
            .iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect();
        assert!(args.iter().any(|a| a == "/tmp/a.zip"));
        assert!(args.iter().any(|a| a == "/tmp/out"));
    }

    #[test]
    fn rejects_items_from_different_folders() {
        let items = vec![p("/tmp/a/one"), p("/tmp/b/two")];
        assert!(compress_plan(&items, &p("/tmp/a/x.zip")).is_err());
    }

    #[test]
    fn unique_path_numbers_collisions() {
        let dir = std::env::temp_dir().join("luna-files-archive-test");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("Paczka.zip"), b"x").unwrap();
        let next = unique_path(&dir, "Paczka", "zip");
        assert_eq!(next.file_name().unwrap(), "Paczka (2).zip");
        let _ = std::fs::remove_dir_all(&dir);
    }
}