Add SFV panel: compute/verify CRC32/MD5/SHA1 checksums, .sfv/.md5/.sha1 sidecar files

Fully local feature - no network access, no credentials. New aurora-curator-core::sfv
module streams file hashing in 64KB chunks and handles both the traditional .sfv
format (CRC32, "name checksum" layout) and the GNU coreutils md5sum/sha1sum sidecar
convention (.md5/.sha1, "checksum  name" layout), dispatched on a shared ChecksumKind
enum. New src-tauri/src/sfv.rs wraps it in three Tauri commands - compute_checksums
(per-file error isolation), save_checksum_file (native Save dialog), and
verify_checksum_file (resolves each entry against the checksum file's own parent
directory, reports OK/Mismatch/Missing per entry). New SfvPanel.tsx/.css wires it all
into the sidebar's SFV screen - load files, pick a checksum type, Compute, Save, or
Verify against an existing checksum file.

14 new core-crate tests + 1 new src-tauri test, all passing (workspace: 160/160).
Verified end-to-end with a 22-check Playwright script against npm run dev.
This commit is contained in:
Renamr Scaffold
2026-08-29 15:59:05 +00:00
parent ed26391651
commit 316dbfc1ff
11 changed files with 940 additions and 8 deletions
+6
View File
@@ -25,6 +25,12 @@ regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "macros"] }
# Checksum algorithms for the SFV panel (crc32fast for .sfv files, md-5/sha1 for the
# .md5/.sha1 sidecar-file formats the original FileBot SFV panel also supports).
crc32fast = "1"
md-5 = "0.10"
sha1 = "0.10"
[dev-dependencies]
# Local HTTP mock server so the TMDB client's request/response handling can be unit
# tested without live network access or a real API key.
+1
View File
@@ -10,6 +10,7 @@ pub mod format;
pub mod history;
pub mod matching;
pub mod rename;
pub mod sfv;
pub mod similarity;
pub mod web;
+406
View File
@@ -0,0 +1,406 @@
//! Checksum computation and verification ("SFV" - Simple File Verification), a partial
//! port of `net.filebot.ui.sfv.SfvPanel`. Computes CRC32/MD5/SHA-1 checksums for a set
//! of files, builds a sidecar checksum-file's text content (`.sfv`/`.md5`/`.sha1`), and
//! verifies a set of files against an already-parsed checksum file - flagging each
//! referenced file OK, mismatched, or missing on disk. Deliberately local/offline only,
//! unlike every other feature added this session - no web client, no credentials.
use std::fmt;
use std::fs::File;
use std::io::{self, Read};
use std::path::Path;
use md5::{Digest, Md5};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
const READ_BUFFER_SIZE: usize = 64 * 1024;
/// Which checksum algorithm/file format is in play. `Crc32` is the traditional ".sfv"
/// format (whole-file CRC32, 8 uppercase hex digits); `Md5`/`Sha1` follow the GNU
/// coreutils `md5sum`/`sha1sum` sidecar-file convention instead (`<hex> <filename>`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChecksumKind {
Crc32,
Md5,
Sha1,
}
impl ChecksumKind {
/// The sidecar file extension conventionally used for this kind (no leading dot).
pub fn extension(&self) -> &'static str {
match self {
ChecksumKind::Crc32 => "sfv",
ChecksumKind::Md5 => "md5",
ChecksumKind::Sha1 => "sha1",
}
}
/// A human-readable label for UI display.
pub fn label(&self) -> &'static str {
match self {
ChecksumKind::Crc32 => "CRC32",
ChecksumKind::Md5 => "MD5",
ChecksumKind::Sha1 => "SHA-1",
}
}
}
impl fmt::Display for ChecksumKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
/// Streams `path` and returns its checksum as hex text - uppercase for CRC32 (matching
/// traditional `.sfv` file convention), lowercase for MD5/SHA-1 (matching
/// `md5sum`/`sha1sum`). Streams in fixed-size chunks rather than reading the whole file
/// into memory at once, so this scales to large video files without a huge allocation.
pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
let mut file = File::open(path)?;
let mut buf = [0u8; READ_BUFFER_SIZE];
match kind {
ChecksumKind::Crc32 => {
let mut hasher = crc32fast::Hasher::new();
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:08X}", hasher.finalize()))
}
ChecksumKind::Md5 => {
let mut hasher = Md5::new();
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
ChecksumKind::Sha1 => {
let mut hasher = Sha1::new();
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
}
}
/// One entry parsed out of an existing checksum file: the file name it references
/// (not yet resolved to a real path - checksum files reference files by name only,
/// always relative to wherever the checksum file itself lives) and the checksum text
/// as written in the file, normalized to the kind's own casing convention.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChecksumEntry {
pub file_name: String,
pub checksum: String,
}
/// Parses the text content of a checksum file into its entries, dispatching on format
/// by `kind` (`.sfv`'s `name checksum` layout vs. the coreutils `checksum name` one).
pub fn parse_checksum_file(content: &str, kind: ChecksumKind) -> Vec<ChecksumEntry> {
match kind {
ChecksumKind::Crc32 => parse_sfv(content),
ChecksumKind::Md5 | ChecksumKind::Sha1 => parse_hashsum(content),
}
}
/// `.sfv` format: one entry per line, `<filename><whitespace><8-hex-digit CRC32>`.
/// Blank lines and comment lines (leading `;`, the format's own convention) are
/// skipped. The filename is whatever comes before the LAST run of whitespace on the
/// line, so a filename containing spaces still parses correctly.
fn parse_sfv(content: &str) -> Vec<ChecksumEntry> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(';'))
.filter_map(|line| {
let (name, checksum) = line.rsplit_once(char::is_whitespace)?;
let name = name.trim();
if name.is_empty() {
return None;
}
Some(ChecksumEntry {
file_name: name.to_string(),
checksum: checksum.trim().to_uppercase(),
})
})
.collect()
}
/// `.md5`/`.sha1` format (GNU coreutils `md5sum`/`sha1sum` convention): one entry per
/// line, `<hex checksum><two spaces, or one space + '*' for binary mode><filename>`.
fn parse_hashsum(content: &str) -> Vec<ChecksumEntry> {
content
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut parts = line.splitn(2, char::is_whitespace);
let checksum = parts.next()?.trim();
let rest = parts.next()?.trim_start();
let name = rest.strip_prefix('*').unwrap_or(rest);
if checksum.is_empty() || name.is_empty() {
return None;
}
Some(ChecksumEntry {
file_name: name.to_string(),
checksum: checksum.to_lowercase(),
})
})
.collect()
}
/// Builds the text content of a checksum file from a set of entries, in the given
/// kind's own format/casing convention - the inverse of `parse_checksum_file`.
pub fn build_checksum_file(entries: &[ChecksumEntry], kind: ChecksumKind) -> String {
let mut out = String::new();
match kind {
ChecksumKind::Crc32 => {
out.push_str("; Generated by Aurora Curator\n");
for entry in entries {
out.push_str(&entry.file_name);
out.push(' ');
out.push_str(&entry.checksum.to_uppercase());
out.push('\n');
}
}
ChecksumKind::Md5 | ChecksumKind::Sha1 => {
for entry in entries {
out.push_str(&entry.checksum.to_lowercase());
out.push_str(" ");
out.push_str(&entry.file_name);
out.push('\n');
}
}
}
out
}
/// The outcome of checking one checksum-file entry against the actual file on disk.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum VerifyStatus {
Ok,
Mismatch,
/// The checksum file lists this name, but no such file exists at the resolved path.
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyResult {
pub file_name: String,
pub expected: String,
/// `None` only for `Missing` - there was nothing to hash.
pub actual: Option<String>,
pub status: VerifyStatus,
}
/// Verifies each parsed entry against the real file on disk, resolved as
/// `base_dir.join(&entry.file_name)` - checksum files almost always reference files by
/// bare name, sitting alongside them, so `base_dir` is normally the checksum file's own
/// parent directory.
pub fn verify_entries(entries: &[ChecksumEntry], base_dir: &Path, kind: ChecksumKind) -> Vec<VerifyResult> {
entries
.iter()
.map(|entry| {
let path = base_dir.join(&entry.file_name);
match compute_checksum(&path, kind) {
Ok(actual) => {
let status = if actual.eq_ignore_ascii_case(&entry.checksum) {
VerifyStatus::Ok
} else {
VerifyStatus::Mismatch
};
VerifyResult {
file_name: entry.file_name.clone(),
expected: entry.checksum.clone(),
actual: Some(actual),
status,
}
}
Err(_) => VerifyResult {
file_name: entry.file_name.clone(),
expected: entry.checksum.clone(),
actual: None,
status: VerifyStatus::Missing,
},
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("aurora_curator_sfv_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn crc32_matches_the_well_known_check_value() {
// "123456789" is the standard CRC32 (IEEE) test vector - checksum CBF43926.
let dir = temp_dir();
let path = dir.join("check.txt");
fs::write(&path, b"123456789").unwrap();
assert_eq!(compute_checksum(&path, ChecksumKind::Crc32).unwrap(), "CBF43926");
}
#[test]
fn md5_matches_a_known_hash() {
// md5("") = d41d8cd98f00b204e9800998ecf8427e (the empty-string test vector).
let dir = temp_dir();
let path = dir.join("empty.txt");
fs::write(&path, b"").unwrap();
assert_eq!(compute_checksum(&path, ChecksumKind::Md5).unwrap(), "d41d8cd98f00b204e9800998ecf8427e");
}
#[test]
fn sha1_matches_a_known_hash() {
// sha1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d (the classic FIPS 180 test vector).
let dir = temp_dir();
let path = dir.join("abc.txt");
fs::write(&path, b"abc").unwrap();
assert_eq!(compute_checksum(&path, ChecksumKind::Sha1).unwrap(), "a9993e364706816aba3e25717850c26c9cd0d89d");
}
#[test]
fn compute_checksum_errors_when_the_file_does_not_exist() {
let dir = temp_dir();
let missing = dir.join("nope.txt");
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
}
#[test]
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
let content = "; Generated by Aurora Curator\n\nShow.S01E01.mkv CBF43926\nShow.S01E02.mkv 12345678\n";
let entries = parse_checksum_file(content, ChecksumKind::Crc32);
assert_eq!(
entries,
vec![
ChecksumEntry { file_name: "Show.S01E01.mkv".to_string(), checksum: "CBF43926".to_string() },
ChecksumEntry { file_name: "Show.S01E02.mkv".to_string(), checksum: "12345678".to_string() },
]
);
}
#[test]
fn sfv_filenames_with_spaces_still_parse_correctly() {
let content = "My Show S01E01.mkv CBF43926\n";
let entries = parse_checksum_file(content, ChecksumKind::Crc32);
assert_eq!(entries, vec![ChecksumEntry { file_name: "My Show S01E01.mkv".to_string(), checksum: "CBF43926".to_string() }]);
}
#[test]
fn parses_a_typical_md5sum_style_file_including_binary_mode_marker() {
let content = "d41d8cd98f00b204e9800998ecf8427e empty.txt\na9993e364706816aba3e25717850c26c9cd0d89d *abc.txt\n";
let entries = parse_checksum_file(content, ChecksumKind::Sha1);
assert_eq!(
entries,
vec![
ChecksumEntry { file_name: "empty.txt".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() },
ChecksumEntry { file_name: "abc.txt".to_string(), checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string() },
]
);
}
#[test]
fn build_checksum_file_round_trips_through_parse_for_sfv() {
let entries = vec![
ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "cbf43926".to_string() },
ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "deadbeef".to_string() },
];
let text = build_checksum_file(&entries, ChecksumKind::Crc32);
let parsed = parse_checksum_file(&text, ChecksumKind::Crc32);
assert_eq!(
parsed,
vec![
ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "CBF43926".to_string() },
ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "DEADBEEF".to_string() },
]
);
}
#[test]
fn build_checksum_file_round_trips_through_parse_for_md5() {
let entries = vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "D41D8CD98F00B204E9800998ECF8427E".to_string() }];
let text = build_checksum_file(&entries, ChecksumKind::Md5);
let parsed = parse_checksum_file(&text, ChecksumKind::Md5);
assert_eq!(parsed, vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() }]);
}
#[test]
fn verify_entries_reports_ok_for_a_matching_file() {
let dir = temp_dir();
fs::write(dir.join("a.txt"), b"abc").unwrap();
let entries = vec![ChecksumEntry {
file_name: "a.txt".to_string(),
checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
}];
let results = verify_entries(&entries, &dir, ChecksumKind::Sha1);
assert_eq!(results.len(), 1);
assert_eq!(results[0].status, VerifyStatus::Ok);
assert_eq!(results[0].actual.as_deref(), Some("a9993e364706816aba3e25717850c26c9cd0d89d"));
}
#[test]
fn verify_entries_reports_mismatch_for_a_changed_file() {
let dir = temp_dir();
fs::write(dir.join("a.txt"), b"changed content").unwrap();
let entries = vec![ChecksumEntry {
file_name: "a.txt".to_string(),
checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(),
}];
let results = verify_entries(&entries, &dir, ChecksumKind::Sha1);
assert_eq!(results[0].status, VerifyStatus::Mismatch);
assert!(results[0].actual.is_some());
}
#[test]
fn verify_entries_reports_missing_for_a_file_that_does_not_exist() {
let dir = temp_dir();
let entries = vec![ChecksumEntry { file_name: "nope.txt".to_string(), checksum: "CBF43926".to_string() }];
let results = verify_entries(&entries, &dir, ChecksumKind::Crc32);
assert_eq!(results[0].status, VerifyStatus::Missing);
assert_eq!(results[0].actual, None);
}
#[test]
fn verify_is_case_insensitive_on_the_checksum_text() {
let dir = temp_dir();
fs::write(dir.join("a.txt"), b"123456789").unwrap();
let entries = vec![ChecksumEntry { file_name: "a.txt".to_string(), checksum: "cbf43926".to_string() }];
let results = verify_entries(&entries, &dir, ChecksumKind::Crc32);
assert_eq!(results[0].status, VerifyStatus::Ok);
}
#[test]
fn checksum_kind_extension_and_label_match_convention() {
assert_eq!(ChecksumKind::Crc32.extension(), "sfv");
assert_eq!(ChecksumKind::Md5.extension(), "md5");
assert_eq!(ChecksumKind::Sha1.extension(), "sha1");
assert_eq!(ChecksumKind::Crc32.label(), "CRC32");
}
}