use crate::{
cli::{FileArgs, PasswordArgs},
command::{
Command, ExitCodeError, ask_password,
core::{SplitArchiveReader, collect_split_archives},
},
utils::{BsdGlobMatcher, io::streams_equal},
};
use clap::{Parser, ValueEnum};
#[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,
#[arg(
long,
default_value = "plain",
help = "Output format [unstable: jsonl]",
long_help = "Output format. plain: tar-style text. jsonl: one JSON Lines record per difference with fields `path`, `kind` (one of: missing, size, content, mode, mtime, uid, gid, type, symlink, hardlink) and, for kind=hardlink only, `target` (the stored link target). mode/mtime/uid/gid comparisons only occur on Unix."
)]
format: Format,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
#[value(rename_all = "lower")]
enum Format {
Plain,
JsonL,
}
impl Format {
#[inline]
const fn is_unstable(self) -> bool {
matches!(self, Self::JsonL)
}
}
impl fmt::Display for Format {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.to_possible_value().unwrap().get_name())
}
}
impl Command for DiffCommand {
#[inline]
fn execute(self, ctx: &crate::cli::GlobalContext) -> anyhow::Result<()> {
match diff_archive(ctx, 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(ctx: &crate::cli::GlobalContext, args: DiffCommand) -> anyhow::Result<usize> {
if args.format.is_unstable() && !ctx.unstable() {
anyhow::bail!(
"The '--format {}' option is unstable and requires --unstable flag",
args.format
);
}
let password = ask_password(args.password)?;
let archives = collect_split_archives(&args.file.archive)?;
let options = CompareOptions {
full_compare: args.full_compare,
format: args.format,
};
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, serde::Serialize)]
#[serde(tag = "kind")]
enum DiffKind {
#[serde(rename = "missing")]
Missing,
#[serde(rename = "size")]
SizeDiffers,
#[serde(rename = "content")]
ContentsDiffer,
#[cfg(unix)]
#[serde(rename = "mode")]
ModeDiffers,
#[cfg(unix)]
#[serde(rename = "mtime")]
MtimeDiffers,
#[cfg(unix)]
#[serde(rename = "uid")]
UidDiffers,
#[cfg(unix)]
#[serde(rename = "gid")]
GidDiffers,
#[serde(rename = "type")]
TypeMismatch,
#[serde(rename = "symlink")]
SymlinkDiffers,
#[serde(rename = "hardlink")]
NotLinked { target: String },
}
impl DiffKind {
fn display<'a>(&'a self, path: &'a str) -> DiffMessage<'a> {
DiffMessage { kind: self, path }
}
}
#[derive(serde::Serialize)]
struct DiffRecord<'a> {
path: &'a str,
#[serde(flatten)]
kind: &'a DiffKind,
}
fn report(kind: &DiffKind, path: &str, format: Format) {
match format {
Format::Plain => println!("{}", kind.display(path)),
Format::JsonL => println!(
"{}",
serde_json::to_string(&DiffRecord { path, kind }).unwrap()
),
}
}
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 differs", self.path),
DiffKind::SymlinkDiffers => write!(f, "{}: Symlink differs", self.path),
DiffKind::NotLinked { target } => write!(f, "{}: Not linked to {target}", self.path),
}
}
}
#[derive(Clone, Debug)]
struct CompareOptions {
#[cfg_attr(not(unix), allow(dead_code))]
full_compare: bool,
format: Format,
}
#[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 => {
report(&DiffKind::Missing, path_str, options.format);
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 {
report(diff, path_str, options.format);
}
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) {
report(&DiffKind::SizeDiffers, path_str, options.format);
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)? {
report(&DiffKind::ContentsDiffer, path_str, options.format);
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 {
report(diff, path_str, options.format);
}
}
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()) {
report(&DiffKind::SymlinkDiffers, path_str, options.format);
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) => {
report(
&DiffKind::NotLinked {
target: stored.to_string(),
},
path_str,
options.format,
);
diff_count += 1;
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
report(&DiffKind::Missing, path_str, options.format);
diff_count += 1;
}
Err(e) => return Err(e),
}
}
_ => {
report(&DiffKind::TypeMismatch, path_str, options.format);
diff_count += 1;
}
}
Ok(diff_count)
}