use std::path::Path;
use tracing::{debug, info, warn};
use crate::metalink::parser::{HashAlgorithm, HashEntry, MetalinkFile};
#[derive(Debug, Clone)]
pub struct HashVerificationResult {
pub algorithm: String,
pub expected: String,
pub computed: String,
pub matches: bool,
}
impl HashVerificationResult {
pub fn is_valid(&self) -> bool {
self.matches
}
}
pub fn verify_checksum(
file_path: &Path,
expected: &HashEntry,
) -> Result<HashVerificationResult, String> {
if !file_path.exists() {
return Err(format!("File does not exist: {}", file_path.display()));
}
let data = std::fs::read(file_path)
.map_err(|e| format!("Failed to read file {}: {}", file_path.display(), e))?;
debug!(
path = %file_path.display(),
size = data.len(),
algo = %expected.algo.as_standard_name(),
"Computing file checksum"
);
let computed = compute_hash(&data, &expected.algo)?;
let matches = computed.to_lowercase() == expected.value.to_lowercase();
let result = HashVerificationResult {
algorithm: expected.algo.as_standard_name().to_string(),
expected: expected.value.clone(),
computed,
matches,
};
if matches {
info!(
algo = %result.algorithm,
path = %file_path.display(),
"Checksum verification passed"
);
} else {
warn!(
algo = %result.algorithm,
expected = %result.expected,
computed = %result.computed,
path = %file_path.display(),
"Checksum verification FAILED"
);
}
Ok(result)
}
pub fn verify_all_checksums(
file_path: &Path,
metalink_file: &MetalinkFile,
) -> Result<Vec<HashVerificationResult>, String> {
if metalink_file.hashes.is_empty() {
debug!(
name = %metalink_file.name,
"No checksums declared, skipping verification"
);
return Ok(vec![]);
}
info!(
name = %metalink_file.name,
hash_count = metalink_file.hashes.len(),
path = %file_path.display(),
"Verifying all declared checksums"
);
let mut results = Vec::with_capacity(metalink_file.hashes.len());
for hash_entry in &metalink_file.hashes {
match verify_checksum(file_path, hash_entry) {
Ok(result) => results.push(result),
Err(e) => {
warn!(
error = %e,
algo = %hash_entry.algo.as_standard_name(),
"Failed to verify hash, recording as mismatch"
);
results.push(HashVerificationResult {
algorithm: hash_entry.algo.as_standard_name().to_string(),
expected: hash_entry.value.clone(),
computed: format!("<error: {}>", e),
matches: false,
});
}
}
}
let passed = results.iter().filter(|r| r.matches).count();
let total = results.len();
info!(
name = %metalink_file.name,
passed,
total,
"Checksum verification complete"
);
Ok(results)
}
fn compute_hash(data: &[u8], algo: &HashAlgorithm) -> Result<String, String> {
use digest::Digest;
match algo {
HashAlgorithm::Sha256 => {
use sha2::Sha256;
let mut hasher = Sha256::new();
hasher.update(data);
Ok(format!("{:x}", hasher.finalize()))
}
HashAlgorithm::Sha1 => {
use sha1::Sha1;
let mut hasher = Sha1::new();
hasher.update(data);
Ok(format!("{:x}", hasher.finalize()))
}
HashAlgorithm::Sha512 => {
use sha2::Sha512;
let mut hasher = Sha512::new();
hasher.update(data);
Ok(format!("{:x}", hasher.finalize()))
}
HashAlgorithm::Md5 => {
let digest = md5::compute(data);
Ok(format!("{:x}", digest))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn make_test_file(content: &[u8], suffix: &str) -> PathBuf {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
% 1_000_000_000;
let dir =
std::env::temp_dir().join(format!("metalink_cksum_test_{}_{}", std::process::id(), ts));
let _ = fs::create_dir_all(&dir);
let path = dir.join(format!("test_file{}", suffix));
fs::write(&path, content).expect("Should write test file");
path
}
fn cleanup_test_file(path: &Path) {
if let Some(parent) = path.parent() {
let _ = fs::remove_file(path);
let _ = fs::remove_dir_all(parent);
}
}
#[test]
fn test_sha256_verification_match() {
let path = make_test_file(b"", "_sha256_match");
let expected = HashEntry::new(
HashAlgorithm::Sha256,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
let result = verify_checksum(&path, &expected).expect("Verification should succeed");
assert!(
result.is_valid(),
"SHA-256 of empty string should match known hash"
);
assert_eq!(result.algorithm, "sha-256");
cleanup_test_file(&path);
}
#[test]
fn test_sha256_verification_mismatch_fails() {
let path = make_test_file(b"hello world", "_sha256_mismatch");
let expected = HashEntry::new(
HashAlgorithm::Sha256,
"0000000000000000000000000000000000000000000000000000000000000000000",
);
let result = verify_checksum(&path, &expected).expect("Verification should succeed");
assert!(!result.is_valid(), "Wrong hash should fail verification");
assert_ne!(result.computed, result.expected);
cleanup_test_file(&path);
}
#[test]
fn test_no_checksum_skips_gracefully() {
let path = make_test_file(b"some data", "_no_checksum");
let metalink_file = MetalinkFile::new("test_no_hash.bin");
let results =
verify_all_checksums(&path, &metalink_file).expect("Should succeed without hashes");
assert!(
results.is_empty(),
"No hashes declared should return empty results"
);
cleanup_test_file(&path);
}
#[test]
fn test_multiple_hashes_all_must_pass() {
let content = b"test content for multiple hashes";
let path = make_test_file(content, "_multi_hash");
let mut metalink_file = MetalinkFile::new("multi_hash_test.bin");
let sha256_expected =
compute_hash(content, &HashAlgorithm::Sha256).expect("Should compute SHA-256");
metalink_file
.hashes
.push(HashEntry::new(HashAlgorithm::Sha256, &sha256_expected));
let sha1_expected =
compute_hash(content, &HashAlgorithm::Sha1).expect("Should compute SHA-1");
metalink_file
.hashes
.push(HashEntry::new(HashAlgorithm::Sha1, &sha1_expected));
let md5_expected = compute_hash(content, &HashAlgorithm::Md5).expect("Should compute MD5");
metalink_file
.hashes
.push(HashEntry::new(HashAlgorithm::Md5, &md5_expected));
let results =
verify_all_checksums(&path, &metalink_file).expect("Should verify all hashes");
assert_eq!(results.len(), 3, "Should have 3 verification results");
assert!(
results.iter().all(|r| r.is_valid()),
"All correct hashes should pass"
);
cleanup_test_file(&path);
}
#[test]
fn test_multiple_hashes_one_fails() {
let content = b"sensitive data";
let path = make_test_file(content, "_one_fail");
let mut metalink_file = MetalinkFile::new("sensitive_data.bin");
let sha256_ok = compute_hash(content, &HashAlgorithm::Sha256).unwrap();
metalink_file
.hashes
.push(HashEntry::new(HashAlgorithm::Sha256, &sha256_ok));
metalink_file.hashes.push(HashEntry::new(
HashAlgorithm::Sha1,
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
));
let results = verify_all_checksums(&path, &metalink_file)
.expect("Should complete despite one failure");
assert_eq!(results.len(), 2);
assert!(results[0].is_valid(), "First hash (SHA-256) should pass");
assert!(
!results[1].is_valid(),
"Second hash (wrong SHA-1) should fail"
);
cleanup_test_file(&path);
}
#[test]
fn test_sha1_verification() {
let path = make_test_file(b"test", "_sha1_test");
let expected = HashEntry::new(
HashAlgorithm::Sha1,
"a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
);
let result = verify_checksum(&path, &expected).expect("SHA-1 verification should work");
assert!(result.is_valid(), "SHA-1 should match");
assert_eq!(result.algorithm, "sha-1");
cleanup_test_file(&path);
}
#[test]
fn test_md5_verification() {
let path = make_test_file(b"test", "_md5_test");
let actual_md5 = md5::compute(b"test");
let actual_hex = format!("{:x}", actual_md5);
let expected = HashEntry::new(HashAlgorithm::Md5, &actual_hex);
let result = verify_checksum(&path, &expected).expect("MD5 verification should work");
assert!(result.is_valid(), "MD5 should match");
assert_eq!(result.algorithm, "md5");
cleanup_test_file(&path);
}
#[test]
fn test_verify_nonexistent_file_error() {
let nonexistent = PathBuf::from("/tmp/this_file_should_not_exist_12345.dat");
let expected = HashEntry::new(HashAlgorithm::Sha256, "abc123");
let result = verify_checksum(&nonexistent, &expected);
assert!(result.is_err(), "Nonexistent file should return error");
assert!(
result.unwrap_err().contains("does not exist"),
"Error should mention file doesn't exist"
);
}
#[test]
fn test_compute_hash_known_values() {
let empty_data = b"";
let sha256 = compute_hash(empty_data, &HashAlgorithm::Sha256).unwrap();
assert_eq!(
sha256, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"SHA-256 of empty string"
);
let sha1 = compute_hash(empty_data, &HashAlgorithm::Sha1).unwrap();
assert_eq!(
sha1, "da39a3ee5e6b4b0d3255bfef95601890afd80709",
"SHA-1 of empty string"
);
let md5 = compute_hash(empty_data, &HashAlgorithm::Md5).unwrap();
assert_eq!(
md5, "d41d8cd98f00b204e9800998ecf8427e",
"MD5 of empty string"
);
}
#[test]
fn test_large_file_hashing() {
let large_content: Vec<u8> = (0..=255).cycle().take(1024).collect();
let path = make_test_file(&large_content, "_large_file");
let hash1 = compute_hash(&large_content, &HashAlgorithm::Sha256).unwrap();
let hash2 = compute_hash(&large_content, &HashAlgorithm::Sha256).unwrap();
assert_eq!(
hash1, hash2,
"Hashing same data twice should produce same result"
);
assert_eq!(hash1.len(), 64, "SHA-256 output should be 64 hex chars");
cleanup_test_file(&path);
}
}