File hashing in naming schemes: {crc32}/{md5}/{sha1} via Hash Files
Closes the last remaining gap in porting-roadmap item 1 (full naming-
scheme bindings) - user picked this from a short list of next-feature
options after the recent naming-scheme/Match usability work.
crates/aurora-curator-core/src/sfv: new compute_all_checksums(path),
alongside the existing compute_checksum(path, kind). Streams the file
ONCE, updating CRC32/MD5/SHA-1 hashers concurrently over each chunk,
rather than calling compute_checksum three times (three full reads of
what's often a multi-gigabyte video file). The SFV panel itself keeps
using compute_checksum directly - it only ever needs one algorithm at a
time, picked by the user, so computing the other two nobody asked for
would be wasteful in the other direction. 3 new tests: matches known
CRC32/MD5/SHA-1 test vectors together, agrees with compute_checksum on a
file bigger than the read buffer (catches a hasher only seeing the first
chunk), errors on a missing file.
src-tauri/src/sfv.rs: new NamingHashOutcome struct (plain
#[serde(rename_all = "camelCase")] struct, not a tagged enum - the class
of bug fixed in the nineteenth delivery doesn't apply here) and
compute_naming_hashes command, batching compute_all_checksums the same
blocking-thread way compute_checksums already does, with the same
per-file error isolation (one bad file doesn't abort the batch).
Registered in lib.rs.
RenamePanel.tsx: new runHashFiles() and a "Hash Files" button (new
HashIcon in Icons.tsx) next to Fetch Data. Deliberately NOT wired into
Match automatically like the rest of its local steps - hashing reads a
file's entire contents rather than a small header, so running it
unasked on every Match click for every loaded file would make Match
unpredictably slow. Its own busy state (hashing) disables Match/Fetch
Data/Hash Files against each other while any one is running. New "File
hashing" field-reference group ({crc32}/{md5}/{sha1} as clickable chips,
reusing the field-chip insert-at-cursor mechanism from the earlier
naming-scheme UX delivery) and updated inline help text.
Verified via cargo test --workspace (191 -> 193 in the core crate: 145 ->
148) + cargo check --workspace (clean) + npm run build (clean) + a
10-check Playwright script against npm run dev (mocking
window.__TAURI_INTERNALS__.invoke): confirmed the three field chips are
present, Hash Files calls compute_naming_hashes with every loaded path,
the status line correctly reports hashed-vs-failed counts, a computed
CRC32 reaches the live rename preview, a failed file's row doesn't
fabricate a hash, and clicking Hash Files with nothing loaded is handled
gracefully. Caught one test-mock bug along the way (not an app bug): an
early mock response for a successful hash omitted the `error` field
entirely rather than setting it to `null`, which the app correctly (per
the real Rust command's always-present Option<String> field) treated as
a truthy non-null value and skipped merging - fixed the mock to match
the real wire contract.
Updated README (status paragraph, project layout for sfv.rs's now-four
commands, a new "Hash Files" section placed after "Real media-file
probing", porting roadmap item 1's "still missing" list).
This commit is contained in:
@@ -98,6 +98,48 @@ pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// All three checksums for one file, computed together - see [`compute_all_checksums`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AllChecksums {
|
||||
pub crc32: String,
|
||||
pub md5: String,
|
||||
pub sha1: String,
|
||||
}
|
||||
|
||||
/// Same job as calling [`compute_checksum`] three times (once per [`ChecksumKind`]),
|
||||
/// but streams the file exactly once instead of three times - used for the naming-scheme
|
||||
/// `{crc32}`/`{md5}`/`{sha1}` fields (see `src-tauri/src/sfv.rs`'s `compute_naming_hashes`),
|
||||
/// where a scheme referencing more than one hash field would otherwise hash the same
|
||||
/// (often multi-gigabyte video) file redundantly. The SFV panel itself still calls
|
||||
/// [`compute_checksum`] directly - it only ever needs one algorithm at a time, picked by
|
||||
/// the user, so tripling its I/O for the other two nobody asked for would be wasteful in
|
||||
/// the other direction.
|
||||
pub fn compute_all_checksums(path: &Path) -> io::Result<AllChecksums> {
|
||||
let mut file = File::open(path)?;
|
||||
let mut buf = [0u8; READ_BUFFER_SIZE];
|
||||
|
||||
let mut crc32 = crc32fast::Hasher::new();
|
||||
let mut md5 = Md5::new();
|
||||
let mut sha1 = Sha1::new();
|
||||
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let chunk = &buf[..n];
|
||||
crc32.update(chunk);
|
||||
md5.update(chunk);
|
||||
sha1.update(chunk);
|
||||
}
|
||||
|
||||
Ok(AllChecksums {
|
||||
crc32: format!("{:08X}", crc32.finalize()),
|
||||
md5: format!("{:x}", md5.finalize()),
|
||||
sha1: format!("{:x}", sha1.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
|
||||
@@ -293,6 +335,44 @@ mod tests {
|
||||
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_matches_the_known_test_vectors_all_at_once() {
|
||||
// Same "123456789" CRC32 vector as above, computed alongside MD5/SHA-1 of the
|
||||
// same bytes in one pass - pins that streaming all three hashers together over
|
||||
// the same chunks produces the identical result as hashing separately.
|
||||
let dir = temp_dir();
|
||||
let path = dir.join("check.txt");
|
||||
fs::write(&path, b"123456789").unwrap();
|
||||
let all = compute_all_checksums(&path).unwrap();
|
||||
assert_eq!(all.crc32, "CBF43926");
|
||||
assert_eq!(all.md5, "25f9e794323b453885f5181f1b624d0b"); // md5("123456789")
|
||||
assert_eq!(all.sha1, "f7c3bc1d808e04732adf679965ccc34ca7ae3441"); // sha1("123456789")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_agrees_with_compute_checksum_on_a_multi_chunk_file() {
|
||||
// Bigger than READ_BUFFER_SIZE (64 KiB) so both the individual and combined
|
||||
// paths each have to loop over more than one read() - catches a bug where one
|
||||
// of the three hashers only got the first chunk (e.g. an accidental `break`
|
||||
// inside one match arm instead of the shared loop).
|
||||
let dir = temp_dir();
|
||||
let path = dir.join("big.bin");
|
||||
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
|
||||
let all = compute_all_checksums(&path).unwrap();
|
||||
assert_eq!(all.crc32, compute_checksum(&path, ChecksumKind::Crc32).unwrap());
|
||||
assert_eq!(all.md5, compute_checksum(&path, ChecksumKind::Md5).unwrap());
|
||||
assert_eq!(all.sha1, compute_checksum(&path, ChecksumKind::Sha1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_all_checksums_errors_when_the_file_does_not_exist() {
|
||||
let dir = temp_dir();
|
||||
let missing = dir.join("nope.txt");
|
||||
assert!(compute_all_checksums(&missing).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";
|
||||
|
||||
Reference in New Issue
Block a user