1use 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#[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
36pub async fn validate_install(
41 manifest: &WabbajackManifest,
42 staging_dir: &Path,
43) -> Result<ValidationReport> {
44 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
101pub 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
119pub(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, },
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 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 tokio::fs::write(staging.path().join("wrong.txt"), b"actual data")
414 .await
415 .unwrap();
416
417 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, },
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}