#![allow(clippy::as_conversions)]
#![allow(clippy::arithmetic_side_effects)]
#![allow(clippy::cast_possible_truncation)]
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, is_exarch_format};
use crate::error::Error;
use crate::format::{self, ArchiveFormat};
use crate::{cpt, gzinspect, lz4, sit, size, updater};
use sha2::{Digest, Sha256};
use std::io::IsTerminal;
use std::io::{BufReader, Cursor};
#[derive(Parser)]
#[command(name = "archmeld", version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[arg(long, global = true)]
check_update: bool,
#[arg(long, global = true)]
self_update: bool,
#[arg(long, global = true)]
no_self_update: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Extract {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long, default_value = ".")]
output: PathBuf,
#[arg(short, long)]
format: Option<FormatArg>,
#[arg(long, default_value = "100M")]
max_file_size: String,
#[arg(long, default_value = "1G")]
max_total_size: String,
#[arg(long, default_value_t = 10_000)]
max_files: usize,
#[arg(long, default_value_t = 100)]
max_compression_ratio: u64,
#[arg(long)]
allow_symlinks: bool,
#[arg(long)]
allow_hardlinks: bool,
#[arg(long)]
preserve_permissions: bool,
#[arg(long)]
force: bool,
#[arg(long)]
allow_solid_archives: bool,
#[arg(long)]
allow_world_writable: bool,
},
List {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(short, long)]
format: Option<FormatArg>,
#[arg(long)]
json: bool,
},
Info {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
json: bool,
},
#[command(name = "gz-inspect")]
GzInspect {
#[arg(value_name = "FILE")]
input: PathBuf,
#[arg(long)]
verify: bool,
#[arg(long)]
chunks: 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,
},
}
#[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,
Lzma,
Lha,
Rar,
Arc,
Zoo,
Xar,
}
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::Lzma => Self::Lzma,
FormatArg::Lha => Self::Lha,
FormatArg::Rar => Self::Rar,
FormatArg::Arc => Self::Arc,
FormatArg::Zoo => Self::Zoo,
FormatArg::Xar => Self::Xar,
}
}
}
#[allow(clippy::fn_params_excessive_bools)]
pub fn run() -> anyhow::Result<u8> {
let cli = Cli::parse();
if cli.check_update {
let outcome = updater::check_update();
println!("{}", outcome.message());
return Ok(outcome.exit_code());
}
if cli.self_update {
let disabled = updater::is_disabled(cli.no_self_update);
let interactive = std::io::stdin().is_terminal();
let outcome = updater::self_update(disabled, interactive)?;
println!("{}", outcome.message());
return Ok(outcome.exit_code());
}
let Some(command) = cli.command else {
let mut cmd = <Cli as clap::CommandFactory>::command();
cmd.print_help()?;
println!();
return Ok(2);
};
run_command(command).map(|()| 0)
}
#[allow(clippy::fn_params_excessive_bools)]
fn run_command(command: Commands) -> anyhow::Result<()> {
match command {
Commands::Extract {
input,
output,
format,
max_file_size,
max_total_size,
max_files,
max_compression_ratio,
allow_symlinks,
allow_hardlinks,
preserve_permissions,
force,
allow_solid_archives,
allow_world_writable,
} => cmd_extract(
&input,
&output,
format,
&max_file_size,
&max_total_size,
max_files,
max_compression_ratio,
allow_symlinks,
allow_hardlinks,
preserve_permissions,
force,
allow_solid_archives,
allow_world_writable,
),
Commands::List {
input,
format,
json,
} => cmd_list(&input, format, json),
Commands::Info { input, json } => cmd_info(&input, json),
Commands::GzInspect {
input,
verify,
chunks,
json,
} => cmd_gz_inspect(&input, verify, chunks, 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 } => cmd_verify(&input),
}
}
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::fn_params_excessive_bools)]
#[allow(clippy::too_many_arguments)]
fn build_security_config(
max_file_size: u64,
max_total_size: u64,
max_files: usize,
max_compression_ratio: u64,
allow_symlinks: bool,
allow_hardlinks: bool,
preserve_permissions: bool,
allow_solid_archives: bool,
allow_world_writable: bool,
) -> exarch_core::SecurityConfig {
#[allow(clippy::cast_precision_loss)]
let ratio = max_compression_ratio as f64;
exarch_core::SecurityConfig {
max_file_size,
max_total_size,
max_compression_ratio: ratio,
max_file_count: max_files,
allow_solid_archives,
allowed: exarch_core::config::AllowedFeatures {
symlinks: allow_symlinks,
hardlinks: allow_hardlinks,
world_writable: allow_world_writable,
..Default::default()
},
preserve_permissions,
..exarch_core::SecurityConfig::default()
}
}
#[allow(clippy::fn_params_excessive_bools)]
#[allow(clippy::too_many_arguments)]
fn cmd_extract(
input: &Path,
output: &Path,
format_arg: Option<FormatArg>,
max_file_size_str: &str,
max_total_size_str: &str,
max_files: usize,
max_compression_ratio: u64,
allow_symlinks: bool,
allow_hardlinks: bool,
preserve_permissions: bool,
force: bool,
allow_solid_archives: bool,
allow_world_writable: bool,
) -> anyhow::Result<()> {
let max_file_size = size::parse_size(max_file_size_str)?;
let max_total_size = size::parse_size(max_total_size_str)?;
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 {} archive: {}", fmt, input.display());
if fmt == ArchiveFormat::Xar {
return extract_xar(input, output, force);
}
if fmt == ArchiveFormat::Dds {
anyhow::bail!(
"DDS is a texture format, not an \
extractable archive. Use 'info' to \
inspect it."
);
}
if is_exarch_format(fmt) {
extract_via_exarch(
input,
output,
max_file_size,
max_total_size,
max_files,
max_compression_ratio,
allow_symlinks,
allow_hardlinks,
preserve_permissions,
allow_solid_archives,
allow_world_writable,
)
} else {
extract_native(
input,
&data,
fmt,
output,
&NativeExtractPolicy {
max_file_size,
max_total_size,
max_files,
max_compression_ratio,
force,
},
)
}
}
#[allow(clippy::fn_params_excessive_bools)]
#[allow(clippy::too_many_arguments)]
fn extract_via_exarch(
input: &Path,
output: &Path,
max_file_size: u64,
max_total_size: u64,
max_files: usize,
max_compression_ratio: u64,
allow_symlinks: bool,
allow_hardlinks: bool,
preserve_permissions: bool,
allow_solid_archives: bool,
allow_world_writable: bool,
) -> anyhow::Result<()> {
#[allow(clippy::disallowed_methods)]
fs::create_dir_all(output)?;
let config = build_security_config(
max_file_size,
max_total_size,
max_files,
max_compression_ratio,
allow_symlinks,
allow_hardlinks,
preserve_permissions,
allow_solid_archives,
allow_world_writable,
);
let report = exarch_core::extract_archive(input, output, &config)
.map_err(|e| Error::ExarchExtraction(e.to_string()))?;
for warning in &report.warnings {
eprintln!("Warning: {warning}");
}
eprintln!(
"Extracted {} files, {} dirs ({}) to {}",
report.files_extracted,
report.directories_created,
ByteSize(report.bytes_written),
output.display()
);
if report.files_skipped > 0 {
eprintln!("Skipped {} files (security checks)", report.files_skipped);
}
if report.files_extracted == 0 && report.files_skipped > 0 {
anyhow::bail!(
"No files extracted: all {} files were \
skipped by security checks. Consider \
--allow-solid-archives, \
--allow-world-writable, or adjusting \
--max-file-size / --max-total-size.",
report.files_skipped
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
#[cfg(unix)]
fn set_safe_mode(path: &Path, is_dir: bool) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let mode = if is_dir { 0o755 } else { 0o644 };
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
fn set_safe_mode(_path: &Path, _is_dir: bool) -> std::io::Result<()> {
Ok(())
}
struct NativeExtractPolicy {
max_file_size: u64,
max_total_size: u64,
max_files: usize,
max_compression_ratio: u64,
force: bool,
}
fn extract_native(
input: &Path,
data: &[u8],
fmt: ArchiveFormat,
output: &Path,
policy: &NativeExtractPolicy,
) -> anyhow::Result<()> {
let force = policy.force;
let extractor = Extractor::new()
.with_max_file_size(policy.max_file_size)
.with_max_total_size(policy.max_total_size)
.with_max_files(policy.max_files)
.with_max_compression_ratio(policy.max_compression_ratio);
let files = extractor.extract(data, fmt)?;
#[allow(clippy::disallowed_methods)]
fs::create_dir_all(output)?;
let mut count = 0u32;
let mut total_bytes: u64 = 0;
for file in &files {
let path = if file.path == "decompressed" {
derive_decompressed_name(input)
} else {
file.path.clone()
};
let dest = output.join(&path);
if file.is_directory {
#[allow(clippy::disallowed_methods)]
fs::create_dir_all(&dest)?;
continue;
}
if let Some(parent) = dest.parent() {
#[allow(clippy::disallowed_methods)]
fs::create_dir_all(parent)?;
set_safe_mode(parent, true)?;
}
if dest.exists() && !force {
eprintln!("Skipping (exists): {}", dest.display());
continue;
}
#[allow(clippy::disallowed_methods)]
fs::write(&dest, &file.data)?;
set_safe_mode(&dest, false)?;
count += 1;
total_bytes += file.size;
}
eprintln!(
"Extracted {count} files ({}) to {}",
ByteSize(total_bytes),
output.display()
);
Ok(())
}
fn derive_decompressed_name(input: &Path) -> String {
let stem = input.file_stem().and_then(|s| s.to_str());
match stem {
Some(s) if !s.is_empty() => s.to_owned(),
_ => "decompressed".to_owned(),
}
}
fn cmd_list(input: &Path, format_arg: Option<FormatArg>, json: bool) -> 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()
);
}
if fmt == ArchiveFormat::Xar {
return list_xar(input, json);
}
if is_exarch_format(fmt) {
return list_via_exarch(input, json);
}
let extractor = Extractor::new();
let entries: Vec<ArchiveEntry> = 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()
};
print_list_output(&entries, input, json)
}
fn list_via_exarch(input: &Path, json: bool) -> anyhow::Result<()> {
let config = exarch_core::SecurityConfig::default();
let manifest = exarch_core::list_archive(input, &config)
.map_err(|e| Error::ExarchExtraction(e.to_string()))?;
let entries: Vec<ArchiveEntry> = manifest
.entries
.iter()
.map(|e| {
let is_dir = e.entry_type == exarch_core::ManifestEntryType::Directory;
ArchiveEntry {
path: e.path.to_string_lossy().into_owned(),
compressed_size: e.compressed_size.unwrap_or(e.size),
uncompressed_size: e.size,
is_directory: is_dir,
compression_method: format!("{}", e.entry_type),
}
})
.collect();
print_list_output(&entries, input, json)
}
fn print_list_output(entries: &[ArchiveEntry], input: &Path, json: bool) -> anyhow::Result<()> {
if json {
let out = serde_json::to_string_pretty(entries)?;
println!("{out}");
} else {
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(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(),
]);
}
println!("{table}");
eprintln!("{} entries in {}", entries.len(), input.display());
}
Ok(())
}
fn cmd_info(input: &Path, json: bool) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = format::detect_format_from_path(input, &data);
let info = format::format_info(fmt);
if json {
let out = serde_json::to_string_pretty(&info)?;
println!("{out}");
} else {
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));
if fmt == ArchiveFormat::Dds {
print_dds_info(&data)?;
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
fn cmd_gz_inspect(input: &Path, verify: bool, chunks: 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))
);
}
}
if chunks {
print_gz_chunks(input)?;
}
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"))
}
});
#[allow(clippy::disallowed_methods)]
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())));
#[allow(clippy::disallowed_methods)]
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(vec![
entry.name.clone(),
if entry.is_directory { "dir" } else { "file" }.into(),
ByteSize(u64::from(entry.data_compressed_size)).to_string(),
ByteSize(u64::from(entry.data_uncompressed_size)).to_string(),
entry.compression_method_name.clone(),
if entry.is_encrypted { "YES" } else { "no" }.into(),
]);
}
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(vec![
d.name.clone(),
"dir".into(),
"-".into(),
"-".into(),
"-".into(),
"-".into(),
]);
},
cpt::CptEntry::File(f) => {
table.add_row(vec![
f.name.clone(),
"file".into(),
ByteSize(u64::from(f.data_uncompressed_size)).to_string(),
ByteSize(u64::from(f.rsrc_uncompressed_size)).to_string(),
format!("d:{} r:{}", f.data_lzh, f.rsrc_lzh),
if f.is_encrypted { "YES" } else { "no" }.into(),
]);
},
}
}
println!("{table}");
}
}
Ok(())
}
fn cmd_verify(input: &Path) -> anyhow::Result<()> {
let data = read_file(input)?;
let fmt = format::detect_format_from_path(input, &data);
eprintln!("Verifying {} (format: {})", input.display(), fmt);
if fmt == ArchiveFormat::Xar {
verify_xar(input)?;
} else if is_exarch_format(fmt) {
verify_via_exarch(input)?;
} else {
match fmt {
ArchiveFormat::Gz | ArchiveFormat::TarGz => {
let valid = gzinspect::verify_crc(&data)?;
if valid {
println!(
"PASS: Gzip CRC-32 \
verification succeeded"
);
} else {
anyhow::bail!(
"FAIL: Gzip CRC-32 \
verification failed"
);
}
},
ArchiveFormat::CompactPro => {
let valid = cpt::verify(&data)?;
if valid {
println!(
"PASS: Compact Pro header \
CRC-32 verified"
);
} else {
anyhow::bail!(
"FAIL: Compact Pro header \
CRC-32 mismatch"
);
}
},
ArchiveFormat::StuffIt => {
let analysis = sit::analyze(&data)?;
println!(
"PASS: StuffIt archive header \
parsed ({} entries)",
analysis.entries.len()
);
},
ArchiveFormat::Lz4 => {
let _info = lz4::parse_frame_header(&data)?;
let decompressed = lz4::decompress_frame(&data)?;
println!(
"PASS: LZ4 frame decompressed \
successfully ({} bytes)",
decompressed.len()
);
},
ArchiveFormat::Zip
| ArchiveFormat::Tar
| ArchiveFormat::TarBz2
| ArchiveFormat::TarXz
| ArchiveFormat::TarZst
| ArchiveFormat::TarLz4
| ArchiveFormat::SevenZip
| ArchiveFormat::Bz2
| ArchiveFormat::Xz
| ArchiveFormat::Zstd
| ArchiveFormat::Lzma
| ArchiveFormat::Lha
| ArchiveFormat::Rar
| ArchiveFormat::Arc
| ArchiveFormat::Zoo
| ArchiveFormat::Xar
| ArchiveFormat::Dds
| ArchiveFormat::Unknown => {
let extractor = Extractor::new();
let files = extractor.extract(&data, fmt)?;
println!(
"PASS: Archive verified \
({} entries)",
files.len()
);
},
}
}
let mut hasher = Sha256::new();
hasher.update(&data);
let hash = hex::encode(hasher.finalize());
println!("SHA-256: {hash}");
std::io::stdout().flush()?;
Ok(())
}
fn verify_via_exarch(input: &Path) -> anyhow::Result<()> {
let config = exarch_core::SecurityConfig::default();
let report = exarch_core::verify_archive(input, &config)
.map_err(|e| Error::ExarchExtraction(e.to_string()))?;
println!(
"Verification: {} ({} entries, {})",
report.status,
report.total_entries,
ByteSize(report.total_size)
);
println!(" Integrity: {}", report.integrity_status);
println!(" Security: {}", report.security_status);
if !report.issues.is_empty() {
println!("\nIssues:");
for issue in &report.issues {
println!(
" [{}/{}] {}",
issue.severity, issue.category, issue.message
);
if let Some(ref path) = issue.entry_path {
println!(" Entry: {}", path.display());
}
}
}
if report.is_safe() {
println!("PASS: Archive verified");
} else {
anyhow::bail!(
"FAIL: Security issues found \
({} suspicious entries)",
report.suspicious_entries
);
}
Ok(())
}
fn extract_xar(input: &Path, output: &Path, _force: bool) -> anyhow::Result<()> {
let file = fs::File::open(input)?;
let mut archive = xara::XarArchive::open(file).map_err(|e| Error::Xar(e.to_string()))?;
#[allow(clippy::disallowed_methods)]
fs::create_dir_all(output)?;
let stats = archive
.extract_all(output)
.map_err(|e| Error::Xar(e.to_string()))?;
eprintln!(
"Extracted {} files, {} dirs ({}) to {}",
stats.files,
stats.dirs,
ByteSize(stats.bytes),
output.display()
);
if stats.symlinks_skipped > 0 {
eprintln!("Skipped {} symlinks (security)", stats.symlinks_skipped);
}
Ok(())
}
fn list_xar(input: &Path, json: bool) -> anyhow::Result<()> {
let file = fs::File::open(input)?;
let archive = xara::XarArchive::open(file).map_err(|e| Error::Xar(e.to_string()))?;
let entries: Vec<ArchiveEntry> = archive
.files()
.iter()
.map(|f| {
let is_dir = f.file_type == xara::XarFileType::Directory;
let (compressed, uncompressed) = if let Some(ref data) = f.data {
(data.length, data.size)
} else {
(0, 0)
};
ArchiveEntry {
path: f.path.clone(),
compressed_size: compressed,
uncompressed_size: uncompressed,
is_directory: is_dir,
compression_method: f
.data
.as_ref()
.map_or_else(|| "none".into(), |d| d.encoding.clone()),
}
})
.collect();
print_list_output(&entries, input, json)
}
fn verify_xar(input: &Path) -> anyhow::Result<()> {
let file = fs::File::open(input)?;
let archive = xara::XarArchive::open(file).map_err(|e| Error::Xar(e.to_string()))?;
let header = archive.header();
let file_count = archive.files().len();
println!(
"PASS: XAR archive verified \
(version {}, checksum {:?}, \
{} entries)",
header.version, header.checksum_algo, file_count
);
Ok(())
}
fn print_gz_chunks(input: &Path) -> anyhow::Result<()> {
let file = fs::File::open(input)?;
let mut reader = BufReader::new(file);
let mut offset: u64 = 0;
let mut chunk_number: usize = 0;
let mut total_compressed: u64 = 0;
let mut total_uncompressed: u64 = 0;
println!("\n=== Gzip Members (chunks) ===");
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"#",
"Offset",
"Compressed",
"Uncompressed",
"Ratio",
"Header",
]);
loop {
match gzinspector::read_chunk(&mut reader, offset, chunk_number) {
Ok(info) => {
table.add_row(vec![
format!("{}", info.chunk_number),
format!("0x{:X}", info.offset),
ByteSize(info.compressed_size).to_string(),
ByteSize(info.uncompressed_size).to_string(),
format!("{:.1}x", info.compression_ratio),
info.header_info.clone(),
]);
total_compressed += info.compressed_size;
total_uncompressed += info.uncompressed_size;
offset += info.compressed_size;
chunk_number += 1;
},
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
break;
},
Err(e) => {
if chunk_number > 0 {
break;
}
return Err(e.into());
},
}
}
println!("{table}");
eprintln!(
"Total: {chunk_number} members, \
{} compressed, {} uncompressed",
ByteSize(total_compressed),
ByteSize(total_uncompressed)
);
Ok(())
}
fn print_dds_info(data: &[u8]) -> anyhow::Result<()> {
let dds = image_dds::ddsfile::Dds::read(&mut Cursor::new(data))
.map_err(|e| Error::InvalidArchive(format!("DDS parse error: {e}")))?;
let width = dds.get_width();
let height = dds.get_height();
let depth = dds.get_depth();
let mip_levels = dds.get_num_mipmap_levels();
let array_layers = dds.get_num_array_layers();
let format_str = match image_dds::dds_image_format(&dds) {
Ok(fmt) => format!("{fmt:?}"),
Err(info) => format!("Unknown (DXGI: {:?}, D3D: {:?})", info.dxgi, info.d3d),
};
println!("\n=== DDS Texture Details ===");
println!("Dimensions: {width} x {height}");
if depth > 1 {
println!("Depth: {depth}");
}
println!("Mip levels: {mip_levels}");
if array_layers > 1 {
println!("Array layers: {array_layers}");
}
println!("Pixel format: {format_str}");
println!(
"Data size: {}",
ByteSize(u64::from(dds.get_main_texture_size().unwrap_or(0)))
);
Ok(())
}
#[cfg(all(test, unix))]
#[allow(clippy::disallowed_methods, clippy::tests_outside_test_module)]
mod tests {
use super::set_safe_mode;
use std::os::unix::fs::PermissionsExt as _;
#[test]
fn set_safe_mode_tightens_a_permissive_file_to_0644() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("loose.bin");
std::fs::write(&file, b"x").expect("write");
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o666)).expect("loosen");
set_safe_mode(&file, false).expect("set_safe_mode");
let mode = std::fs::metadata(&file).expect("stat").permissions().mode() & 0o7777;
assert_eq!(
mode, 0o644,
"an extracted file must end up 0o644 regardless of how it was created"
);
}
#[test]
fn set_safe_mode_tightens_a_permissive_dir_to_0755() {
let dir = tempfile::tempdir().expect("tempdir");
let sub = dir.path().join("loose");
std::fs::create_dir_all(&sub).expect("mkdir");
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o777)).expect("loosen");
set_safe_mode(&sub, true).expect("set_safe_mode");
let mode = std::fs::metadata(&sub).expect("stat").permissions().mode() & 0o7777;
assert_eq!(mode, 0o755, "an extracted directory must end up 0o755");
}
#[test]
fn set_safe_mode_strips_setuid_setgid_and_sticky() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("suid.bin");
std::fs::write(&file, b"x").expect("write");
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o7777)).expect("loosen");
set_safe_mode(&file, false).expect("set_safe_mode");
let mode = std::fs::metadata(&file).expect("stat").permissions().mode() & 0o7777;
assert_eq!(
mode & 0o4000,
0,
"setuid must be stripped, mode was {mode:o}"
);
assert_eq!(
mode & 0o2000,
0,
"setgid must be stripped, mode was {mode:o}"
);
assert_eq!(
mode & 0o1000,
0,
"sticky must be stripped, mode was {mode:o}"
);
assert_eq!(
mode & 0o002,
0,
"world-write must be off, mode was {mode:o}"
);
}
}