222 lines
7.5 KiB
Rust
222 lines
7.5 KiB
Rust
//! Create, list, and extract portable tar archives through standard WASI files.
|
|
use flate2::{Compression, read::GzDecoder, write::GzEncoder};
|
|
use std::{
|
|
fs::File,
|
|
io::{self, Read, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
|
|
fn append<W: Write>(
|
|
builder: &mut tar::Builder<W>,
|
|
source: &Path,
|
|
name: &Path,
|
|
verbose: Option<bool>,
|
|
) -> io::Result<()> {
|
|
let meta = std::fs::symlink_metadata(source)?;
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_uid(0);
|
|
header.set_gid(0);
|
|
header.set_mtime(
|
|
meta.modified()?
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs(),
|
|
);
|
|
if let Some(stdout) = verbose {
|
|
let suffix = if meta.is_dir() { "/" } else { "" };
|
|
if stdout {
|
|
println!("{}{suffix}", name.display());
|
|
} else {
|
|
eprintln!("{}{suffix}", name.display());
|
|
}
|
|
}
|
|
if meta.is_dir() {
|
|
header.set_entry_type(tar::EntryType::Directory);
|
|
header.set_size(0);
|
|
header.set_mode(0o755);
|
|
header.set_cksum();
|
|
builder.append_data(&mut header, name, io::empty())?;
|
|
let mut entries = std::fs::read_dir(source)?.collect::<io::Result<Vec<_>>>()?;
|
|
entries.sort_by_key(|e| e.file_name());
|
|
for entry in entries {
|
|
append(
|
|
builder,
|
|
&entry.path(),
|
|
&name.join(entry.file_name()),
|
|
verbose,
|
|
)?;
|
|
}
|
|
} else if meta.is_file() {
|
|
header.set_size(meta.len());
|
|
header.set_mode(0o644);
|
|
header.set_cksum();
|
|
builder.append_data(&mut header, name, File::open(source)?)?;
|
|
} else if meta.file_type().is_symlink() {
|
|
header.set_entry_type(tar::EntryType::Symlink);
|
|
header.set_size(0);
|
|
header.set_mode(0o777);
|
|
header.set_cksum();
|
|
builder.append_link(&mut header, name, std::fs::read_link(source)?)?;
|
|
} else {
|
|
return Err(io::Error::other("unsupported special file"));
|
|
}
|
|
Ok(())
|
|
}
|
|
fn create<W: Write>(
|
|
writer: W,
|
|
cwd: &Path,
|
|
paths: &[String],
|
|
verbose: Option<bool>,
|
|
) -> io::Result<W> {
|
|
let mut builder = tar::Builder::new(writer);
|
|
for path in paths {
|
|
let name = Path::new(path).strip_prefix("/").unwrap_or(Path::new(path));
|
|
append(&mut builder, &cwd.join(path), name, verbose)?;
|
|
}
|
|
builder.finish()?;
|
|
builder.into_inner()
|
|
}
|
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut args = std::env::args().skip(1);
|
|
let mut mode = None;
|
|
let mut compressed = false;
|
|
let mut verbose = false;
|
|
let mut archive = None;
|
|
let mut cwd = PathBuf::from(".");
|
|
let mut strip = 0usize;
|
|
let mut paths = Vec::new();
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--" {
|
|
paths.extend(args);
|
|
break;
|
|
}
|
|
if arg == "--strip-components" {
|
|
strip = args.next().ok_or("missing strip count")?.parse()?;
|
|
continue;
|
|
}
|
|
if let Some(n) = arg.strip_prefix("--strip-components=") {
|
|
strip = n.parse()?;
|
|
continue;
|
|
}
|
|
if arg.starts_with('-') || (mode.is_none() && arg.chars().all(|c| "cxtzvf".contains(c))) {
|
|
let flags = arg.trim_start_matches('-');
|
|
let mut chars = flags.chars();
|
|
while let Some(flag) = chars.next() {
|
|
match flag {
|
|
'c' | 'x' | 't' => {
|
|
if mode.replace(flag).is_some() {
|
|
return Err("choose one archive operation".into());
|
|
}
|
|
}
|
|
'z' => compressed = true,
|
|
'v' => verbose = true,
|
|
'f' | 'C' => {
|
|
let tail = chars.as_str();
|
|
let value = if tail.is_empty() {
|
|
args.next().ok_or("missing option value")?
|
|
} else {
|
|
tail.to_owned()
|
|
};
|
|
if flag == 'f' {
|
|
archive = Some(value);
|
|
} else {
|
|
cwd = PathBuf::from(value);
|
|
}
|
|
break;
|
|
}
|
|
_ => return Err(format!("unsupported option: {flag}").into()),
|
|
}
|
|
}
|
|
} else {
|
|
paths.push(arg);
|
|
}
|
|
}
|
|
let archive = archive.ok_or("-f is required")?;
|
|
match mode.ok_or("an operation is required")? {
|
|
'c' => {
|
|
if paths.is_empty() {
|
|
return Err("refusing to create an empty archive".into());
|
|
}
|
|
let verbose = verbose.then_some(archive != "-");
|
|
let writer: Box<dyn Write> = if archive == "-" {
|
|
Box::new(io::stdout())
|
|
} else {
|
|
Box::new(File::create(archive)?)
|
|
};
|
|
if compressed {
|
|
create(
|
|
GzEncoder::new(writer, Compression::default()),
|
|
&cwd,
|
|
&paths,
|
|
verbose,
|
|
)?
|
|
.finish()?
|
|
.flush()?;
|
|
} else {
|
|
create(writer, &cwd, &paths, verbose)?.flush()?;
|
|
}
|
|
}
|
|
operation => {
|
|
let reader: Box<dyn Read> = if archive == "-" {
|
|
Box::new(io::stdin())
|
|
} else {
|
|
Box::new(File::open(archive)?)
|
|
};
|
|
let reader: Box<dyn Read> = if compressed {
|
|
Box::new(GzDecoder::new(reader))
|
|
} else {
|
|
reader
|
|
};
|
|
let mut archive = tar::Archive::new(reader);
|
|
for entry in archive.entries()? {
|
|
let mut entry = entry?;
|
|
let name = entry.path()?.into_owned();
|
|
if !paths.is_empty() && !paths.iter().any(|p| name.starts_with(p)) {
|
|
continue;
|
|
}
|
|
if operation == 't' {
|
|
println!("{}", name.display());
|
|
continue;
|
|
}
|
|
let name: PathBuf = name
|
|
.components()
|
|
.filter(|c| !matches!(c, std::path::Component::CurDir))
|
|
.skip(strip)
|
|
.collect();
|
|
if name.as_os_str().is_empty() {
|
|
continue;
|
|
}
|
|
if name
|
|
.components()
|
|
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
|
{
|
|
return Err("archive path escapes destination".into());
|
|
}
|
|
if verbose {
|
|
println!("{}", name.display());
|
|
}
|
|
let dest = cwd.join(name);
|
|
if entry.header().entry_type().is_dir() {
|
|
std::fs::create_dir_all(dest)?;
|
|
} else if entry.header().entry_type().is_file() {
|
|
if let Some(parent) = dest.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
io::copy(&mut entry, &mut File::create(dest)?)?;
|
|
} else {
|
|
return Err(
|
|
"world extraction supports regular files and directories only".into(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn main() {
|
|
if let Err(e) = run() {
|
|
eprintln!("tar: {e}");
|
|
std::process::exit(2);
|
|
}
|
|
}
|