use std::path::{Path, PathBuf};
use crate as cindy;
use crate::Context;
use super::Format;
#[derive(Clone, Default, PartialEq, Eq)]
#[crate::wire]
pub struct State {
pub src: PathBuf,
pub dest: PathBuf,
pub format: Option<Format>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum EntryStatus {
Added,
Changed,
Unchanged,
}
struct Entry {
path: PathBuf,
is_dir: bool,
size: u64,
}
impl Entry {
fn status(&self, dest: &Path) -> EntryStatus {
let target = dest.join(&self.path);
match std::fs::symlink_metadata(&target) {
Err(_) => EntryStatus::Added,
Ok(_) if self.is_dir => EntryStatus::Unchanged,
Ok(meta) if meta.is_file() && meta.len() == self.size => EntryStatus::Unchanged,
Ok(_) => EntryStatus::Changed,
}
}
}
const SUMMARY_LIST_CAP: usize = 20;
fn render_summary(entries: &[Entry], dest: &Path) {
let mut added = 0usize;
let mut changed = 0usize;
let mut unchanged = 0usize;
let mut listed = 0usize;
for entry in entries {
let (mark, count) = match entry.status(dest) {
EntryStatus::Added => ('+', &mut added),
EntryStatus::Changed => ('~', &mut changed),
EntryStatus::Unchanged => {
unchanged += 1;
continue;
}
};
*count += 1;
if listed < SUMMARY_LIST_CAP {
eprintln!(" {mark} {}", entry.path.display());
listed += 1;
}
}
let extra = added + changed - listed;
if extra > 0 {
eprintln!(" … and {extra} more");
}
eprintln!(" {added} added, {changed} changed, {unchanged} unchanged");
}
fn list_tar(tar_bytes: &[u8]) -> crate::Result<Vec<Entry>> {
let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes));
let mut out = Vec::new();
for entry in archive.entries().context("Couldn't read tar entries")? {
let entry = entry.context("Couldn't read a tar entry")?;
let path = entry.path().context("tar entry has no path")?.into_owned();
let is_dir = entry.header().entry_type().is_dir();
out.push(Entry {
path,
is_dir,
size: entry.size(),
});
}
Ok(out)
}
fn list_zip(src: &Path) -> crate::Result<Vec<Entry>> {
let file = std::fs::File::open(src).context(format!("Couldn't open {}", src.display()))?;
let mut archive =
zip::ZipArchive::new(file).context(format!("Couldn't open zip {}", src.display()))?;
let mut out = Vec::with_capacity(archive.len());
for i in 0..archive.len() {
let f = archive.by_index(i).context("Couldn't read a zip entry")?;
out.push(Entry {
path: PathBuf::from(f.name()),
is_dir: f.is_dir(),
size: f.size(),
});
}
Ok(out)
}
fn list_7z(src: &Path) -> crate::Result<Vec<Entry>> {
let archive = sevenz_rust2::Archive::open(src)
.map_err(|e| anyhow_serde::Error::msg(format!("Couldn't read 7z header: {e}")))?;
Ok(archive
.files
.iter()
.map(|f| Entry {
path: PathBuf::from(f.name()),
is_dir: f.is_directory(),
size: f.size(),
})
.collect())
}
fn extract_tar(tar_bytes: &[u8], dest: &Path) -> crate::Result<()> {
let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes));
archive
.unpack(dest)
.context(format!("Couldn't unpack tar into {}", dest.display()))
}
fn extract_zip(src: &Path, dest: &Path) -> crate::Result<()> {
let file = std::fs::File::open(src).context(format!("Couldn't open {}", src.display()))?;
let mut archive =
zip::ZipArchive::new(file).context(format!("Couldn't open zip {}", src.display()))?;
archive
.extract(dest)
.context(format!("Couldn't extract zip into {}", dest.display()))
}
fn extract_7z(src: &Path, dest: &Path) -> crate::Result<()> {
sevenz_rust2::decompress_file(src, dest).context(format!(
"Couldn't extract 7z {} into {}",
src.display(),
dest.display()
))
}
#[crate::remote]
pub fn unarchive(state: State) -> crate::Result<super::Return> {
let format = match state.format {
Some(f) => f,
None => Format::from_path(&state.src)?,
};
let (user, group) = super::current_owner_names();
super::path::directory_raw::inner(state.dest.clone(), user, group, 0o755.into())?;
eprintln!(
"unarchive {} -> {} ({:?})",
state.src.display(),
state.dest.display(),
format,
);
match format {
Format::Tar(codec) => {
let raw = std::fs::read(&state.src)
.context(format!("Couldn't read {}", state.src.display()))?;
let tar_bytes =
super::decompress::decompress::inner(raw, codec.unwrap_or(super::Codec::Store))?;
render_summary(&list_tar(&tar_bytes)?, &state.dest);
extract_tar(&tar_bytes, &state.dest)?;
}
Format::Zip => {
render_summary(&list_zip(&state.src)?, &state.dest);
extract_zip(&state.src, &state.dest)?;
}
Format::SevenZ => {
render_summary(&list_7z(&state.src)?, &state.dest);
extract_7z(&state.src, &state.dest)?;
}
}
Ok(super::Return::Changed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::builtin::{Format, archive};
fn round_trip(format: Format, ext: &str) {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("payload");
std::fs::create_dir_all(src.join("nested")).unwrap();
std::fs::write(src.join("a.txt"), b"hello world").unwrap();
std::fs::write(src.join("nested/b.bin"), [0u8, 1, 2, 3, 255]).unwrap();
let archive_path = tmp.path().join(format!("out{ext}"));
let changed = archive::archive::inner(archive::State {
sources: vec![src.clone()],
dest: archive_path.clone(),
format: Some(format),
mode: Some(0o644.into()),
})
.unwrap_or_else(|e| panic!("archive {format:?} failed: {e}"));
assert!(changed.changed(), "archive should report Changed");
assert!(archive_path.exists(), "archive file should exist");
let out = tmp.path().join("extracted");
unarchive::inner(State {
src: archive_path,
dest: out.clone(),
format: Some(format),
})
.unwrap_or_else(|e| panic!("unarchive {format:?} failed: {e}"));
let a = std::fs::read(out.join("payload/a.txt"))
.unwrap_or_else(|e| panic!("missing a.txt for {format:?}: {e}"));
assert_eq!(a, b"hello world", "a.txt content mismatch for {format:?}");
let b = std::fs::read(out.join("payload/nested/b.bin"))
.unwrap_or_else(|e| panic!("missing b.bin for {format:?}: {e}"));
assert_eq!(
b,
[0u8, 1, 2, 3, 255],
"b.bin content mismatch for {format:?}"
);
}
#[test]
fn round_trip_tar() {
round_trip(Format::Tar(None), ".tar");
}
#[test]
fn round_trip_tar_gz() {
round_trip(Format::Tar(Some(crate::builtin::Codec::Gzip)), ".tar.gz");
}
#[test]
fn round_trip_tar_xz() {
round_trip(Format::Tar(Some(crate::builtin::Codec::Xz)), ".tar.xz");
}
#[test]
fn round_trip_tar_zst() {
round_trip(Format::Tar(Some(crate::builtin::Codec::Zstd)), ".tar.zst");
}
#[test]
fn round_trip_zip() {
round_trip(Format::Zip, ".zip");
}
#[test]
fn round_trip_7z() {
round_trip(Format::SevenZ, ".7z");
}
#[test]
fn entry_status_classification() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path();
std::fs::write(dest.join("same.txt"), b"1234").unwrap();
std::fs::write(dest.join("resized.txt"), b"12").unwrap();
std::fs::create_dir(dest.join("adir")).unwrap();
let added = Entry {
path: "new.txt".into(),
is_dir: false,
size: 9,
};
assert!(matches!(added.status(dest), EntryStatus::Added));
let same = Entry {
path: "same.txt".into(),
is_dir: false,
size: 4,
};
assert!(matches!(same.status(dest), EntryStatus::Unchanged));
let resized = Entry {
path: "resized.txt".into(),
is_dir: false,
size: 99,
};
assert!(matches!(resized.status(dest), EntryStatus::Changed));
let adir = Entry {
path: "adir".into(),
is_dir: true,
size: 0,
};
assert!(matches!(adir.status(dest), EntryStatus::Unchanged));
}
#[test]
fn format_inference() {
use crate::builtin::Codec;
let cases = [
("x.tar", Format::Tar(None)),
("x.tar.gz", Format::Tar(Some(Codec::Gzip))),
("x.tgz", Format::Tar(Some(Codec::Gzip))),
("x.tar.xz", Format::Tar(Some(Codec::Xz))),
("x.tar.zst", Format::Tar(Some(Codec::Zstd))),
("x.zip", Format::Zip),
("x.7z", Format::SevenZ),
];
for (name, want) in cases {
assert_eq!(Format::from_path(std::path::Path::new(name)).unwrap(), want);
}
assert!(Format::from_path(std::path::Path::new("x.rar")).is_err());
}
}