1#![deny(warnings)]
9
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use sha2::{Digest, Sha256};
14use tokio::sync::Semaphore;
15
16use crate::models::integrity::AssetIntegrity;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IntegrityStatus {
25 Pass,
27 Fail,
29 Missing,
31}
32
33#[derive(Debug, Clone)]
35pub struct IntegrityResult {
36 pub path: String,
38 pub expected_hash: String,
40 pub actual_hash: Option<String>,
42 pub status: IntegrityStatus,
44}
45
46pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
52 use std::fmt::Write as _;
53 let mut hasher = Sha256::new();
54 hasher.update(bytes);
55 let result = hasher.finalize();
56 result.iter().fold(String::new(), |mut s, b| {
57 let _ = write!(s, "{b:02x}");
58 s
59 })
60}
61
62fn check_file(path: &str, disk_path: &Path, expected_hash: &str) -> IntegrityResult {
64 match std::fs::read(disk_path) {
65 Err(_) => IntegrityResult {
66 path: path.to_string(),
67 expected_hash: expected_hash.to_string(),
68 actual_hash: None,
69 status: IntegrityStatus::Missing,
70 },
71 Ok(bytes) => {
72 let actual = sha256_hex(&bytes);
73 let status = if actual == expected_hash {
74 IntegrityStatus::Pass
75 } else {
76 IntegrityStatus::Fail
77 };
78 IntegrityResult {
79 path: path.to_string(),
80 expected_hash: expected_hash.to_string(),
81 actual_hash: Some(actual),
82 status,
83 }
84 }
85 }
86}
87
88pub fn verify_integrity(manifest: &AssetIntegrity, repo_root: &Path) -> Vec<IntegrityResult> {
99 let mut results: Vec<IntegrityResult> = Vec::new();
100
101 for tree in &manifest.trees {
103 for file in &tree.files {
104 let disk_path = repo_root.join(&file.path);
105 results.push(check_file(&file.path, &disk_path, &file.sha256));
106 }
107 }
108
109 for file in &manifest.root_files {
111 let disk_path = repo_root.join(&file.path);
112 results.push(check_file(&file.path, &disk_path, &file.sha256));
113 }
114
115 results.sort_by(|a, b| a.path.cmp(&b.path));
116 results
117}
118
119pub async fn verify_integrity_parallel(
128 manifest: &AssetIntegrity,
129 repo_root: &Path,
130) -> Vec<IntegrityResult> {
131 const MAX_CONCURRENT: usize = 8;
132 let sem = Arc::new(Semaphore::new(MAX_CONCURRENT));
133
134 let mut entries: Vec<(String, String, PathBuf)> = Vec::new();
136
137 for tree in &manifest.trees {
138 for file in &tree.files {
139 entries.push((
140 file.path.clone(),
141 file.sha256.clone(),
142 repo_root.join(&file.path),
143 ));
144 }
145 }
146 for file in &manifest.root_files {
147 entries.push((
148 file.path.clone(),
149 file.sha256.clone(),
150 repo_root.join(&file.path),
151 ));
152 }
153
154 let mut handles = Vec::with_capacity(entries.len());
155
156 for (path_str, expected, disk_path) in entries {
157 let permit = Arc::clone(&sem);
158 let handle = tokio::spawn(async move {
159 let _guard = permit.acquire_owned().await.expect("semaphore closed");
160 tokio::task::spawn_blocking(move || check_file(&path_str, &disk_path, &expected))
162 .await
163 .expect("spawn_blocking panicked")
164 });
165 handles.push(handle);
166 }
167
168 let mut results: Vec<IntegrityResult> = Vec::with_capacity(handles.len());
169 for handle in handles {
170 results.push(handle.await.expect("task panicked"));
171 }
172
173 results.sort_by(|a, b| a.path.cmp(&b.path));
174 results
175}
176
177pub fn manifest_changed(current_manifest_hash: &str, cached_hash: Option<&str>) -> bool {
187 match cached_hash {
188 None => true,
189 Some(cached) => current_manifest_hash != cached,
190 }
191}
192
193#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::models::integrity::{AssetIntegrity, IntegrityFile, IntegrityScope, IntegrityTree};
201 use proptest::prelude::*;
202 use std::fs;
203 use tempfile::TempDir;
204
205 fn make_manifest(files: Vec<IntegrityFile>) -> AssetIntegrity {
210 AssetIntegrity {
211 manifest_version: 1,
212 algorithm: "sha256".to_string(),
213 scope: IntegrityScope {
214 trees: vec![],
215 root_files: vec![],
216 },
217 trees: vec![IntegrityTree {
218 tree: "test-tree".to_string(),
219 aggregate_sha256: "0".to_string(),
220 files,
221 }],
222 root_files: vec![],
223 aggregate_sha256: "0".to_string(),
224 }
225 }
226
227 #[test]
232 fn pass_when_hash_matches() {
233 let dir = TempDir::new().unwrap();
234 let content = b"hello world";
235 let hash = sha256_hex(content);
236 let rel = "file.txt";
237 fs::write(dir.path().join(rel), content).unwrap();
238
239 let manifest = make_manifest(vec![IntegrityFile {
240 path: rel.to_string(),
241 sha256: hash.clone(),
242 bytes: content.len() as u64,
243 }]);
244
245 let results = verify_integrity(&manifest, dir.path());
246 assert_eq!(results.len(), 1);
247 assert_eq!(results[0].status, IntegrityStatus::Pass);
248 assert_eq!(results[0].actual_hash.as_deref(), Some(hash.as_str()));
249 }
250
251 #[test]
252 fn fail_when_content_changed() {
253 let dir = TempDir::new().unwrap();
254 let original = b"original content";
255 let hash = sha256_hex(original);
256 let rel = "file.txt";
257 fs::write(dir.path().join(rel), b"tampered content").unwrap();
259
260 let manifest = make_manifest(vec![IntegrityFile {
261 path: rel.to_string(),
262 sha256: hash,
263 bytes: original.len() as u64,
264 }]);
265
266 let results = verify_integrity(&manifest, dir.path());
267 assert_eq!(results[0].status, IntegrityStatus::Fail);
268 assert!(results[0].actual_hash.is_some());
269 }
270
271 #[test]
272 fn missing_when_file_absent() {
273 let dir = TempDir::new().unwrap();
274 let manifest = make_manifest(vec![IntegrityFile {
275 path: "nonexistent.txt".to_string(),
276 sha256: "abc123".to_string(),
277 bytes: 0,
278 }]);
279
280 let results = verify_integrity(&manifest, dir.path());
281 assert_eq!(results[0].status, IntegrityStatus::Missing);
282 assert!(results[0].actual_hash.is_none());
283 }
284
285 #[test]
286 fn results_sorted_by_path() {
287 let dir = TempDir::new().unwrap();
288 let content = b"x";
289 let hash = sha256_hex(content);
290
291 for name in &["z.txt", "a.txt", "m.txt"] {
292 fs::write(dir.path().join(name), content).unwrap();
293 }
294
295 let manifest = make_manifest(vec![
296 IntegrityFile {
297 path: "z.txt".into(),
298 sha256: hash.clone(),
299 bytes: 1,
300 },
301 IntegrityFile {
302 path: "a.txt".into(),
303 sha256: hash.clone(),
304 bytes: 1,
305 },
306 IntegrityFile {
307 path: "m.txt".into(),
308 sha256: hash.clone(),
309 bytes: 1,
310 },
311 ]);
312
313 let results = verify_integrity(&manifest, dir.path());
314 let paths: Vec<&str> = results.iter().map(|r| r.path.as_str()).collect();
315 assert_eq!(paths, vec!["a.txt", "m.txt", "z.txt"]);
316 }
317
318 #[test]
319 fn manifest_changed_returns_true_when_different() {
320 assert!(manifest_changed("new_hash", Some("old_hash")));
321 }
322
323 #[test]
324 fn manifest_changed_returns_false_when_same() {
325 assert!(!manifest_changed("same_hash", Some("same_hash")));
326 }
327
328 #[test]
329 fn manifest_changed_returns_true_when_no_cache() {
330 assert!(manifest_changed("any_hash", None));
331 }
332
333 #[tokio::test]
335 async fn parallel_verify_matches_sync() {
336 let dir = TempDir::new().unwrap();
337 let content = b"parallel test content";
338 let hash = sha256_hex(content);
339
340 for i in 0..10 {
341 fs::write(dir.path().join(format!("file{i}.txt")), content).unwrap();
342 }
343
344 let files: Vec<IntegrityFile> = (0..10)
345 .map(|i| IntegrityFile {
346 path: format!("file{i}.txt"),
347 sha256: hash.clone(),
348 bytes: content.len() as u64,
349 })
350 .collect();
351
352 let manifest = make_manifest(files);
353
354 let sync_results = verify_integrity(&manifest, dir.path());
355 let async_results = verify_integrity_parallel(&manifest, dir.path()).await;
356
357 assert_eq!(sync_results.len(), async_results.len());
358 for (s, a) in sync_results.iter().zip(async_results.iter()) {
359 assert_eq!(s.path, a.path);
360 assert_eq!(s.status, a.status);
361 assert_eq!(s.actual_hash, a.actual_hash);
362 }
363 }
364
365 proptest! {
370 #![proptest_config(ProptestConfig::with_cases(256))]
371
372 #[test]
382 fn prop_integrity_classify_correct(
383 raw_files in prop::collection::vec(
385 (
386 0u8..200, prop::collection::vec(any::<u8>(), 1..50), prop::bool::ANY, prop::bool::ANY, ),
391 1..10,
392 ),
393 ) {
394 let dir = TempDir::new().unwrap();
395 let mut manifest_files: Vec<IntegrityFile> = Vec::new();
396
397 let mut expect_pass: Vec<String> = Vec::new();
399 let mut expect_fail: Vec<String> = Vec::new();
400 let mut expect_missing: Vec<String> = Vec::new();
401
402 for (iter_idx, (_idx, content, mutate, delete)) in raw_files.iter().enumerate() {
403 let name = format!("f{iter_idx}.bin");
405 let path = dir.path().join(&name);
406 let hash = sha256_hex(content);
407
408 fs::write(&path, content).unwrap();
409
410 manifest_files.push(IntegrityFile {
411 path: name.clone(),
412 sha256: hash,
413 bytes: content.len() as u64,
414 });
415
416 if *delete {
417 fs::remove_file(&path).unwrap();
418 expect_missing.push(name);
419 } else if *mutate {
420 let mut appended = content.clone();
422 appended.push(0xFF);
423 fs::write(&path, &appended).unwrap();
424 expect_fail.push(name);
425 } else {
426 expect_pass.push(name);
427 }
428 }
429
430 let manifest = make_manifest(manifest_files);
431 let results = verify_integrity(&manifest, dir.path());
432
433 let result_map: std::collections::HashMap<&str, &IntegrityStatus> =
435 results.iter().map(|r| (r.path.as_str(), &r.status)).collect();
436
437 for name in &expect_pass {
438 let got = result_map.get(name.as_str()).copied();
439 prop_assert!(
440 got == Some(&IntegrityStatus::Pass),
441 "expected Pass for {:?}, got {:?}",
442 name,
443 got
444 );
445 }
446 for name in &expect_fail {
447 let got = result_map.get(name.as_str()).copied();
448 prop_assert!(
449 got == Some(&IntegrityStatus::Fail),
450 "expected Fail for {:?}, got {:?}",
451 name,
452 got
453 );
454 }
455 for name in &expect_missing {
456 let got = result_map.get(name.as_str()).copied();
457 prop_assert!(
458 got == Some(&IntegrityStatus::Missing),
459 "expected Missing for {:?}, got {:?}",
460 name,
461 got
462 );
463 }
464 }
465
466 #[test]
468 fn prop_manifest_changed_pure(
469 hash_a in "[0-9a-f]{64}",
470 hash_b in "[0-9a-f]{64}",
471 ) {
472 prop_assert!(!manifest_changed(&hash_a, Some(&hash_a)));
474 if hash_a != hash_b {
476 prop_assert!(manifest_changed(&hash_a, Some(&hash_b)));
477 }
478 }
479 }
480}