summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorStefan Kreutz <mail@skreutz.com>2024-03-24 15:04:09 +0100
committerStefan Kreutz <mail@skreutz.com>2024-03-24 15:04:09 +0100
commitc1fa48e9bd617d70e823efef5d6dcea41b1d2087 (patch)
tree421e69c512ac54bf65495ef23fd7d9ec5a5e67d5 /src
downloadbrck-c1fa48e9bd617d70e823efef5d6dcea41b1d2087.tar
Add initial implementationbrck-0.1.0
Diffstat (limited to 'src')
-rw-r--r--src/core.rs306
-rw-r--r--src/fs.rs144
-rw-r--r--src/main.rs343
3 files changed, 793 insertions, 0 deletions
diff --git a/src/core.rs b/src/core.rs
new file mode 100644
index 0000000..6ac3ba5
--- /dev/null
+++ b/src/core.rs
@@ -0,0 +1,306 @@
+//! Core functionality.
+//!
+//! This module contains Brck's core types and functions.
+
+use std::{
+ cmp::Ordering,
+ fs::OpenOptions,
+ io::{BufReader, Read},
+ path::{Path, PathBuf},
+ sync::{atomic::AtomicBool, Arc},
+};
+
+use chrono::{DateTime, Utc};
+use either::Either;
+use flate2::bufread::GzDecoder;
+use itertools::{EitherOrBoth, Itertools};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use walkdir::WalkDir;
+
+/// Produces an iterator over the regular files below the given path.
+///
+/// The files are sorted by their full path.
+/// Symlinks are not followed, except for the original `path`.
+pub fn find_files<P: AsRef<Path>>(
+ path: P,
+) -> impl Iterator<Item = Result<PathBuf, walkdir::Error>> {
+ WalkDir::new(path.as_ref())
+ .sort_by_path()
+ .into_iter()
+ .filter_ok(|entry| entry.file_type().is_file())
+ .map_ok(|entry| entry.into_path())
+}
+
+/// Extension for [WalkDir].
+trait WalkDirExt {
+ /// Sort directory entries by full path.
+ ///
+ /// Yiedls "foo bar/buzz" before "foo/buzz".
+ fn sort_by_path(self) -> WalkDir;
+}
+
+impl WalkDirExt for WalkDir {
+ fn sort_by_path(self) -> WalkDir {
+ self.sort_by_key(|a| {
+ if a.file_type().is_dir() {
+ let mut name = a.file_name().to_os_string();
+ name.push(std::path::MAIN_SEPARATOR.to_string());
+ name
+ } else {
+ a.file_name().to_os_string()
+ }
+ })
+ }
+}
+
+/// Returns an iterator over the records of the given database.
+pub fn read_db<P: AsRef<Path>>(
+ path: P,
+) -> Result<impl Iterator<Item = Result<Record, serde_json::Error>>, std::io::Error> {
+ let file = OpenOptions::new().read(true).open(path)?;
+ let buf = BufReader::new(file);
+ let dec = GzDecoder::new(buf);
+ let iter = serde_json::Deserializer::from_reader(dec).into_iter::<Record>();
+ Ok(iter)
+}
+
+/// A record of a file's modification time and hash sum.
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+pub struct Record {
+ pub path: PathBuf,
+ pub modified: DateTime<Utc>,
+ #[serde(with = "hex::serde")]
+ pub sha256: Vec<u8>,
+}
+
+impl Record {
+ /// Constructs a `Record` of the given file.
+ ///
+ /// This function reads the given file in chunks of at most `chunk_size` bytes. It checks the
+ /// `terminate` flag before reading each chunk, returning the `RecordError::Interrupt` error
+ /// when set.
+ pub fn from_path<P: AsRef<Path>>(
+ path: P,
+ chunk_size: u64,
+ terminate: Arc<AtomicBool>,
+ ) -> Result<Record, RecordError> {
+ let modified = std::fs::symlink_metadata(&path)?.modified()?;
+ let mut file = std::fs::File::open(&path)?;
+ let mut hasher = Sha256::new();
+ loop {
+ if terminate.load(std::sync::atomic::Ordering::SeqCst) {
+ return Err(RecordError::Interrupt);
+ }
+ let mut reader = std::io::Read::by_ref(&mut file).take(chunk_size);
+ if std::io::copy(&mut reader, &mut hasher)? == 0 {
+ break;
+ }
+ }
+ let hash = hasher.finalize();
+ Ok(Record {
+ path: path.as_ref().into(),
+ modified: modified.into(),
+ sha256: hash.as_slice().into(),
+ })
+ }
+}
+
+/// The possible errors of [Record::from_path].
+#[derive(Debug)]
+pub enum RecordError {
+ IO(std::io::Error),
+ Interrupt,
+}
+
+impl std::fmt::Display for RecordError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ RecordError::IO(err) => write!(f, "{}", err),
+ RecordError::Interrupt => write!(f, "Received interrupt signal"),
+ }
+ }
+}
+
+impl std::error::Error for RecordError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ RecordError::IO(err) => Some(err),
+ RecordError::Interrupt => None,
+ }
+ }
+}
+
+impl From<std::io::Error> for RecordError {
+ fn from(value: std::io::Error) -> Self {
+ RecordError::IO(value)
+ }
+}
+
+/// Returns an iterator over the difference of the given files.
+pub fn diff<I, J, E, F>(cached: I, found: J) -> impl Iterator<Item = Result<Diff, Either<E, F>>>
+where
+ I: Iterator<Item = Result<Record, E>>,
+ J: Iterator<Item = Result<Record, F>>,
+{
+ cached
+ .merge_join_by(found, |cached, found| match (cached, found) {
+ (Err(_), _) => Ordering::Less,
+ (Ok(_), Err(_)) => Ordering::Greater,
+ (Ok(cached), Ok(found)) => cached.path.cmp(&found.path),
+ })
+ .map(|item| match item {
+ EitherOrBoth::Left(Err(err)) => Err(Either::Left(err)),
+ EitherOrBoth::Right(Err(err)) => Err(Either::Right(err)),
+ EitherOrBoth::Left(Ok(old)) => Ok(Diff::Removed { old }),
+ EitherOrBoth::Right(Ok(new)) => Ok(Diff::Added { new }),
+ EitherOrBoth::Both(Ok(old), Ok(new)) => {
+ if old.modified == new.modified {
+ if old.sha256 == new.sha256 {
+ Ok(Diff::Unchanged { old, new })
+ } else {
+ Ok(Diff::Corrupted { old, new })
+ }
+ } else if old.sha256 == new.sha256 {
+ Ok(Diff::Touched { old, new })
+ } else {
+ Ok(Diff::Changed { old, new })
+ }
+ }
+ _ => unreachable!(),
+ })
+}
+
+/// The list of possible types of differences.
+#[derive(Debug, Clone, Hash, Eq, PartialEq, clap::ValueEnum)]
+pub enum DiffKind {
+ Added,
+ Touched,
+ Changed,
+ Unchanged,
+ Removed,
+ Corrupted,
+}
+
+/// The difference between two records of a file.
+#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum Diff {
+ // Modeled variants as structs to improve their JSON representation.
+ Added { new: Record },
+ Touched { old: Record, new: Record },
+ Changed { old: Record, new: Record },
+ Unchanged { old: Record, new: Record },
+ Removed { old: Record },
+ Corrupted { old: Record, new: Record },
+}
+
+impl Diff {
+ pub fn kind(&self) -> DiffKind {
+ match self {
+ Diff::Added { .. } => DiffKind::Added,
+ Diff::Touched { .. } => DiffKind::Touched,
+ Diff::Changed { .. } => DiffKind::Changed,
+ Diff::Unchanged { .. } => DiffKind::Unchanged,
+ Diff::Removed { .. } => DiffKind::Removed,
+ Diff::Corrupted { .. } => DiffKind::Corrupted,
+ }
+ }
+}
+
+impl std::fmt::Display for Diff {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Diff::Added { new, .. } => write!(f, "added: {}", new.path.display()),
+ Diff::Touched { old, .. } => write!(f, "touched: {}", old.path.display()),
+ Diff::Changed { old, .. } => write!(f, "changed: {}", old.path.display()),
+ Diff::Unchanged { old, .. } => write!(f, "unchanged: {}", old.path.display()),
+ Diff::Removed { old, .. } => write!(f, "removed: {}", old.path.display()),
+ Diff::Corrupted { old, .. } => write!(f, "corrupted: {}", old.path.display()),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use testresult::TestResult;
+
+ use super::*;
+
+ fn record(path: &str, modified: &str, sha256: &str) -> Record {
+ Record {
+ path: path.into(),
+ modified: DateTime::parse_from_rfc3339(modified).unwrap().into(),
+ sha256: sha256.into(),
+ }
+ }
+
+ #[test]
+ fn diff_works() -> TestResult {
+ let a = record("a", "2024-03-22T00:00:00Z", "apple");
+ let b = record("b", "2024-03-22T00:00:00Z", "banana");
+ let b_touched = record("b", "2024-03-22T01:00:00Z", "banana");
+ let c = record("c", "2024-03-22T00:00:00Z", "cherry");
+ let c_changed = record("c", "2024-03-22T01:00:00Z", "cashew");
+ let d = record("d", "2024-03-22T00:00:00Z", "date");
+ let e = record("e", "2024-03-22T00:00:00Z", "elderberry");
+ let f = record("f", "2024-03-22T00:00:00Z", "fig");
+ let f_corrupted = record("f", "2024-03-22T00:00:00Z", "feijoa");
+
+ let db = [
+ Err("foo"),
+ Ok(b.clone()),
+ Ok(c.clone()),
+ Ok(d.clone()),
+ Ok(e.clone()),
+ Ok(f.clone()),
+ ];
+
+ let fs = [
+ Ok(a.clone()),
+ Ok(b_touched.clone()),
+ Err("bar"),
+ Ok(c_changed.clone()),
+ Ok(d.clone()),
+ Ok(f_corrupted.clone()),
+ ];
+
+ let mut result = diff(db.into_iter(), fs.into_iter());
+
+ assert_eq!(result.next(), Some(Err(Either::Left("foo"))));
+ assert_eq!(result.next(), Some(Ok(Diff::Added { new: a })));
+ assert_eq!(
+ result.next(),
+ Some(Ok(Diff::Touched {
+ old: b,
+ new: b_touched
+ }))
+ );
+ assert_eq!(result.next(), Some(Err(Either::Right("bar"))));
+ assert_eq!(
+ result.next(),
+ Some(Ok(Diff::Changed {
+ old: c,
+ new: c_changed
+ }))
+ );
+ assert_eq!(
+ result.next(),
+ Some(Ok(Diff::Unchanged {
+ old: d.clone(),
+ new: d
+ }))
+ );
+ assert_eq!(result.next(), Some(Ok(Diff::Removed { old: e })));
+ assert_eq!(
+ result.next(),
+ Some(Ok(Diff::Corrupted {
+ old: f,
+ new: f_corrupted
+ }))
+ );
+ assert_eq!(result.next(), None);
+
+ Ok(())
+ }
+}
diff --git a/src/fs.rs b/src/fs.rs
new file mode 100644
index 0000000..f962e53
--- /dev/null
+++ b/src/fs.rs
@@ -0,0 +1,144 @@
+//! Filesytem functionality.
+//!
+//! This module contains several extensions to [std::fs].
+
+use std::{
+ fs::{File, OpenOptions},
+ path::{Path, PathBuf},
+};
+
+/// A std::fs::File wrapper that removes the file when dropped.
+#[derive(Debug)]
+pub struct TmpFile {
+ path: PathBuf,
+ file: File,
+}
+
+impl TmpFile {
+ /// Opens a new temporary file at the given path.
+ pub fn open<P: AsRef<Path>>(path: P) -> Result<TmpFile, std::io::Error> {
+ let mut options = OpenOptions::new();
+ options.create_new(true).write(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.mode(0o600);
+ }
+ let file = options.open(&path)?;
+ Ok(TmpFile {
+ path: path.as_ref().into(),
+ file,
+ })
+ }
+
+ pub fn file(&self) -> &File {
+ &self.file
+ }
+}
+
+impl Drop for TmpFile {
+ fn drop(&mut self) {
+ if let Err(err) = remove_file_if_exists(&self.path) {
+ eprintln!(
+ "Failed to remove temporary file {}: {}",
+ &self.path.display(),
+ err
+ );
+ }
+ }
+}
+
+/// Removes a file from the filesystem, if it exists.
+pub fn remove_file_if_exists<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> {
+ std::fs::remove_file(path).or_else(|err| {
+ if err.kind() == std::io::ErrorKind::NotFound {
+ Ok(())
+ } else {
+ Err(err)
+ }
+ })
+}
+
+/// Returns the path without it's final component, if there is one.
+///
+/// In contrast to [Path::parent], this function returns the [CurDir][std::path::Component::CurDir]
+/// instead of the empty string for the current directory.
+pub fn parent_dir<P: AsRef<Path>>(path: &P) -> Option<&Path> {
+ path.as_ref().parent().map(|p| {
+ if p.as_os_str().is_empty() {
+ Path::new(std::path::Component::CurDir.as_os_str())
+ } else {
+ p
+ }
+ })
+}
+
+/// Rename a file.
+///
+/// This function should be atomic on POSIX-conform systems.
+///
+/// Both paths must be located on the same filesystem.
+pub fn rename_file<P, Q>(from: P, to: Q) -> Result<(), std::io::Error>
+where
+ P: AsRef<Path>,
+ Q: AsRef<Path>,
+{
+ File::open(&from)?.sync_all()?;
+ std::fs::rename(&from, &to)?;
+
+ let dest_dir = parent_dir(&to).ok_or_else(|| std::io::Error::other("is root directory"))?;
+ File::open(dest_dir)?.sync_all()?;
+
+ let src_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?;
+ if src_dir != dest_dir {
+ File::open(src_dir)?.sync_all()?;
+ }
+
+ Ok(())
+}
+
+/// Copy a file.
+///
+/// This functions hard links `from` to `tmp`, and then renames the latter to `to`. The renaming
+/// should be atomic on POSIX-conform systems.
+///
+/// All three paths must be located on the same filesystem.
+// Didn't use https://crates.io/crates/atomic-write-file or https://crates.io/crates/atomicwrites
+// because they create randomly named temporary files, which I wanted to unveil(2) on OpenBSD, and
+// remove when receiving a termination signal.
+pub fn copy_file<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error>
+where
+ P: AsRef<Path>,
+ Q: AsRef<Path>,
+ R: AsRef<Path>,
+{
+ fn inner<P, Q, R>(from: P, tmp: Q, to: R) -> Result<(), std::io::Error>
+ where
+ P: AsRef<Path>,
+ Q: AsRef<Path>,
+ R: AsRef<Path>,
+ {
+ let tmp_dir =
+ parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?;
+ File::open(tmp_dir)?.sync_all()?;
+
+ std::fs::rename(&tmp, &to)?;
+
+ let to_dir = parent_dir(&from).ok_or_else(|| std::io::Error::other("is root directory"))?;
+ File::open(to_dir)?.sync_all()?;
+
+ Ok(())
+ }
+
+ File::open(&from)?.sync_all()?;
+ std::fs::hard_link(&from, &tmp)?;
+ inner(&from, &tmp, &to).inspect_err(|_| {
+ if let Err(err) = remove_file_if_exists(&tmp) {
+ eprintln!(
+ "Failed to remove temporary file {}: {}",
+ tmp.as_ref().display(),
+ err
+ );
+ }
+ })
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..2d26eed
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,343 @@
+#![forbid(unsafe_code)]
+
+use std::{
+ collections::HashMap,
+ num::{NonZeroU64, NonZeroUsize},
+ path::PathBuf,
+ sync::{atomic::AtomicBool, Arc},
+};
+
+use anyhow::{anyhow, Context};
+use clap::Parser;
+use either::Either;
+use flate2::{write::GzEncoder, Compression};
+use itertools::Itertools;
+use parseq::ParallelIterator;
+
+mod core;
+use core::*;
+
+mod fs;
+use fs::*;
+
+// std::process::ExitCode::exit_process is unstable
+// Exit codes should fit u8.
+const EXIT_SUCCESS: i32 = 0;
+const EXIT_FAILURE: i32 = 1;
+const EXIT_INTERRUPT: i32 = 130;
+
+/// A simple bit rot checker for legacy file systems.
+///
+/// On the first invocation, Brck records the modification time and a cryptographic hash sum of each regular file in the current working directory to a .brck file.
+/// On subsequent invocations, Brck compares the recorded files against the current file system.
+///
+/// A file can be added, touched, changed, unchanged, removed, or corrupted.
+/// A file is corrupted if it's content changed but it's modification did not.
+///
+/// Without any options, Brck denies corrupted files.
+/// If Brck finds one or more denied differences, it prints them to standard output and exits >0.
+/// Otherwise, Brck updates the .brck file.
+#[derive(Debug, Parser)]
+#[command(about, version, author)]
+struct Args {
+ /// Increase verbosity.
+ ///
+ /// Specify once to print all changed files.
+ /// Specify twice to print unchanged files, too.
+ /// By default, only denied differences are printed.
+ #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)]
+ verbosity: u8,
+
+ /// Do not actually modify the database.
+ #[arg(short = 'n', long)]
+ dry_run: bool,
+
+ /// Specify the number of worker threads.
+ ///
+ /// Defaults to the available parallelism, typically the number of CPUs.
+ #[arg(short, long)]
+ jobs: Option<NonZeroUsize>,
+
+ /// Deny select differences only.
+ ///
+ /// This option accepts a comma-separated list of differences to deny.
+ /// Without this option, only corrupted files are denied.
+ /// With this option, only the specified differences are denied.
+ #[arg(value_enum, short, long, default_values_t = vec![DiffKind::Corrupted], value_delimiter = ',', action = clap::ArgAction::Append)]
+ deny: Vec<DiffKind>,
+
+ /// Enable structured output.
+ #[arg(short = 'J', long)]
+ json: bool,
+
+ /// Print summary to standard error.
+ #[arg(short, long)]
+ summary: bool,
+
+ /// Specify the maximum number of bytes a worker thread may read without checking for an interrupt signal.
+ ///
+ /// Defaults to 1 GiB.
+ #[arg(long)]
+ chunk_size: Option<NonZeroU64>,
+
+ /// Specify the maximum number of files to read ahead.
+ ///
+ /// This should be greater than the number of worker threads.
+ ///
+ /// Defaults to 1024 files per worker thread.
+ #[arg(long)]
+ queue_size: Option<NonZeroUsize>,
+}
+
+fn main() {
+ std::process::exit(match run() {
+ Ok(_) => EXIT_SUCCESS,
+ Err(err) if err.is::<clap::Error>() => {
+ // Use clap's own error formatter
+ eprintln!("{err}");
+ EXIT_FAILURE
+ }
+ Err(err) => {
+ eprintln!("Error: {:?}", err);
+ match err.downcast_ref::<RecordError>() {
+ Some(RecordError::Interrupt) => EXIT_INTERRUPT,
+ _ => EXIT_FAILURE,
+ }
+ }
+ })
+}
+
+fn run() -> Result<(), anyhow::Error> {
+ let terminate = Arc::new(AtomicBool::new(false));
+ let args = Args::try_parse()?;
+
+ // Paths must begin with ./ for correct filtering.
+ let current_dir: PathBuf = std::path::Component::CurDir.as_os_str().into();
+ let db_path = current_dir.join(".brck");
+ let backup_path = current_dir.join(".brck.bak");
+
+ // Creating temporary files in the same directory instead of std::env_temp_dir() to enable atomic rename(2) on POSIX-conform systems.
+ // The drawback is that the operating system won't remove these files automatically.
+ let tmp_db_path = current_dir.join(".brck.tmp");
+ let tmp_backup_path = current_dir.join(".brck.bak.tmp");
+
+ #[cfg(target_os = "openbsd")]
+ {
+ use std::{os::unix::ffi::OsStrExt, path::Path};
+
+ pledge::pledge![Stdio Rpath Wpath Cpath Unveil, Stdio].context("Failed to pledge(2)")?;
+
+ fn unveil<P: AsRef<Path>>(path: P, permissions: &str) -> Result<(), anyhow::Error> {
+ unveil::unveil(path.as_ref().as_os_str().as_bytes(), permissions).with_context(|| {
+ format!(
+ "Failed to unveil(2) {} with permissions {}",
+ path.as_ref().display(),
+ permissions
+ )
+ })
+ }
+
+ unveil(".", "r")?;
+ unveil(&db_path, "rwc")?;
+ unveil(&tmp_db_path, "rwc")?;
+ unveil(&backup_path, "rwc")?;
+ unveil(&tmp_backup_path, "rwc")?;
+ unveil::unveil("", "").context("Failed to disable unveil(2)")?;
+ }
+
+ let t = terminate.clone();
+ ctrlc::try_set_handler(move || {
+ if t.load(std::sync::atomic::Ordering::SeqCst) {
+ eprintln!("Received second interrupt signal");
+ std::process::exit(EXIT_INTERRUPT);
+ }
+ eprintln!("Received interrupt signal");
+ t.store(true, std::sync::atomic::Ordering::SeqCst);
+ })
+ .context("Failed to register interrupt signal handler")?;
+
+ let (db, first_run) = match read_db(&db_path) {
+ Ok(db) => Ok((Either::Left(db), false)),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
+ Ok((Either::Right(std::iter::empty()), true))
+ }
+ Err(err) => Err(err),
+ }
+ .with_context(|| format!("Failed to read database {}", db_path.display()))?;
+ let db = db.map(|item| {
+ item.map_err(|err| {
+ anyhow::Error::new(err).context(format!(
+ "Failed to decode record from database {}",
+ &db_path.display()
+ ))
+ })
+ });
+
+ let tmp = TmpFile::open(&tmp_db_path).with_context(|| {
+ format!(
+ "Failed to create temporary database {}",
+ tmp_db_path.display()
+ )
+ })?;
+ let mut tmp = GzEncoder::new(tmp.file(), Compression::default());
+
+ let jobs = args
+ .jobs
+ .or_else(|| std::thread::available_parallelism().ok())
+ .map(NonZeroUsize::get)
+ .unwrap_or(1);
+ let queue_size = args
+ .queue_size
+ .map(NonZeroUsize::get)
+ .unwrap_or_else(|| jobs.saturating_mul(1024));
+ let chunk_size = args
+ .chunk_size
+ .map(NonZeroU64::get)
+ .unwrap_or(1024 * 1024 * 1024);
+
+ let fs = find_files(&current_dir)
+ .filter_ok(|path| path != &db_path && path != &tmp_db_path && path != &backup_path)
+ .map(|item| {
+ item.map_err(|err| anyhow::Error::new(err).context("Failed to walk filesystem"))
+ })
+ .map_parallel_limit(jobs, queue_size, move |item| {
+ item.and_then(|path| {
+ Record::from_path(&path, chunk_size, terminate.clone())
+ .with_context(|| format!("Failed to read file {}", path.display()))
+ })
+ });
+
+ let mut counter = HashMap::new();
+ let mut denied = 0;
+
+ diff(db, fs).try_for_each(|item| match item {
+ Err(Either::Left(err)) => Err(err),
+ Err(Either::Right(err)) => Err(err),
+ Ok(diff) => {
+ *counter.entry(diff.kind()).or_insert(0) += 1;
+ record(&diff, &mut tmp).with_context(|| {
+ format!(
+ "Failed to write to temporary database {}",
+ tmp_db_path.display()
+ )
+ })?;
+ if args.deny.contains(&diff.kind()) {
+ if args.json {
+ println!("{}", serde_json::to_string(&diff).unwrap());
+ } else {
+ println!("{diff}");
+ }
+ denied += 1;
+ } else if args.verbosity > 1
+ || (diff.kind() != DiffKind::Unchanged && args.verbosity > 0)
+ {
+ if args.json {
+ println!("{}", serde_json::to_string(&diff).unwrap());
+ } else {
+ println!("{diff}");
+ }
+ }
+ Ok(())
+ }
+ })?;
+
+ if args.summary {
+ let total: usize = counter.values().sum();
+ let width = usize::try_from(total.checked_ilog10().unwrap_or(0) + 1).unwrap_or(0);
+ eprintln!(
+ "Removed: {:>width$}",
+ counter.get(&DiffKind::Removed).unwrap_or(&0)
+ );
+ eprintln!(
+ "Added: {:>width$}",
+ counter.get(&DiffKind::Added).unwrap_or(&0)
+ );
+ eprintln!(
+ "Unchanged: {:>width$}",
+ counter.get(&DiffKind::Unchanged).unwrap_or(&0)
+ );
+ eprintln!(
+ "Corrupted: {:>width$}",
+ counter.get(&DiffKind::Corrupted).unwrap_or(&0)
+ );
+ eprintln!(
+ "Touched: {:>width$}",
+ counter.get(&DiffKind::Touched).unwrap_or(&0)
+ );
+ eprintln!(
+ "Changed: {:>width$}",
+ counter.get(&DiffKind::Changed).unwrap_or(&0)
+ );
+ eprintln!("Total: {:>width$}", total);
+ }
+
+ if denied > 0 {
+ return Err(anyhow!(
+ "Found {} denied {}",
+ denied,
+ if denied == 1 {
+ "difference"
+ } else {
+ "differences"
+ }
+ ));
+ }
+
+ if args.dry_run {
+ eprintln!("Exiting due to dry run");
+ return Ok(());
+ }
+
+ tmp.finish().with_context(|| {
+ format!(
+ "Failed to synchronize temporary database {}",
+ tmp_db_path.display()
+ )
+ })?;
+ if !first_run {
+ copy_file(&db_path, tmp_backup_path, &backup_path).with_context(|| {
+ format!(
+ "Failed to backup database {} to {}",
+ db_path.display(),
+ backup_path.display()
+ )
+ })?;
+ }
+ rename_file(&tmp_db_path, &db_path).with_context(|| {
+ format!(
+ "Failed to persist temporary database {} to {}",
+ tmp_db_path.display(),
+ db_path.display()
+ )
+ })?;
+
+ Ok(())
+}
+
+fn record<T: std::io::Write>(diff: &Diff, mut writer: T) -> Result<(), std::io::Error> {
+ let file = match diff {
+ Diff::Removed { .. } => None,
+ Diff::Added { new, .. } => Some(new),
+ Diff::Unchanged { new, .. } => Some(new),
+ Diff::Corrupted { new, .. } => Some(new),
+ Diff::Touched { new, .. } => Some(new),
+ Diff::Changed { new, .. } => Some(new),
+ };
+ if let Some(file) = file {
+ let s = serde_json::to_string(file).unwrap();
+ writeln!(writer, "{}", s)
+ } else {
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn verify_clap_app() {
+ use clap::CommandFactory;
+ Args::command().debug_assert()
+ }
+}
Generated by cgit. See skreutz.com for my tech blog and contact information.