use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use bytesize::ByteSize;
use clap::{Parser, Subcommand, ValueEnum};
use comfy_table::{Table, presets::UTF8_FULL};
use crate::archive::{ArchiveEntry, Extractor};
use crate::error::Error;
use crate::format::{self, ArchiveFormat};
use crate::{cpt, gzinspect, lz4, sit};
use sha2::{Digest, Sha256};
#[derive(Clone, Copy, Default, ValueEnum)]
enum OutputFormat {
#[default]
Text,
Json,
Sarif,
Markdown,
}
#[derive(Parser)]
#[command(name = "archmeld", version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(clap::Args)]
struct ExtractArgs {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, default_value = ".")]
output: PathBuf,
#[arg(short, long)]
format: Option<FormatArg>,
#[arg(long, default_value = "100")]
max_file_size: u64,
#[arg(long, default_value = "1024")]
max_total_size: u64,
#[arg(long, default_value = "100000")]
max_entries: usize,
#[arg(long, default_value = "1000")]
max_ratio: u64,
}
#[derive(Subcommand)]
enum Commands {
Extract(ExtractArgs),
List {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
format: Option<FormatArg>,
#[arg(long)]
json: bool,
#[arg(long, short = 'O', value_enum, default_value = "text")]
output_format: OutputFormat,
},
Info {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
json: bool,
#[arg(long, short = 'O', value_enum, default_value = "text")]
output_format: OutputFormat,
},
#[command(name = "gz-inspect")]
GzInspect {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
verify: bool,
#[arg(long)]
json: bool,
},
Lz4 {
#[command(subcommand)]
action: Lz4Action,
},
#[command(name = "sit-inspect")]
SitInspect {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
json: bool,
},
#[command(name = "cpt-inspect")]
CptInspect {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
verify: bool,
#[arg(long)]
json: bool,
},
Verify {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long, short = 'O', value_enum, default_value = "text")]
output_format: OutputFormat,
},
#[command(name = "gz-compress")]
GzCompress {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Validate {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
format: Option<FormatArg>,
#[arg(long)]
json: bool,
#[arg(long, short = 'O', value_enum, default_value = "text")]
output_format: OutputFormat,
},
}
#[derive(Subcommand)]
enum Lz4Action {
Decompress {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Compress {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
},
Inspect {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
json: bool,
},
}
#[derive(Clone, ValueEnum)]
enum FormatArg {
Zip,
Tar,
TarGz,
TarBz2,
TarXz,
TarZst,
TarLz4,
#[value(name = "7z")]
SevenZip,
Gz,
Bz2,
Xz,
Lz4,
Zstd,
Ar,
Deb,
Cab,
Rar,
Brotli,
Snappy,
Lzma,
Bz3,
Zpaq,
Qcow2,
Iso9660,
}
impl From<FormatArg> for ArchiveFormat {
fn from(arg: FormatArg) -> Self {
match arg {
FormatArg::Zip => Self::Zip,
FormatArg::Tar => Self::Tar,
FormatArg::TarGz => Self::TarGz,
FormatArg::TarBz2 => Self::TarBz2,
FormatArg::TarXz => Self::TarXz,
FormatArg::TarZst => Self::TarZst,
FormatArg::TarLz4 => Self::TarLz4,
FormatArg::SevenZip => Self::SevenZip,
FormatArg::Gz => Self::Gz,
FormatArg::Bz2 => Self::Bz2,
FormatArg::Xz => Self::Xz,
FormatArg::Lz4 => Self::Lz4,
FormatArg::Zstd => Self::Zstd,
FormatArg::Ar => Self::Ar,
FormatArg::Deb => Self::Deb,
FormatArg::Cab => Self::Cab,
FormatArg::Rar => Self::Rar,
FormatArg::Brotli => Self::Brotli,
FormatArg::Snappy => Self::Snappy,
FormatArg::Lzma => Self::Lzma,
FormatArg::Bz3 => Self::Bz3,
FormatArg::Zpaq => Self::Zpaq,
FormatArg::Qcow2 => Self::Qcow2,
FormatArg::Iso9660 => Self::Iso9660,
}
}
}
pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Extract(args) => {
let extractor = Extractor::new()
.with_max_file_size(args.max_file_size * 1024 * 1024)
.with_max_total_size(args.max_total_size * 1024 * 1024)
.with_max_entries(args.max_entries)
.with_max_compression_ratio(f64::from(
u32::try_from(args.max_ratio).unwrap_or(1000),
));
cmd_extract(
&args.input,
&args.output,
args.format,
&extractor,
args.max_file_size,
args.max_total_size,
args.max_entries,
)
},
Commands::List {
input,
format,
json,
output_format,
} => cmd_list(&input, format, resolve_output_format(json, output_format)),
Commands::Info {
input,
json,
output_format,
} => cmd_info(&input, resolve_output_format(json, output_format)),
Commands::GzInspect {
input,
verify,
json,
} => cmd_gz_inspect(&input, verify, json),
Commands::Lz4 { action } => cmd_lz4(action),
Commands::SitInspect { input, json } => cmd_sit_inspect(&input, json),
Commands::CptInspect {
input,
verify,
json,
} => cmd_cpt_inspect(&input, verify, json),
Commands::Verify {
input,
output_format,
} => cmd_verify(&input, output_format),
Commands::GzCompress { input, output } => cmd_gz_compress(&input, output.as_deref()),
Commands::Validate {
input,
format,
json,
output_format,
} => cmd_validate(&input, format, resolve_output_format(json, output_format)),
}
}
const fn resolve_output_format(json: bool, fmt: OutputFormat) -> OutputFormat {
if json { OutputFormat::Json } else { fmt }
}
fn make_sarif_log(results: &[serde_json::Value]) -> serde_json::Value {
serde_json::json!({
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": "archmeld",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://github.com/ndaal/archmeld"
}
},
"results": results
}]
})
}
fn print_markdown_table(headers: &[&str], rows: &[Vec<String>]) {
println!("| {} |", headers.join(" | "));
println!(
"| {} |",
headers
.iter()
.map(|_| "---")
.collect::<Vec<_>>()
.join(" | ")
);
for row in rows {
println!("| {} |", row.join(" | "));
}
}
fn read_file(path: &Path) -> anyhow::Result<Vec<u8>> {
if !path.exists() {
return Err(Error::FileNotFound(path.to_path_buf()).into());
}
Ok(fs::read(path)?)
}
fn resolve_format(path: &Path, data: &[u8], format_arg: Option<FormatArg>) -> ArchiveFormat {
format_arg.map_or_else(
|| format::detect_format_from_path(path, data),
std::convert::Into::into,
)
}
#[allow(clippy::too_many_arguments)]
fn cmd_extract(
input: &Path,
output: &Path,
format_arg: Option<FormatArg>,
extractor: &Extractor,
max_file_size_mib: u64,
max_total_size_mib: u64,
max_entries: usize,
) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = resolve_format(input, &data, format_arg);
if fmt == ArchiveFormat::Unknown {
anyhow::bail!(
"Could not determine archive format for: {}",
input.display()
);
}
eprintln!("Extracting {fmt} archive: {}", input.display());
if fmt == ArchiveFormat::Zip {
let limits = safe_unzip::Limits {
max_total_bytes: max_total_size_mib * 1024 * 1024,
max_file_count: max_entries,
max_single_file: max_file_size_mib * 1024 * 1024,
max_path_depth: 50,
};
let safe_ext = safe_unzip::Extractor::new_or_create(output)
.map_err(|e| anyhow::anyhow!("safe_unzip init: {e}"))?
.limits(limits)
.symlinks(safe_unzip::SymlinkPolicy::Error)
.mode(safe_unzip::ExtractionMode::ValidateFirst)
.overwrite(safe_unzip::OverwritePolicy::Skip);
let report = safe_ext
.extract(std::io::Cursor::new(&data))
.map_err(|e| anyhow::anyhow!("safe_unzip: {e}"))?;
eprintln!(
"Extracted {} files ({}) to {} [secure mode via safe_unzip]",
report.files_extracted,
ByteSize(report.bytes_written),
output.display()
);
return Ok(());
}
let files = extractor.extract_path(input, fmt)?;
drop(data);
fs::create_dir_all(output)?;
let mut count = 0u32;
let mut total_bytes: u64 = 0;
for file in &files {
let dest = output.join(&file.path);
if file.is_directory {
fs::create_dir_all(&dest)?;
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dest, &file.data)?;
count += 1;
total_bytes += file.size;
}
eprintln!(
"Extracted {count} files ({}) to {}",
ByteSize(total_bytes),
output.display()
);
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_list(
input: &Path,
format_arg: Option<FormatArg>,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = resolve_format(input, &data, format_arg);
if fmt == ArchiveFormat::Unknown {
anyhow::bail!(
"Could not determine archive format for: {}",
input.display()
);
}
let extractor = Extractor::new();
let entries: Vec<ArchiveEntry> = if let Ok(e) = extractor.list_path(input, fmt) {
e
} else if let Ok(e) = extractor.list(&data, fmt) {
e
} else {
let files = extractor.extract(&data, fmt)?;
files
.into_iter()
.map(|f| ArchiveEntry {
path: f.path,
compressed_size: f.size,
uncompressed_size: f.size,
is_directory: f.is_directory,
compression_method: "unknown".into(),
})
.collect()
};
match output_format {
OutputFormat::Json => {
let out = serde_json::to_string_pretty(&entries)?;
println!("{out}");
},
OutputFormat::Sarif => {
let results: Vec<serde_json::Value> = entries
.iter()
.map(|entry| {
serde_json::json!({
"ruleId": "archmeld/archive-entry",
"level": "note",
"message": {
"text": format!(
"{}: {} ({})",
if entry.is_directory { "dir" } else { "file" },
entry.path,
entry.compression_method
)
},
"properties": {
"path": entry.path,
"compressedSize": entry.compressed_size,
"uncompressedSize": entry.uncompressed_size,
"compressionMethod": entry.compression_method,
"isDirectory": entry.is_directory
}
})
})
.collect();
let log = make_sarif_log(&results);
println!("{}", serde_json::to_string_pretty(&log)?);
},
OutputFormat::Markdown => {
let rows: Vec<Vec<String>> = entries
.iter()
.map(|entry| {
vec![
entry.path.clone(),
ByteSize(entry.compressed_size).to_string(),
ByteSize(entry.uncompressed_size).to_string(),
entry.compression_method.clone(),
if entry.is_directory { "dir" } else { "file" }.into(),
]
})
.collect();
print_markdown_table(
&["Path", "Compressed", "Uncompressed", "Method", "Type"],
&rows,
);
eprintln!("{} entries in {}", entries.len(), input.display());
},
OutputFormat::Text => {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Path", "Compressed", "Uncompressed", "Method", "Type"]);
for entry in &entries {
table.add_row([
entry.path.as_str(),
&ByteSize(entry.compressed_size).to_string(),
&ByteSize(entry.uncompressed_size).to_string(),
entry.compression_method.as_str(),
if entry.is_directory { "dir" } else { "file" },
]);
}
println!("{table}");
eprintln!("{} entries in {}", entries.len(), input.display());
},
}
Ok(())
}
fn cmd_info(input: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = format::detect_format_from_path(input, &data);
let info = format::format_info(fmt);
match output_format {
OutputFormat::Json => {
let out = serde_json::to_string_pretty(&info)?;
println!("{out}");
},
OutputFormat::Sarif => {
let result = serde_json::json!({
"ruleId": "archmeld/format-detection",
"level": "note",
"message": {
"text": format!(
"Detected format: {} ({})",
info.format, info.description
)
},
"properties": {
"format": info.format.to_string(),
"description": info.description,
"mimeType": info.mime_type,
"isArchive": info.is_archive,
"isCompressed": info.is_compressed,
"fileSize": data.len()
}
});
let log = make_sarif_log(&[result]);
println!("{}", serde_json::to_string_pretty(&log)?);
},
OutputFormat::Markdown => {
print_markdown_table(
&["Property", "Value"],
&[
vec!["File".into(), input.display().to_string()],
vec!["Format".into(), info.format.to_string()],
vec!["Description".into(), info.description.clone()],
vec!["MIME type".into(), info.mime_type.clone()],
vec!["Is archive".into(), info.is_archive.to_string()],
vec!["Compressed".into(), info.is_compressed.to_string()],
vec!["File size".into(), ByteSize(data.len() as u64).to_string()],
],
);
},
OutputFormat::Text => {
println!("File: {}", input.display());
println!("Format: {}", info.format);
println!("Description: {}", info.description);
println!("MIME type: {}", info.mime_type);
println!("Is archive: {}", info.is_archive);
println!("Compressed: {}", info.is_compressed);
println!("File size: {}", ByteSize(data.len() as u64));
},
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_gz_inspect(input: &Path, verify: bool, json: bool) -> anyhow::Result<()> {
let data = read_file(input)?;
let analysis = gzinspect::inspect(&data)?;
if verify {
let valid = gzinspect::verify_crc(&data)?;
if valid {
eprintln!("CRC-32 verification: PASSED");
} else {
eprintln!("CRC-32 verification: FAILED");
anyhow::bail!("CRC-32 verification failed");
}
}
if json {
let out = serde_json::to_string_pretty(&analysis)?;
println!("{out}");
} else {
let h = &analysis.header;
println!("=== Gzip Header ===");
println!("Compression: {}", h.compression_method_name);
println!("Flags: 0x{:02X}", h.flags);
println!(" FTEXT: {}", h.is_text);
println!(" FHCRC: {}", h.has_header_crc);
println!(" FEXTRA: {}", h.has_extra);
println!(" FNAME: {}", h.has_name);
println!(" FCOMMENT: {}", h.has_comment);
println!("Mod time: {}", h.mtime_formatted);
println!(
"Extra flags: {} ({})",
h.extra_flags, h.extra_flags_description
);
println!("OS: {} ({})", h.os_code, h.os_name);
if let Some(ref name) = h.original_name {
println!("Orig name: {name}");
}
if let Some(ref comment) = h.comment {
println!("Comment: {comment}");
}
if let Some(ref extra) = h.extra_data {
println!("Extra data: {} bytes", extra.len());
}
if let Some(crc) = h.header_crc16 {
println!("Header CRC16: 0x{crc:04X}");
}
println!("\n=== File Statistics ===");
println!("Header size: {} bytes", h.header_size);
println!("Compressed size: {}", ByteSize(analysis.compressed_size));
println!("File size: {}", ByteSize(analysis.file_size));
println!("SHA-256: {}", analysis.sha256);
println!("Members: {}", analysis.member_count);
println!("Multi-member: {}", analysis.is_multi_member);
if let Some(ref t) = analysis.trailer {
println!("\n=== Gzip Trailer ===");
println!("CRC-32: 0x{:08X}", t.crc32);
println!(
"Original size: {} (mod 2^32)",
ByteSize(u64::from(t.original_size))
);
}
}
Ok(())
}
fn cmd_lz4(action: Lz4Action) -> anyhow::Result<()> {
match action {
Lz4Action::Decompress { input, output } => {
let data = read_file(&input)?;
let decompressed = lz4::decompress_frame(&data)?;
let out_path = output.unwrap_or_else(|| {
let name = input.to_string_lossy();
if let Some(stripped) = name.strip_suffix(".lz4") {
PathBuf::from(stripped)
} else {
PathBuf::from(format!("{name}.decompressed"))
}
});
fs::write(&out_path, &decompressed)?;
eprintln!(
"Decompressed {} -> {} ({})",
input.display(),
out_path.display(),
ByteSize(decompressed.len() as u64)
);
Ok(())
},
Lz4Action::Compress { input, output } => {
let data = read_file(&input)?;
let compressed = lz4::compress_frame(&data)?;
let out_path =
output.unwrap_or_else(|| PathBuf::from(format!("{}.lz4", input.display())));
fs::write(&out_path, &compressed)?;
eprintln!(
"Compressed {} -> {} ({} -> {})",
input.display(),
out_path.display(),
ByteSize(data.len() as u64),
ByteSize(compressed.len() as u64)
);
Ok(())
},
Lz4Action::Inspect { input, json } => {
let data = read_file(&input)?;
let info = lz4::parse_frame_header(&data)?;
if json {
let out = serde_json::to_string_pretty(&info)?;
println!("{out}");
} else {
println!("=== LZ4 Frame Header ===");
println!("Block independent: {}", info.block_independent);
println!("Block checksum: {}", info.block_checksum);
println!(
"Content size: {}",
info.content_size
.map_or_else(|| "not present".into(), |s| ByteSize(s).to_string(),)
);
println!("Content checksum: {}", info.content_checksum);
println!(
"Block max size: {}",
ByteSize(u64::from(info.block_max_size))
);
}
Ok(())
},
}
}
fn cmd_sit_inspect(input: &Path, json: bool) -> anyhow::Result<()> {
let data = read_file(input)?;
let analysis = sit::analyze(&data)?;
if json {
let out = serde_json::to_string_pretty(&analysis)?;
println!("{out}");
} else {
let h = &analysis.header;
println!("=== StuffIt Archive ===");
println!("Signature: {}", h.signature);
println!("Version: {}", h.version);
println!(
"Format: {}",
if h.is_stuffit5 {
"StuffIt 5.x"
} else {
"Classic StuffIt"
}
);
println!("Entries: {}", h.num_entries);
println!("Size: {}", ByteSize(u64::from(h.archive_size)));
if !analysis.entries.is_empty() {
println!("\n=== Entries ===");
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Name",
"Type",
"Compressed",
"Uncompressed",
"Method",
"Encrypted",
]);
for entry in &analysis.entries {
table.add_row([
entry.name.as_str(),
if entry.is_directory { "dir" } else { "file" },
&ByteSize(u64::from(entry.data_compressed_size)).to_string(),
&ByteSize(u64::from(entry.data_uncompressed_size)).to_string(),
entry.compression_method_name.as_str(),
if entry.is_encrypted { "YES" } else { "no" },
]);
}
println!("{table}");
}
}
Ok(())
}
fn cmd_cpt_inspect(input: &Path, verify: bool, json: bool) -> anyhow::Result<()> {
let data = read_file(input)?;
let analysis = cpt::analyze(&data)?;
if verify {
let valid = cpt::verify(&data)?;
if valid {
eprintln!("Header CRC-32 verification: PASSED");
} else {
eprintln!("Header CRC-32 verification: FAILED");
}
}
if json {
let out = serde_json::to_string_pretty(&analysis)?;
println!("{out}");
} else {
let h = &analysis.header;
println!("=== Compact Pro Archive ===");
println!("Volume: {}", h.volume_number);
println!("Header CRC: 0x{:08X}", h.header_crc32);
println!("Entries: {}", h.total_entries);
if let Some(ref c) = h.comment {
println!("Comment: {c}");
}
if !analysis.entries.is_empty() {
println!("\n=== Entries ===");
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Name",
"Type",
"Data Size",
"Rsrc Size",
"LZH",
"Encrypted",
]);
for entry in &analysis.entries {
match entry {
cpt::CptEntry::Directory(d) => {
table.add_row([d.name.as_str(), "dir", "-", "-", "-", "-"]);
},
cpt::CptEntry::File(f) => {
let data_size = ByteSize(u64::from(f.data_uncompressed_size)).to_string();
let rsrc_size = ByteSize(u64::from(f.rsrc_uncompressed_size)).to_string();
let lzh = format!("d:{} r:{}", f.data_lzh, f.rsrc_lzh);
table.add_row([
f.name.as_str(),
"file",
&data_size,
&rsrc_size,
&lzh,
if f.is_encrypted { "YES" } else { "no" },
]);
},
}
}
println!("{table}");
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_verify(input: &Path, output_format: OutputFormat) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = format::detect_format_from_path(input, &data);
eprintln!("Verifying {} (format: {fmt})", input.display());
let (passed, check_name, message) = match fmt {
ArchiveFormat::Gz | ArchiveFormat::TarGz => {
let valid = gzinspect::verify_crc(&data)?;
(
valid,
"Gzip CRC-32",
if valid {
"Gzip CRC-32 verification succeeded".into()
} else {
"Gzip CRC-32 verification failed".into()
},
)
},
ArchiveFormat::Zip => {
let extractor = Extractor::new();
let files = extractor.extract(&data, fmt)?;
(
true,
"ZIP extraction",
format!("ZIP archive verified ({} entries)", files.len()),
)
},
ArchiveFormat::CompactPro => {
let valid = cpt::verify(&data)?;
(
valid,
"Compact Pro CRC-32",
if valid {
"Compact Pro header CRC-32 verified".into()
} else {
"Compact Pro header CRC-32 mismatch".into()
},
)
},
ArchiveFormat::StuffIt => {
let analysis = sit::analyze(&data)?;
(
true,
"StuffIt header",
format!(
"StuffIt archive header parsed ({} entries)",
analysis.entries.len()
),
)
},
ArchiveFormat::Lz4 => {
let _info = lz4::parse_frame_header(&data)?;
let decompressed = lz4::decompress_frame(&data)?;
(
true,
"LZ4 decompression",
format!(
"LZ4 frame decompressed successfully ({} bytes)",
decompressed.len()
),
)
},
_ => {
let extractor = Extractor::new();
let files = extractor.extract(&data, fmt)?;
(
true,
"Generic extraction",
format!("Archive verified ({} entries)", files.len()),
)
},
};
let mut hasher = Sha256::new();
hasher.update(&data);
let hash = hex::encode(hasher.finalize());
let status_label = if passed { "PASS" } else { "FAIL" };
match output_format {
OutputFormat::Json => {
let obj = serde_json::json!({
"file": input.display().to_string(),
"format": fmt.to_string(),
"check": check_name,
"passed": passed,
"message": message,
"sha256": hash
});
println!("{}", serde_json::to_string_pretty(&obj)?);
},
OutputFormat::Sarif => {
let result = serde_json::json!({
"ruleId": "archmeld/verify",
"level": if passed { "note" } else { "error" },
"message": {
"text": format!("{status_label}: {message}")
},
"properties": {
"check": check_name,
"passed": passed,
"format": fmt.to_string(),
"sha256": hash
}
});
let log = make_sarif_log(&[result]);
println!("{}", serde_json::to_string_pretty(&log)?);
},
OutputFormat::Markdown => {
print_markdown_table(
&["Check", "Status", "Message", "SHA-256"],
&[vec![
check_name.into(),
status_label.into(),
message.clone(),
hash,
]],
);
},
OutputFormat::Text => {
println!("{status_label}: {message}");
println!("SHA-256: {hash}");
},
}
std::io::stdout().flush()?;
if !passed {
anyhow::bail!("{status_label}: {message}");
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_validate(
input: &Path,
format_arg: Option<FormatArg>,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = resolve_format(input, &data, format_arg);
if fmt == ArchiveFormat::Unknown {
anyhow::bail!(
"Could not determine archive format for: {}",
input.display()
);
}
let extractor = Extractor::new();
let result = extractor.validate_path(input, fmt);
match result {
Ok(vr) => match output_format {
OutputFormat::Json => {
let out = serde_json::to_string_pretty(&vr)?;
println!("{out}");
},
OutputFormat::Sarif => {
let result_obj = serde_json::json!({
"ruleId": "archmeld/validate",
"level": "note",
"message": {
"text": format!(
"VALID: {} can be decompressed ({} entries, {})",
input.display(),
vr.entry_count,
ByteSize(vr.total_uncompressed_size)
)
},
"properties": {
"format": vr.format.to_string(),
"entryCount": vr.entry_count,
"totalUncompressedSize": vr.total_uncompressed_size,
"isValid": vr.is_valid
}
});
let log = make_sarif_log(&[result_obj]);
println!("{}", serde_json::to_string_pretty(&log)?);
},
OutputFormat::Markdown => {
print_markdown_table(
&["Property", "Value"],
&[
vec!["File".into(), input.display().to_string()],
vec!["Valid".into(), "true".into()],
vec!["Format".into(), vr.format.to_string()],
vec!["Entries".into(), vr.entry_count.to_string()],
vec![
"Uncompressed size".into(),
ByteSize(vr.total_uncompressed_size).to_string(),
],
],
);
},
OutputFormat::Text => {
println!("VALID: {} can be decompressed", input.display());
println!(" Format: {}", vr.format);
println!(" Entries: {}", vr.entry_count);
println!(
" Uncompressed size: {}",
ByteSize(vr.total_uncompressed_size)
);
},
},
Err(e) => {
match output_format {
OutputFormat::Json => {
let err_obj = serde_json::json!({
"is_valid": false,
"error": e.to_string(),
});
println!("{}", serde_json::to_string_pretty(&err_obj)?);
},
OutputFormat::Sarif => {
let result_obj = serde_json::json!({
"ruleId": "archmeld/validate",
"level": "error",
"message": {
"text": format!(
"INVALID: {} cannot be decompressed: {}",
input.display(),
e
)
},
"properties": {
"isValid": false,
"error": e.to_string()
}
});
let log = make_sarif_log(&[result_obj]);
println!("{}", serde_json::to_string_pretty(&log)?);
},
OutputFormat::Markdown => {
print_markdown_table(
&["Property", "Value"],
&[
vec!["File".into(), input.display().to_string()],
vec!["Valid".into(), "false".into()],
vec!["Error".into(), e.to_string()],
],
);
},
OutputFormat::Text => {
eprintln!("INVALID: {} cannot be decompressed", input.display());
eprintln!(" Error: {e}");
},
}
anyhow::bail!("Validation failed for: {}", input.display());
},
}
Ok(())
}
#[allow(clippy::cast_precision_loss)]
fn cmd_gz_compress(input: &Path, output: Option<&Path>) -> anyhow::Result<()> {
let data = read_file(input)?;
let out_path = output.map_or_else(
|| PathBuf::from(format!("{}.gz", input.display())),
PathBuf::from,
);
let compressed = crate::archive::compress_zopfli(&data)?;
fs::write(&out_path, &compressed)?;
eprintln!(
"Compressed {} -> {} ({} -> {}, ratio {:.1}x)",
input.display(),
out_path.display(),
ByteSize(data.len() as u64),
ByteSize(compressed.len() as u64),
data.len() as f64 / compressed.len() as f64,
);
Ok(())
}