🔒 Repository is read-only – file editing is disabled.
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);
}
}