use std::fs::File;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use crate::fileops::{format_epoch_utc, human_size};
use crate::preview::table::TableData;
pub const MAX_ENTRIES: usize = 100_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveKind {
Zip,
Tar,
TarGz,
}
impl ArchiveKind {
pub fn from_path(path: &Path) -> Option<Self> {
let name = path.file_name()?.to_str()?.to_ascii_lowercase();
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
Some(ArchiveKind::TarGz)
} else if name.ends_with(".tar") {
Some(ArchiveKind::Tar)
} else if name.ends_with(".zip") {
Some(ArchiveKind::Zip)
} else {
None
}
}
}
struct Entry {
name: String,
size: u64,
is_dir: bool,
modified: String,
}
pub fn list(path: &Path, kind: ArchiveKind) -> Result<TableData> {
let len = std::fs::metadata(path)
.with_context(|| format!("stat: {}", path.display()))?
.len();
if len == 0 {
bail!("empty file: {}", path.display());
}
let (entries, truncated) = match kind {
ArchiveKind::Zip => list_zip(path)?,
ArchiveKind::Tar => {
let f = File::open(path).with_context(|| format!("open: {}", path.display()))?;
list_tar(f)?
}
ArchiveKind::TarGz => {
let f = File::open(path).with_context(|| format!("open: {}", path.display()))?;
list_tar(flate2::read::GzDecoder::new(f))?
}
};
let rows: Vec<Vec<String>> = entries
.into_iter()
.map(|e| {
vec![
e.name,
if e.is_dir {
"--".to_string()
} else {
human_size(e.size)
},
e.modified,
]
})
.collect();
Ok(TableData {
headers: vec!["Name".into(), "Size".into(), "Modified".into()],
rows,
ncols: 3,
truncated,
})
}
fn list_zip(path: &Path) -> Result<(Vec<Entry>, bool)> {
let f = File::open(path).with_context(|| format!("open: {}", path.display()))?;
let mut archive = zip::ZipArchive::new(f).context("parse zip central directory")?;
let total = archive.len();
let n = total.min(MAX_ENTRIES);
let mut out = Vec::with_capacity(n);
for i in 0..n {
let file = archive
.by_index_raw(i)
.with_context(|| format!("zip entry {i}"))?;
out.push(Entry {
name: file.name().to_string(),
size: file.size(),
is_dir: file.is_dir(),
modified: file
.last_modified()
.map(|dt| dt.to_string())
.unwrap_or_default(),
});
}
Ok((out, total > MAX_ENTRIES))
}
fn list_tar<R: Read>(reader: R) -> Result<(Vec<Entry>, bool)> {
let mut archive = tar::Archive::new(reader);
let mut out = Vec::new();
let mut truncated = false;
for entry in archive.entries().context("read tar entries")? {
let entry = entry.context("read tar entry")?;
if out.len() >= MAX_ENTRIES {
truncated = true;
break;
}
let name = String::from_utf8_lossy(&entry.path_bytes()).into_owned();
let is_dir = entry.header().entry_type().is_dir();
let size = entry.header().size().unwrap_or(0);
let modified = entry
.header()
.mtime()
.ok()
.filter(|&t| t > 0)
.map(format_epoch_utc)
.unwrap_or_default();
out.push(Entry {
name,
size,
is_dir,
modified,
});
}
Ok((out, truncated))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp_dir(name: &str) -> std::path::PathBuf {
let dir =
std::env::temp_dir().join(format!("konoma_archive_test_{name}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_zip(path: &Path, entries: &[(&str, &[u8], bool)]) {
let f = File::create(path).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for (name, content, is_dir) in entries {
if *is_dir {
zw.add_directory(*name, opts).unwrap();
} else {
zw.start_file(*name, opts).unwrap();
zw.write_all(content).unwrap();
}
}
zw.finish().unwrap();
}
fn write_tar(path: &Path, entries: &[(&str, &[u8])]) {
let f = File::create(path).unwrap();
let mut b = tar::Builder::new(f);
for (name, content) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_mtime(1_700_000_000);
header.set_cksum();
b.append_data(&mut header, *name, *content).unwrap();
}
b.finish().unwrap();
}
fn write_tar_gz(path: &Path, entries: &[(&str, &[u8])]) {
let f = File::create(path).unwrap();
let gz = flate2::write::GzEncoder::new(f, flate2::Compression::default());
let mut b = tar::Builder::new(gz);
for (name, content) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_mtime(1_700_000_000);
header.set_cksum();
b.append_data(&mut header, *name, *content).unwrap();
}
b.into_inner().unwrap().finish().unwrap();
}
#[test]
fn from_path_recognizes_all_kinds_case_insensitively() {
assert_eq!(
ArchiveKind::from_path(Path::new("a.zip")),
Some(ArchiveKind::Zip)
);
assert_eq!(
ArchiveKind::from_path(Path::new("A.ZIP")),
Some(ArchiveKind::Zip)
);
assert_eq!(
ArchiveKind::from_path(Path::new("a.tar")),
Some(ArchiveKind::Tar)
);
assert_eq!(
ArchiveKind::from_path(Path::new("a.TAR")),
Some(ArchiveKind::Tar)
);
assert_eq!(
ArchiveKind::from_path(Path::new("a.tar.gz")),
Some(ArchiveKind::TarGz)
);
assert_eq!(
ArchiveKind::from_path(Path::new("a.TGZ")),
Some(ArchiveKind::TarGz)
);
assert_eq!(ArchiveKind::from_path(Path::new("a.txt")), None);
assert_eq!(ArchiveKind::from_path(Path::new("Makefile")), None);
}
#[test]
fn lists_zip_entries_with_name_size_dir_and_order_preserved() {
let dir = tmp_dir("zip_basic");
let p = dir.join("t.zip");
write_zip(
&p,
&[
("hello.txt", b"hello world!", false),
("dir/", b"", true),
("dir/nested.txt", b"nested", false),
],
);
let t = list(&p, ArchiveKind::Zip).unwrap();
assert_eq!(t.headers, vec!["Name", "Size", "Modified"]);
assert_eq!(t.ncols, 3);
assert!(!t.truncated);
assert_eq!(t.rows.len(), 3);
assert_eq!(t.rows[0][0], "hello.txt");
assert_eq!(t.rows[0][1], "12 B");
assert_eq!(t.rows[1][0], "dir/");
assert_eq!(
t.rows[1][1], "--",
"ディレクトリのサイズは -- (tree 詳細列と同流儀)"
);
assert_eq!(t.rows[2][0], "dir/nested.txt");
assert_eq!(t.rows[2][1], "6 B");
assert!(!t.rows[0][2].is_empty());
}
#[test]
fn lists_tar_entries() {
let dir = tmp_dir("tar_basic");
let p = dir.join("t.tar");
write_tar(&p, &[("a.txt", b"hello"), ("b.txt", b"xy")]);
let t = list(&p, ArchiveKind::Tar).unwrap();
assert_eq!(t.rows.len(), 2);
assert_eq!(t.rows[0][0], "a.txt");
assert_eq!(t.rows[0][1], "5 B");
assert!(
t.rows[0][2].ends_with("UTC"),
"tar の mtime は真の UTC epoch"
);
assert_eq!(t.rows[1][0], "b.txt");
assert_eq!(t.rows[1][1], "2 B");
}
#[test]
fn lists_tar_gz_entries() {
let dir = tmp_dir("targz_basic");
let p = dir.join("t.tar.gz");
write_tar_gz(&p, &[("c.txt", b"z")]);
let t = list(&p, ArchiveKind::TarGz).unwrap();
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0][0], "c.txt");
assert_eq!(t.rows[0][1], "1 B");
}
#[test]
fn empty_file_is_err_for_all_three_formats() {
let dir = tmp_dir("empty");
for (name, kind) in [
("e.zip", ArchiveKind::Zip),
("e.tar", ArchiveKind::Tar),
("e.tar.gz", ArchiveKind::TarGz),
] {
let p = dir.join(name);
std::fs::write(&p, b"").unwrap();
assert!(
list(&p, kind).is_err(),
"空ファイル({name})は Err(→ ヒント降格)、panic しない"
);
}
}
#[test]
fn garbage_bytes_are_err_not_panic_for_all_three_formats() {
let dir = tmp_dir("garbage");
let junk = vec![0x41u8; 4096];
for (name, kind) in [
("g.zip", ArchiveKind::Zip),
("g.tar", ArchiveKind::Tar),
("g.tar.gz", ArchiveKind::TarGz),
] {
let p = dir.join(name);
std::fs::write(&p, &junk).unwrap();
assert!(list(&p, kind).is_err(), "{name}: 壊れたファイルは Err");
}
}
#[test]
fn truncated_real_zip_is_err_not_panic() {
let dir = tmp_dir("truncated");
let full = dir.join("full.zip");
write_zip(
&full,
&[("a.txt", b"hello world, this is some content", false)],
);
let bytes = std::fs::read(&full).unwrap();
let cut = dir.join("cut.zip");
std::fs::write(&cut, &bytes[..bytes.len() / 2]).unwrap();
assert!(list(&cut, ArchiveKind::Zip).is_err());
}
#[test]
fn nonexistent_file_is_err_not_panic() {
let p = Path::new("/no/such/archive.zip");
assert!(list(p, ArchiveKind::Zip).is_err());
assert!(list(Path::new("/no/such/archive.tar"), ArchiveKind::Tar).is_err());
assert!(list(Path::new("/no/such/archive.tar.gz"), ArchiveKind::TarGz).is_err());
}
#[test]
fn traversal_and_absolute_looking_names_are_display_strings_only() {
let dir = tmp_dir("evil");
let p = dir.join("evil.zip");
write_zip(
&p,
&[
("../../../tmp/konoma_zip_slip_probe", b"pwned?", false),
("/absolute/looking/evil", b"pwned2?", false),
],
);
let t = list(&p, ArchiveKind::Zip).unwrap();
assert!(t
.rows
.iter()
.any(|r| r[0] == "../../../tmp/konoma_zip_slip_probe"));
assert!(t.rows.iter().any(|r| r[0] == "/absolute/looking/evil"));
assert!(
!Path::new("/absolute/looking/evil").exists(),
"一覧化だけでファイルシステムへ書き込まれてはいけない"
);
}
#[test]
fn entry_count_is_capped_and_marks_truncated() {
let dir = tmp_dir("cap");
let p = dir.join("many.zip");
let names: Vec<String> = (0..(MAX_ENTRIES + 10)).map(|i| format!("f{i}")).collect();
let f = File::create(&p).unwrap();
let mut zw = zip::ZipWriter::new(f);
let opts = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
for n in &names {
zw.start_file(n, opts).unwrap();
}
zw.finish().unwrap();
let t = list(&p, ArchiveKind::Zip).unwrap();
assert_eq!(t.rows.len(), MAX_ENTRIES, "MAX_ENTRIES で打ち切る");
assert!(t.truncated, "打ち切ったら truncated=true");
}
#[test]
fn lists_the_bundled_sample_zip() {
let p = Path::new("samples/sample.zip");
if !p.exists() {
return;
}
let t = list(p, ArchiveKind::Zip).expect("bundled sample.zip should list cleanly");
assert!(!t.rows.is_empty());
assert!(t.rows.iter().any(|r| r[0].ends_with("hello.rs")));
}
#[test]
fn lists_the_bundled_sample_tar_gz() {
let p = Path::new("samples/sample.tar.gz");
if !p.exists() {
return;
}
let t = list(p, ArchiveKind::TarGz).expect("bundled sample.tar.gz should list cleanly");
assert!(!t.rows.is_empty());
assert!(t.rows.iter().any(|r| r[0] == "sample.csv"));
}
}