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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user