use crate::{
cli::{FileArgs, PasswordArgs},
command::{
Command, ExitCodeError, ask_password,
core::{SplitArchiveReader, collect_split_archives},
},
utils::{BsdGlobMatcher, io::streams_equal},
};
use clap::Parser;
#[cfg(unix)]
use pna::prelude::MetadataTimeExt;
use pna::{DataKind, EntryContent, NormalEntry, ReadOptions};
use same_file::is_same_file;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::time::SystemTime;
use std::{fmt, fs, io, path::Path};
#[derive(Parser, Clone, Debug)]
pub(crate) struct DiffCommand {
#[command(flatten)]
file: FileArgs,
#[command(flatten)]
password: PasswordArgs,
#[arg(
long,
help = "Compare directory mtime and ownership (by default, only mode is compared for directories)"
)]
full_compare: bool,
}
impl Command for DiffCommand {
#[inline]
fn execute(self, _ctx: &crate::cli::GlobalContext) -> anyhow::Result<()> {
match diff_archive(self) {
Ok(0) => Ok(()),
Ok(_) => Err(ExitCodeError::silent(1).into()),
Err(err) => Err(ExitCodeError::with_source(2, err).into()),
}
}
}
#[hooq::hooq(anyhow)]
fn diff_archive(args: DiffCommand) -> anyhow::Result<usize> {
let password = ask_password(args.password)?;
let archives = collect_split_archives(&args.file.archive)?;
let options = CompareOptions {
full_compare: args.full_compare,
};
let mut globs = BsdGlobMatcher::new(args.file.files.iter().map(|s| s.as_str()));
let filter_enabled = !globs.is_empty();
let read_options = ReadOptions::with_password(password.as_deref());
let mut source = SplitArchiveReader::new(archives)?;
let mut diff_count = 0usize;
source.for_each_entry(
&read_options,
#[hooq::skip_all]
|entry| {
let entry = entry?;
let path = entry.header().path();
if filter_enabled && !globs.matches(path) {
return Ok(());
}
diff_count += compare_entry(entry, &read_options, &options)?;
Ok(())
},
)?;
globs.ensure_all_matched()?;
Ok(diff_count)
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum DiffKind {
Missing,
SizeDiffers,
ContentsDiffer,
#[cfg(unix)]
ModeDiffers,
#[cfg(unix)]
MtimeDiffers,
#[cfg(unix)]
UidDiffers,
#[cfg(unix)]
GidDiffers,
TypeMismatch,
SymlinkDiffers,
NotLinked(String),
}
impl DiffKind {
fn display<'a>(&'a self, path: &'a str) -> DiffMessage<'a> {
DiffMessage { kind: self, path }
}
}
struct DiffMessage<'a> {
kind: &'a DiffKind,
path: &'a str,
}
impl fmt::Display for DiffMessage<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
DiffKind::Missing => {
write!(
f,
"{}: Warning: Cannot stat: No such file or directory",
self.path
)
}
DiffKind::SizeDiffers => write!(f, "{}: Size differs", self.path),
DiffKind::ContentsDiffer => write!(f, "{}: Contents differ", self.path),
#[cfg(unix)]
DiffKind::ModeDiffers => write!(f, "{}: Mode differs", self.path),
#[cfg(unix)]
DiffKind::MtimeDiffers => write!(f, "{}: Mod time differs", self.path),
#[cfg(unix)]
DiffKind::UidDiffers => write!(f, "{}: Uid differs", self.path),
#[cfg(unix)]
DiffKind::GidDiffers => write!(f, "{}: Gid differs", self.path),
DiffKind::TypeMismatch => write!(f, "{}: File type mismatch", self.path),
DiffKind::SymlinkDiffers => write!(f, "{}: Symlink differs", self.path),
DiffKind::NotLinked(target) => write!(f, "{}: Not linked to {target}", self.path),
}
}
}
#[derive(Clone, Debug, Default)]
struct CompareOptions {
#[cfg_attr(not(unix), allow(dead_code))]
full_compare: bool,
}
#[cfg(unix)]
fn times_equal(a: SystemTime, b: SystemTime) -> bool {
match a.duration_since(b) {
Ok(d) => d.as_secs() == 0,
Err(e) => e.duration().as_secs() == 0,
}
}
#[cfg(unix)]
fn compare_file_metadata<T: AsRef<[u8]>>(
entry: &NormalEntry<T>,
fs_meta: &fs::Metadata,
_options: &CompareOptions,
) -> Vec<DiffKind> {
let mut diffs = Vec::new();
let ownership = crate::ext::ResolvedOwnership::from_metadata(entry.metadata());
if let Some(mode) = ownership.mode {
let archive_mode = mode & 0o7777;
let fs_mode = (fs_meta.permissions().mode() & 0o7777) as u16;
if archive_mode != fs_mode {
diffs.push(DiffKind::ModeDiffers);
}
}
if let Some(archive_mtime) = entry.metadata().saturating_modified_time()
&& let Ok(fs_mtime) = fs_meta.modified()
&& !times_equal(archive_mtime, fs_mtime)
{
diffs.push(DiffKind::MtimeDiffers);
}
if let Some(uid) = ownership.uid
&& uid != fs_meta.uid() as u64
{
diffs.push(DiffKind::UidDiffers);
}
if let Some(gid) = ownership.gid
&& gid != fs_meta.gid() as u64
{
diffs.push(DiffKind::GidDiffers);
}
diffs
}
#[cfg(not(unix))]
fn compare_file_metadata<T: AsRef<[u8]>>(
_entry: &NormalEntry<T>,
_fs_meta: &fs::Metadata,
_options: &CompareOptions,
) -> Vec<DiffKind> {
Vec::new()
}
#[cfg(unix)]
fn compare_directory_metadata<T: AsRef<[u8]>>(
entry: &NormalEntry<T>,
fs_meta: &fs::Metadata,
options: &CompareOptions,
) -> Vec<DiffKind> {
let mut diffs = Vec::new();
let ownership = crate::ext::ResolvedOwnership::from_metadata(entry.metadata());
if let Some(mode) = ownership.mode {
let archive_mode = mode & 0o7777;
let fs_mode = (fs_meta.permissions().mode() & 0o7777) as u16;
if archive_mode != fs_mode {
diffs.push(DiffKind::ModeDiffers);
}
}
if options.full_compare {
if let Some(archive_mtime) = entry.metadata().saturating_modified_time()
&& let Ok(fs_mtime) = fs_meta.modified()
&& !times_equal(archive_mtime, fs_mtime)
{
diffs.push(DiffKind::MtimeDiffers);
}
if let Some(uid) = ownership.uid
&& uid != fs_meta.uid() as u64
{
diffs.push(DiffKind::UidDiffers);
}
if let Some(gid) = ownership.gid
&& gid != fs_meta.gid() as u64
{
diffs.push(DiffKind::GidDiffers);
}
}
diffs
}
#[cfg(not(unix))]
fn compare_directory_metadata<T: AsRef<[u8]>>(
_entry: &NormalEntry<T>,
_fs_meta: &fs::Metadata,
_options: &CompareOptions,
) -> Vec<DiffKind> {
Vec::new()
}
fn compare_entry<T: AsRef<[u8]>>(
entry: NormalEntry<T>,
read_options: &ReadOptions,
options: &CompareOptions,
) -> io::Result<usize> {
let data_kind = entry.header().data_kind();
let path = entry.header().path();
let path_str = path.as_str();
let meta = match fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
println!("{}", DiffKind::Missing.display(path_str));
return Ok(1);
}
Err(e) => return Err(e),
};
let mut diff_count = 0usize;
match data_kind {
DataKind::FILE if meta.is_file() => {
let meta_diffs = compare_file_metadata(&entry, &meta, options);
diff_count += meta_diffs.len();
for diff in meta_diffs {
println!("{}", diff.display(path_str));
}
let fs_size = meta.len();
let archive_size = entry.metadata().raw_file_size();
if archive_size.is_some_and(|s| s != fs_size as u128) {
println!("{}", DiffKind::SizeDiffers.display(path_str));
diff_count += 1;
} else {
let fs_file = fs::File::open(path)?;
let archive_reader = entry.reader(read_options)?;
if !streams_equal(fs_file, archive_reader)? {
println!("{}", DiffKind::ContentsDiffer.display(path_str));
diff_count += 1;
}
}
}
DataKind::DIRECTORY if meta.is_dir() => {
let diffs = compare_directory_metadata(&entry, &meta, options);
diff_count += diffs.len();
for diff in diffs {
println!("{}", diff.display(path_str));
}
}
DataKind::SYMBOLIC_LINK if meta.is_symlink() => {
let link = fs::read_link(path)?;
let EntryContent::SymbolicLink(stored) = entry.content(read_options)? else {
unreachable!("data_kind() returned SymbolicLink");
};
if link.as_path() != Path::new(stored.as_str()) {
println!("{}", DiffKind::SymlinkDiffers.display(path_str));
diff_count += 1;
}
}
DataKind::HARD_LINK if meta.is_file() => {
let EntryContent::HardLink(stored) = entry.content(read_options)? else {
unreachable!("data_kind() returned HardLink");
};
match is_same_file(path, stored.as_str()) {
Ok(true) => (),
Ok(false) => {
println!(
"{}",
DiffKind::NotLinked(stored.to_string()).display(path_str)
);
diff_count += 1;
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
println!("{}", DiffKind::Missing.display(path_str));
diff_count += 1;
}
Err(e) => return Err(e),
}
}
_ => {
println!("{}", DiffKind::TypeMismatch.display(path_str));
diff_count += 1;
}
}
Ok(diff_count)
}