use std::cell::Cell;
use std::fs::File;
use std::io::{Read, Seek};
use std::path::Path;
use std::rc::Rc;
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;
const MAX_TAR_GZ_READ_BYTES: u64 = 64 * 1024 * 1024;
#[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_seek(f)?
}
ArchiveKind::TarGz => {
let f = File::open(path).with_context(|| format!("open: {}", path.display()))?;
list_tar_gz(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 entry_from_tar<R: Read>(entry: &tar::Entry<'_, R>) -> Entry {
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();
Entry {
name,
size,
is_dir,
modified,
}
}
fn list_tar_seek<R: Read + Seek>(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_with_seek().context("read tar entries")? {
let entry = entry.context("read tar entry")?;
if out.len() >= MAX_ENTRIES {
truncated = true;
break;
}
out.push(entry_from_tar(&entry));
}
Ok((out, truncated))
}
struct CountingRead<R> {
inner: R,
total: Rc<Cell<u64>>,
}
impl<R: Read> Read for CountingRead<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.total.set(self.total.get() + n as u64);
Ok(n)
}
}
fn list_tar_gz<R: Read>(reader: R) -> Result<(Vec<Entry>, bool)> {
let total = Rc::new(Cell::new(0u64));
let counted = CountingRead {
inner: reader,
total: Rc::clone(&total),
};
let mut archive = tar::Archive::new(counted);
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 || total.get() >= MAX_TAR_GZ_READ_BYTES {
truncated = true;
break;
}
out.push(entry_from_tar(&entry));
}
Ok((out, truncated))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::unique_tmp;
use std::io::{Cursor, SeekFrom, Write};
struct CountingReadSeek<R> {
inner: R,
reads: usize,
}
impl<R: Read> Read for CountingReadSeek<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.reads += 1;
self.inner.read(buf)
}
}
impl<R: Seek> Seek for CountingReadSeek<R> {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
#[test]
fn seeking_past_a_tar_entrys_body_never_reads_it() {
let big = vec![0x5Au8; 5 * 1024 * 1024];
let mut bytes = Vec::new();
{
let mut b = tar::Builder::new(&mut bytes);
let mut header = tar::Header::new_gnu();
header.set_size(big.len() as u64);
header.set_mode(0o644);
header.set_cksum();
b.append_data(&mut header, "big.bin", big.as_slice())
.unwrap();
let mut header2 = tar::Header::new_gnu();
header2.set_size(3);
header2.set_mode(0o644);
header2.set_cksum();
b.append_data(&mut header2, "small.bin", &b"abc"[..])
.unwrap();
b.finish().unwrap();
}
let mut seek_reader = CountingReadSeek {
inner: Cursor::new(bytes.clone()),
reads: 0,
};
let names: Vec<String> = tar::Archive::new(&mut seek_reader)
.entries_with_seek()
.unwrap()
.map(|e| String::from_utf8_lossy(&e.unwrap().path_bytes()).into_owned())
.collect();
assert_eq!(
names,
vec!["big.bin", "small.bin"],
"seek 経路でも一覧の内容は(順序含め)変わらない"
);
assert!(
seek_reader.reads < 20,
"entries_with_seek がヘッダ以外に大量の read を発行している(skip が seek でなく read になっている疑い): reads={}",
seek_reader.reads
);
let mut buffered_reader = CountingReadSeek {
inner: Cursor::new(bytes),
reads: 0,
};
let names2: Vec<String> = tar::Archive::new(&mut buffered_reader)
.entries()
.unwrap()
.map(|e| String::from_utf8_lossy(&e.unwrap().path_bytes()).into_owned())
.collect();
assert_eq!(
names2,
vec!["big.bin", "small.bin"],
"非 seek 経路でも一覧の内容は(順序含め)変わらない(対照実験)"
);
assert!(
buffered_reader.reads > 100,
"非 seek 経路は 5MB のボディを 32KB ずつ読み飛ばすので read が100回を超えるはず\
(対照になっていない=このテストが何も証明していない): reads={}",
buffered_reader.reads
);
}
fn tmp_dir(name: &str) -> std::path::PathBuf {
let dir = unique_tmp(&format!("konoma_archive_test_{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn sample_path_or_skip(name: &str) -> Option<std::path::PathBuf> {
let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("samples")
.join(name);
if p.exists() {
Some(p)
} else {
eprintln!(
"SKIP: samples/{name} not found (excluded from the published crate) — this test verifies nothing this run"
);
None
}
}
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 targz_read_is_bounded_by_bytes_not_just_entry_count() {
let dir = tmp_dir("targz_byte_cap");
let p = dir.join("big.tar.gz");
let filler = vec![0x5Au8; 5 * 1024 * 1024]; let entries: Vec<(&str, &[u8])> = (0..40)
.map(|i| -> (&str, &[u8]) {
let _ = i;
("f", filler.as_slice())
})
.collect();
write_tar_gz(&p, &entries);
let t = list(&p, ArchiveKind::TarGz).unwrap();
assert!(
t.truncated,
"200MB 分の tar.gz を全部読み切ってしまっている(バイト上限が効いていない)"
);
assert!(
t.rows.len() < entries.len(),
"40件全部が一覧化されている(バイト上限が効いていない): rows={}",
t.rows.len()
);
assert!(!t.rows.is_empty(), "上限が厳しすぎて1件も読めていない");
}
#[test]
fn lists_the_bundled_sample_zip() {
let Some(p) = sample_path_or_skip("sample.zip") else {
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 Some(p) = sample_path_or_skip("sample.tar.gz") else {
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"));
}
}