Skip to main content

cas_kit/
hasher.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! BLAKE3 hashing utilities for the content-addressed store.
3//!
4//! All content in the store is identified by its BLAKE3 hash. BLAKE3 was
5//! chosen for its SIMD acceleration (AVX2, SSE4.1, NEON), parallel hashing
6//! for large files, 256-bit output (2^128 collision resistance), and
7//! derivation mode for domain-separated hashing.
8
9use std::io::Read;
10
11use crate::hash::Hash;
12use crate::CasError;
13
14/// Domain-separation string mixed into [`hash_with_context`] key
15/// derivation. Kept byte-identical to the origin codebase so context-
16/// keyed hashes are stable across both implementations.
17const CONTEXT_DOMAIN: &[u8] = b"suture-context-v1";
18
19/// Compute the BLAKE3 hash of a byte slice.
20///
21/// This is the primary hashing function used by the store.
22/// It uses the default BLAKE3 settings (no key, no context).
23#[inline]
24#[must_use]
25pub fn hash_bytes(data: &[u8]) -> Hash {
26    Hash::from_data(data)
27}
28
29/// Compute the BLAKE3 hash of a file by streaming it in chunks.
30///
31/// This avoids loading the entire file into memory, which is critical
32/// for multi-gigabyte media files.
33pub fn hash_file(path: &std::path::Path) -> Result<Hash, std::io::Error> {
34    let mut hasher = blake3::Hasher::new();
35    let file = std::fs::File::open(path)?;
36    let mut reader = std::io::BufReader::new(file);
37    const BUFFER_SIZE: usize = 64 * 1024; // 64 KB chunks
38    let mut buffer = [0u8; BUFFER_SIZE];
39
40    loop {
41        let n = reader.read(&mut buffer)?;
42        if n == 0 {
43            break;
44        }
45        hasher.update(&buffer[..n]);
46    }
47
48    Ok(Hash::from(*hasher.finalize().as_bytes()))
49}
50
51/// Compute the BLAKE3 hash with a domain-separated context string.
52///
53/// Context strings prevent cross-domain hash collisions. For example,
54/// a patch hash and a blob hash should never collide even if they
55/// contain identical data. We use keyed hashing with a context-derived key.
56#[must_use]
57pub fn hash_with_context(context: &str, data: &[u8]) -> Hash {
58    let context_key = blake3::derive_key(context, CONTEXT_DOMAIN);
59    let mut hasher = blake3::Hasher::new_keyed(&context_key);
60    hasher.update(data);
61    Hash::from(*hasher.finalize().as_bytes())
62}
63
64/// Verify that data matches an expected hash.
65///
66/// Returns `Ok(())` if `blake3(data) == expected`, `Err(CasError::HashMismatch)` otherwise.
67pub fn verify_hash(data: &[u8], expected: &Hash) -> Result<(), CasError> {
68    let actual = hash_bytes(data);
69    if actual == *expected {
70        Ok(())
71    } else {
72        Err(CasError::HashMismatch {
73            expected: expected.to_hex(),
74            actual: actual.to_hex(),
75        })
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn test_hash_bytes_deterministic() {
85        let h1 = hash_bytes(b"hello");
86        let h2 = hash_bytes(b"hello");
87        assert_eq!(h1, h2);
88    }
89
90    #[test]
91    fn test_hash_bytes_different() {
92        let h1 = hash_bytes(b"hello");
93        let h2 = hash_bytes(b"world");
94        assert_ne!(h1, h2);
95    }
96
97    #[test]
98    fn test_hash_empty() {
99        let h = hash_bytes(b"");
100        // BLAKE3 of empty string is a known constant.
101        let hex = h.to_hex();
102        assert_eq!(hex.len(), 64);
103    }
104
105    #[test]
106    fn test_hash_with_context_differs() {
107        let data = b"same data";
108        let h1 = hash_with_context("blob", data);
109        let h2 = hash_with_context("patch", data);
110        assert_ne!(h1, h2, "Different contexts must produce different hashes");
111    }
112
113    #[test]
114    fn test_verify_hash_ok() {
115        let data = b"test data";
116        let hash = hash_bytes(data);
117        assert!(verify_hash(data, &hash).is_ok());
118    }
119
120    #[test]
121    fn test_verify_hash_mismatch() {
122        let hash = hash_bytes(b"original");
123        assert!(verify_hash(b"tampered", &hash).is_err());
124    }
125
126    #[test]
127    fn test_hash_file() -> Result<(), Box<dyn std::error::Error>> {
128        let dir = tempfile::tempdir()?;
129        let file_path = dir.path().join("test.txt");
130        std::fs::write(&file_path, b"file content for hashing")?;
131
132        let h1 = hash_file(&file_path)?;
133        let h2 = hash_file(&file_path)?;
134        assert_eq!(h1, h2);
135
136        let h_direct = hash_bytes(b"file content for hashing");
137        assert_eq!(h1, h_direct, "File hash must match direct hash");
138        Ok(())
139    }
140}