Skip to main content

modde_core/hash/
mod.rs

1use std::path::Path;
2
3use sha2::{Digest, Sha256};
4use tokio::io::AsyncReadExt;
5use xxhash_rust::xxh3::xxh3_64;
6use xxhash_rust::xxh64::xxh64;
7
8use crate::error::Result;
9use crate::CoreError;
10
11const BUF_SIZE: usize = 64 * 1024;
12
13/// Return a `HashMismatch` error with hex-formatted u64 hashes.
14fn hash_mismatch(path: &Path, expected: u64, actual: u64) -> CoreError {
15    CoreError::HashMismatch {
16        path: path.to_path_buf(),
17        expected: format!("{expected:016x}"),
18        actual: format!("{actual:016x}"),
19    }
20}
21
22/// Compute xxHash (XXH3-64) of a file.
23pub async fn hash_file_xxhash(path: &Path) -> Result<u64> {
24    let data = tokio::fs::read(path).await?;
25    Ok(xxh3_64(&data))
26}
27
28/// Verify a file's XXH3-64 hash matches the expected value.
29pub async fn verify_xxhash(path: &Path, expected: u64) -> Result<()> {
30    let actual = hash_file_xxhash(path).await?;
31    if actual != expected {
32        return Err(hash_mismatch(path, expected, actual));
33    }
34    Ok(())
35}
36
37/// Verify a file's classic xxHash64 matches the expected value (used by Wabbajack).
38pub async fn verify_xxh64(path: &Path, expected: u64) -> Result<()> {
39    let data = tokio::fs::read(path).await?;
40    let actual = xxh64(&data, 0);
41    if actual != expected {
42        return Err(hash_mismatch(path, expected, actual));
43    }
44    Ok(())
45}
46
47/// Verify a file's hash using Wabbajack-compatible strategy: try xxHash64 first, fall back to XXH3.
48///
49/// This is the canonical entry point for download verification where the hash algorithm is ambiguous.
50pub async fn verify_xxhash_compat(path: &Path, expected: u64) -> Result<u64> {
51    let data = tokio::fs::read(path).await?;
52    let h64 = xxh64(&data, 0);
53    if h64 == expected || xxh3_64(&data) == expected {
54        return Ok(expected);
55    }
56    Err(hash_mismatch(path, expected, h64))
57}
58
59/// Compute SHA-256 of a file using streaming reads.
60pub async fn hash_file_sha256(path: &Path) -> Result<String> {
61    let mut file = tokio::fs::File::open(path).await?;
62    let mut hasher = Sha256::new();
63    let mut buf = vec![0u8; BUF_SIZE];
64
65    loop {
66        let n = file.read(&mut buf).await?;
67        if n == 0 {
68            break;
69        }
70        hasher.update(&buf[..n]);
71    }
72
73    Ok(format!("{:x}", hasher.finalize()))
74}
75
76/// Verify a file's SHA-256 matches the expected hex string.
77pub async fn verify_sha256(path: &Path, expected: &str) -> Result<()> {
78    let actual = hash_file_sha256(path).await?;
79    if actual != expected {
80        return Err(CoreError::HashMismatch {
81            path: path.to_path_buf(),
82            expected: expected.to_string(),
83            actual,
84        });
85    }
86    Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use std::io::Write;
93    use tempfile::NamedTempFile;
94
95    fn create_temp_file(content: &[u8]) -> NamedTempFile {
96        let mut f = NamedTempFile::new().unwrap();
97        f.write_all(content).unwrap();
98        f
99    }
100
101    #[tokio::test]
102    async fn test_xxhash_roundtrip() {
103        let f = create_temp_file(b"hello world");
104        let hash = hash_file_xxhash(f.path()).await.unwrap();
105        verify_xxhash(f.path(), hash).await.unwrap();
106    }
107
108    #[tokio::test]
109    async fn test_xxhash_mismatch() {
110        let f = create_temp_file(b"hello world");
111        let result = verify_xxhash(f.path(), 0).await;
112        assert!(result.is_err());
113    }
114
115    #[tokio::test]
116    async fn test_sha256_known_value() {
117        let f = create_temp_file(b"hello world");
118        let hash = hash_file_sha256(f.path()).await.unwrap();
119        // SHA-256 of "hello world"
120        assert_eq!(
121            hash,
122            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
123        );
124    }
125
126    #[tokio::test]
127    async fn test_sha256_verify() {
128        let f = create_temp_file(b"test data");
129        let hash = hash_file_sha256(f.path()).await.unwrap();
130        verify_sha256(f.path(), &hash).await.unwrap();
131    }
132
133    #[tokio::test]
134    async fn test_sha256_mismatch() {
135        let f = create_temp_file(b"test data");
136        let result = verify_sha256(f.path(), "0000").await;
137        assert!(result.is_err());
138    }
139
140    #[tokio::test]
141    async fn test_xxhash_empty_file() {
142        let f = create_temp_file(b"");
143        let hash = hash_file_xxhash(f.path()).await.unwrap();
144        let expected = xxh3_64(b"");
145        assert_eq!(hash, expected);
146    }
147
148    #[tokio::test]
149    async fn test_xxhash_large_file() {
150        let data = vec![0xABu8; 1024 * 1024]; // 1MB
151        let f = create_temp_file(&data);
152        let hash = hash_file_xxhash(f.path()).await.unwrap();
153        let expected = xxh3_64(&data);
154        assert_eq!(hash, expected);
155    }
156
157    #[tokio::test]
158    async fn test_sha256_empty_file() {
159        let f = create_temp_file(b"");
160        let hash = hash_file_sha256(f.path()).await.unwrap();
161        assert_eq!(
162            hash,
163            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
164        );
165    }
166
167    #[tokio::test]
168    async fn test_xxhash_nonexistent_file() {
169        let result = hash_file_xxhash(Path::new("/tmp/nonexistent_file_xxhash_test")).await;
170        assert!(result.is_err());
171    }
172
173    #[tokio::test]
174    async fn test_sha256_nonexistent_file() {
175        let result = hash_file_sha256(Path::new("/tmp/nonexistent_file_sha256_test")).await;
176        assert!(result.is_err());
177    }
178
179    #[tokio::test]
180    async fn test_xxhash_different_content_different_hash() {
181        let f1 = create_temp_file(b"content alpha");
182        let f2 = create_temp_file(b"content beta");
183        let h1 = hash_file_xxhash(f1.path()).await.unwrap();
184        let h2 = hash_file_xxhash(f2.path()).await.unwrap();
185        assert_ne!(h1, h2);
186    }
187
188    #[tokio::test]
189    async fn test_xxhash_same_content_same_hash() {
190        let f1 = create_temp_file(b"identical content");
191        let f2 = create_temp_file(b"identical content");
192        let h1 = hash_file_xxhash(f1.path()).await.unwrap();
193        let h2 = hash_file_xxhash(f2.path()).await.unwrap();
194        assert_eq!(h1, h2);
195    }
196}