use async_trait::async_trait;
use super::{Builtin, Context, resolve_path};
use crate::error::Result;
use crate::interpreter::ExecResult;
const MAGIC: &[u8] = b"BKZIP\x01";
const FOOTER: &[u8] = b"BKEND";
pub struct Zip;
pub struct Unzip;
struct ZipOptions {
archive: String,
files: Vec<String>,
recursive: bool,
}
struct UnzipOptions {
archive: String,
list_only: bool,
extract_dir: Option<String>,
overwrite: bool,
}
fn parse_zip_args(args: &[String]) -> std::result::Result<ZipOptions, String> {
let mut recursive = false;
let mut positional = Vec::new();
for arg in args {
match arg.as_str() {
"-r" => recursive = true,
_ if !arg.starts_with('-') => positional.push(arg.clone()),
_ => {} }
}
if positional.is_empty() {
return Err("zip: missing archive name".to_string());
}
if positional.len() < 2 {
return Err("zip: missing files to add".to_string());
}
let archive = positional.remove(0);
Ok(ZipOptions {
archive,
files: positional,
recursive,
})
}
fn parse_unzip_args(args: &[String]) -> std::result::Result<UnzipOptions, String> {
let mut list_only = false;
let mut extract_dir = None;
let mut overwrite = false;
let mut positional = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if p.flag("-l") {
list_only = true;
} else if p.flag("-o") {
overwrite = true;
} else if let Some(val) = p.flag_value("-d", "unzip")? {
extract_dir = Some(val.to_string());
} else if let Some(arg) = p.positional() {
positional.push(arg.to_string());
} else {
p.advance(); }
}
if positional.is_empty() {
return Err("unzip: missing archive name".to_string());
}
Ok(UnzipOptions {
archive: positional.remove(0),
list_only,
extract_dir,
overwrite,
})
}
struct ArchiveEntry {
path: String,
data: Vec<u8>,
}
fn encode_archive(entries: &[ArchiveEntry]) -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(MAGIC);
for entry in entries {
let path_bytes = entry.path.as_bytes();
buf.extend_from_slice(&(path_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(path_bytes);
buf.extend_from_slice(&(entry.data.len() as u32).to_le_bytes());
buf.extend_from_slice(&entry.data);
}
buf.extend_from_slice(FOOTER);
buf
}
fn decode_archive(data: &[u8]) -> std::result::Result<Vec<ArchiveEntry>, String> {
if data.len() < MAGIC.len() + FOOTER.len() {
return Err("not a valid archive (too small)".to_string());
}
if &data[..MAGIC.len()] != MAGIC {
return Err("not a valid archive (bad magic)".to_string());
}
if &data[data.len() - FOOTER.len()..] != FOOTER {
return Err("not a valid archive (bad footer)".to_string());
}
let payload = &data[MAGIC.len()..data.len() - FOOTER.len()];
let mut entries = Vec::new();
let mut pos = 0;
while pos < payload.len() {
if pos + 4 > payload.len() {
return Err("truncated archive (path length)".to_string());
}
let path_len = u32::from_le_bytes(
payload[pos..pos + 4]
.try_into()
.map_err(|_| "bad path length bytes".to_string())?,
) as usize;
pos += 4;
if pos + path_len > payload.len() {
return Err("truncated archive (path data)".to_string());
}
let path = String::from_utf8(payload[pos..pos + path_len].to_vec())
.map_err(|_| "invalid UTF-8 in path".to_string())?;
pos += path_len;
if pos + 4 > payload.len() {
return Err("truncated archive (data length)".to_string());
}
let data_len = u32::from_le_bytes(
payload[pos..pos + 4]
.try_into()
.map_err(|_| "bad data length bytes".to_string())?,
) as usize;
pos += 4;
if pos + data_len > payload.len() {
return Err("truncated archive (file data)".to_string());
}
let file_data = payload[pos..pos + data_len].to_vec();
pos += data_len;
entries.push(ArchiveEntry {
path,
data: file_data,
});
}
Ok(entries)
}
async fn collect_files_recursive(
fs: &std::sync::Arc<dyn crate::fs::FileSystem>,
dir: &std::path::Path,
prefix: &str,
) -> Vec<(String, Vec<u8>)> {
let mut result = Vec::new();
let mut dirs = vec![(dir.to_path_buf(), prefix.to_string())];
while let Some((current, current_prefix)) = dirs.pop() {
if let Ok(entries) = fs.read_dir(¤t).await {
for entry in entries {
let path = current.join(&entry.name);
let entry_prefix = if current_prefix.is_empty() {
entry.name.clone()
} else {
format!("{}/{}", current_prefix, entry.name)
};
if entry.metadata.file_type.is_dir() {
dirs.push((path, entry_prefix));
} else if entry.metadata.file_type.is_file()
&& let Ok(data) = fs.read_file(&path).await
{
result.push((entry_prefix, data));
}
}
}
}
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
#[async_trait]
impl Builtin for Zip {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let opts = match parse_zip_args(ctx.args) {
Ok(o) => o,
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)),
};
let mut entries = Vec::new();
let mut output = String::new();
for file_arg in &opts.files {
let path = resolve_path(ctx.cwd, file_arg);
if let Ok(meta) = ctx.fs.stat(&path).await
&& meta.file_type.is_dir()
{
if !opts.recursive {
return Ok(ExecResult::err(
format!("zip: {}: is a directory (use -r for recursive)\n", file_arg),
1,
));
}
let dir_files = collect_files_recursive(&ctx.fs, &path, file_arg).await;
for (rel_path, data) in dir_files {
output.push_str(&format!(" adding: {}\n", rel_path));
entries.push(ArchiveEntry {
path: rel_path,
data,
});
}
continue;
}
match ctx.fs.read_file(&path).await {
Ok(data) => {
output.push_str(&format!(" adding: {}\n", file_arg));
entries.push(ArchiveEntry {
path: file_arg.clone(),
data,
});
}
Err(e) => {
return Ok(ExecResult::err(format!("zip: {}: {}\n", file_arg, e), 1));
}
}
}
let archive_data = encode_archive(&entries);
let archive_path = resolve_path(ctx.cwd, &opts.archive);
ctx.fs.write_file(&archive_path, &archive_data).await?;
Ok(ExecResult::ok(output))
}
}
#[async_trait]
impl Builtin for Unzip {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let opts = match parse_unzip_args(ctx.args) {
Ok(o) => o,
Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)),
};
let archive_path = resolve_path(ctx.cwd, &opts.archive);
let archive_data = match ctx.fs.read_file(&archive_path).await {
Ok(d) => d,
Err(e) => {
return Ok(ExecResult::err(
format!("unzip: {}: {}\n", opts.archive, e),
1,
));
}
};
let entries = match decode_archive(&archive_data) {
Ok(e) => e,
Err(e) => {
return Ok(ExecResult::err(
format!("unzip: {}: {}\n", opts.archive, e),
1,
));
}
};
let mut output = String::new();
if opts.list_only {
output.push_str(" Length Name\n");
output.push_str("--------- ----------\n");
let mut total_size = 0usize;
for entry in &entries {
output.push_str(&format!("{:>9} {}\n", entry.data.len(), entry.path));
total_size += entry.data.len();
}
output.push_str("--------- ----------\n");
output.push_str(&format!("{:>9} {} file(s)\n", total_size, entries.len()));
return Ok(ExecResult::ok(output));
}
let extract_base = if let Some(ref dir) = opts.extract_dir {
let dir_path = resolve_path(ctx.cwd, dir);
ctx.fs.mkdir(&dir_path, true).await?;
dir_path
} else {
ctx.cwd.clone()
};
for entry in &entries {
let entry_path = entry.path.strip_prefix('/').unwrap_or(&entry.path);
let target = extract_base.join(entry_path);
if !opts.overwrite
&& let Ok(true) = ctx.fs.exists(&target).await
{
output.push_str(&format!(
"skipping: {} (already exists, use -o to overwrite)\n",
entry.path
));
continue;
}
if let Some(parent) = target.parent()
&& parent != std::path::Path::new("/")
&& parent != std::path::Path::new("")
{
ctx.fs.mkdir(parent, true).await?;
}
ctx.fs.write_file(&target, &entry.data).await?;
output.push_str(&format!(" inflating: {}\n", entry.path));
}
Ok(ExecResult::ok(output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFs};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
async fn run_zip(args: &[&str], files: &[(&str, &[u8])]) -> (ExecResult, Arc<InMemoryFs>) {
let fs = Arc::new(InMemoryFs::new());
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
for (path, content) in files {
let p = Path::new(path);
if let Some(parent) = p.parent()
&& parent != Path::new("/")
{
let _ = fs_trait.mkdir(parent, true).await;
}
fs_trait.write_file(p, content).await.unwrap();
}
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs_trait,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Zip.execute(ctx).await.unwrap();
(result, fs)
}
async fn run_unzip(args: &[&str], fs: Arc<InMemoryFs>) -> ExecResult {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs_dyn = fs as Arc<dyn FileSystem>;
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs_dyn,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Unzip.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_zip_and_unzip_basic() {
let (result, fs) = run_zip(
&["/archive.zip", "/a.txt", "/b.txt"],
&[("/a.txt", b"hello"), ("/b.txt", b"world")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("adding: /a.txt"));
let result = run_unzip(&["-d", "/out", "/archive.zip"], fs).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("inflating"));
}
#[tokio::test]
async fn test_zip_missing_archive() {
let fs = Arc::new(InMemoryFs::new());
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
let args: Vec<String> = vec![];
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs_trait,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Zip.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing archive"));
}
#[tokio::test]
async fn test_zip_missing_files_arg() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let args = vec!["archive.zip".to_string()];
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Zip.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing files"));
}
#[tokio::test]
async fn test_zip_file_not_found() {
let (result, _fs) = run_zip(&["/archive.zip", "/nonexistent.txt"], &[]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("/nonexistent.txt"));
}
#[tokio::test]
async fn test_zip_directory_without_recursive() {
let (result, _fs) =
run_zip(&["/archive.zip", "/dir"], &[("/dir/file.txt", b"content")]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("is a directory"));
}
#[tokio::test]
async fn test_zip_recursive_directory() {
let (result, fs) = run_zip(
&["-r", "/archive.zip", "/dir"],
&[("/dir/a.txt", b"aaa"), ("/dir/sub/b.txt", b"bbb")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("adding:"));
let fs_trait = fs as Arc<dyn FileSystem>;
assert!(fs_trait.exists(Path::new("/archive.zip")).await.unwrap());
}
#[tokio::test]
async fn test_unzip_list() {
let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"hello world")]).await;
let result = run_unzip(&["-l", "/archive.zip"], fs).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("/a.txt"));
assert!(result.stdout.contains("11")); assert!(result.stdout.contains("1 file(s)"));
}
#[tokio::test]
async fn test_unzip_extract_dir() {
let (_, fs) = run_zip(
&["/archive.zip", "/a.txt", "/b.txt"],
&[("/a.txt", b"aaa"), ("/b.txt", b"bbb")],
)
.await;
let result = run_unzip(&["-d", "/extracted", "/archive.zip"], fs.clone()).await;
assert_eq!(result.exit_code, 0);
let fs_trait = fs as Arc<dyn FileSystem>;
let content = fs_trait
.read_file(Path::new("/extracted/a.txt"))
.await
.unwrap();
assert_eq!(&content, b"aaa");
}
#[tokio::test]
async fn test_unzip_skip_existing() {
let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"archived")]).await;
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
fs_trait
.write_file(Path::new("/a.txt"), b"existing")
.await
.unwrap();
let result = run_unzip(&["/archive.zip"], fs.clone()).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("skipping"));
let content = fs_trait.read_file(Path::new("/a.txt")).await.unwrap();
assert_eq!(&content, b"existing");
}
#[tokio::test]
async fn test_unzip_overwrite() {
let (_, fs) = run_zip(&["/archive.zip", "/a.txt"], &[("/a.txt", b"archived")]).await;
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
fs_trait
.write_file(Path::new("/a.txt"), b"existing")
.await
.unwrap();
let result = run_unzip(&["-o", "/archive.zip"], fs.clone()).await;
assert_eq!(result.exit_code, 0);
let content = fs_trait.read_file(Path::new("/a.txt")).await.unwrap();
assert_eq!(&content, b"archived");
}
#[tokio::test]
async fn test_unzip_missing_archive() {
let fs = Arc::new(InMemoryFs::new());
let result = run_unzip(&["/nonexistent.zip"], fs).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("unzip:"));
}
#[tokio::test]
async fn test_unzip_invalid_archive() {
let fs = Arc::new(InMemoryFs::new());
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
fs_trait
.write_file(Path::new("/bad.zip"), b"not a zip file")
.await
.unwrap();
let result = run_unzip(&["/bad.zip"], fs).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("not a valid archive"));
}
#[tokio::test]
async fn test_encode_decode_roundtrip() {
let entries = vec![
ArchiveEntry {
path: "hello.txt".to_string(),
data: b"hello world".to_vec(),
},
ArchiveEntry {
path: "dir/nested.txt".to_string(),
data: b"nested content".to_vec(),
},
];
let encoded = encode_archive(&entries);
let decoded = decode_archive(&encoded).unwrap();
assert_eq!(decoded.len(), 2);
assert_eq!(decoded[0].path, "hello.txt");
assert_eq!(decoded[0].data, b"hello world");
assert_eq!(decoded[1].path, "dir/nested.txt");
assert_eq!(decoded[1].data, b"nested content");
}
#[tokio::test]
async fn test_decode_empty_data() {
let result = decode_archive(b"");
assert!(result.is_err());
}
#[tokio::test]
async fn test_unzip_no_args() {
let fs = Arc::new(InMemoryFs::new());
let fs_trait = fs.clone() as Arc<dyn FileSystem>;
let args: Vec<String> = vec![];
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs_trait,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Unzip.execute(ctx).await.unwrap();
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("missing archive"));
}
}