use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{CommandFactory, Parser};
use stuffr::entries::{self, ConvertOpts, ExtractOpts, SalvageOpts, Selection};
use stuffr::ops::{self, CompressOpts, DecompressOpts, Input, Output, RecompressOpts};
use stuffr::{Chain, FormatId};
use stuffr_cli::cli::{Cli, Command};
const EXAMPLES: &str = include_str!("examples.txt");
fn input_of(s: &str) -> Input {
if s == "-" {
Input::Stdin
} else {
Input::Path(PathBuf::from(s))
}
}
fn output_of(s: &str) -> Output {
if s == "-" {
Output::Stdout
} else {
Output::Path(PathBuf::from(s))
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.examples {
print!("{EXAMPLES}");
return ExitCode::SUCCESS;
}
let command = match cli.command {
Some(c) => c,
None => {
let _ = Cli::command().print_help();
println!();
return ExitCode::from(2);
}
};
match run(command) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("stuffr: {e}");
ExitCode::from(e.exit_code() as u8)
}
}
}
fn destination_is_stdout(cmd: &Command) -> bool {
match cmd {
Command::Cat { .. } | Command::Info { .. } | Command::Formats | Command::List { .. } => {
true
}
Command::Salvage { list, .. } => *list,
Command::Pack {
output: Some(o), ..
}
| Command::Unpack {
output: Some(o), ..
}
| Command::Convert {
output: Some(o), ..
}
| Command::Convert {
output_pos: Some(o),
..
} => o == "-",
_ => false,
}
}
fn run(command: Command) -> stuffr::Result<()> {
let stdout_dest = destination_is_stdout(&command);
match dispatch(command) {
Ok(()) => Ok(()),
Err(stuffr::Error::Io(e)) if stdout_dest && e.kind() == std::io::ErrorKind::BrokenPipe => {
Ok(())
}
Err(e) => Err(e),
}
}
fn parse_memory_limit(raw: Option<String>) -> stuffr::Result<Option<u64>> {
match raw {
Some(s) => Ok(Some(
stuffr_cli::size::parse_size(&s).map_err(stuffr::Error::Usage)?,
)),
None => Ok(None),
}
}
fn dispatch(command: Command) -> stuffr::Result<()> {
match command {
Command::Pack {
paths,
output,
format,
level,
force,
no_sync,
allow_weak_encoder,
threads,
turbo,
memory_limit,
strict_fidelity,
} => {
let memory_limit = parse_memory_limit(memory_limit)?;
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let opts = CompressOpts {
format: fmt,
level,
force,
sync: !no_sync,
allow_weak_encoder,
threads,
turbo,
memory_limit,
};
if let Some((container, codec)) = resolve_pack_chain(fmt, output.as_deref())? {
refuse_unhonoured_encoder_flags(
EncoderFlags::of(&opts),
codec.is_some(),
NoCodecLayer::PackContainer,
)?;
let inputs = pack_inputs(&paths)?;
let dst = match output {
Some(o) => output_of(&o),
None if inputs.len() == 1 => {
Output::Path(ops::suggest_packed(&inputs[0], container)?)
}
None => {
return Err(stuffr::Error::Usage(
"collecting several paths into an archive needs an explicit \
-o NAME.tar"
.into(),
));
}
};
let out = entries::create_archive(&inputs, dst, container, codec, &opts)?;
eprintln!(
"{} path(s) -> {} ({} -> {} bytes, {})",
inputs.len(),
out.format,
out.bytes_in,
out.bytes_out,
write_fidelity_summary(&out.fidelity)
);
for note in &out.notes {
eprintln!("stuffr: note: {note}");
}
return report_fidelity(&out.fidelity, strict_fidelity);
}
if paths.len() > 1 {
return Err(stuffr::Error::Usage(format!(
"packing {} paths needs an output naming a container (`-o bundle.tar`, \
or --format tar); a codec compresses one stream and has nowhere to \
put a second. A container INSIDE a codec does work in one step — \
`-o bundle.tar.gz` and `-o bundle.tgz` both name a container.",
paths.len()
)));
}
let input = paths.into_iter().next().expect("clap requires one path");
let dst = match output {
Some(o) => output_of(&o),
None => {
let path = match input_of(&input) {
Input::Path(p) => p,
Input::Stdin => {
return Err(stuffr::Error::Usage(
"reading stdin needs an explicit -o".into(),
));
}
};
let chosen = match fmt {
Some(f) => f,
None => ops::default_format()?,
};
Output::Path(ops::suggest_packed(&path, chosen)?)
}
};
let out = ops::compress(input_of(&input), dst, &opts)?;
eprintln!(
"{} -> {} ({} -> {} bytes, {})",
input,
out.format,
out.bytes_in,
out.bytes_out,
write_fidelity_summary(&out.fidelity)
);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Unpack {
input,
patterns,
index,
directory,
output,
force,
format,
max_ratio,
no_sync,
memory_limit,
strict_fidelity,
} => {
let named_entries = !patterns.is_empty() || !index.is_empty();
if let Some(dir) = directory {
let selection = selection_of(patterns, index)?;
refuse_unhonoured_extract_flags(output.is_some(), format.is_some())?;
let opts = ExtractOpts {
max_ratio: max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
force,
memory_limit: Some(
parse_memory_limit(memory_limit)?
.unwrap_or_else(stuffr::default_memory_limit),
),
..Default::default()
};
let out = entries::extract(input_of(&input), Path::new(&dir), &selection, &opts)?;
eprintln!(
"{} -> {} bytes extracted into {dir} ({} fidelity)",
out.format, out.bytes_out, out.fidelity.rung
);
return report_fidelity(&out.fidelity, strict_fidelity);
}
if named_entries {
let named = match patterns.first() {
Some(p) => format!("`{p}`"),
None => format!("`--index {}`", index[0]),
};
return Err(stuffr::Error::Usage(format!(
"{named} names an archive entry; pass -C DIR to say where entries \
should be extracted to"
)));
}
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let mut opts = DecompressOpts {
force,
sync: !no_sync,
memory_limit,
..Default::default()
};
opts.format = fmt;
if let Some(r) = max_ratio {
opts.max_ratio = r;
}
ops::refuse_directory_for(&input_of(&input), "unpack")?;
let dst = match output {
Some(o) => output_of(&o),
None => match input_of(&input) {
Input::Path(p) => Output::Path(ops::suggest_unpacked(&p)?),
Input::Stdin => {
return Err(stuffr::Error::Usage(
"reading stdin needs an explicit -o".into(),
));
}
},
};
let out = ops::decompress(input_of(&input), dst, &opts)?;
eprintln!("{} -> {} bytes", out.format, out.bytes_out);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Convert {
input,
output_pos,
output,
format,
level,
threads,
turbo,
allow_weak_encoder,
force,
strict_fidelity,
max_ratio,
memory_limit,
no_sync,
} => {
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let out_arg = match (output_pos, output) {
(Some(_), Some(_)) => {
return Err(stuffr::Error::Usage(
"convert takes its destination once: as a second positional OUT or \
as -o OUT, not both"
.into(),
));
}
(None, None) => {
return Err(stuffr::Error::Usage(
"convert needs a destination: `stuffr convert IN OUT` or \
`stuffr convert IN -o OUT`"
.into(),
));
}
(Some(o), None) | (None, Some(o)) => o,
};
let dst = output_of(&out_arg);
let out_name = match dst {
Output::Path(_) => Some(out_arg.as_str()),
Output::Stdout => None,
};
if out_name.is_none() && fmt.is_none() {
return Err(stuffr::Error::Usage(
"-o - writes to stdout, which has no name to take the target format \
from; pass --format"
.into(),
));
}
let target = convert_target_chain(fmt, out_name)?;
refuse_unhonoured_encoder_flags(
EncoderFlags {
threads: threads.is_some(),
turbo,
allow_weak_encoder,
},
target.outermost_codec().is_some(),
NoCodecLayer::ConvertTarget(&target),
)?;
let src = input_of(&input);
if let (Input::Path(i), Output::Path(o)) = (&src, &dst) {
refuse_converting_onto_itself(i, o)?;
}
#[cfg(unix)]
if let (Input::Stdin, Output::Path(o)) = (&src, &dst) {
refuse_stdin_redirected_from(o)?;
}
let mut opts = RecompressOpts {
codec: target.outermost_codec(),
level,
force,
sync: !no_sync,
allow_weak_encoder,
threads,
turbo,
memory_limit,
..Default::default()
};
if let Some(r) = max_ratio {
opts.max_ratio = r;
}
ops::check_recompress_target(&opts)?;
let convert_opts = ConvertOpts {
level,
force,
sync: !no_sync,
allow_weak_encoder,
threads,
turbo,
max_ratio: opts.max_ratio,
memory_limit,
spill: stuffr::SpillPolicy::default(),
};
if let Some(container) = target.container() {
match entries::check_convert_target(
container,
target.outermost_codec(),
&convert_opts,
) {
Err(stuffr::Error::CapabilityUnavailable { format, .. })
if format == container => {}
other => other?,
}
}
let source = match ops::ConvertSource::open(src, memory_limit) {
Err(stuffr::Error::UnknownFormat { seen }) => {
return Err(stuffr::Error::Usage(format!(
"`{input}` is not in any format this build recognises (leading \
bytes were {seen}). convert changes a format; to compress a plain \
file use `stuffr pack`"
)));
}
other => other?,
};
let reencode = level.is_some() || threads.is_some() || turbo;
let out = match convert_mode(
source.chain(),
&target,
reencode,
matches!(dst, Output::Stdout),
)? {
ConvertMode::Codec => ops::recompress_source(source, dst, &opts)?,
ConvertMode::Entry => {
let container = target
.container()
.expect("convert_mode answers Entry only for a container target");
entries::convert_archive_source(
source,
dst,
container,
target.outermost_codec(),
&convert_opts,
)?
}
};
eprintln!(
"{input} -> {} ({} -> {} bytes, {})",
target.describe(),
out.bytes_in,
out.bytes_out,
write_fidelity_summary(&out.fidelity)
);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Cat {
input,
patterns,
index,
format,
max_ratio,
memory_limit,
} => {
if !patterns.is_empty() || !index.is_empty() {
let selection = selection_of(patterns, index)?;
refuse_unhonoured_extract_flags(false, format.is_some())?;
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let mut out = std::io::stdout();
entries::cat(
input_of(&input),
&selection,
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
&mut out,
)?;
return Ok(());
}
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let fmt = match format.as_deref() {
Some(name) => Some(format_by_name(name)?),
None => None,
};
let mut opts = DecompressOpts {
format: fmt,
memory_limit,
..Default::default()
};
if let Some(r) = max_ratio {
opts.max_ratio = r;
}
ops::refuse_directory_for(&input_of(&input), "cat")?;
ops::decompress(input_of(&input), Output::Stdout, &opts)?;
Ok(())
}
Command::Info {
input,
json,
memory_limit,
} => {
use std::io::Write;
let memory_limit =
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit);
let i = ops::inspect(input_of(&input))?;
let mut out = std::io::stdout();
if json {
let mut v = serde_json::to_value(&i).expect("Inspection serializes");
if let serde_json::Value::Object(ref mut map) = v {
map.insert("memory_limit".into(), serde_json::json!(memory_limit));
}
writeln!(out, "{v}")?;
} else {
writeln!(out, "format: {}", i.format)?;
writeln!(out, "chain: {}", i.chain)?;
writeln!(out, "rung: {}", i.fidelity.rung)?;
match i.bytes_in {
Some(n) => writeln!(out, "size: {n} bytes")?,
None => writeln!(out, "size: unknown (stream)")?,
}
if !i.fidelity_evaluated {
writeln!(
out,
"fidelity: not evaluated (info does not open the archive; \
run `stuffr test` for that)"
)?;
} else if i.fidelity.has_warnings() {
writeln!(out, "fidelity: {} warning(s)", i.fidelity.warnings.len())?;
for w in &i.fidelity.warnings {
writeln!(out, " - {w}")?;
}
} else {
writeln!(out, "fidelity: nothing approximated")?;
}
writeln!(
out,
"memory: {}",
stuffr_cli::size::format_size(memory_limit)
)?;
}
Ok(())
}
Command::Formats => print_formats(),
Command::List {
input,
json,
max_ratio,
memory_limit,
strict_fidelity,
} => {
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
print_list(
input_of(&input),
json,
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
strict_fidelity,
)
}
Command::Test {
input,
max_ratio,
memory_limit,
strict_fidelity,
} => {
let memory_limit = Some(
parse_memory_limit(memory_limit)?.unwrap_or_else(stuffr::default_memory_limit),
);
let out = entries::test(
input_of(&input),
max_ratio.unwrap_or(stuffr::DEFAULT_MAX_RATIO),
memory_limit,
)?;
eprintln!(
"{} -> {} bytes verified ({})",
out.format,
out.bytes_out,
read_fidelity_summary(&out.fidelity)
);
report_fidelity(&out.fidelity, strict_fidelity)
}
Command::Salvage {
input,
list,
directory,
output,
index,
partial,
max_entry,
strict,
format,
} => {
let code = dispatch_salvage(SalvageArgs {
input,
list,
directory,
output,
index,
partial,
max_entry,
strict,
format,
})?;
if code == 0 {
Ok(())
} else {
std::process::exit(code)
}
}
}
}
fn describe_salvage_status(status: stuffr::salvage::SalvageStatus) -> &'static str {
use stuffr::salvage::{SalvageStatus, UnverifiedCause};
match status {
SalvageStatus::Intact => "Intact",
SalvageStatus::Complete => "Complete",
SalvageStatus::Unattested => "Unattested",
SalvageStatus::Partial => "Partial",
SalvageStatus::Unverified(UnverifiedCause::UndecodableMethod) => {
"Unverified (undecodable method)"
}
SalvageStatus::Unverified(UnverifiedCause::NoDeclaredLength) => {
"Unverified (no declared length)"
}
SalvageStatus::Unverified(UnverifiedCause::OverEntryCeiling { .. }) => {
"Unverified (over the ceiling)"
}
}
}
fn describe_salvage_row(record: &entries::SalvagedRecord) -> String {
let status = match &record.disposition {
entries::SalvageDisposition::WrittenPartial { cause, .. }
| entries::SalvageDisposition::SkippedPartial(cause)
| entries::SalvageDisposition::WrittenDisambiguated {
partial: Some(cause),
..
} => match cause {
entries::PartialCause::Truncated => "Partial (truncated)",
entries::PartialCause::DecodeFailed => "Partial (decode failed)",
entries::PartialCause::ChecksumMismatch => "Partial (checksum mismatch)",
},
entries::SalvageDisposition::WrittenDisambiguated { partial: None, .. }
| entries::SalvageDisposition::Written(_)
| entries::SalvageDisposition::Directory(_)
| entries::SalvageDisposition::SkippedUnverified
| entries::SalvageDisposition::SkippedUnattested
| entries::SalvageDisposition::SkippedNotBuiltIn
| entries::SalvageDisposition::SkippedShadow(_)
| entries::SalvageDisposition::SkippedUnsupportedKind
| entries::SalvageDisposition::SkippedUnwritable { .. }
| entries::SalvageDisposition::SkippedUnsafePath { .. }
| entries::SalvageDisposition::NotSelected
| entries::SalvageDisposition::NotWritten => describe_salvage_status(record.status),
};
let mut line = format!(
"{:<4} {:<32} {}",
record.scan_position,
status,
stuffr::fmt_name(&record.name)
);
if let Some(earlier) = record.shadows {
line.push_str(&format!(" [shadowed: dup of #{earlier}]"));
}
if let Some(earlier) = record.collides_with {
line.push_str(&format!(" [name collision: #{earlier} uses this name too]"));
}
if record.marked_deleted {
line.push_str(" [deleted: the archive marks this entry removed]");
}
if record.status == stuffr::salvage::SalvageStatus::Unattested {
line.push_str(" [no checksum and no header self-check in this format]");
}
if matches!(
record.disposition,
entries::SalvageDisposition::SkippedUnattested
) {
line.push_str(" [not written: --strict demands proof this format cannot give]");
}
if let entries::SalvageDisposition::WrittenDisambiguated { path, .. } = &record.disposition {
line.push_str(&format!(
" [written as {}]",
stuffr::fmt_name(
&path
.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
)
));
}
if matches!(record.disposition, entries::SalvageDisposition::NotSelected) {
line.push_str(" [not selected]");
}
if let stuffr::salvage::SalvageStatus::Unverified(
stuffr::salvage::UnverifiedCause::OverEntryCeiling { needed, ceiling },
) = record.status
{
line.push_str(&format!(
" [needs {needed} bytes; this run reads at most {ceiling} per entry]"
));
}
if let entries::SalvageDisposition::SkippedUnwritable { reason } = &record.disposition {
line.push_str(&format!(" [not written: {reason}]"));
}
if let entries::SalvageDisposition::SkippedUnsafePath { reason } = &record.disposition {
line.push_str(&format!(" [refused: unsafe entry path: {reason}]"));
}
line
}
fn print_salvage_list(entries: &[entries::SalvagedRecord]) -> stuffr::Result<()> {
use std::io::Write;
let mut out = std::io::stdout();
for record in entries {
writeln!(out, "{}", describe_salvage_row(record))?;
}
Ok(())
}
fn print_salvage_write_failures(entries: &[entries::SalvagedRecord]) {
let failed: Vec<&entries::SalvagedRecord> = entries
.iter()
.filter(|r| {
matches!(
r.disposition,
entries::SalvageDisposition::SkippedUnwritable { .. }
| entries::SalvageDisposition::SkippedUnsafePath { .. }
)
})
.collect();
if failed.is_empty() {
return;
}
const SHOWN: usize = 10;
eprintln!(
"salvage -> {} entr{} could not be written:",
failed.len(),
if failed.len() == 1 { "y" } else { "ies" }
);
for record in failed.iter().take(SHOWN) {
let reason = match &record.disposition {
entries::SalvageDisposition::SkippedUnwritable { reason } => reason.clone(),
entries::SalvageDisposition::SkippedUnsafePath { reason } => {
format!("refused: unsafe entry path: {reason}")
}
other => unreachable!("filtered above, got {other:?}"),
};
eprintln!(
" - #{} {}: {}",
record.scan_position,
stuffr::fmt_name(&record.name),
stuffr::fmt_name(&reason)
);
}
if let Some(rest) = failed.len().checked_sub(SHOWN).filter(|n| *n > 0) {
eprintln!(" … and {rest} more");
}
}
fn print_salvage_summary(entries: &[entries::SalvagedRecord]) {
for line in salvage_summary_lines(entries) {
eprintln!("{line}");
}
}
fn salvage_summary_lines(entries: &[entries::SalvagedRecord]) -> Vec<String> {
let (mut written, mut partial, mut skipped, mut unverified, mut not_selected, mut not_written) =
(0, 0, 0, 0, 0, 0);
let mut renamed = 0;
let mut unattested = 0;
let mut unattested_kept = 0;
for record in entries {
if record.status == stuffr::salvage::SalvageStatus::Unattested {
unattested += 1;
if matches!(
record.disposition,
entries::SalvageDisposition::Written(_)
| entries::SalvageDisposition::WrittenDisambiguated { .. }
| entries::SalvageDisposition::WrittenPartial { .. }
| entries::SalvageDisposition::Directory(_)
) {
unattested_kept += 1;
}
}
match &record.disposition {
entries::SalvageDisposition::Written(_) | entries::SalvageDisposition::Directory(_) => {
written += 1;
}
entries::SalvageDisposition::WrittenDisambiguated { partial: cause, .. } => {
renamed += 1;
match cause {
Some(_) => partial += 1,
None => written += 1,
}
}
entries::SalvageDisposition::WrittenPartial { .. } => partial += 1,
entries::SalvageDisposition::SkippedPartial(_)
| entries::SalvageDisposition::SkippedNotBuiltIn
| entries::SalvageDisposition::SkippedShadow(_)
| entries::SalvageDisposition::SkippedUnsupportedKind
| entries::SalvageDisposition::SkippedUnattested
| entries::SalvageDisposition::SkippedUnwritable { .. }
| entries::SalvageDisposition::SkippedUnsafePath { .. } => skipped += 1,
entries::SalvageDisposition::SkippedUnverified => unverified += 1,
entries::SalvageDisposition::NotSelected => not_selected += 1,
entries::SalvageDisposition::NotWritten => not_written += 1,
}
}
let mut lines = vec![format!(
"salvage -> {} scanned: {written} written, {partial} written as .partial, {skipped} \
skipped, {unverified} unverified, {not_selected} not selected, {not_written} not \
written (no destination), {renamed} renamed to avoid a name collision, {unattested} \
unattested",
entries.len()
)];
if unattested > 0 {
lines.push(unattested_note(unattested, unattested_kept));
}
lines
}
fn unattested_note(count: usize, kept: usize) -> String {
let fate = if kept == count {
"kept under the real name unless --strict".to_string()
} else {
format!(
"{kept} kept under the real name and {} not written",
count - kept
)
};
format!(
"salvage -> {count} entr{} unattested: this format carries no checksum and no header \
self-check, so nothing proves a recovered record is a real entry rather than a \
coincidental header match — {fate}, and the run exits 4",
if count == 1 { "y is" } else { "ies are" }
)
}
fn validate_scan_positions(requested: &[usize], total: usize) -> stuffr::Result<()> {
if !requested.is_empty() && requested.iter().all(|&pos| pos >= total) {
return Err(stuffr::Error::Usage(format!(
"no requested scan position exists; this archive's scan found {total} record(s) \
(valid scan positions are 0-{})",
total.saturating_sub(1)
)));
}
Ok(())
}
struct TempDirGuard(PathBuf);
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn fresh_temp_dir(label: &str) -> stuffr::Result<TempDirGuard> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut dir = std::env::temp_dir();
dir.push(format!(
"stuffr-salvage-{label}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir)?;
Ok(TempDirGuard(dir))
}
fn finish_single_file_recovery(
outcome: &entries::SalvageOutcome,
scan_position: usize,
output_path: &Path,
) -> stuffr::Result<()> {
let record = outcome
.entries
.iter()
.find(|r| r.scan_position == scan_position)
.expect("validate_scan_positions already confirmed this scan position was scanned");
let (from, is_partial) = match &record.disposition {
entries::SalvageDisposition::Written(path) => (path.clone(), false),
entries::SalvageDisposition::WrittenPartial { path, .. } => (path.clone(), true),
entries::SalvageDisposition::WrittenDisambiguated { path, partial, .. } => {
(path.clone(), partial.is_some())
}
entries::SalvageDisposition::Directory(_) => {
return Err(stuffr::Error::Usage(format!(
"scan position {scan_position} is a directory entry; -o recovers one file's \
bytes — use -C to recover a directory"
)));
}
entries::SalvageDisposition::SkippedShadow(_)
| entries::SalvageDisposition::SkippedUnverified
| entries::SalvageDisposition::SkippedUnattested
| entries::SalvageDisposition::SkippedNotBuiltIn
| entries::SalvageDisposition::SkippedPartial(_)
| entries::SalvageDisposition::SkippedUnsupportedKind
| entries::SalvageDisposition::SkippedUnwritable { .. }
| entries::SalvageDisposition::SkippedUnsafePath { .. }
| entries::SalvageDisposition::NotSelected
| entries::SalvageDisposition::NotWritten => {
eprintln!(
"salvage -> nothing written for scan position {scan_position}: {}",
describe_salvage_row(record)
);
return Ok(());
}
};
let mut final_path = output_path.to_path_buf();
if is_partial {
let mut name = final_path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(".partial");
final_path = final_path.with_file_name(name);
}
if let Some(parent) = final_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let _ = std::fs::remove_file(&final_path);
if std::fs::rename(&from, &final_path).is_err() {
std::fs::copy(&from, &final_path)?;
std::fs::remove_file(&from)?;
}
eprintln!("salvage -> wrote {}", final_path.display());
if record.status == stuffr::salvage::SalvageStatus::Unattested {
eprintln!("{}", unattested_note(1, 1));
}
Ok(())
}
struct SalvageArgs {
input: String,
list: bool,
directory: Option<String>,
output: Option<String>,
index: Vec<usize>,
partial: Option<String>,
max_entry: Option<String>,
strict: bool,
format: Option<String>,
}
fn dispatch_salvage(args: SalvageArgs) -> stuffr::Result<i32> {
let SalvageArgs {
input,
list,
directory,
output,
index,
partial,
max_entry,
strict,
format,
} = args;
let format = match format.as_deref() {
Some(name) => Some(salvage_format_by_name(name)?),
None => None,
};
if directory.is_some() && output.is_some() {
return Err(stuffr::Error::Usage(
"-C and -o both name a destination; pass one or the other, not both".into(),
));
}
if directory.is_none() && output.is_none() && !list {
return Err(stuffr::Error::Usage(
"pass -C DIR to recover into a directory, -o FILE to recover one entry by \
--index, or --list to see what the scan found without writing anything"
.into(),
));
}
if output.is_some() && index.len() != 1 {
return Err(stuffr::Error::Usage(
"-o recovers exactly one entry; pass exactly one --index N naming its scan \
position"
.into(),
));
}
let path = match input_of(&input) {
Input::Path(p) => p,
Input::Stdin => {
return Err(stuffr::Error::Usage(
"salvage scans back and forth over the archive, which needs a real seekable \
file; pass a path, not `-`"
.into(),
));
}
};
let partial_policy = match partial.as_deref() {
None | Some("keep") => stuffr::salvage::PartialPolicy::Keep,
Some("skip") => stuffr::salvage::PartialPolicy::Skip,
Some("ask") => stuffr::salvage::PartialPolicy::Ask,
Some(other) => {
return Err(stuffr::Error::Usage(format!(
"unknown --partial value `{other}`; expected keep, skip or ask"
)));
}
};
let mut policy = stuffr::salvage::SalvagePolicy {
partial: partial_policy,
strict,
..Default::default()
};
if let Some(raw) = max_entry {
policy.max_entry = stuffr_cli::size::parse_size(&raw).map_err(stuffr::Error::Usage)?;
}
let select: Option<HashSet<usize>> = if index.is_empty() {
None
} else {
Some(index.iter().copied().collect())
};
let temp_guard = if output.is_some() {
Some(fresh_temp_dir("one")?)
} else {
None
};
let dest = match (&directory, &temp_guard) {
(Some(dir), _) => Some(PathBuf::from(dir)),
(None, Some(guard)) => Some(guard.0.clone()),
(None, None) => None,
};
let opts = SalvageOpts {
dest,
policy,
select: select.clone(),
format,
};
let outcome = entries::salvage(&path, &opts)?;
if let Some(note) = stuffr::salvage::describe_sightings(&outcome.sightings) {
eprintln!("salvage -> {note}");
}
if let Some(stop) = &outcome.walk_stop {
eprintln!("salvage -> {}", stuffr::salvage::describe_walk_stop(stop));
}
if entries::salvage_exit_code(&outcome) == 5 {
eprintln!("salvage -> the scan found nothing recoverable in this archive");
return Ok(5);
}
validate_scan_positions(&index, outcome.entries.len())?;
print_salvage_write_failures(&outcome.entries);
if list {
print_salvage_list(&outcome.entries)?;
}
if let Some(output_path) = &output {
finish_single_file_recovery(&outcome, index[0], Path::new(output_path))?;
} else if !list {
let subset: Vec<entries::SalvagedRecord> = match &select {
None => outcome.entries.clone(),
Some(set) => outcome
.entries
.iter()
.filter(|r| set.contains(&r.scan_position))
.cloned()
.collect(),
};
print_salvage_summary(&subset);
}
let (subset, sightings): (Vec<entries::SalvagedRecord>, _) = match &select {
None => (outcome.entries, outcome.sightings),
Some(set) => (
outcome
.entries
.into_iter()
.filter(|r| set.contains(&r.scan_position))
.collect(),
Vec::new(),
),
};
Ok(entries::salvage_exit_code(&entries::SalvageOutcome {
entries: subset,
sightings,
walk_stop: outcome.walk_stop,
}))
}
fn read_fidelity_summary(report: &stuffr::FidelityReport) -> String {
match report.warnings.len() {
0 => format!("{} fidelity", report.rung),
n => format!("{} access, {n} fidelity loss(es)", report.rung),
}
}
fn write_fidelity_summary(report: &stuffr::FidelityReport) -> String {
match report.warnings.len() {
0 => "no fidelity loss".to_string(),
n => format!("{n} fidelity loss(es)"),
}
}
fn report_fidelity(report: &stuffr::FidelityReport, strict: bool) -> stuffr::Result<()> {
if !report.has_warnings() {
return Ok(());
}
const SHOWN: usize = 10;
eprintln!("stuffr: {} fidelity warning(s):", report.warnings.len());
for w in report.warnings.iter().take(SHOWN) {
eprintln!(" - {w}");
}
if let Some(rest) = report.warnings.len().checked_sub(SHOWN).filter(|n| *n > 0) {
eprintln!(" … and {rest} more");
}
if strict {
return Err(stuffr::Error::FidelityDegraded(report.warnings.len()));
}
Ok(())
}
fn resolve_pack_chain(
fmt: Option<FormatId>,
output: Option<&str>,
) -> stuffr::Result<Option<(FormatId, Option<FormatId>)>> {
let registry = stuffr::registry();
if let Some(id) = fmt {
if registry.container(id).is_none() {
return Ok(None);
}
let chain = output.map(|o| stuffr::chain_for_new_path(registry, Path::new(o)));
if let Some(chain) = &chain
&& let Some(named) = chain.container()
&& named != id
{
let out = output.unwrap_or_default();
return Err(stuffr::Error::Usage(format!(
"--format {id} asks for a {id} archive, but the output name `{out}` says \
{named}. Writing it would exit 0 and leave a file `stuffr list` \
then calls corrupt, because a reader takes the container from \
the name. Rename the output, or drop --format and let the name \
decide."
)));
}
let codec = chain.and_then(|c| c.outermost_codec());
return Ok(Some((id, codec)));
}
let Some(output) = output else {
return Ok(None);
};
let chain = stuffr::chain_for_new_path(registry, Path::new(output));
match chain.container() {
Some(container) => Ok(Some((container, chain.outermost_codec()))),
None => Ok(None),
}
}
fn pack_inputs(paths: &[String]) -> stuffr::Result<Vec<PathBuf>> {
if paths.iter().any(|p| p == "-") {
return Err(stuffr::Error::Usage(
"stdin has no name to store an entry under; name files instead of `-`".into(),
));
}
Ok(paths.iter().map(PathBuf::from).collect())
}
fn refuse_unhonoured_extract_flags(output: bool, format: bool) -> stuffr::Result<()> {
if output {
return Err(stuffr::Error::Usage(
"-o writes one decoded stream to one file; -C extracts entries into a \
directory. Pass one or the other."
.into(),
));
}
if format {
return Err(stuffr::Error::Usage(
"--format names a codec for a single-stream decode; entry-aware extraction \
detects the container (and any codec above it) from the archive itself"
.into(),
));
}
Ok(())
}
fn refuse_unhonoured_encoder_flags(
flags: EncoderFlags,
has_codec: bool,
target: NoCodecLayer<'_>,
) -> stuffr::Result<()> {
if has_codec {
return Ok(());
}
let (threads_why, turbo_why, weak_why) = match target {
NoCodecLayer::PackContainer => (
"no container in this build has a parallel encoder of its own, so there is no \
worker count to hand it"
.to_string(),
"no container in this build has a parallel encoder of its own".to_string(),
"no container in this build has an encoder that can fall back".to_string(),
),
NoCodecLayer::ConvertTarget(chain) => {
let d = chain.describe();
(
format!(
"the target, {d}, has no codec layer, so there is no encoder to hand a \
worker count to"
),
format!("the target, {d}, has no codec layer, so there is no encoder to run"),
format!("the target, {d}, has no codec layer, so there is no encoder to fall back"),
)
}
};
if flags.threads {
return Err(stuffr::Error::Usage(format!(
"--threads governs a codec's parallel encoder; {threads_why}"
)));
}
if flags.turbo {
return Err(stuffr::Error::Usage(format!(
"--turbo lifts the CPU cap for a codec's parallel encoder; {turbo_why}"
)));
}
if flags.allow_weak_encoder {
return Err(stuffr::Error::Usage(format!(
"--allow-weak-encoder consents to a codec's fallback encoder; {weak_why}"
)));
}
Ok(())
}
struct EncoderFlags {
threads: bool,
turbo: bool,
allow_weak_encoder: bool,
}
impl EncoderFlags {
fn of(opts: &CompressOpts) -> Self {
Self {
threads: opts.threads.is_some(),
turbo: opts.turbo,
allow_weak_encoder: opts.allow_weak_encoder,
}
}
}
enum NoCodecLayer<'a> {
PackContainer,
ConvertTarget(&'a Chain),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ConvertMode {
Codec,
Entry,
}
fn convert_mode(
src: &Chain,
dst: &Chain,
reencode: bool,
dst_is_stdout: bool,
) -> stuffr::Result<ConvertMode> {
match (src.container(), dst.container()) {
(Some(a), Some(b)) if a != b => Ok(ConvertMode::Entry),
(None, Some(_)) => Err(stuffr::Error::Usage(format!(
"the input is {} and the output would be {}: a plain stream has no entries \
to put in an archive. Decode it with `stuffr unpack`, then `stuffr pack` the \
file into the archive.",
src.describe(),
dst.describe()
))),
(Some(_), None) => Err(stuffr::Error::Usage(format!(
"the input is {} and the output would be {}: an archive's entries do not fit \
in a plain stream. Take them out with `stuffr unpack -C DIR` or `stuffr cat`, \
or name an output that keeps the container. {}",
src.describe(),
dst.describe(),
if dst_is_stdout {
format!(
"To stream it, pipe through pack: `stuffr convert IN -o - --format {} | \
stuffr pack - -o - --format {}`.",
src.container().map(|c| c.to_string()).unwrap_or_default(),
dst.outermost_codec()
.map(|c| c.to_string())
.unwrap_or_default()
)
} else {
String::new()
}
))),
_ if src == dst && src.outermost_codec().is_none() => Err(stuffr::Error::Usage(format!(
"nothing to convert: the input is already {}, which has no codec layer to \
change.",
src.describe()
))),
_ if src == dst && !reencode => Err(stuffr::Error::Usage(format!(
"nothing to convert: the input is already {}. Pass --level, --threads or \
--turbo to re-encode it anyway.",
src.describe()
))),
_ => Ok(ConvertMode::Codec),
}
}
fn convert_target_chain(fmt: Option<FormatId>, output: Option<&str>) -> stuffr::Result<Chain> {
if let Some((container, codec)) = resolve_pack_chain(fmt, output)? {
let inner = Chain::Container { container };
return Ok(match codec {
Some(codec) => Chain::Codec {
codec,
inner: Box::new(inner),
},
None => inner,
});
}
let named = output.map(|o| stuffr::chain_for_new_path(stuffr::registry(), Path::new(o)));
let Some(id) = fmt else {
return Ok(named.unwrap_or(Chain::Raw));
};
match named {
None | Some(Chain::Raw) => Ok(Chain::Codec {
codec: id,
inner: Box::new(Chain::Raw),
}),
Some(chain) if chain.outermost_codec() == Some(id) => Ok(chain),
Some(chain) => Err(stuffr::Error::Usage(format!(
"--format {id} asks for {id}, but the output name `{}` says {}. Rename the \
output, or drop --format and let the name decide.",
output.unwrap_or_default(),
chain.describe()
))),
}
}
fn refuse_converting_onto_itself(input: &Path, output: &Path) -> stuffr::Result<()> {
let canonical_match = match (std::fs::canonicalize(input), std::fs::canonicalize(output)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
};
#[cfg(unix)]
let inode_match = {
use std::os::unix::fs::MetadataExt;
match (std::fs::metadata(input), std::fs::metadata(output)) {
(Ok(a), Ok(b)) => (a.dev(), a.ino()) == (b.dev(), b.ino()),
_ => false,
}
};
#[cfg(not(unix))]
let inode_match = false;
if canonical_match || inode_match {
return Err(same_file_error(&input.display().to_string(), output));
}
Ok(())
}
fn same_file_error(input: &str, output: &Path) -> stuffr::Error {
stuffr::Error::Usage(format!(
"`{input}` and `{}` are the same file; convert reads its input while writing its \
output, so it cannot convert a file onto itself. Name a different output.",
output.display()
))
}
#[cfg(unix)]
fn refuse_stdin_redirected_from(output: &Path) -> stuffr::Result<()> {
use std::os::fd::AsFd;
use std::os::unix::fs::MetadataExt;
let Ok(fd) = std::io::stdin().as_fd().try_clone_to_owned() else {
return Ok(());
};
let Ok(stdin_meta) = std::fs::File::from(fd).metadata() else {
return Ok(());
};
if !stdin_meta.is_file() {
return Ok(());
}
match std::fs::metadata(output) {
Ok(o) if (o.dev(), o.ino()) == (stdin_meta.dev(), stdin_meta.ino()) => {
Err(same_file_error("-", output))
}
_ => Ok(()),
}
}
fn entry_kind_str(kind: &stuffr::EntryKind) -> &'static str {
match kind {
stuffr::EntryKind::File => "file",
stuffr::EntryKind::Dir => "dir",
stuffr::EntryKind::Symlink { .. } => "symlink",
stuffr::EntryKind::Hardlink { .. } => "hardlink",
stuffr::EntryKind::Other => "other",
_ => "other",
}
}
fn print_list(
src: Input,
json: bool,
max_ratio: u64,
memory_limit: Option<u64>,
strict_fidelity: bool,
) -> stuffr::Result<()> {
use std::io::Write;
let (entries, outcome) = entries::list(src, max_ratio, memory_limit)?;
let mut out = std::io::stdout();
if json {
let rows: Vec<serde_json::Value> = entries
.iter()
.enumerate()
.map(|(i, e)| {
serde_json::json!({
"index": i,
"name": e.name,
"kind": entry_kind_str(&e.kind),
"size": e.size,
})
})
.collect();
writeln!(out, "{}", serde_json::Value::Array(rows))?;
} else {
for (i, e) in entries.iter().enumerate() {
match e.size {
Some(n) => writeln!(out, "{i:>5} {n:>12} {}", stuffr::fmt_name(&e.name))?,
None => writeln!(out, "{i:>5} {:>12} {}", "-", stuffr::fmt_name(&e.name))?,
}
}
}
out.flush()?;
report_fidelity(&outcome.fidelity, strict_fidelity)
}
fn selection_of(patterns: Vec<String>, index: Vec<usize>) -> stuffr::Result<Selection> {
match (patterns.is_empty(), index.is_empty()) {
(true, true) => Ok(Selection::All),
(false, true) => Ok(Selection::Names(patterns)),
(true, false) => Ok(Selection::Indices(index)),
(false, false) => Err(stuffr::Error::Usage(
"--index and entry patterns are two ways of choosing the same thing; \
pass one or the other, not both"
.into(),
)),
}
}
fn format_by_name(name: &str) -> stuffr::Result<FormatId> {
stuffr::registry()
.matrix()
.into_iter()
.find(|r| r.id.as_str() == name)
.map(|r| r.id)
.ok_or_else(|| {
let known: Vec<&str> = stuffr::registry()
.matrix()
.iter()
.map(|r| r.id.as_str())
.collect();
stuffr::Error::Usage(format!(
"unknown format `{name}`; this build has: {}",
if known.is_empty() {
"none".to_string()
} else {
known.join(", ")
}
))
})
}
fn salvage_format_by_name(name: &str) -> stuffr::Result<FormatId> {
match name {
"zip" => Ok(FormatId::new("zip")),
"tar" => Ok(FormatId::new("tar")),
"ar" => Ok(FormatId::new("ar")),
"cpio" => Ok(FormatId::new("cpio")),
"arc" => Ok(FormatId::new("arc")),
"zoo" => Ok(FormatId::new("zoo")),
"lha" => Ok(FormatId::new("lha")),
"arj" => Ok(FormatId::new("arj")),
other => Err(stuffr::Error::Usage(format!(
"unknown format `{other}`; salvage recognizes: zip, tar, ar, cpio, arc, zoo, lha, \
arj"
))),
}
}
fn print_formats() -> stuffr::Result<()> {
use std::io::Write;
let mut out = std::io::stdout();
let rows = stuffr::registry().matrix();
if rows.is_empty() {
writeln!(out, "This build contains 0 formats.")?;
writeln!(
out,
"(Phase 0 validates the core against mock formats; Phase 1 adds real ones.)"
)?;
return Ok(());
}
writeln!(
out,
"{:<16} {:<10} {:<5} {:<5} {:<8} {:<7} EXTENSIONS",
"FORMAT", "KIND", "READ", "WRITE", "PARALLEL", "SALVAGE"
)?;
let registry = stuffr::registry();
for r in rows {
let weak = r.write
&& r.kind == stuffr::FormatKind::Codec
&& registry.codec(r.id).is_some_and(|c| c.caps().weak_encoder);
writeln!(
out,
"{:<16} {:<10} {:<5} {:<5} {:<8} {:<7} {}",
r.id.as_str(),
match r.kind {
stuffr::FormatKind::Codec => "codec",
stuffr::FormatKind::Container => "container",
},
if r.read { "yes" } else { "-" },
if weak {
"weak"
} else if r.write {
"yes"
} else {
"-"
},
if r.parallel { "yes" } else { "-" },
salvage_column(registry, r.id),
r.extensions.join(", "),
)?;
}
writeln!(out)?;
writeln!(
out,
"(WRITE shows `weak` for a codec whose encoder in this build is a fallback markedly \
worse than the format's usual one — see --allow-weak-encoder.)"
)?;
writeln!(
out,
"(SALVAGE shows `yes` where `stuffr salvage` scans, verifies each entry against a \
checksum the archive carries, and writes; `weak` where it scans and writes but the \
format checksums nothing an entry's content can be verified against.)"
)?;
Ok(())
}
fn salvage_column(registry: &stuffr::Registry, id: FormatId) -> &'static str {
use stuffr::salvage::Attestation;
let supported = registry.container(id).is_some_and(|c| c.caps().salvage);
if !supported {
return "-";
}
match stuffr::entries::salvage_attestation(id) {
Some(Attestation::ContentChecksum) => "yes",
Some(Attestation::HeaderChecksumOnly | Attestation::Nothing) => "weak",
None => "-",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn salvage_format_by_name_recognises_every_registered_container() {
let containers: Vec<_> = stuffr::registry()
.matrix()
.into_iter()
.filter(|row| row.kind == stuffr::FormatKind::Container)
.collect();
assert!(
!containers.is_empty(),
"sanity: this build must register at least one container format"
);
for row in &containers {
let name = row.id.as_str();
assert!(
salvage_format_by_name(name).is_ok(),
"salvage_format_by_name does not recognise `{name}`, which this build's own \
registry reports as a live container — `salvage_format_by_name` needs a new \
arm for it"
);
}
}
fn record(
status: stuffr::salvage::SalvageStatus,
disposition: entries::SalvageDisposition,
) -> entries::SalvagedRecord {
entries::SalvagedRecord {
scan_position: 0,
name: "notes.txt".into(),
status,
shadows: None,
collides_with: None,
marked_deleted: false,
disposition,
}
}
#[test]
fn an_unattested_run_says_why_on_the_default_summary() {
let written = entries::SalvageDisposition::Written(std::path::PathBuf::from("/o/n"));
let lines = salvage_summary_lines(&[
record(stuffr::salvage::SalvageStatus::Unattested, written.clone()),
record(stuffr::salvage::SalvageStatus::Unattested, written.clone()),
]);
assert!(
lines[0].contains("2 written") && lines[0].contains("2 unattested"),
"the counts must still add up and name the tally: {lines:?}"
);
assert_eq!(
lines.len(),
2,
"a reason line must follow the counts: {lines:?}"
);
assert!(
lines[1].contains("no checksum and no header self-check")
&& lines[1].contains("exits 4"),
"the reason must name the format's shortcoming and the exit code: {lines:?}"
);
let lines = salvage_summary_lines(&[record(
stuffr::salvage::SalvageStatus::Unattested,
entries::SalvageDisposition::SkippedUnattested,
)]);
assert!(
lines[0].contains("1 skipped") && lines[0].contains("1 unattested"),
"{lines:?}"
);
let lines =
salvage_summary_lines(&[record(stuffr::salvage::SalvageStatus::Complete, written)]);
assert_eq!(lines.len(), 1, "no reason line for a clean run: {lines:?}");
assert!(lines[0].contains("0 unattested"), "{lines:?}");
}
#[test]
fn the_unattested_note_counts_only_what_was_kept() {
let written = entries::SalvageDisposition::Written(std::path::PathBuf::from("/o/n"));
let mut records =
vec![record(stuffr::salvage::SalvageStatus::Unattested, written.clone()); 4];
records.push(record(
stuffr::salvage::SalvageStatus::Unattested,
entries::SalvageDisposition::SkippedUnsupportedKind,
));
let lines = salvage_summary_lines(&records);
assert!(
lines[0].contains("4 written") && lines[0].contains("5 unattested"),
"{lines:?}"
);
assert!(
lines[1].contains("5 entries are unattested")
&& lines[1].contains("4 kept under the real name and 1 not written")
&& !lines[1].contains("unless --strict"),
"{lines:?}"
);
let lines = salvage_summary_lines(&records[..4]);
assert!(
lines[1].contains("4 entries are unattested")
&& lines[1].contains("kept under the real name unless --strict"),
"{lines:?}"
);
}
#[test]
fn a_strict_skipped_unattested_row_names_the_flag() {
let row = describe_salvage_row(&record(
stuffr::salvage::SalvageStatus::Unattested,
entries::SalvageDisposition::SkippedUnattested,
));
assert!(row.contains("Unattested"), "{row}");
assert!(
row.contains("--strict"),
"the row must name the flag: {row}"
);
}
#[test]
fn an_unattested_row_carries_the_reason_beside_the_entry() {
let record = entries::SalvagedRecord {
scan_position: 3,
name: "notes.txt".into(),
status: stuffr::salvage::SalvageStatus::Unattested,
shadows: None,
collides_with: None,
marked_deleted: false,
disposition: entries::SalvageDisposition::Written(std::path::PathBuf::from(
"/out/notes.txt",
)),
};
let row = describe_salvage_row(&record);
assert!(
row.contains("Unattested"),
"the row must name the tier: {row}"
);
assert!(
row.contains("no checksum and no header self-check in this format"),
"the row must carry the reason, not just the tier: {row}"
);
assert!(
row.contains("notes.txt"),
"the row must name the entry: {row}"
);
let complete = entries::SalvagedRecord {
status: stuffr::salvage::SalvageStatus::Complete,
..record
};
assert!(
!describe_salvage_row(&complete).contains("no checksum"),
"the reason tag must not leak onto a tier that does not have that problem"
);
}
}