#![feature(seek_stream_len)]
use colored::{Color, Colorize};
use sit::structs::v5::EntryFlags;
use std::{
collections::HashSet,
fs::metadata,
io::{self, Seek},
path::{Path, PathBuf},
process::{self},
};
use std::{fmt, fs};
use walkdir::DirEntry;
use binrw::BinReaderExt;
use clap::{Parser, Subcommand};
use humansize::DECIMAL;
use sit::{
Archive, Entry, Error, Fork,
error::ChecksumLocation,
structs::{
Algorithm, ArchiveHeader,
v5::{self},
},
};
pub mod structs;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long)]
verbose: bool,
}
#[derive(Subcommand)]
enum Commands {
Extract {
#[arg()]
path: PathBuf,
},
Verify {
#[arg(short, long)]
recursive: bool,
#[arg(long)]
fail_early: bool,
#[arg()]
path: PathBuf,
},
Inspect {
#[arg(short, long)]
recursive: bool,
#[arg()]
path: PathBuf,
},
Debug {
#[arg()]
path: Vec<PathBuf>,
},
}
pub fn main() {
pretty_env_logger::init();
log::debug!("Startup");
let cli = Cli::parse();
match &cli.command {
Commands::Extract { path } => {
println!("{}", path.display());
let Ok(mut file) = std::fs::File::open(path) else {
eprintln!("Could not open file");
return;
};
let Ok(_) = file.read_be::<ArchiveHeader>() else {
eprintln!("Not a StuffIt archive");
return;
};
file.seek(io::SeekFrom::Start(0)).unwrap();
let mut current_path: Vec<String> = Vec::new();
current_path.push(".".to_string());
let mut archive = Archive::try_from(file).unwrap();
for entry in archive.iter() {
match entry {
Entry::File(file) => {
let mut rsrc_path = PathBuf::from(current_path.join("/"));
rsrc_path.push(format!("{}.rsrc", file.name()));
println!(
"Extracting {}/{} to {}",
current_path.join("/"),
file.name(),
rsrc_path.display()
);
let mut out = fs::File::create(rsrc_path).unwrap();
let mut reader = archive
.open_fork(&file, Fork::Resource)
.unwrap()
.verifying();
io::copy(&mut reader, &mut out).unwrap();
let mut data_path = PathBuf::from(current_path.join("/"));
data_path.push(file.name());
println!(
"Extracting {}/{} to {}",
current_path.join("/"),
file.name(),
data_path.display()
);
let mut out = fs::File::create(data_path).unwrap();
let mut reader = archive.open_fork(&file, Fork::Data).unwrap().verifying();
io::copy(&mut reader, &mut out).unwrap();
}
Entry::Directory(dir) => {
current_path.push(dir.name().to_string());
}
Entry::DirectoryEnd(_) => {
current_path.pop();
}
}
}
}
Commands::Debug { path } => {
for path in path {
match metadata(path) {
Err(_) => {
eprintln!("Could not open {} for reading", path.display());
}
Ok(stats) if stats.is_dir() => {
let walker = walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| !is_hidden(e));
for entry in walker {
if !metadata(entry.path())
.map(|f| f.is_file())
.unwrap_or_default()
{
continue;
}
debug_inspect_path(entry.path());
}
}
Ok(_) => {
debug_inspect_path(path);
}
}
}
}
Commands::Inspect { recursive, path } => match metadata(path) {
Err(_) => {
eprintln!("Could not open {} for reading", path.display());
std::process::exit(250);
}
Ok(stats) if stats.is_dir() && !*recursive => {
eprintln!(
"Path {} points to a directory. Use the -r option to verify its contents.",
path.display()
);
std::process::exit(251);
}
Ok(stats) if stats.is_dir() && *recursive => {
let walker = walkdir::WalkDir::new(path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| !is_hidden(e));
for entry in walker {
if !metadata(entry.path())
.map(|f| f.is_file())
.unwrap_or_default()
{
continue;
}
inspect_path(entry.path());
}
}
Ok(_) => {
if !inspect_path(path) {
process::exit(1)
}
process::exit(0)
}
},
Commands::Verify {
recursive,
path,
fail_early,
} => match metadata(path) {
Err(e) => {
eprintln!("Could not open {} for reading", path.display());
log::error!("{e:?}");
std::process::exit(250);
}
Ok(stats) if stats.is_dir() && !*recursive => {
eprintln!(
"Path {} points to a directory. Use the -r option to verify its contents.",
path.display()
);
std::process::exit(251);
}
Ok(stats) if stats.is_dir() && *recursive => {
log::info!("Descending into directory {}", path.display());
let walker = walkdir::WalkDir::new(path);
for entry in walker {
match entry {
Err(e) => log::warn!("Got and error reading directory entry: {e:}"),
Ok(entry) if entry.metadata().unwrap().is_dir() => {
continue;
}
Ok(entry) => match verify(entry.path()) {
Ok(()) => {
println!("{}\tvalid", entry.path().display());
}
Err(e) => {
println!("{}\tvalidation failed\t{:}", entry.path().display(), e);
if *fail_early {
std::process::exit(2);
}
}
},
}
}
}
Ok(_) => match verify(path) {
Ok(()) => {
println!("{}\tvalid", path.display());
std::process::exit(0);
}
Err(e) => {
println!("{}\tvalidation failed\t{:}", path.display(), e);
std::process::exit(1);
}
},
},
}
log::debug!("Shutdown");
std::process::exit(0);
}
fn verify<P: AsRef<Path>>(path: P) -> Result<(), Error> {
log::info!("Verifying {}", path.as_ref().display());
sit::Archive::open_path(path)?.verify()
}
fn collect_archive_attributes(
header: ArchiveHeader,
file: &mut std::fs::File,
) -> Vec<(String, String)> {
let mut result = Vec::new();
file.seek(io::SeekFrom::End(0)).unwrap();
let size = file.stream_position().unwrap();
result.push(("Size".to_string(), humansize::format_size(size, DECIMAL)));
if let ArchiveHeader::V1(header) = &header
&& !header.reserved.iter().all(|x| *x == 0)
{
result.push((
"Reserved".to_string(),
header
.reserved
.iter()
.map(|x: &u8| -> String { format!("{x:02x}") })
.collect::<Vec<_>>()
.join(", "),
));
};
result.push(("Version".to_string(), header.version().to_string()));
if let ArchiveHeader::V1(header) = &header {
result.push(("Identifier".to_string(), header.file_code.name()));
}
result.push(("Entries".to_string(), header.entry_count().to_string()));
file.seek(io::SeekFrom::Start(0)).unwrap();
let mut archive = Archive::try_from(file).unwrap();
match archive.verify() {
Ok(()) => result.push(("Checksums".to_string(), "valid".to_string())),
Err(Error::ChecksumMismatch(ChecksumLocation::ArchiveHeader)) => result.push((
"Checksums".to_string(),
format!("{}", "invalid (archive header)".red()),
)),
Err(Error::ChecksumMismatch(_)) => {
result.push(("Checksums".to_string(), format!("{}", "invalid".yellow())))
}
Err(e) => {
result.push((
"Checksums".to_string(),
format!("Could not validate ({e:?})").red().to_string(),
));
}
}
let mut compression_methods: HashSet<Algorithm> = HashSet::new();
let mut data_forks: usize = 0;
let mut resource_forks: usize = 0;
let mut encrypted: usize = 0;
let mut files: usize = 0;
let mut directories: usize = 0;
archive.iter().for_each(|entry| match entry {
Entry::File(file) => {
files += 1;
if file.has(Fork::Data) {
data_forks += 1;
compression_methods.insert(file.compression_method(Fork::Data));
}
if file.has(Fork::Resource) {
resource_forks += 1;
compression_methods.insert(file.compression_method(Fork::Resource));
}
if file.encrypted(Fork::Resource) || file.encrypted(Fork::Data) {
encrypted += 1;
}
}
Entry::Directory(directory) => {
directories += 1;
if directory.has(Fork::Resource) {
resource_forks += 1;
compression_methods.insert(directory.algorithm(Fork::Resource));
}
if directory.has(Fork::Data) {
data_forks += 1;
compression_methods.insert(directory.algorithm(Fork::Data));
}
if directory.encrypted(Fork::Resource) || directory.encrypted(Fork::Data) {
encrypted += 1;
}
}
Entry::DirectoryEnd(_) => {}
});
let mut methods: Vec<_> = compression_methods
.into_iter()
.map(|s| s.to_string())
.collect();
methods.sort();
result.push(("Compression Methods".to_string(), methods.join(", ")));
result.push((
"Encryption".to_string(),
(if encrypted == 0 { "No" } else { "Yes" }).to_string(),
));
result.push(("Data Forks".to_string(), data_forks.to_string()));
result.push(("Resource Forks".to_string(), resource_forks.to_string()));
if directories != 0 && files != 0 {
result.push((
"Contents".to_string(),
format!("{files} files in {directories} directories"),
));
} else if directories != 0 {
result.push(("Contents".to_string(), format!("{directories} directories")));
} else if files != 0 {
result.push(("Contents".to_string(), format!("{files} files")));
}
result
}
fn inspect_path(path: impl AsRef<Path>) -> bool {
println!("{}", path.as_ref().display());
let Ok(mut file) = std::fs::File::open(path) else {
eprintln!("Could not open file");
return false;
};
let Ok(header) = file.read_be::<ArchiveHeader>() else {
println!("{:>20} Not a StuffIt archive", "");
return false;
};
let attributes = collect_archive_attributes(header, &mut file);
for (key, value) in attributes.iter() {
println!("{key:>20}: {value}");
}
file.seek(io::SeekFrom::Start(0)).unwrap();
let mut current_path: Vec<String> = Vec::new();
current_path.push(".".to_string());
let mut archive = Archive::try_from(file).unwrap();
for entry in archive.iter() {
match entry {
Entry::File(file) => {
print!("{:>20} {}/{}", "", current_path.join("/"), file.name());
log::info!("> {file:?}");
match archive.open_fork(&file, Fork::Resource) {
Ok(s) => match s.verifying().slurp() {
Ok(_) => {}
Err(Error::ChecksumMismatch(_)) => {
print!(" {}", "invalid resource fork".yellow());
}
Err(e) => {
print!(
" {}",
format!("Could not validate resource fork ({e:?})").red()
);
}
},
Err(e) => {
print!(" {}", format!("{e}").yellow());
}
}
match archive.open_fork(&file, Fork::Data) {
Ok(s) => match s.verifying().slurp() {
Ok(_) => {}
Err(Error::ChecksumMismatch(_)) => {
print!(" {}", "invalid data fork".yellow());
}
Err(e) => {
print!(" {}", format!("Could not validate data fork ({e:?})").red());
}
},
Err(e) => {
print!(" {}", format!("{e}").yellow());
}
}
println!();
}
Entry::Directory(dir) => {
print!("{:>20} {}/{}/", "", current_path.join("/"), dir.name());
println!();
current_path.push(dir.name().to_string());
}
Entry::DirectoryEnd(_) => {
current_path.pop();
}
}
}
true
}
fn is_hidden(entry: &DirEntry) -> bool {
entry
.file_name()
.to_str()
.map(|s| s.starts_with("."))
.unwrap_or(false)
}
fn debug_inspect_path(path: impl AsRef<Path>) -> bool {
let print_file_name = true;
let print_entry_name = true;
use Entry::{Directory, File};
let Ok(mut archive) = sit::Archive::open_path(&path) else {
return false;
};
let mut line = String::new();
if print_file_name {
line = format!("{:<40} ", path.as_ref().file_name().unwrap().clean());
}
if !matches!(archive.version(), sit::structs::Version::Five) {
}
let line = format!(
"{}{} {}{}{}{}{}",
line,
match archive.version() {
sit::structs::Version::Early => "1.0",
sit::structs::Version::Later => "1.6",
sit::structs::Version::Five => "5.0",
sit::structs::Version::Unknown(_) => "X.X",
},
match archive.header() {
ArchiveHeader::V1(_) => "_",
ArchiveHeader::V5(archive_header) =>
if archive_header.flags.contains(v5::ArchiveFlags::ENCRYPTED) {
"E"
} else {
"_"
},
},
match archive.header() {
ArchiveHeader::V1(_) => "_",
ArchiveHeader::V5(archive_header) =>
if archive_header.flags.contains(v5::ArchiveFlags::PADDING) {
"P"
} else {
"_"
},
},
match archive.header() {
ArchiveHeader::V1(_) => "_",
ArchiveHeader::V5(archive_header) =>
if archive_header.flags.contains(v5::ArchiveFlags::COMMENT) {
"C"
} else {
"_"
},
},
match archive.header() {
ArchiveHeader::V1(_) => "_",
ArchiveHeader::V5(archive_header) =>
if archive_header.flags.contains(v5::ArchiveFlags::FLAG_40) {
"4"
} else {
"_"
},
},
match archive.header() {
ArchiveHeader::V1(_) => "_",
ArchiveHeader::V5(archive_header) =>
if archive_header.flags.contains(v5::ArchiveFlags::RECEIPT) {
"Q"
} else {
"_"
},
},
);
let mut stack = vec!["".to_string()];
for entry in archive.iter() {
let rsrc_color = match archive
.open_fork(&entry, Fork::Resource)
.into_iter()
.try_for_each(|v| v.verifying().slurp())
{
Ok(()) => Color::Green,
Err(Error::ChecksumMismatch(_)) => Color::Yellow,
Err(_) => Color::Red,
};
let data_color = match archive
.open_fork(&entry, Fork::Data)
.into_iter()
.try_for_each(|v| v.verifying().slurp())
{
Ok(()) => Color::Green,
Err(Error::ChecksumMismatch(_)) => Color::Yellow,
Err(_) => Color::Red,
};
match entry {
File(file) => {
print!("{line} f");
if print_entry_name {
print!(" {:<25}", file.name().clean().truncate(25));
}
print!(
" {}{}{}{}{}{}",
if file.uses_encryption() { "E" } else { "_" },
if file.comment().is_empty() { "_" } else { "C" },
if file.has(Fork::Resource) {
"R".color(rsrc_color)
} else {
"_".normal()
},
if file.has(Fork::Data) {
"D".color(data_color)
} else {
"_".normal()
},
match &file {
sit::structs::File::V1(_) => "_",
sit::structs::File::V5(file)
if file.flags.contains(EntryFlags::UNKNOWN1) =>
"U",
_ => "_",
},
match &file {
sit::structs::File::V1(_) => "_",
sit::structs::File::V5(file) if file.flags.contains(EntryFlags::LOCKED) =>
"L",
_ => "_",
}
);
if !file.has(Fork::Data) && !file.has(Fork::Resource) {
println!();
continue;
}
print!(" |");
print!(" {:4}", file.compression_method(Fork::Resource));
print!(" {:7}", file.uncompressed_size(Fork::Resource));
print!(" {:4}", file.compression_method(Fork::Data));
print!(" {:7}", file.uncompressed_size(Fork::Data));
if let sit::structs::File::V5(f) = &file {
print!(" {:04x}", f.unknown_checksum);
print!(" {}", f.version);
print!(" |");
}
print!(" - {}", file.comment().clean().truncate(256));
println!();
}
Directory(dir) => {
print!("{line} d");
if print_entry_name {
print!(" {:<25}", dir.name().clean().truncate(25),);
}
print!(
" {}{}{}{}{}{}",
if dir.uses_encryption() { "E" } else { "_" },
if dir.comment().is_empty() { "_" } else { "C" },
if dir.has(Fork::Resource) {
"R".color(rsrc_color)
} else {
"_".normal()
},
if dir.has(Fork::Data) {
"D".color(data_color)
} else {
"_".normal()
},
match &dir {
sit::structs::Directory::V1(_) => "_",
sit::structs::Directory::V5(directory)
if directory.flags.contains(EntryFlags::UNKNOWN1) =>
"U",
_ => "_",
},
match &dir {
sit::structs::Directory::V1(_) => "_",
sit::structs::Directory::V5(directory)
if directory.flags.contains(EntryFlags::LOCKED) =>
"L",
_ => "_",
}
);
stack.push(dir.name().to_string());
println!();
}
_ => {
stack.pop();
}
}
}
true
}
trait CleanMacString {
fn clean(&self) -> MacString;
}
impl CleanMacString for &str {
fn clean(&self) -> MacString {
MacString(self.to_owned().to_owned())
}
}
impl CleanMacString for String {
fn clean(&self) -> MacString {
MacString(self.clone())
}
}
impl CleanMacString for std::ffi::OsString {
fn clean(&self) -> MacString {
MacString(self.display().to_string())
}
}
impl CleanMacString for std::ffi::OsStr {
fn clean(&self) -> MacString {
MacString(self.display().to_string())
}
}
struct MacString(String);
impl fmt::Display for MacString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result {
self.0
.replace("\r", "\\r")
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.fmt(f)
}
}
trait TruncateString {
fn truncate(&self, max_length: usize) -> String;
}
impl TruncateString for String {
fn truncate(&self, max_length: usize) -> String {
if self.len() > max_length {
let mut substr = self.chars().take(max_length - 1).collect::<String>();
substr.push('…');
return substr;
}
self.to_owned()
}
}
impl TruncateString for MacString {
fn truncate(&self, max_length: usize) -> String {
format!("{self}").truncate(max_length)
}
}