diff --git a/Cargo.lock b/Cargo.lock index 328ee04..4d0c3e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3884,7 +3884,6 @@ dependencies = [ "serde-saphyr", "serde_json", "sevenz-rust2", - "sha2 0.11.0", "subtle", "temp-env", "tempfile", @@ -3900,6 +3899,7 @@ dependencies = [ "urlencoding", "walkdir", "wiremock", + "xxhash-rust", "zip", ] @@ -4964,6 +4964,12 @@ dependencies = [ "time", ] +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yasna" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 02ef7f8..8161faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ tokio-util = "0.7" anyhow = "1" # Crypto & hashing -sha2 = "0.11" +xxhash-rust = { version = "0.8", features = ["xxh3"] } rand = "0.10" subtle = "2" diff --git a/migrations/022_clear_sha256_hashes.sql b/migrations/022_clear_sha256_hashes.sql new file mode 100644 index 0000000..7b58d15 --- /dev/null +++ b/migrations/022_clear_sha256_hashes.sql @@ -0,0 +1,4 @@ +-- Clear SHA-256 hashes so they get recomputed as xxHash3-64. +-- Integrity checks and convoy catalog skip NULL hashes gracefully. +UPDATE mod_files SET file_hash = NULL; +UPDATE addon_files SET file_hash = NULL; diff --git a/src/spt/mods.rs b/src/spt/mods.rs index 197f234..87ab045 100644 --- a/src/spt/mods.rs +++ b/src/spt/mods.rs @@ -4,7 +4,7 @@ use std::path::Path; use anyhow::{Context, Result}; use sevenz_rust2::{ArchiveReader, Password}; -use sha2::{Digest, Sha256}; +use xxhash_rust::xxh3::Xxh3; use zip::ZipArchive; use crate::dirs::QumaDirs; @@ -149,10 +149,10 @@ impl ExtractionLimits { } } -/// Wraps a writer and computes SHA256 on the fly, enforcing a per-entry byte limit. +/// Wraps a writer and computes xxHash3-64 on the fly, enforcing a per-entry byte limit. struct HashingWriter { inner: W, - hasher: Sha256, + hasher: Xxh3, bytes_written: u64, max_bytes: u64, } @@ -161,14 +161,14 @@ impl HashingWriter { fn new(inner: W, max_bytes: u64) -> Self { Self { inner, - hasher: Sha256::new(), + hasher: Xxh3::new(), bytes_written: 0, max_bytes, } } fn finish(self) -> (u64, String) { - (self.bytes_written, hex_encode(&self.hasher.finalize())) + (self.bytes_written, format!("{:016x}", self.hasher.digest())) } } @@ -260,7 +260,7 @@ pub fn detect_strip_prefix(archive_path: &Path) -> Result { /// Extract a mod archive into `spt_root`, stripping any wrapper directory prefix. /// -/// Returns a list of extracted files with their relative paths, SHA256 hashes, and sizes. +/// Returns a list of extracted files with their relative paths, xxHash3-64 hashes, and sizes. pub fn extract_mod(archive_path: &Path, spt_root: &Path) -> Result> { let prefix = detect_strip_prefix(archive_path)?; let limits = ExtractionLimits::production(); @@ -530,13 +530,13 @@ fn validate_dest_under_root(dest: &Path, spt_root: &Path, raw_name: &str) -> Res Ok(()) } -/// Compute the SHA256 hash of a file on disk, returned as a lowercase hex string. +/// Compute the xxHash3-64 hash of a file on disk, returned as a lowercase hex string. /// Streams through a BufReader to avoid loading the entire file into memory. pub fn compute_file_hash(path: &Path) -> Result { let file = fs::File::open(path) .with_context(|| format!("failed to open file for hashing: {}", path.display()))?; let mut reader = std::io::BufReader::new(file); - let mut hasher = Sha256::new(); + let mut hasher = Xxh3::new(); let mut buf = [0u8; 8192]; loop { let n = reader @@ -547,7 +547,7 @@ pub fn compute_file_hash(path: &Path) -> Result { } hasher.update(&buf[..n]); } - Ok(hex_encode(&hasher.finalize())) + Ok(format!("{:016x}", hasher.digest())) } /// Delete mod files from `spt_root` and clean up empty parent directories. @@ -597,16 +597,13 @@ pub fn scan_mod_directories(dirs: &QumaDirs) -> Result> { Ok(out) } -/// Compute SHA256 of a byte slice, returned as a lowercase hex string. +/// Compute xxHash3-64 of a byte slice, returned as a lowercase hex string. pub fn compute_hash_public(data: &[u8]) -> String { compute_hash(data) } fn compute_hash(data: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(data); - let result = hasher.finalize(); - hex_encode(&result) + format!("{:016x}", xxhash_rust::xxh3::xxh3_64(data)) } /// Recursively scan a directory, collecting file paths relative to `spt_root`. @@ -652,15 +649,6 @@ fn scan_dir_recursive(dir: &Path, spt_root: &Path, out: &mut Vec) -> Res Ok(()) } -/// Encode bytes as a lowercase hex string (avoids pulling in the `hex` crate). -fn hex_encode(bytes: &[u8]) -> String { - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - s.push_str(&format!("{b:02x}")); - } - s -} - /// Given a full entry name from a ZIP, strip a wrapper directory if the /// underlying path starts with a known prefix. This is used by `detect_mod_type` /// to look through wrapper directories. @@ -789,7 +777,7 @@ pub(crate) mod tests { "hash should be hex: {}", f.hash ); - assert_eq!(f.hash.len(), 64, "SHA256 hex should be 64 chars"); + assert_eq!(f.hash.len(), 16, "xxHash3-64 hex should be 16 chars"); } // Verify sizes match content @@ -806,12 +794,24 @@ pub(crate) mod tests { fs::write(tmp.path(), b"hello world").unwrap(); let hash = compute_file_hash(tmp.path()).unwrap(); + // xxHash3-64 of "hello world" assert_eq!( hash, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + format!("{:016x}", xxhash_rust::xxh3::xxh3_64(b"hello world")) ); } + #[test] + fn compute_hash_returns_xxh3() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), b"hello world").unwrap(); + let hash = compute_file_hash(tmp.path()).unwrap(); + // xxHash3-64 of "hello world" = 16-char hex + assert_eq!(hash.len(), 16, "hash should be 16 hex chars (xxHash3-64)"); + // Verify it's valid hex + assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + } + #[test] fn delete_mod_files_and_empty_dirs() { let tmp_dir = TempDir::new().unwrap(); @@ -963,7 +963,7 @@ pub(crate) mod tests { "hash should be hex: {}", f.hash ); - assert_eq!(f.hash.len(), 64, "SHA256 hex should be 64 chars"); + assert_eq!(f.hash.len(), 16, "xxHash3-64 hex should be 16 chars"); } let pkg = files @@ -1017,7 +1017,7 @@ pub(crate) mod tests { assert_eq!(buf, b"hello world"); assert_eq!( hash, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + format!("{:016x}", xxhash_rust::xxh3::xxh3_64(b"hello world")) ); }