Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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 <ALGORITHM>` | Specify hash algorithm explicitly | `sign`, `test`, `update` |
| `--catalog-file <PATH>` | Use custom catalog file location instead of default | `sign`, `test`, `update` |
| `-v, --verbose` | Increase verbosity (use multiple times for more detail) | All commands |
| `--report <FORMAT>` | Generate report in specified format (plain/json) | `test` |
| `--report-filename <FILE>` | Write report to file instead of stderr | `test` |
| `--confirm` | Auto-confirm all updates without prompting | `update` |

### Supported Hash Algorithms

| Algorithm | Flag | Description |
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/algo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ impl Algorithm {
path.exists()
})
}

pub fn try_deduce_from_file(file_path: &Path) -> Option<Self> {
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 {
Expand Down
70 changes: 60 additions & 10 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::{

pub struct Directory {
path: CanonicalPath<PathBuf>,
catalog_path: Option<CanonicalPath<PathBuf>>,
}

impl Directory {
Expand All @@ -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<PathBuf>,
catalog_file: impl Into<PathBuf>,
) -> anyhow::Result<Self> {
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<Algorithm>) -> anyhow::Result<Catalog> {
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)
Expand All @@ -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,
Expand Down Expand Up @@ -101,8 +136,23 @@ impl Directory {
}
}

pub(crate) fn catalog_metadata_with_file(
&self,
algo: Algorithm,
file_path: CanonicalPath<PathBuf>,
) -> 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(),
Expand Down
44 changes: 32 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ struct SignParams {
/// algorithm to use for hashing (run list-algos to view available algorithms)
#[clap(short = 'a', long)]
algo: Option<Algorithm>,
/// path to the catalog file to create/use instead of the default location
#[clap(long)]
catalog_file: Option<PathBuf>,
#[arg(short)]
recursive: bool,
#[clap(default_value = ".")]
Expand All @@ -44,6 +47,10 @@ struct TestParams {
#[clap(short = 'a', long)]
algo: Option<Algorithm>,

/// path to the catalog file to use instead of the default location
#[clap(long)]
catalog_file: Option<PathBuf>,

#[clap(default_value = ".")]
path: PathBuf,
}
Expand All @@ -54,6 +61,10 @@ struct UpdateParams {
#[clap(short = 'a', long)]
algo: Option<Algorithm>,

/// path to the catalog file to use instead of the default location
#[clap(long)]
catalog_file: Option<PathBuf>,

/// automatically confirm all updates without prompting
#[clap(long)]
confirm: bool,
Expand Down Expand Up @@ -141,9 +152,7 @@ fn load_and_verify_catalog(
.collect::<Result<HashSet<_>, _>>()
});

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();

Expand Down Expand Up @@ -171,7 +180,11 @@ fn load_and_verify_catalog(
}

fn create_catalog(params: SignParams, config: &config::Config) -> anyhow::Result<()> {
let directory = catalog::Directory::new(&params.path)?;
let directory = if let Some(catalog_file) = params.catalog_file {
catalog::Directory::with_catalog_file(&params.path, catalog_file)?
} else {
catalog::Directory::new(&params.path)?
};

let algo = params.algo.or(config.default_sign_algo).ok_or_else(|| {
anyhow::format_err!(
Expand All @@ -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(&params.path)?;
let directory = if let Some(catalog_file) = params.catalog_file {
catalog::Directory::with_catalog_file(&params.path, catalog_file)?
} else {
catalog::Directory::new(&params.path)?
};

let report = load_and_verify_catalog(directory, params.algo, &params.path)?.report;

Expand Down Expand Up @@ -313,7 +330,11 @@ fn confirm_updates(
}

fn update_catalog(params: UpdateParams) -> anyhow::Result<()> {
let directory = catalog::Directory::new(&params.path)?;
let directory = if let Some(catalog_file) = &params.catalog_file {
catalog::Directory::with_catalog_file(&params.path, catalog_file)?
} else {
catalog::Directory::new(&params.path)?
};

let verification = load_and_verify_catalog(directory, params.algo, &params.path)?;
let report = verification.report;
Expand All @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -432,7 +448,11 @@ fn update_catalog(params: UpdateParams) -> anyhow::Result<()> {
return Ok(());
}

let directory_for_update = catalog::Directory::new(&params.path)?;
let directory_for_update = if let Some(catalog_file) = &params.catalog_file {
catalog::Directory::with_catalog_file(&params.path, catalog_file)?
} else {
catalog::Directory::new(&params.path)?
};
let mut catalog = directory_for_update.load(params.algo)?;

for path in &files_to_update {
Expand Down
Loading