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