From 40de4709c8f2d44239152ebd5a2175504e04946c Mon Sep 17 00:00:00 2001 From: Rotemy Yaari Date: Sat, 4 Oct 2025 21:03:20 +0300 Subject: [PATCH] Add support for custom catalog locations --- README.md | 65 ++++++++++++++ src/algo.rs | 11 +++ src/catalog.rs | 70 ++++++++++++--- src/main.rs | 44 +++++++--- tests/test_catalog_file.py | 171 +++++++++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 22 deletions(-) create mode 100644 tests/test_catalog_file.py diff --git a/README.md b/README.md index 6f78e09..291536f 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,17 @@ ratify sign . This creates a signature catalog file (e.g., `dirname.sha256`) containing checksums for all files. +#### Using a Custom Catalog File Location + +You can specify a custom location for the catalog file using the `--catalog-file` flag: + +```bash +# Create catalog with custom filename/location +ratify sign -a sha256 --catalog-file ./my-custom-catalog.sha256 . +``` + +**NOTE**: When using `--catalog-file`, you must specify the algorithm explicitly with `-a/--algo` + ### Verifying Files Verify the integrity of files against their signatures: @@ -61,6 +72,21 @@ ratify test . Ratify will check all files against the catalog and report any discrepancies. +#### Using a Custom Catalog File + +When using a custom catalog file location, specify the same file for verification: + +```bash +# Test using custom catalog file +ratify test --catalog-file ./my-custom-catalog.sha256 . + +# Algorithm is auto-detected from file extension +ratify test --catalog-file checksums/backup.sha256 /path/to/directory + +# If algorithm detection fails, specify explicitly +ratify test -a sha256 --catalog-file /tmp/custom-signatures . +``` + ## 🔧 Usage ### Available Commands @@ -72,6 +98,17 @@ Ratify will check all files against the catalog and report any discrepancies. | `update` | Interactively update checksums for changed files | | `list-algos` | Show all available hash algorithms | +### Common Flags + +| Flag | Description | Applies to | +|------|-------------|-----------| +| `-a, --algo ` | Specify hash algorithm explicitly | `sign`, `test`, `update` | +| `--catalog-file ` | Use custom catalog file location instead of default | `sign`, `test`, `update` | +| `-v, --verbose` | Increase verbosity (use multiple times for more detail) | All commands | +| `--report ` | Generate report in specified format (plain/json) | `test` | +| `--report-filename ` | Write report to file instead of stderr | `test` | +| `--confirm` | Auto-confirm all updates without prompting | `update` | + ### Supported Hash Algorithms | Algorithm | Flag | Description | @@ -115,6 +152,9 @@ ratify sign ~/documents # Uses blake3 from config # Recursive signing (default behavior) ratify sign -a sha256 -r /path/to/directory + +# Custom catalog file location +ratify sign -a sha256 --catalog-file /backup/checksums.sha256 ~/documents ``` #### Verification and Reporting @@ -128,6 +168,12 @@ ratify test --report json --report-filename verification_report.json /path/to/di # Specify algorithm explicitly ratify test -a sha256 /path/to/directory + +# Use custom catalog file +ratify test --catalog-file /backup/checksums.sha256 ~/documents + +# Custom catalog with explicit algorithm (if auto-detection fails) +ratify test -a sha256 --catalog-file /tmp/custom-signatures ~/documents ``` #### Managing File Changes @@ -138,6 +184,12 @@ ratify update /path/to/directory # Update with specific algorithm ratify update -a sha256 /path/to/directory + +# Update using custom catalog file +ratify update --catalog-file /backup/checksums.sha256 ~/documents + +# Auto-confirm all changes +ratify update --confirm --catalog-file /backup/checksums.sha256 ~/documents ``` ### Interactive Update Mode @@ -182,6 +234,10 @@ ratify sign -a blake3 ~/important_files # Verify after restore ratify test ~/important_files + +# Use custom catalog location for backups +ratify sign -a blake3 --catalog-file /backup/metadata/checksums.blake3 ~/important_files +ratify test --catalog-file /backup/metadata/checksums.blake3 ~/important_files ``` ### Software Distribution @@ -194,6 +250,11 @@ ratify sign -a sha256 ./release_v1.0 # Users can verify download integrity ratify test ./downloaded_release + +# Distribute catalog separately for security +ratify sign -a sha256 --catalog-file ../release_v1.0_checksums.sha256 ./release_v1.0 +# Users verify with: +ratify test --catalog-file ../release_v1.0_checksums.sha256 ./downloaded_release ``` ### Ongoing File Monitoring @@ -209,6 +270,10 @@ ratify test /etc/configs # Update authorized changes ratify update /etc/configs + +# Store catalog in secure location +ratify sign -a sha256 --catalog-file /secure/etc-configs.sha256 /etc/configs +ratify test --catalog-file /secure/etc-configs.sha256 /etc/configs ``` ## 🔍 Understanding Output diff --git a/src/algo.rs b/src/algo.rs index 403ebbc..9408d88 100644 --- a/src/algo.rs +++ b/src/algo.rs @@ -38,6 +38,17 @@ impl Algorithm { path.exists() }) } + + pub fn try_deduce_from_file(file_path: &Path) -> Option { + let file_name = file_path.file_name()?.to_string_lossy(); + if let Some(extension) = file_name.split('.').next_back() { + Self::iter().find(|variant| { + extension.eq_ignore_ascii_case(&variant.to_string()) + }) + } else { + None + } + } } macro_rules! hash_impl { diff --git a/src/catalog.rs b/src/catalog.rs index 618713a..f9672f2 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -16,6 +16,7 @@ use crate::{ pub struct Directory { path: CanonicalPath, + catalog_path: Option>, } impl Directory { @@ -25,26 +26,60 @@ impl Directory { .with_context(|| format!("Failed resolve path {path:?}"))? .assume_canonical(); - Ok(Self { path }) + Ok(Self { + path, + catalog_path: None, + }) + } + + pub fn with_catalog_file( + path: impl Into, + catalog_file: impl Into, + ) -> anyhow::Result { + let path = path.into(); + let path = std::fs::canonicalize(&path) + .with_context(|| format!("Failed resolve path {path:?}"))? + .assume_canonical(); + + let catalog_file = catalog_file.into(); + let catalog_file = if catalog_file.is_absolute() { + catalog_file.assume_canonical() + } else { + path.as_path().join(catalog_file).assume_canonical() + }; + + Ok(Self { + path, + catalog_path: Some(catalog_file), + }) } pub fn load(self, algo: Option) -> anyhow::Result { - let algo = algo - .or_else(|| Algorithm::try_deduce_from_path(self.path.as_path())) - .ok_or_else(|| anyhow::format_err!("Failed to detect signature file"))?; - let filename = self.signature_file_path(algo); + let (algo, filename) = if let Some(custom_file) = &self.catalog_path { + let algo = algo + .or_else(|| Algorithm::try_deduce_from_file(custom_file.as_path())) + .ok_or_else(|| anyhow::format_err!( + "Failed to detect algorithm from catalog file {custom_file:?}. Please specify algorithm explicitly using --algo" + ))?; + (algo, custom_file.clone()) + } else { + let algo = algo + .or_else(|| Algorithm::try_deduce_from_path(self.path.as_path())) + .ok_or_else(|| anyhow::format_err!("Failed to detect signature file"))?; + (algo, self.signature_file_path(algo)) + }; log::debug!("Opening signature file {filename:?}..."); let file = std::io::BufReader::new( std::fs::File::open(filename.as_path()) - .with_context(|| format!("Failed opening {:?}", self.path))?, + .with_context(|| format!("Failed opening {:?}", filename))?, ); let mut entries = BTreeMap::new(); for (lineno, line) in file.lines().enumerate().map(|(lineno, l)| (lineno + 1, l)) { let line = line.context("Cannot read file")?; let (hash, entry_path) = line.split_once(" *").ok_or_else(|| { - anyhow::anyhow!("Syntax error at line {} of {:?}", lineno, self.path) + anyhow::anyhow!("Syntax error at line {} of {:?}", lineno, filename) })?; let entry = hex::decode(hash) @@ -54,11 +89,11 @@ impl Directory { if prev.is_some() { anyhow::bail!( "Entry {entry_path:?} appears multiple times in {:?}", - self.path + filename ); } } - let metadata = Arc::new(self.catalog_metadata(algo)); + let metadata = Arc::new(self.catalog_metadata_with_file(algo, filename)); Ok(Catalog { metadata, @@ -101,8 +136,23 @@ impl Directory { } } + pub(crate) fn catalog_metadata_with_file( + &self, + algo: Algorithm, + file_path: CanonicalPath, + ) -> CatalogMetadata { + CatalogMetadata { + algo, + signature_file_path: file_path, + } + } + pub(crate) fn empty_catalog(self, algo: Algorithm) -> Catalog { - let metadata = Arc::new(self.catalog_metadata(algo)); + let metadata = if let Some(custom_file) = &self.catalog_path { + Arc::new(self.catalog_metadata_with_file(algo, custom_file.clone())) + } else { + Arc::new(self.catalog_metadata(algo)) + }; Catalog { directory: self, entries: Default::default(), diff --git a/src/main.rs b/src/main.rs index d54fa97..3705612 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,9 @@ struct SignParams { /// algorithm to use for hashing (run list-algos to view available algorithms) #[clap(short = 'a', long)] algo: Option, + /// path to the catalog file to create/use instead of the default location + #[clap(long)] + catalog_file: Option, #[arg(short)] recursive: bool, #[clap(default_value = ".")] @@ -44,6 +47,10 @@ struct TestParams { #[clap(short = 'a', long)] algo: Option, + /// path to the catalog file to use instead of the default location + #[clap(long)] + catalog_file: Option, + #[clap(default_value = ".")] path: PathBuf, } @@ -54,6 +61,10 @@ struct UpdateParams { #[clap(short = 'a', long)] algo: Option, + /// path to the catalog file to use instead of the default location + #[clap(long)] + catalog_file: Option, + /// automatically confirm all updates without prompting #[clap(long)] confirm: bool, @@ -141,9 +152,7 @@ fn load_and_verify_catalog( .collect::, _>>() }); - let catalog = directory - .load(algo_param) - .context("Failed loading directory")?; + let catalog = directory.load(algo_param)?; let catalog_filename = catalog.metadata().signature_file_path().clone(); let algo = catalog.metadata().algo(); @@ -171,7 +180,11 @@ fn load_and_verify_catalog( } fn create_catalog(params: SignParams, config: &config::Config) -> anyhow::Result<()> { - let directory = catalog::Directory::new(¶ms.path)?; + let directory = if let Some(catalog_file) = params.catalog_file { + catalog::Directory::with_catalog_file(¶ms.path, catalog_file)? + } else { + catalog::Directory::new(¶ms.path)? + }; let algo = params.algo.or(config.default_sign_algo).ok_or_else(|| { anyhow::format_err!( @@ -188,7 +201,11 @@ fn create_catalog(params: SignParams, config: &config::Config) -> anyhow::Result fn test_catalog(params: TestParams) -> anyhow::Result<()> { let start = std::time::Instant::now(); - let directory = catalog::Directory::new(¶ms.path)?; + let directory = if let Some(catalog_file) = params.catalog_file { + catalog::Directory::with_catalog_file(¶ms.path, catalog_file)? + } else { + catalog::Directory::new(¶ms.path)? + }; let report = load_and_verify_catalog(directory, params.algo, ¶ms.path)?.report; @@ -313,7 +330,11 @@ fn confirm_updates( } fn update_catalog(params: UpdateParams) -> anyhow::Result<()> { - let directory = catalog::Directory::new(¶ms.path)?; + let directory = if let Some(catalog_file) = ¶ms.catalog_file { + catalog::Directory::with_catalog_file(¶ms.path, catalog_file)? + } else { + catalog::Directory::new(¶ms.path)? + }; let verification = load_and_verify_catalog(directory, params.algo, ¶ms.path)?; let report = verification.report; @@ -324,14 +345,12 @@ fn update_catalog(params: UpdateParams) -> anyhow::Result<()> { let mut files_to_update = HashSet::new(); if params.confirm { - // Auto-confirm mode: add all files with discrepancies for entry in report.entries() { if !matches!(entry.status(), reporting::EntryStatus::Ok) { files_to_update.insert(entry.path()); } } } else { - // Interactive mode: ask user for each file let mut update_all = false; let mut processed_directories = HashSet::new(); let skip_all = false; @@ -350,7 +369,6 @@ fn update_catalog(params: UpdateParams) -> anyhow::Result<()> { continue; } - // Skip if this file's directory was already processed with "d" option let current_dir = entry.path().parent(); if let Some(dir) = current_dir { if processed_directories.contains(dir) { @@ -391,12 +409,10 @@ fn update_catalog(params: UpdateParams) -> anyhow::Result<()> { UpdateAction::UpdateSubdirectory => { files_to_update.insert(entry.path()); - // Mark this directory as processed if let Some(dir) = current_dir { processed_directories.insert(dir); } - // Add all other files in the same directory for other_entry in report.entries() { if matches!(other_entry.status(), reporting::EntryStatus::Ok) { continue; @@ -432,7 +448,11 @@ fn update_catalog(params: UpdateParams) -> anyhow::Result<()> { return Ok(()); } - let directory_for_update = catalog::Directory::new(¶ms.path)?; + let directory_for_update = if let Some(catalog_file) = ¶ms.catalog_file { + catalog::Directory::with_catalog_file(¶ms.path, catalog_file)? + } else { + catalog::Directory::new(¶ms.path)? + }; let mut catalog = directory_for_update.load(params.algo)?; for path in &files_to_update { diff --git a/tests/test_catalog_file.py b/tests/test_catalog_file.py new file mode 100644 index 0000000..aada227 --- /dev/null +++ b/tests/test_catalog_file.py @@ -0,0 +1,171 @@ +# pylint: disable=redefined-outer-name +import pytest +import subprocess +import pathlib + + +@pytest.fixture +def custom_catalog_paths(directory, algorithm, tmpdir): + return { + "subdirectory": directory / "custom_checksums" / f"my_catalog.{algorithm}", + "relative": directory / f"custom.{algorithm}", + "no_extension": directory / "my_catalog_no_ext", + "absolute": pathlib.Path(tmpdir) / f"absolute_catalog.{algorithm}", + "subdir_relative": directory / "catalogs" / f"backup.{algorithm}", + } + + +def get_directory_contents(directory): + return {p.relative_to(directory) for p in directory.rglob("*")} + + +def test_sign_with_custom_catalog_file(directory, run, algorithm, custom_catalog_paths): + custom_catalog = custom_catalog_paths["subdirectory"] + custom_catalog.parent.mkdir(exist_ok=True) + + contents_before = get_directory_contents(directory) + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + + contents_after = get_directory_contents(directory) + contents_after.discard(custom_catalog.relative_to(directory)) + + assert contents_before == contents_after + assert custom_catalog.exists() + + from conftest import Sigfile + + sigfile = Sigfile(custom_catalog) + sigfile.assert_all_files_contained(directory, allow_unknown=False) + + +def test_sign_with_relative_catalog_file( + directory, run, algorithm, custom_catalog_paths +): + custom_catalog = custom_catalog_paths["relative"] + + contents_before = get_directory_contents(directory) + + run(f"sign -a {algorithm} --catalog-file custom.{algorithm} .", cwd=directory) + + contents_after = get_directory_contents(directory) + contents_after.discard(custom_catalog.relative_to(directory)) + + assert contents_before == contents_after + assert custom_catalog.exists() + + from conftest import Sigfile + + sigfile = Sigfile(custom_catalog) + sigfile.assert_all_files_contained(directory, allow_unknown=False) + + +def test_test_with_custom_catalog_file(directory, run, algorithm, custom_catalog_paths): + custom_catalog = custom_catalog_paths["relative"] + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + run(f"test --catalog-file {custom_catalog} .", cwd=directory) + + +def test_test_with_custom_catalog_file_auto_detect_algo( + directory, run, algorithm, custom_catalog_paths +): + custom_catalog = custom_catalog_paths["relative"] + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + run(f"test --catalog-file {custom_catalog} .", cwd=directory) + + +def test_test_with_custom_catalog_file_no_extension_fails( + directory, run, algorithm, custom_catalog_paths +): + custom_catalog = custom_catalog_paths["no_extension"] + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + + with pytest.raises(subprocess.CalledProcessError) as exc_info: + run(f"test --catalog-file {custom_catalog} .", cwd=directory) + + assert exc_info.value.returncode != 0 + + +def test_test_with_custom_catalog_file_no_extension_with_algo( + directory, run, algorithm, custom_catalog_paths +): + custom_catalog = custom_catalog_paths["no_extension"] + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + run(f"test -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + + +def test_update_with_custom_catalog_file( + directory, run, algorithm, random_data_gen, custom_catalog_paths +): + custom_catalog = custom_catalog_paths["relative"] + + run(f"sign -a {algorithm} --catalog-file {custom_catalog} .", cwd=directory) + + test_file = directory / "a" / "1" + with test_file.open("wb") as f: + f.write(random_data_gen()) + + run(f"update --confirm --catalog-file {custom_catalog} .", cwd=directory) + run(f"test --catalog-file {custom_catalog} .", cwd=directory) + + +def test_sign_with_absolute_catalog_file( + directory, run, algorithm, custom_catalog_paths +): + abs_catalog = custom_catalog_paths["absolute"] + + contents_before = get_directory_contents(directory) + + run(f"sign -a {algorithm} --catalog-file {abs_catalog} .", cwd=directory) + + contents_after = get_directory_contents(directory) + + assert contents_before == contents_after + assert abs_catalog.exists() + + from conftest import Sigfile + + sigfile = Sigfile(abs_catalog) + entries = sigfile.entries() + + expected_entries = {p for p in directory.rglob("*") if not p.is_dir()} + + assert len(entries) == len(expected_entries) + run(f"test --catalog-file {abs_catalog} .", cwd=directory) + + +def test_catalog_file_with_wrong_algorithm_extension(directory, run): + wrong_catalog = directory / "wrong.md5" + + run(f"sign -a sha256 --catalog-file {wrong_catalog} .", cwd=directory) + + with pytest.raises(subprocess.CalledProcessError): + run(f"test --catalog-file {wrong_catalog} .", cwd=directory) + + run(f"test -a sha256 --catalog-file {wrong_catalog} .", cwd=directory) + + +def test_catalog_file_in_subdirectory(directory, run, algorithm, custom_catalog_paths): + subdir = directory / "catalogs" + subdir.mkdir() + catalog_file = custom_catalog_paths["subdir_relative"] + + contents_before = get_directory_contents(directory) + + run( + f"sign -a {algorithm} --catalog-file catalogs/backup.{algorithm} .", + cwd=directory, + ) + + contents_after = get_directory_contents(directory) + contents_after.discard(catalog_file.relative_to(directory)) + + assert contents_before == contents_after + assert catalog_file.exists() + + run(f"test --catalog-file catalogs/backup.{algorithm} .", cwd=directory) + run(f"update --confirm --catalog-file catalogs/backup.{algorithm} .", cwd=directory)