Skip to main content

modde_core/hash/
mod.rs

1use std::fmt::Write;
2use std::path::Path;
3
4use sha2::{Digest, Sha256};
5use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
6use xxhash_rust::xxh3::Xxh3;
7use xxhash_rust::xxh64::Xxh64;
8
9use crate::CoreError;
10use crate::error::Result;
11
12const BUF_SIZE: usize = 64 * 1024;
13
14/// Return a `HashMismatch` error with hex-formatted u64 hashes.
15fn hash_mismatch(path: &Path, expected: u64, actual: u64) -> CoreError {
16    CoreError::HashMismatch {
17        path: path.to_path_buf(),
18        expected: format!("{expected:016x}"),
19        actual: format!("{actual:016x}"),
20    }
21}
22
23/// Compute xxHash (XXH3-64) of a file.
24pub async fn hash_file_xxhash(path: &Path) -> Result<u64> {
25    let mut file = tokio::fs::File::open(path).await?;
26    let mut hasher = Xxh3::new();
27    let mut buf = vec![0u8; BUF_SIZE];
28
29    loop {
30        let n = file.read(&mut buf).await?;
31        if n == 0 {
32            break;
33        }
34        hasher.update(&buf[..n]);
35    }
36
37    Ok(hasher.digest())
38}
39
40/// Verify a file's XXH3-64 hash matches the expected value.
41pub async fn verify_xxhash(path: &Path, expected: u64) -> Result<()> {
42    let actual = hash_file_xxhash(path).await?;
43    if actual != expected {
44        return Err(hash_mismatch(path, expected, actual));
45    }
46    Ok(())
47}
48
49/// Verify a file's classic xxHash64 matches the expected value (used by Wabbajack).
50pub async fn verify_xxh64(path: &Path, expected: u64) -> Result<()> {
51    let actual = hash_file_xxh64(path).await?;
52    if actual != expected {
53        return Err(hash_mismatch(path, expected, actual));
54    }
55    Ok(())
56}
57
58/// Compute classic xxHash64 of a file (used by Wabbajack).
59pub async fn hash_file_xxh64(path: &Path) -> Result<u64> {
60    let mut file = tokio::fs::File::open(path).await?;
61    let mut hasher = Xxh64::new(0);
62    let mut buf = vec![0u8; BUF_SIZE];
63
64    loop {
65        let n = file.read(&mut buf).await?;
66        if n == 0 {
67            break;
68        }
69        hasher.update(&buf[..n]);
70    }
71
72    Ok(hasher.digest())
73}
74
75/// Verify a file's hash using Wabbajack-compatible strategy: try xxHash64 first, fall back to XXH3.
76///
77/// This is the canonical entry point for download verification where the hash algorithm is ambiguous.
78pub async fn verify_xxhash_compat(path: &Path, expected: u64) -> Result<u64> {
79    let mut file = tokio::fs::File::open(path).await?;
80    let mut xxh64_hasher = Xxh64::new(0);
81    let mut xxh3_hasher = Xxh3::new();
82    let mut buf = vec![0u8; BUF_SIZE];
83
84    loop {
85        let n = file.read(&mut buf).await?;
86        if n == 0 {
87            break;
88        }
89        xxh64_hasher.update(&buf[..n]);
90        xxh3_hasher.update(&buf[..n]);
91    }
92
93    let h64 = xxh64_hasher.digest();
94    if h64 == expected || xxh3_hasher.digest() == expected {
95        return Ok(expected);
96    }
97    Err(hash_mismatch(path, expected, h64))
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum CompatHashMatch {
102    Xxh64,
103    Xxh3,
104}
105
106/// Copy `reader` into `writer` while computing the Wabbajack-compatible hashes.
107pub async fn copy_and_hash_compat<R, W>(
108    reader: &mut R,
109    writer: &mut W,
110    expected: u64,
111) -> Result<(u64, CompatHashMatch)>
112where
113    R: tokio::io::AsyncRead + Unpin,
114    W: AsyncWrite + Unpin,
115{
116    let mut xxh64_hasher = Xxh64::new(0);
117    let mut xxh3_hasher = Xxh3::new();
118    let mut buf = vec![0u8; BUF_SIZE];
119
120    loop {
121        let n = reader.read(&mut buf).await?;
122        if n == 0 {
123            break;
124        }
125        xxh64_hasher.update(&buf[..n]);
126        xxh3_hasher.update(&buf[..n]);
127        writer.write_all(&buf[..n]).await?;
128    }
129    writer.flush().await?;
130
131    let h64 = xxh64_hasher.digest();
132    if h64 == expected {
133        return Ok((h64, CompatHashMatch::Xxh64));
134    }
135    let h3 = xxh3_hasher.digest();
136    if h3 == expected {
137        return Ok((h3, CompatHashMatch::Xxh3));
138    }
139    Err(hash_mismatch(Path::new("<stream>"), expected, h64))
140}
141
142/// Compute SHA-256 of a file using streaming reads.
143pub async fn hash_file_sha256(path: &Path) -> Result<String> {
144    let mut file = tokio::fs::File::open(path).await?;
145    let mut hasher = Sha256::new();
146    let mut buf = vec![0u8; BUF_SIZE];
147
148    loop {
149        let n = file.read(&mut buf).await?;
150        if n == 0 {
151            break;
152        }
153        hasher.update(&buf[..n]);
154    }
155
156    let mut hex = String::with_capacity(64);
157    for byte in hasher.finalize() {
158        write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail");
159    }
160    Ok(hex)
161}
162
163/// Verify a file's SHA-256 matches the expected hex string.
164pub async fn verify_sha256(path: &Path, expected: &str) -> Result<()> {
165    let actual = hash_file_sha256(path).await?;
166    if actual != expected {
167        return Err(CoreError::HashMismatch {
168            path: path.to_path_buf(),
169            expected: expected.to_string(),
170            actual,
171        });
172    }
173    Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use std::io::Write;
180    use tempfile::NamedTempFile;
181    use xxhash_rust::xxh3::xxh3_64;
182    use xxhash_rust::xxh64::xxh64;
183
184    fn create_temp_file(content: &[u8]) -> NamedTempFile {
185        let mut f = NamedTempFile::new().unwrap();
186        f.write_all(content).unwrap();
187        f
188    }
189
190    #[tokio::test]
191    async fn test_xxhash_roundtrip() {
192        let f = create_temp_file(b"hello world");
193        let hash = hash_file_xxhash(f.path()).await.unwrap();
194        verify_xxhash(f.path(), hash).await.unwrap();
195    }
196
197    #[tokio::test]
198    async fn test_xxhash_mismatch() {
199        let f = create_temp_file(b"hello world");
200        let result = verify_xxhash(f.path(), 0).await;
201        assert!(result.is_err());
202    }
203
204    #[tokio::test]
205    async fn test_sha256_known_value() {
206        let f = create_temp_file(b"hello world");
207        let hash = hash_file_sha256(f.path()).await.unwrap();
208        // SHA-256 of "hello world"
209        assert_eq!(
210            hash,
211            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
212        );
213    }
214
215    #[tokio::test]
216    async fn test_sha256_verify() {
217        let f = create_temp_file(b"test data");
218        let hash = hash_file_sha256(f.path()).await.unwrap();
219        verify_sha256(f.path(), &hash).await.unwrap();
220    }
221
222    #[tokio::test]
223    async fn test_sha256_mismatch() {
224        let f = create_temp_file(b"test data");
225        let result = verify_sha256(f.path(), "0000").await;
226        assert!(result.is_err());
227    }
228
229    #[tokio::test]
230    async fn test_xxhash_empty_file() {
231        let f = create_temp_file(b"");
232        let hash = hash_file_xxhash(f.path()).await.unwrap();
233        let expected = xxh3_64(b"");
234        assert_eq!(hash, expected);
235    }
236
237    #[tokio::test]
238    async fn test_xxhash_large_file() {
239        let data = vec![0xABu8; 1024 * 1024]; // 1MB
240        let f = create_temp_file(&data);
241        let hash = hash_file_xxhash(f.path()).await.unwrap();
242        let expected = xxh3_64(&data);
243        assert_eq!(hash, expected);
244    }
245
246    #[tokio::test]
247    async fn test_sha256_empty_file() {
248        let f = create_temp_file(b"");
249        let hash = hash_file_sha256(f.path()).await.unwrap();
250        assert_eq!(
251            hash,
252            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
253        );
254    }
255
256    #[tokio::test]
257    async fn test_xxhash_nonexistent_file() {
258        let result = hash_file_xxhash(Path::new("/tmp/nonexistent_file_xxhash_test")).await;
259        assert!(result.is_err());
260    }
261
262    #[tokio::test]
263    async fn test_sha256_nonexistent_file() {
264        let result = hash_file_sha256(Path::new("/tmp/nonexistent_file_sha256_test")).await;
265        assert!(result.is_err());
266    }
267
268    #[tokio::test]
269    async fn test_xxhash_different_content_different_hash() {
270        let f1 = create_temp_file(b"content alpha");
271        let f2 = create_temp_file(b"content beta");
272        let h1 = hash_file_xxhash(f1.path()).await.unwrap();
273        let h2 = hash_file_xxhash(f2.path()).await.unwrap();
274        assert_ne!(h1, h2);
275    }
276
277    #[tokio::test]
278    async fn test_xxhash_same_content_same_hash() {
279        let f1 = create_temp_file(b"identical content");
280        let f2 = create_temp_file(b"identical content");
281        let h1 = hash_file_xxhash(f1.path()).await.unwrap();
282        let h2 = hash_file_xxhash(f2.path()).await.unwrap();
283        assert_eq!(h1, h2);
284    }
285
286    #[tokio::test]
287    async fn copy_and_hash_matches_existing_helpers() {
288        let content = b"streamed hash content";
289        let expected = xxh64(content, 0);
290        let mut reader = tokio::io::BufReader::new(&content[..]);
291        let mut writer = Vec::new();
292
293        let (hash, matched) = copy_and_hash_compat(&mut reader, &mut writer, expected)
294            .await
295            .unwrap();
296
297        assert_eq!(writer, content);
298        assert_eq!(hash, expected);
299        assert_eq!(matched, CompatHashMatch::Xxh64);
300    }
301
302    #[tokio::test]
303    async fn copy_and_hash_detects_mismatch() {
304        let content = b"streamed hash content";
305        let mut reader = tokio::io::BufReader::new(&content[..]);
306        let mut writer = Vec::new();
307
308        let err = copy_and_hash_compat(&mut reader, &mut writer, 0)
309            .await
310            .unwrap_err();
311
312        assert!(matches!(err, CoreError::HashMismatch { .. }));
313        assert_eq!(writer, content);
314    }
315}