Skip to main content

modde_sources/wabbajack/
validator.rs

1//! Post-install verification for Wabbajack modlists: re-hashes staged files
2//! against the expected output hashes carried by a [`WabbajackManifest`]'s
3//! directives, plus a cheap existence-only preflight check.
4
5use std::path::Path;
6
7use anyhow::Result;
8use tracing::{info, warn};
9
10use modde_core::manifest::wabbajack::WabbajackManifest;
11
12use super::staging::StagingStore;
13
14/// Result of post-install verification.
15#[derive(Debug)]
16pub struct ValidationReport {
17    pub total_files: usize,
18    pub verified: usize,
19    pub missing: Vec<String>,
20    pub mismatches: Vec<ValidationMismatch>,
21}
22
23#[derive(Debug)]
24pub struct ValidationMismatch {
25    pub path: String,
26    pub expected_hash: u64,
27    pub actual_hash: u64,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub(crate) struct ExpectedFile {
32    pub path: String,
33    pub expected_hash: Option<u64>,
34}
35
36/// Post-install verification: re-hash every installed file against the manifest.
37///
38/// Walks the expected files from the manifest's install directives, checks each
39/// file exists in the staging directory, and verifies its xxHash matches.
40pub async fn validate_install(
41    manifest: &WabbajackManifest,
42    staging_dir: &Path,
43) -> Result<ValidationReport> {
44    // Build expected file map from install directives and archives
45    let expected_files = collect_expected_files(manifest);
46
47    let total_files = expected_files.len();
48    let mut verified = 0usize;
49    let mut missing = Vec::new();
50    let mut mismatches = Vec::new();
51    let staging = StagingStore::new(staging_dir);
52
53    for expected in &expected_files {
54        if !staging.logical_exists(&expected.path).await {
55            warn!(path = %expected.path, "expected file missing from staging directory");
56            missing.push(expected.path.clone());
57            continue;
58        }
59
60        let Some(expected_hash) = expected.expected_hash else {
61            verified += 1;
62            continue;
63        };
64
65        let (actual_xxh64, actual_xxh3) = staging.hash_logical_file_compat(&expected.path).await?;
66
67        if actual_xxh64 == expected_hash || actual_xxh3 == expected_hash {
68            verified += 1;
69        } else {
70            warn!(
71                path = %expected.path,
72                expected = format!("{expected_hash:016x}"),
73                actual_xxh64 = format!("{actual_xxh64:016x}"),
74                actual_xxh3 = format!("{actual_xxh3:016x}"),
75                "hash mismatch"
76            );
77            mismatches.push(ValidationMismatch {
78                path: expected.path.clone(),
79                expected_hash,
80                actual_hash: actual_xxh3,
81            });
82        }
83    }
84
85    info!(
86        total_files,
87        verified,
88        missing = missing.len(),
89        mismatches = mismatches.len(),
90        "post-install validation complete"
91    );
92
93    Ok(ValidationReport {
94        total_files,
95        verified,
96        missing,
97        mismatches,
98    })
99}
100
101/// Cheap pre-install check: returns `true` when every file the manifest
102/// expects already exists in the staging directory.  Only checks existence
103/// (no hashing), so it is fast enough to run unconditionally before the
104/// install pipeline.
105pub async fn preflight_staging(manifest: &WabbajackManifest, staging_dir: &Path) -> bool {
106    let expected = collect_expected_files(manifest);
107    if expected.is_empty() {
108        return false;
109    }
110    let staging = StagingStore::new(staging_dir);
111    for expected in &expected {
112        if !staging.logical_exists(&expected.path).await {
113            return false;
114        }
115    }
116    true
117}
118
119/// Collect expected logical staging files from the manifest.
120///
121/// `InlineFile`, `RemappedInlineFile`, and `PatchedFromArchive` carry expected
122/// output hashes in the manifest. `FromArchive` directives do not; for those we
123/// can validate existence only without inventing a false source-hash check.
124pub(crate) fn collect_expected_files(manifest: &WabbajackManifest) -> Vec<ExpectedFile> {
125    use modde_core::manifest::wabbajack::RawDirective;
126
127    let mut files = Vec::new();
128
129    for directive in &manifest.directives {
130        match directive {
131            RawDirective::FromArchive { to, .. } => files.push(ExpectedFile {
132                path: to.clone(),
133                expected_hash: None,
134            }),
135            RawDirective::PatchedFromArchive { to, hash, .. } => {
136                files.push(ExpectedFile {
137                    path: to.clone(),
138                    expected_hash: Some(*hash),
139                });
140            }
141            RawDirective::InlineFile { to, hash, .. }
142            | RawDirective::RemappedInlineFile { to, hash, .. } => {
143                files.push(ExpectedFile {
144                    path: to.clone(),
145                    expected_hash: Some(*hash),
146                });
147            }
148            RawDirective::CreateBSA { .. } | RawDirective::Unknown => {}
149        }
150    }
151
152    files
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::wabbajack::staging::{StagingCompressionPolicy, StagingStore, compressed_path};
159    use modde_core::manifest::wabbajack::WabbajackManifest;
160    use xxhash_rust::xxh3::xxh3_64;
161
162    fn empty_manifest() -> WabbajackManifest {
163        WabbajackManifest {
164            name: "test".to_string(),
165            author: "test".to_string(),
166            description: "test".to_string(),
167            game: "skyrimse".to_string(),
168            version: "1.0".to_string(),
169            archives: vec![],
170            directives: vec![],
171        }
172    }
173
174    #[tokio::test]
175    async fn test_validate_empty_manifest() {
176        let staging = tempfile::tempdir().unwrap();
177        let manifest = empty_manifest();
178        let report = validate_install(&manifest, staging.path()).await.unwrap();
179        assert_eq!(report.total_files, 0);
180        assert_eq!(report.verified, 0);
181        assert!(report.missing.is_empty());
182        assert!(report.mismatches.is_empty());
183    }
184
185    #[tokio::test]
186    async fn test_validate_missing_file() {
187        let staging = tempfile::tempdir().unwrap();
188        let manifest = WabbajackManifest {
189            name: "test".to_string(),
190            author: "test".to_string(),
191            description: "test".to_string(),
192            game: "skyrimse".to_string(),
193            version: "1.0".to_string(),
194            archives: vec![modde_core::manifest::wabbajack::ArchiveEntry {
195                hash: 12345,
196                name: "test.zip".to_string(),
197                size: 100,
198                state: None,
199            }],
200            directives: vec![modde_core::manifest::wabbajack::RawDirective::FromArchive {
201                archive_hash_path: vec![
202                    serde_json::Value::Number(12345.into()),
203                    serde_json::Value::String("inner.txt".to_string()),
204                ],
205                to: "output/inner.txt".to_string(),
206                size: 0,
207            }],
208        };
209
210        let report = validate_install(&manifest, staging.path()).await.unwrap();
211        assert_eq!(report.total_files, 1);
212        assert_eq!(report.missing.len(), 1);
213        assert_eq!(report.missing[0], "output/inner.txt");
214    }
215
216    #[tokio::test]
217    async fn preflight_and_validate_accept_compressed_logical_files() {
218        let staging = tempfile::tempdir().unwrap();
219        let rel = "mods/test/texture.dds";
220        let content = vec![7_u8; 128 * 1024];
221        let store = StagingStore::with_policy(
222            staging.path(),
223            StagingCompressionPolicy {
224                min_bytes: 1,
225                level: 1,
226                suffix: ".modde-zst".to_string(),
227            },
228        );
229        store.prepare_fresh().await.unwrap();
230        let file_path = staging.path().join(rel);
231        tokio::fs::create_dir_all(file_path.parent().unwrap())
232            .await
233            .unwrap();
234        tokio::fs::write(&file_path, &content).await.unwrap();
235        store.compress_eligible_files(1).await.unwrap();
236
237        let manifest = WabbajackManifest {
238            directives: vec![modde_core::manifest::wabbajack::RawDirective::InlineFile {
239                hash: xxh3_64(&content),
240                size: content.len() as u64,
241                source_data_id: "inline".into(),
242                to: rel.into(),
243            }],
244            ..empty_manifest()
245        };
246
247        assert!(!file_path.exists());
248        assert!(compressed_path(&file_path).exists());
249        assert!(preflight_staging(&manifest, staging.path()).await);
250        let report = validate_install(&manifest, staging.path()).await.unwrap();
251        assert_eq!(report.verified, 1);
252        assert!(report.missing.is_empty());
253        assert!(report.mismatches.is_empty());
254    }
255
256    #[tokio::test]
257    async fn validate_fails_on_corrupt_compressed_logical_file() {
258        let staging = tempfile::tempdir().unwrap();
259        let rel = "mods/test/texture.dds";
260        let content = vec![9_u8; 128 * 1024];
261        let store = StagingStore::with_policy(
262            staging.path(),
263            StagingCompressionPolicy {
264                min_bytes: 1,
265                level: 1,
266                suffix: ".modde-zst".to_string(),
267            },
268        );
269        store.prepare_fresh().await.unwrap();
270        let file_path = staging.path().join(rel);
271        tokio::fs::create_dir_all(file_path.parent().unwrap())
272            .await
273            .unwrap();
274        tokio::fs::write(&file_path, &content).await.unwrap();
275        store.compress_eligible_files(1).await.unwrap();
276        tokio::fs::write(compressed_path(&file_path), b"not zstd")
277            .await
278            .unwrap();
279
280        let manifest = WabbajackManifest {
281            directives: vec![modde_core::manifest::wabbajack::RawDirective::InlineFile {
282                hash: xxh3_64(&content),
283                size: content.len() as u64,
284                source_data_id: "inline".into(),
285                to: rel.into(),
286            }],
287            ..empty_manifest()
288        };
289
290        assert!(validate_install(&manifest, staging.path()).await.is_err());
291    }
292
293    #[tokio::test]
294    async fn test_validate_hash_mismatch() {
295        let staging = tempfile::tempdir().unwrap();
296        let file_path = staging.path().join("test.txt");
297        tokio::fs::write(&file_path, b"wrong content")
298            .await
299            .unwrap();
300
301        let manifest = WabbajackManifest {
302            name: "test".to_string(),
303            author: "test".to_string(),
304            description: "test".to_string(),
305            game: "skyrimse".to_string(),
306            version: "1.0".to_string(),
307            archives: vec![],
308            directives: vec![
309                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
310                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
311                    patch_id: String::new(),
312                    size: 0,
313                    to: "test.txt".to_string(),
314                    hash: 99999, // wrong hash
315                },
316            ],
317        };
318
319        let report = validate_install(&manifest, staging.path()).await.unwrap();
320        assert_eq!(report.total_files, 1);
321        assert_eq!(report.mismatches.len(), 1);
322    }
323
324    #[tokio::test]
325    async fn test_validate_correct_file() {
326        let staging = tempfile::tempdir().unwrap();
327        let content = b"correct file content";
328        let file_path = staging.path().join("test.txt");
329        tokio::fs::write(&file_path, content).await.unwrap();
330
331        let expected_hash = xxh3_64(content);
332
333        let manifest = WabbajackManifest {
334            name: "test".to_string(),
335            author: "test".to_string(),
336            description: "test".to_string(),
337            game: "skyrimse".to_string(),
338            version: "1.0".to_string(),
339            archives: vec![],
340            directives: vec![
341                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
342                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
343                    patch_id: String::new(),
344                    size: 0,
345                    to: "test.txt".to_string(),
346                    hash: expected_hash,
347                },
348            ],
349        };
350
351        let report = validate_install(&manifest, staging.path()).await.unwrap();
352        assert_eq!(report.total_files, 1);
353        assert_eq!(report.verified, 1);
354        assert!(report.missing.is_empty());
355        assert!(report.mismatches.is_empty());
356    }
357
358    #[tokio::test]
359    async fn test_validate_multiple_missing() {
360        let staging = tempfile::tempdir().unwrap();
361
362        let manifest = WabbajackManifest {
363            name: "test".to_string(),
364            author: "test".to_string(),
365            description: "test".to_string(),
366            game: "skyrimse".to_string(),
367            version: "1.0".to_string(),
368            archives: vec![],
369            directives: vec![
370                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
371                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
372                    patch_id: String::new(),
373                    size: 0,
374                    to: "file_a.txt".to_string(),
375                    hash: 111,
376                },
377                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
378                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
379                    patch_id: String::new(),
380                    size: 0,
381                    to: "file_b.txt".to_string(),
382                    hash: 222,
383                },
384                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
385                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
386                    patch_id: String::new(),
387                    size: 0,
388                    to: "file_c.txt".to_string(),
389                    hash: 333,
390                },
391            ],
392        };
393
394        let report = validate_install(&manifest, staging.path()).await.unwrap();
395        assert_eq!(report.total_files, 3);
396        assert_eq!(report.verified, 0);
397        assert_eq!(report.missing.len(), 3);
398        assert!(report.mismatches.is_empty());
399    }
400
401    #[tokio::test]
402    async fn test_validate_mixed_results() {
403        let staging = tempfile::tempdir().unwrap();
404
405        // File with correct hash
406        let correct_content = b"correct data";
407        let correct_hash = xxh3_64(correct_content);
408        tokio::fs::write(staging.path().join("correct.txt"), correct_content)
409            .await
410            .unwrap();
411
412        // File with wrong hash
413        tokio::fs::write(staging.path().join("wrong.txt"), b"actual data")
414            .await
415            .unwrap();
416
417        // missing.txt is not created
418
419        let manifest = WabbajackManifest {
420            name: "test".to_string(),
421            author: "test".to_string(),
422            description: "test".to_string(),
423            game: "skyrimse".to_string(),
424            version: "1.0".to_string(),
425            archives: vec![],
426            directives: vec![
427                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
428                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
429                    patch_id: String::new(),
430                    size: 0,
431                    to: "correct.txt".to_string(),
432                    hash: correct_hash,
433                },
434                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
435                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
436                    patch_id: String::new(),
437                    size: 0,
438                    to: "wrong.txt".to_string(),
439                    hash: 99999,
440                },
441                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
442                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
443                    patch_id: String::new(),
444                    size: 0,
445                    to: "missing.txt".to_string(),
446                    hash: 55555,
447                },
448            ],
449        };
450
451        let report = validate_install(&manifest, staging.path()).await.unwrap();
452        assert_eq!(report.total_files, 3);
453        assert_eq!(report.verified, 1);
454        assert_eq!(report.missing.len(), 1);
455        assert_eq!(report.missing[0], "missing.txt");
456        assert_eq!(report.mismatches.len(), 1);
457        assert_eq!(report.mismatches[0].path, "wrong.txt");
458    }
459
460    #[tokio::test]
461    async fn test_validate_patched_directive_uses_hash_field() {
462        let staging = tempfile::tempdir().unwrap();
463        let content = b"patched output data";
464        let patched_hash = xxh3_64(content);
465        tokio::fs::write(staging.path().join("patched.esp"), content)
466            .await
467            .unwrap();
468
469        let manifest = WabbajackManifest {
470            name: "test".to_string(),
471            author: "test".to_string(),
472            description: "test".to_string(),
473            game: "skyrimse".to_string(),
474            version: "1.0".to_string(),
475            archives: vec![modde_core::manifest::wabbajack::ArchiveEntry {
476                hash: 77777,
477                name: "source.zip".to_string(),
478                size: 200,
479                state: None,
480            }],
481            directives: vec![
482                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
483                    archive_hash_path: vec![serde_json::Value::Number(77777.into())],
484                    patch_id: String::new(),
485                    size: 0,
486                    to: "patched.esp".to_string(),
487                    hash: patched_hash, // uses the directive's own hash, not archive hash
488                },
489            ],
490        };
491
492        let report = validate_install(&manifest, staging.path()).await.unwrap();
493        assert_eq!(report.total_files, 1);
494        assert_eq!(report.verified, 1);
495        assert!(report.mismatches.is_empty());
496    }
497
498    #[tokio::test]
499    async fn test_validate_create_bsa_ignored() {
500        let staging = tempfile::tempdir().unwrap();
501
502        let manifest = WabbajackManifest {
503            name: "test".to_string(),
504            author: "test".to_string(),
505            description: "test".to_string(),
506            game: "skyrimse".to_string(),
507            version: "1.0".to_string(),
508            archives: vec![],
509            directives: vec![modde_core::manifest::wabbajack::RawDirective::CreateBSA {
510                temp_id: "bsa_temp_001".to_string(),
511                to: "output.bsa".to_string(),
512                file_states: vec![],
513            }],
514        };
515
516        let expected = collect_expected_files(&manifest);
517        assert!(
518            expected.is_empty(),
519            "CreateBSA should not produce expected files"
520        );
521
522        let report = validate_install(&manifest, staging.path()).await.unwrap();
523        assert_eq!(report.total_files, 0);
524    }
525
526    #[tokio::test]
527    async fn test_validate_unknown_directive_ignored() {
528        let staging = tempfile::tempdir().unwrap();
529
530        let manifest = WabbajackManifest {
531            name: "test".to_string(),
532            author: "test".to_string(),
533            description: "test".to_string(),
534            game: "skyrimse".to_string(),
535            version: "1.0".to_string(),
536            archives: vec![],
537            directives: vec![modde_core::manifest::wabbajack::RawDirective::Unknown],
538        };
539
540        let expected = collect_expected_files(&manifest);
541        assert!(
542            expected.is_empty(),
543            "Unknown directives should be filtered out"
544        );
545
546        let report = validate_install(&manifest, staging.path()).await.unwrap();
547        assert_eq!(report.total_files, 0);
548    }
549
550    #[tokio::test]
551    async fn test_validate_nested_file_path() {
552        let staging = tempfile::tempdir().unwrap();
553        let subdir = staging.path().join("subdir");
554        tokio::fs::create_dir_all(&subdir).await.unwrap();
555
556        let content = b"nested file content";
557        let expected_hash = xxh3_64(content);
558        tokio::fs::write(subdir.join("test.txt"), content)
559            .await
560            .unwrap();
561
562        let manifest = WabbajackManifest {
563            name: "test".to_string(),
564            author: "test".to_string(),
565            description: "test".to_string(),
566            game: "skyrimse".to_string(),
567            version: "1.0".to_string(),
568            archives: vec![],
569            directives: vec![
570                modde_core::manifest::wabbajack::RawDirective::PatchedFromArchive {
571                    archive_hash_path: vec![serde_json::Value::Number(0.into())],
572                    patch_id: String::new(),
573                    size: 0,
574                    to: "subdir/test.txt".to_string(),
575                    hash: expected_hash,
576                },
577            ],
578        };
579
580        let report = validate_install(&manifest, staging.path()).await.unwrap();
581        assert_eq!(report.total_files, 1);
582        assert_eq!(report.verified, 1);
583        assert!(report.missing.is_empty());
584        assert!(report.mismatches.is_empty());
585    }
586}