Skip to main content

vfa_tui/federation/
integrity.rs

1//! Asset integrity verification engine (Tasks 7.16 / 7.17).
2//!
3//! Reads an [`AssetIntegrity`] manifest and verifies each listed file against
4//! its recorded SHA-256 hash, reporting `Pass`, `Fail`, or `Missing` for
5//! every entry.  Parallel verification (up to 8 concurrent I/O ops) is
6//! provided via the async variant.
7
8#![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// ---------------------------------------------------------------------------
19// Public types
20// ---------------------------------------------------------------------------
21
22/// Classification of a single integrity check.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IntegrityStatus {
25    /// Hash matches the manifest entry.
26    Pass,
27    /// File exists but hash does not match.
28    Fail,
29    /// File does not exist on disk.
30    Missing,
31}
32
33/// Result of checking one file's integrity.
34#[derive(Debug, Clone)]
35pub struct IntegrityResult {
36    /// Relative path as recorded in the manifest.
37    pub path: String,
38    /// Hash from the manifest.
39    pub expected_hash: String,
40    /// Computed hash, or `None` when the file is absent.
41    pub actual_hash: Option<String>,
42    /// Pass / Fail / Missing.
43    pub status: IntegrityStatus,
44}
45
46// ---------------------------------------------------------------------------
47// Hash helper
48// ---------------------------------------------------------------------------
49
50/// Compute SHA-256 hex digest of `bytes`.
51pub(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
62/// Verify a single file at `disk_path` against `expected_hash`.
63fn 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
88// ---------------------------------------------------------------------------
89// Synchronous API
90// ---------------------------------------------------------------------------
91
92/// Verify every file in `manifest` (trees + root_files) against the filesystem.
93///
94/// `repo_root` is the directory that relative paths in the manifest are
95/// resolved against.
96///
97/// Results are returned in deterministic order (sorted by path).
98pub fn verify_integrity(manifest: &AssetIntegrity, repo_root: &Path) -> Vec<IntegrityResult> {
99    let mut results: Vec<IntegrityResult> = Vec::new();
100
101    // Walk all files from every tree
102    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    // Root files
110    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
119// ---------------------------------------------------------------------------
120// Async / parallel API (up to 8 concurrent I/O operations)
121// ---------------------------------------------------------------------------
122
123/// Async version of [`verify_integrity`] with up to 8 concurrent I/O ops.
124///
125/// `repo_root` is cloned into each task so callers need not hold a `'static`
126/// reference.
127pub 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    // Collect all (path_str, expected_hash, disk_path) triples
135    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            // Offload blocking I/O to the blocking thread pool
161            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
177// ---------------------------------------------------------------------------
178// Manifest-change detection helper
179// ---------------------------------------------------------------------------
180
181/// Returns `true` when `current_manifest_hash` differs from the previously
182/// cached hash, indicating the manifest was regenerated.
183///
184/// If `cached_hash` is `None` (never cached), returns `true` so callers
185/// treat the first encounter as a change.
186pub 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// ---------------------------------------------------------------------------
194// Tests
195// ---------------------------------------------------------------------------
196
197#[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    // -----------------------------------------------------------------------
206    // Helper: build a minimal AssetIntegrity manifest
207    // -----------------------------------------------------------------------
208
209    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    // -----------------------------------------------------------------------
228    // Deterministic unit tests
229    // -----------------------------------------------------------------------
230
231    #[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        // Write different content
258        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    // Async parallel test
334    #[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    // -----------------------------------------------------------------------
366    // Property tests (proptest) — Property 14
367    // -----------------------------------------------------------------------
368
369    proptest! {
370        #![proptest_config(ProptestConfig::with_cases(256))]
371
372        /// Property 14a — Pass for untouched files, Fail for mutated files,
373        /// Missing for deleted files.
374        ///
375        /// Strategy:
376        ///   - Generate N files with random content
377        ///   - Hash them into a manifest
378        ///   - Optionally mutate some files (by appending a byte)
379        ///   - Optionally delete some files
380        ///   - Verify the classify matches expectations
381        #[test]
382        fn prop_integrity_classify_correct(
383            // raw_files: list of (file_name_suffix, content_bytes, mutate, delete)
384            raw_files in prop::collection::vec(
385                (
386                    0u8..200,                          // file index / name disambiguator
387                    prop::collection::vec(any::<u8>(), 1..50), // content
388                    prop::bool::ANY,                   // mutate?
389                    prop::bool::ANY,                   // delete?
390                ),
391                1..10,
392            ),
393        ) {
394            let dir = TempDir::new().unwrap();
395            let mut manifest_files: Vec<IntegrityFile> = Vec::new();
396
397            // State tracking for assertions
398            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                // Use iter_idx (not the generated _idx) to guarantee unique file names.
404                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                    // Append a byte to change the hash
421                    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            // Build result maps for O(1) lookup
434            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        /// Property 14b — manifest_changed is a pure equality check.
467        #[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            // When hashes are identical the function must return false
473            prop_assert!(!manifest_changed(&hash_a, Some(&hash_a)));
474            // When hashes differ the function must return true (unless they happen to be equal)
475            if hash_a != hash_b {
476                prop_assert!(manifest_changed(&hash_a, Some(&hash_b)));
477            }
478        }
479    }
480}