1use crate::IndexResult as Result;
30use crate::Platform;
31use crate::apply::{ApplyConfig, ApplySession};
32use crate::apply::path::{dat_path, generic_path, index_path};
35use crate::apply::sqpk::empty_block_header;
36use crate::index::plan::{PartExpected, PartSource, Plan, Region, Target, TargetPath};
37#[cfg(feature = "parallel-verify")]
38use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
39use std::cell::RefCell;
40use std::collections::BTreeMap;
41use std::fs::File;
42use std::io::{Read, Seek, SeekFrom};
43use std::path::{Path, PathBuf};
44use tracing::{debug, debug_span, info, info_span, trace};
45
46const READ_BUF_CAPACITY: usize = 64 * 1024;
47
48thread_local! {
49 static REGION_SCRATCH: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
50}
51
52#[non_exhaustive]
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct RepairManifest {
76 pub missing_regions: BTreeMap<usize, Vec<usize>>,
80 pub missing_targets: Vec<usize>,
82 pub size_mismatched: Vec<usize>,
85}
86
87impl RepairManifest {
88 #[must_use]
90 pub fn is_clean(&self) -> bool {
91 self.missing_regions.is_empty()
92 && self.missing_targets.is_empty()
93 && self.size_mismatched.is_empty()
94 }
95
96 #[must_use]
98 pub fn total_missing_regions(&self) -> usize {
99 self.missing_regions.values().map(Vec::len).sum()
100 }
101}
102
103pub struct PlanVerifier {
115 install_root: PathBuf,
116 platform_override: Option<Platform>,
117}
118
119impl PlanVerifier {
120 pub fn new(install_root: impl Into<PathBuf>) -> Self {
122 Self {
123 install_root: install_root.into(),
124 platform_override: None,
125 }
126 }
127
128 #[must_use]
130 pub fn with_platform(mut self, platform: Platform) -> Self {
131 self.platform_override = Some(platform);
132 self
133 }
134
135 pub fn execute(self, plan: &Plan) -> Result<RepairManifest> {
156 let span = info_span!(
157 crate::tracing_schema::span_names::VERIFY_PLAN,
158 targets = plan.targets.len()
159 );
160 let _enter = span.enter();
161 let started = std::time::Instant::now();
162
163 let PlanVerifier {
164 install_root,
165 platform_override,
166 } = self;
167
168 let platform = platform_override.unwrap_or(plan.platform);
169 let mut ctx = ApplyConfig::new(install_root)
172 .with_platform(platform)
173 .into_session();
174
175 let mut resolved: Vec<PathBuf> = Vec::with_capacity(plan.targets.len());
176 for target in &plan.targets {
177 resolved.push(resolve_target_path(&mut ctx, &target.path)?);
178 }
179
180 let parent = &span;
181 #[cfg(feature = "parallel-verify")]
186 let pair_iter = plan.targets.par_iter().zip(resolved.par_iter());
187 #[cfg(not(feature = "parallel-verify"))]
188 let pair_iter = plan.targets.iter().zip(resolved.iter());
189 let outcomes: Vec<PerTargetOutcome> = pair_iter
190 .enumerate()
191 .map(|(idx, (target, path))| {
192 parent.in_scope(|| {
193 let sub = debug_span!(
194 crate::tracing_schema::span_names::VERIFY_TARGET,
195 target = idx
196 );
197 let _e = sub.enter();
198 REGION_SCRATCH
199 .with(|cell| verify_target(idx, path, target, &mut cell.borrow_mut()))
200 })
201 })
202 .collect::<Result<Vec<_>>>()?;
203
204 let mut manifest = RepairManifest::default();
205 for (idx, outcome) in outcomes.into_iter().enumerate() {
206 match outcome {
207 PerTargetOutcome::Missing => {
208 manifest.missing_targets.push(idx);
209 let region_count = plan.targets[idx].regions.len();
210 if region_count != 0 {
211 manifest
212 .missing_regions
213 .insert(idx, (0..region_count).collect());
214 }
215 }
216 PerTargetOutcome::Present {
217 size_mismatch,
218 flagged,
219 } => {
220 if size_mismatch {
221 manifest.size_mismatched.push(idx);
222 }
223 if !flagged.is_empty() {
224 manifest.missing_regions.insert(idx, flagged);
225 }
226 }
227 }
228 }
229 manifest.missing_targets.sort_unstable();
231 manifest.size_mismatched.sort_unstable();
232 for v in manifest.missing_regions.values_mut() {
233 v.sort_unstable();
234 }
235 info!(
236 targets = plan.targets.len(),
237 missing_targets = manifest.missing_targets.len(),
238 size_mismatched = manifest.size_mismatched.len(),
239 damaged_targets = manifest.missing_regions.len(),
240 damaged_regions = manifest.total_missing_regions(),
241 elapsed_ms = started.elapsed().as_millis() as u64,
242 "verify_plan: scan complete"
243 );
244 Ok(manifest)
245 }
246}
247
248enum PerTargetOutcome {
249 Missing,
250 Present {
251 size_mismatch: bool,
252 flagged: Vec<usize>,
253 },
254}
255
256fn verify_target(
257 idx: usize,
258 path: &Path,
259 target: &Target,
260 scratch: &mut Vec<u8>,
261) -> Result<PerTargetOutcome> {
262 trace!(target = idx, path = %path.display(), "verify target");
263
264 let Some(actual_size) = stat_size(path)? else {
265 debug!(target = idx, path = %path.display(), "verify: target file missing");
266 return Ok(PerTargetOutcome::Missing);
267 };
268
269 let size_mismatch = actual_size < target.final_size;
270 if size_mismatch {
271 debug!(
272 target = idx,
273 actual_size,
274 final_size = target.final_size,
275 "verify: target size mismatch"
276 );
277 }
278
279 let mut file = File::open(path)?;
285 let mut flagged: Vec<usize> = Vec::new();
286
287 for (region_idx, region) in target.regions.iter().enumerate() {
288 if region_fails(region, actual_size, &mut file, scratch)? {
289 flagged.push(region_idx);
290 }
291 }
292
293 Ok(PerTargetOutcome::Present {
294 size_mismatch,
295 flagged,
296 })
297}
298
299fn stat_size(path: &std::path::Path) -> Result<Option<u64>> {
300 match std::fs::metadata(path) {
301 Ok(meta) => Ok(Some(meta.len())),
302 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
303 Err(e) => Err(e.into()),
304 }
305}
306
307fn resolve_target_path(ctx: &mut ApplySession, tp: &TargetPath) -> Result<PathBuf> {
308 match *tp {
309 TargetPath::SqpackDat {
310 main_id,
311 sub_id,
312 file_id,
313 } => dat_path(ctx, main_id, sub_id, file_id).map_err(Into::into),
314 TargetPath::SqpackIndex {
315 main_id,
316 sub_id,
317 file_id,
318 } => index_path(ctx, main_id, sub_id, file_id).map_err(Into::into),
319 TargetPath::Generic(ref rel) => Ok(generic_path(ctx, rel)),
320 }
321}
322
323fn region_fails(
324 region: &Region,
325 actual_size: u64,
326 file: &mut File,
327 scratch: &mut Vec<u8>,
328) -> Result<bool> {
329 let len_u64 = u64::from(region.length);
330 let end = region.target_offset.saturating_add(len_u64);
331 if end > actual_size {
332 return Ok(true);
333 }
334
335 if let PartExpected::Crc32(expected) = region.expected {
340 return check_crc32(file, region.target_offset, region.length, scratch, expected);
341 }
342
343 match region.source {
344 PartSource::Patch { .. } => Ok(false),
345 PartSource::Zeros => check_zeros(file, region.target_offset, len_u64, scratch),
346 PartSource::EmptyBlock { units } => {
347 check_empty_block(file, region.target_offset, units, scratch)
348 }
349 PartSource::Unavailable => Ok(true),
350 }
351}
352
353fn check_crc32(
354 file: &mut File,
355 offset: u64,
356 length: u32,
357 scratch: &mut Vec<u8>,
358 expected: u32,
359) -> Result<bool> {
360 let needed = length as usize;
361 if scratch.len() < needed {
362 scratch.resize(needed, 0);
363 }
364 file.seek(SeekFrom::Start(offset))?;
365 file.read_exact(&mut scratch[..needed])?;
366 Ok(crc32fast::hash(&scratch[..needed]) != expected)
367}
368
369fn check_zeros(file: &mut File, offset: u64, len: u64, scratch: &mut Vec<u8>) -> Result<bool> {
370 if len == 0 {
371 return Ok(false);
372 }
373 if scratch.len() < READ_BUF_CAPACITY {
376 scratch.resize(READ_BUF_CAPACITY, 0);
377 }
378 file.seek(SeekFrom::Start(offset))?;
379 let mut remaining = len;
380 while remaining > 0 {
381 let take = remaining.min(READ_BUF_CAPACITY as u64) as usize;
382 file.read_exact(&mut scratch[..take])?;
383 if scratch[..take].iter().any(|&b| b != 0) {
384 return Ok(true);
385 }
386 remaining -= take as u64;
387 }
388 Ok(false)
389}
390
391fn check_empty_block(
392 file: &mut File,
393 offset: u64,
394 units: u32,
395 scratch: &mut Vec<u8>,
396) -> Result<bool> {
397 if units == 0 {
398 return Err(crate::IndexError::InvalidField {
399 context: "EmptyBlock units must be non-zero",
400 });
401 }
402 if scratch.len() < READ_BUF_CAPACITY {
409 scratch.resize(READ_BUF_CAPACITY, 0);
410 }
411 file.seek(SeekFrom::Start(offset))?;
412 let total = u64::from(units) * 128;
413 let header = empty_block_header(units);
414 let mut emitted: u64 = 0;
415 let mut first = true;
416 while emitted < total {
417 let chunk_len = (total - emitted).min(READ_BUF_CAPACITY as u64) as usize;
418 file.read_exact(&mut scratch[..chunk_len])?;
419 if first {
420 if scratch[..20] != header {
423 return Ok(true);
424 }
425 if scratch[20..chunk_len].iter().any(|&b| b != 0) {
426 return Ok(true);
427 }
428 first = false;
429 } else if scratch[..chunk_len].iter().any(|&b| b != 0) {
430 return Ok(true);
431 }
432 emitted += chunk_len as u64;
433 }
434 Ok(false)
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::index::PatchRef;
441 use crate::index::plan::{PartExpected, Region, Target, TargetPath};
442
443 fn dat_target(regions: Vec<Region>, final_size: u64) -> Target {
444 Target {
445 path: TargetPath::SqpackDat {
446 main_id: 0,
447 sub_id: 0,
448 file_id: 0,
449 },
450 final_size,
451 regions,
452 }
453 }
454
455 fn plan_with(targets: Vec<Target>) -> Plan {
456 Plan {
457 schema_version: Plan::CURRENT_SCHEMA_VERSION,
458 platform: Platform::Win32,
459 patches: vec![PatchRef {
460 name: "synthetic".into(),
461 patch_type: None,
462 }],
463 targets,
464 fs_ops: vec![],
465 }
466 }
467
468 #[test]
469 fn repair_manifest_is_clean_when_empty() {
470 let m = RepairManifest::default();
471 assert!(m.is_clean());
472 assert_eq!(m.total_missing_regions(), 0);
473 }
474
475 #[test]
476 fn total_missing_regions_sums_per_target_buckets() {
477 let mut m = RepairManifest::default();
478 m.missing_regions.insert(0, vec![1, 2, 3]);
479 m.missing_regions.insert(1, vec![4, 5, 6]);
480 assert!(!m.is_clean());
481 assert_eq!(m.total_missing_regions(), 6);
482 }
483
484 #[test]
485 fn verifier_against_missing_target_flags_entire_target() {
486 let regions = vec![
487 Region {
488 target_offset: 0,
489 length: 16,
490 source: PartSource::Zeros,
491 expected: PartExpected::Zeros,
492 },
493 Region {
494 target_offset: 16,
495 length: 16,
496 source: PartSource::Zeros,
497 expected: PartExpected::Zeros,
498 },
499 ];
500 let plan = plan_with(vec![dat_target(regions, 32)]);
501
502 let tmp = tempfile::tempdir().unwrap();
503 let manifest = PlanVerifier::new(tmp.path()).execute(&plan).unwrap();
504
505 assert!(manifest.missing_targets.contains(&0));
506 let regions = manifest
507 .missing_regions
508 .get(&0)
509 .expect("missing target must populate every region");
510 assert_eq!(regions, &vec![0, 1]);
511 }
512
513 fn canonical_empty_block_bytes(units: u32) -> Vec<u8> {
514 let mut buf = vec![0u8; (units as usize) * 128];
515 buf[0..4].copy_from_slice(&128u32.to_le_bytes());
516 buf[12..16].copy_from_slice(&units.wrapping_sub(1).to_le_bytes());
517 buf
518 }
519
520 fn write_to_temp(bytes: &[u8]) -> std::fs::File {
521 use std::io::{Seek, Write};
522 let mut f = tempfile::tempfile().unwrap();
523 f.write_all(bytes).unwrap();
524 f.seek(SeekFrom::Start(0)).unwrap();
525 f
526 }
527
528 #[test]
529 fn check_empty_block_accepts_canonical_payload() {
530 for units in [1u32, 4, 1024, 8192] {
533 let mut f = write_to_temp(&canonical_empty_block_bytes(units));
534 let mut scratch = Vec::new();
535 let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
536 assert!(!fails, "units={units}: canonical payload must verify clean");
537 }
538 }
539
540 #[test]
541 fn check_empty_block_flags_corrupted_header() {
542 let units = 4u32;
543 let mut buf = vec![0u8; (units as usize) * 128];
544 let mut f = write_to_temp(&buf);
547 let mut scratch = Vec::new();
548 let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
549 assert!(fails, "missing header must be flagged");
550
551 buf[0..4].copy_from_slice(&128u32.to_le_bytes());
553 buf[12..16].copy_from_slice(&999u32.to_le_bytes());
554 let mut f = write_to_temp(&buf);
555 let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
556 assert!(fails, "wrong units-1 field must be flagged");
557 }
558
559 #[test]
560 fn check_empty_block_flags_corruption_in_zero_region() {
561 let units = 8u32; let mut buf = canonical_empty_block_bytes(units);
563 buf[500] = 0xFF;
564 let mut f = write_to_temp(&buf);
565 let mut scratch = Vec::new();
566 let fails = check_empty_block(&mut f, 0, units, &mut scratch).unwrap();
567 assert!(fails, "non-zero byte in body must be flagged");
568 }
569
570 #[test]
571 fn check_empty_block_rejects_zero_units() {
572 let mut f = tempfile::tempfile().unwrap();
573 let mut scratch = Vec::new();
574 let err = check_empty_block(&mut f, 0, 0, &mut scratch).unwrap_err();
575 assert!(
576 matches!(err, crate::IndexError::InvalidField { context } if context.contains("non-zero")),
577 "got {err:?}"
578 );
579 }
580
581 fn generic_target(rel: impl Into<String>, final_size: u64) -> Target {
584 let region = Region {
585 target_offset: 0,
586 length: final_size as u32,
587 source: PartSource::Zeros,
588 expected: PartExpected::Zeros,
589 };
590 Target {
591 path: TargetPath::Generic(rel.into()),
592 final_size,
593 regions: vec![region],
594 }
595 }
596
597 #[test]
602 fn parallel_fan_out_manifest_is_deterministic_and_sorted() {
603 const TOTAL: usize = 36;
604 const STRIPE: usize = TOTAL / 3; let dir = tempfile::tempdir().unwrap();
606 let mut targets = Vec::with_capacity(TOTAL);
607 for i in 0..TOTAL {
608 let rel = format!("tgt_{i:03}");
609 if i < STRIPE {
610 targets.push(generic_target(&rel, 16));
612 } else if i < 2 * STRIPE {
613 let p = dir.path().join(&rel);
615 std::fs::write(p, [0u8; 8]).unwrap();
616 targets.push(generic_target(&rel, 1024 * 1024));
618 } else {
619 let p = dir.path().join(&rel);
621 std::fs::write(p, [0u8; 16]).unwrap();
622 targets.push(generic_target(&rel, 16));
623 }
624 }
625 let plan = plan_with(targets);
626
627 let run1 = PlanVerifier::new(dir.path()).execute(&plan).unwrap();
628
629 assert_eq!(run1.missing_targets.len(), STRIPE);
630 assert_eq!(run1.size_mismatched.len(), STRIPE);
631
632 for w in run1.missing_targets.windows(2) {
633 assert!(w[0] < w[1], "missing_targets not sorted: {w:?}");
634 }
635 for w in run1.size_mismatched.windows(2) {
636 assert!(w[0] < w[1], "size_mismatched not sorted: {w:?}");
637 }
638 for (key, regions) in &run1.missing_regions {
639 for w in regions.windows(2) {
640 assert!(w[0] < w[1], "missing_regions[{key}] not sorted: {w:?}");
641 }
642 }
643
644 let run2 = PlanVerifier::new(dir.path()).execute(&plan).unwrap();
645 assert_eq!(
646 run1, run2,
647 "two equivalent runs produced different manifests"
648 );
649 }
650
651 #[test]
656 fn parallel_fan_out_shuffled_target_order_manifest_sorted() {
657 const GROUPS: usize = 8; let dir = tempfile::tempdir().unwrap();
661 let mut targets = Vec::with_capacity(GROUPS * 4);
662 for g in 0..GROUPS {
663 let base = g * 4;
664 targets.push(generic_target(format!("s_{base:03}"), 16));
666 let rel_c = format!("s_{:03}", base + 1);
668 std::fs::write(dir.path().join(&rel_c), [0u8; 16]).unwrap();
669 targets.push(generic_target(rel_c, 16));
670 targets.push(generic_target(format!("s_{:03}", base + 2), 16));
672 let rel_sm = format!("s_{:03}", base + 3);
674 std::fs::write(dir.path().join(&rel_sm), [0u8; 4]).unwrap();
675 targets.push(generic_target(rel_sm, 1024));
676 }
677 let plan = plan_with(targets);
678 let manifest = PlanVerifier::new(dir.path()).execute(&plan).unwrap();
679
680 for w in manifest.missing_targets.windows(2) {
681 assert!(w[0] < w[1], "missing_targets not sorted: {w:?}");
682 }
683 for w in manifest.size_mismatched.windows(2) {
684 assert!(w[0] < w[1], "size_mismatched not sorted: {w:?}");
685 }
686 for (key, regions) in &manifest.missing_regions {
687 for w in regions.windows(2) {
688 assert!(w[0] < w[1], "missing_regions[{key}] not sorted: {w:?}");
689 }
690 }
691 assert_eq!(manifest.missing_targets.len(), GROUPS * 2);
693 assert_eq!(manifest.size_mismatched.len(), GROUPS);
694 }
695
696 #[cfg(target_family = "unix")]
701 #[test]
702 fn parallel_fan_out_propagates_io_error_from_one_target() {
703 use std::os::unix::fs::PermissionsExt;
704
705 let dir = tempfile::tempdir().unwrap();
706
707 let rel_ok = "ok_target";
709 let rel_err = "err_target";
710 std::fs::write(dir.path().join(rel_ok), [0u8; 16]).unwrap();
711 std::fs::write(dir.path().join(rel_err), [0u8; 16]).unwrap();
712 std::fs::set_permissions(
713 dir.path().join(rel_err),
714 std::fs::Permissions::from_mode(0o000),
715 )
716 .unwrap();
717
718 if std::fs::File::open(dir.path().join(rel_err)).is_ok() {
720 std::fs::set_permissions(
721 dir.path().join(rel_err),
722 std::fs::Permissions::from_mode(0o644),
723 )
724 .unwrap();
725 eprintln!("skipping: running with CAP_DAC_OVERRIDE");
726 return;
727 }
728
729 let targets = vec![generic_target(rel_ok, 16), generic_target(rel_err, 16)];
730 let plan = plan_with(targets);
731 let result = PlanVerifier::new(dir.path()).execute(&plan);
732
733 std::fs::set_permissions(
734 dir.path().join(rel_err),
735 std::fs::Permissions::from_mode(0o644),
736 )
737 .unwrap();
738
739 assert!(
740 result.is_err(),
741 "expected Err from unreadable target, got Ok"
742 );
743 }
744}