1mod safe_fs;
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::ffi::OsString;
7use std::path::{Path, PathBuf};
8
9use sha1::Digest as _;
10
11use crate::create::{CancellationToken, HashProgress, ProgressSink, hash_v2_open_file_sequential};
12use crate::metainfo::{RawMetainfo, V1Metainfo, V2Metainfo};
13use crate::{Error, Metainfo, Result, TorrentMode};
14use safe_fs::{FileSnapshot, SafePathError, SafeRoot};
15
16#[cfg(test)]
17type TestHook = std::sync::Arc<dyn Fn(TestEvent) + Send + Sync>;
18
19#[cfg(test)]
20#[derive(Clone, Debug, Eq, PartialEq)]
21enum TestEvent {
22 AfterStructure,
23 BeforeExpectedOpen(PathBuf),
24 BeforeExtraOpen(PathBuf),
25}
26
27#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
29pub enum MismatchMode {
30 FailFast,
32 #[default]
34 CollectAll,
35}
36
37#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39pub enum ExtraFilePolicy {
40 #[default]
42 Ignore,
43 Report,
45}
46
47#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
49pub enum MismatchKind {
50 Missing,
52 WrongSize,
54 Extra,
56 UnsafePath,
58 V1Hash,
60 V2Hash,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct Mismatch {
67 kind: MismatchKind,
68 path: PathBuf,
69 piece: Option<u64>,
70}
71
72impl Mismatch {
73 #[must_use]
75 pub const fn kind(&self) -> MismatchKind {
76 self.kind
77 }
78
79 #[must_use]
81 pub fn path(&self) -> &Path {
82 &self.path
83 }
84
85 #[must_use]
87 pub const fn piece(&self) -> Option<u64> {
88 self.piece
89 }
90
91 #[must_use]
93 pub fn sort_key(&self) -> (PathBuf, MismatchKind, Option<u64>) {
94 (self.path.clone(), self.kind, self.piece)
95 }
96}
97
98#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct VerificationReport {
101 mismatches: Vec<Mismatch>,
102}
103
104impl VerificationReport {
105 #[must_use]
107 pub fn is_valid(&self) -> bool {
108 self.mismatches.is_empty()
109 }
110
111 #[must_use]
113 pub fn mismatches(&self) -> &[Mismatch] {
114 &self.mismatches
115 }
116}
117
118#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
120pub struct VerifyOptions {
121 mismatch_mode: MismatchMode,
122 extra_files: ExtraFilePolicy,
123}
124
125impl VerifyOptions {
126 #[must_use]
128 pub const fn builder() -> VerifyOptionsBuilder {
129 VerifyOptionsBuilder(VerifyOptions {
130 mismatch_mode: MismatchMode::CollectAll,
131 extra_files: ExtraFilePolicy::Ignore,
132 })
133 }
134}
135
136#[derive(Clone, Copy, Debug)]
138pub struct VerifyOptionsBuilder(VerifyOptions);
139
140impl VerifyOptionsBuilder {
141 #[must_use]
143 pub const fn mismatch_mode(mut self, mode: MismatchMode) -> Self {
144 self.0.mismatch_mode = mode;
145 self
146 }
147
148 #[must_use]
150 pub const fn extra_files(mut self, policy: ExtraFilePolicy) -> Self {
151 self.0.extra_files = policy;
152 self
153 }
154
155 #[must_use]
157 pub const fn build(self) -> VerifyOptions {
158 self.0
159 }
160}
161
162pub struct Verifier<'a> {
164 metainfo: &'a Metainfo,
165 payload: PathBuf,
166 options: VerifyOptions,
167 cancellation: CancellationToken,
168 #[cfg(test)]
169 test_hook: Option<TestHook>,
170}
171
172struct OpenPayload {
173 expected: BTreeSet<PathBuf>,
174 snapshots: BTreeMap<PathBuf, FileSnapshot>,
175 mismatches: Vec<Mismatch>,
176}
177
178impl<'a> Verifier<'a> {
179 #[must_use]
181 pub fn new(metainfo: &'a Metainfo, payload: impl Into<PathBuf>) -> Self {
182 Self {
183 metainfo,
184 payload: payload.into(),
185 options: VerifyOptions::builder().build(),
186 cancellation: CancellationToken::new(),
187 #[cfg(test)]
188 test_hook: None,
189 }
190 }
191
192 #[must_use]
194 pub const fn options(mut self, options: VerifyOptions) -> Self {
195 self.options = options;
196 self
197 }
198
199 #[must_use]
201 pub fn cancellation(mut self, cancellation: CancellationToken) -> Self {
202 self.cancellation = cancellation;
203 self
204 }
205
206 #[cfg(test)]
207 fn test_hook(mut self, hook: TestHook) -> Self {
208 self.test_hook = Some(hook);
209 self
210 }
211
212 pub fn verify(&self, progress: &impl ProgressSink) -> Result<VerificationReport> {
224 self.check_cancelled()?;
225 let raw = RawMetainfo::from_bytes_with_options(
226 self.metainfo.original_bytes(),
227 self.metainfo.parse_options(),
228 )?;
229 let root = match self.open_root()? {
230 Ok(root) => root,
231 Err(report) => return Ok(report),
232 };
233 let OpenPayload {
234 expected,
235 snapshots,
236 mut mismatches,
237 } = self.open_expected_files(&root)?;
238 self.report_extras(&root, &expected, &mut mismatches)?;
239 #[cfg(test)]
240 if let Some(hook) = &self.test_hook {
241 hook(TestEvent::AfterStructure);
242 }
243 self.revalidate_snapshots(&root, &snapshots, &mut mismatches)?;
244 let structural_mismatch = mismatches.iter().any(|mismatch| {
245 matches!(
246 mismatch.kind,
247 MismatchKind::Missing | MismatchKind::WrongSize | MismatchKind::UnsafePath
248 )
249 });
250 match self.metainfo.mode() {
251 TorrentMode::V1 => {
252 self.verify_v1(
253 &raw,
254 &root,
255 &snapshots,
256 structural_mismatch,
257 &mut mismatches,
258 progress,
259 )?;
260 }
261 TorrentMode::V2 => {
262 self.verify_v2(&raw, &root, &snapshots, &mut mismatches, progress)?;
263 }
264 TorrentMode::Hybrid => {
265 self.verify_v1(
266 &raw,
267 &root,
268 &snapshots,
269 structural_mismatch,
270 &mut mismatches,
271 progress,
272 )?;
273 if !should_stop(&mismatches, self.options.mismatch_mode) {
274 self.verify_v2(&raw, &root, &snapshots, &mut mismatches, progress)?;
275 }
276 }
277 }
278 self.revalidate_snapshots(&root, &snapshots, &mut mismatches)?;
279 self.check_cancelled()?;
280 Ok(finish(mismatches))
281 }
282
283 fn open_root(&self) -> Result<std::result::Result<SafeRoot, VerificationReport>> {
284 match SafeRoot::open(&self.payload) {
285 Ok(root) => Ok(Ok(root)),
286 Err(SafePathError::Missing) => Ok(Err(root_report(MismatchKind::Missing))),
287 Err(SafePathError::Unsafe) => Ok(Err(root_report(MismatchKind::UnsafePath))),
288 Err(SafePathError::Io(error)) => Err(error),
289 }
290 }
291
292 fn open_expected_files(&self, root: &SafeRoot) -> Result<OpenPayload> {
293 let payload_files = self
294 .metainfo
295 .files()
296 .iter()
297 .filter(|file| !file.is_padding())
298 .collect::<Vec<_>>();
299 let single_file = payload_files.len() == 1
300 && payload_files[0].path_components() == [self.metainfo.name().to_vec()];
301 if root.is_file() && !single_file {
302 return Ok(OpenPayload {
303 expected: BTreeSet::new(),
304 snapshots: BTreeMap::new(),
305 mismatches: root_mismatches(MismatchKind::Missing),
306 });
307 }
308 let mut expected = BTreeSet::new();
309 let mut snapshots = BTreeMap::new();
310 let mut mismatches = Vec::new();
311 for file in payload_files {
312 self.check_cancelled()?;
313 let relative = path_from_components(file.path_components())?;
314 expected.insert(relative.clone());
315 #[cfg(test)]
316 if let Some(hook) = &self.test_hook {
317 hook(TestEvent::BeforeExpectedOpen(relative.clone()));
318 }
319 match root.open_file(&relative) {
320 Ok(payload_file) if payload_file.length() == file.length() => {
321 snapshots.insert(relative, payload_file.snapshot());
322 }
323 Ok(_) => push_mismatch(
324 &mut mismatches,
325 self.options.mismatch_mode,
326 MismatchKind::WrongSize,
327 relative,
328 None,
329 ),
330 Err(SafePathError::Missing) => push_mismatch(
331 &mut mismatches,
332 self.options.mismatch_mode,
333 MismatchKind::Missing,
334 relative,
335 None,
336 ),
337 Err(SafePathError::Unsafe) => push_mismatch(
338 &mut mismatches,
339 self.options.mismatch_mode,
340 MismatchKind::UnsafePath,
341 relative,
342 None,
343 ),
344 Err(SafePathError::Io(error)) => return Err(error),
345 }
346 if should_stop(&mismatches, self.options.mismatch_mode) {
347 break;
348 }
349 }
350 Ok(OpenPayload {
351 expected,
352 snapshots,
353 mismatches,
354 })
355 }
356
357 fn report_extras(
358 &self,
359 root: &SafeRoot,
360 expected: &BTreeSet<PathBuf>,
361 mismatches: &mut Vec<Mismatch>,
362 ) -> Result<()> {
363 if self.options.extra_files != ExtraFilePolicy::Report || !root.is_directory() {
364 return Ok(());
365 }
366 let (actual, unsafe_paths) = root.collect_files(&|path| {
367 #[cfg(not(test))]
368 let _ = path;
369 #[cfg(test)]
370 if let Some(hook) = &self.test_hook {
371 hook(TestEvent::BeforeExtraOpen(path.to_path_buf()));
372 }
373 })?;
374 for path in unsafe_paths {
375 push_mismatch(
376 mismatches,
377 self.options.mismatch_mode,
378 MismatchKind::UnsafePath,
379 path,
380 None,
381 );
382 }
383 for path in actual.into_iter().filter(|path| !expected.contains(path)) {
384 push_mismatch(
385 mismatches,
386 self.options.mismatch_mode,
387 MismatchKind::Extra,
388 path,
389 None,
390 );
391 if should_stop(mismatches, self.options.mismatch_mode) {
392 break;
393 }
394 }
395 Ok(())
396 }
397
398 fn revalidate_snapshots(
399 &self,
400 root: &SafeRoot,
401 snapshots: &BTreeMap<PathBuf, FileSnapshot>,
402 mismatches: &mut Vec<Mismatch>,
403 ) -> Result<()> {
404 root.refresh_names();
405 for (relative, snapshot) in snapshots {
406 if should_stop(mismatches, self.options.mismatch_mode) {
407 break;
408 }
409 self.check_cancelled()?;
410 let kind = match root.same_file(relative, snapshot) {
411 Ok(true) => continue,
412 Ok(false) | Err(SafePathError::Unsafe) => MismatchKind::UnsafePath,
413 Err(SafePathError::Missing) => MismatchKind::Missing,
414 Err(SafePathError::Io(error)) => return Err(error),
415 };
416 push_mismatch(
417 mismatches,
418 self.options.mismatch_mode,
419 kind,
420 relative.clone(),
421 None,
422 );
423 }
424 Ok(())
425 }
426
427 fn verify_v1(
428 &self,
429 raw: &RawMetainfo<'_>,
430 root: &SafeRoot,
431 snapshots: &BTreeMap<PathBuf, FileSnapshot>,
432 structural_mismatch: bool,
433 mismatches: &mut Vec<Mismatch>,
434 progress: &impl ProgressSink,
435 ) -> Result<()> {
436 let v1 = V1Metainfo::from_raw(raw)?;
437 if structural_mismatch {
438 return Ok(());
439 }
440 let actual = hash_v1_payload(&v1, root, snapshots, &self.cancellation, progress)?;
441 let expected_count = v1.pieces().len() / 20;
442 if actual.len() != expected_count {
443 push_mismatch(
444 mismatches,
445 self.options.mismatch_mode,
446 MismatchKind::V1Hash,
447 PathBuf::new(),
448 Some(u64::try_from(actual.len().min(expected_count)).unwrap_or(u64::MAX)),
449 );
450 if should_stop(mismatches, self.options.mismatch_mode) {
451 return Ok(());
452 }
453 }
454 for (index, (expected, actual)) in
455 v1.pieces().chunks_exact(20).zip(actual.iter()).enumerate()
456 {
457 if expected != actual {
458 push_mismatch(
459 mismatches,
460 self.options.mismatch_mode,
461 MismatchKind::V1Hash,
462 PathBuf::new(),
463 Some(u64::try_from(index).unwrap_or(u64::MAX)),
464 );
465 if should_stop(mismatches, self.options.mismatch_mode) {
466 break;
467 }
468 }
469 }
470 Ok(())
471 }
472
473 fn verify_v2(
474 &self,
475 raw: &RawMetainfo<'_>,
476 root: &SafeRoot,
477 snapshots: &BTreeMap<PathBuf, FileSnapshot>,
478 mismatches: &mut Vec<Mismatch>,
479 progress: &impl ProgressSink,
480 ) -> Result<()> {
481 let v2 = V2Metainfo::from_raw(raw)?;
482 let total_bytes = v2.total_length();
483 let mut bytes_before = 0_u64;
484 let mut pieces_before = 0_u64;
485 for file in v2.files() {
486 let relative = path_from_borrowed_components(file.path_components())?;
487 let Some(snapshot) = snapshots.get(&relative) else {
488 continue;
489 };
490 self.check_cancelled()?;
491 if should_stop(mismatches, self.options.mismatch_mode) {
492 break;
493 }
494 let mut payload_file = match root.open_file(&relative) {
495 Ok(file) if file.snapshot() == *snapshot => file,
496 Ok(_) | Err(SafePathError::Unsafe) => {
497 push_mismatch(
498 mismatches,
499 self.options.mismatch_mode,
500 MismatchKind::UnsafePath,
501 relative,
502 None,
503 );
504 continue;
505 }
506 Err(SafePathError::Missing) => {
507 push_mismatch(
508 mismatches,
509 self.options.mismatch_mode,
510 MismatchKind::Missing,
511 relative,
512 None,
513 );
514 continue;
515 }
516 Err(SafePathError::Io(error)) => return Err(error),
517 };
518 let path = root.display_path(&relative);
519 payload_file.rewind(&path)?;
520 let aggregate = VerifyProgress {
521 inner: progress,
522 bytes_before,
523 pieces_before,
524 total_bytes,
525 };
526 let actual = hash_v2_open_file_sequential(
527 payload_file.file_mut(),
528 &path,
529 file.length(),
530 v2.piece_length(),
531 &self.cancellation,
532 &aggregate,
533 )?;
534 if !payload_file.unchanged(&path)? || !same_snapshot(root, &relative, snapshot)? {
535 push_mismatch(
536 mismatches,
537 self.options.mismatch_mode,
538 MismatchKind::UnsafePath,
539 relative,
540 None,
541 );
542 if should_stop(mismatches, self.options.mismatch_mode) {
543 break;
544 }
545 continue;
546 }
547 bytes_before = bytes_before.checked_add(file.length()).ok_or_else(|| {
548 Error::metainfo_field("verification progress", "byte count overflowed")
549 })?;
550 pieces_before = pieces_before
551 .checked_add(file.length().div_ceil(v2.piece_length()))
552 .ok_or_else(|| {
553 Error::metainfo_field("verification progress", "piece count overflowed")
554 })?;
555 if actual.pieces_root() != file.pieces_root() {
556 push_mismatch(
557 mismatches,
558 self.options.mismatch_mode,
559 MismatchKind::V2Hash,
560 relative,
561 None,
562 );
563 if should_stop(mismatches, self.options.mismatch_mode) {
564 break;
565 }
566 }
567 }
568 Ok(())
569 }
570
571 fn check_cancelled(&self) -> Result<()> {
572 if self.cancellation.is_cancelled() {
573 Err(Error::cancelled())
574 } else {
575 Ok(())
576 }
577 }
578}
579
580fn hash_v1_payload(
581 v1: &V1Metainfo<'_>,
582 root: &SafeRoot,
583 snapshots: &BTreeMap<PathBuf, FileSnapshot>,
584 cancellation: &CancellationToken,
585 progress: &impl ProgressSink,
586) -> Result<Vec<[u8; 20]>> {
587 let total_real = crate::metainfo::checked_total_length(
588 v1.files()
589 .iter()
590 .filter(|file| !file.is_padding())
591 .map(crate::metainfo::V1File::length),
592 "verification payload length",
593 )?;
594 let mut stream = V1Stream::new(v1.piece_length());
595 let mut bytes_hashed = 0_u64;
596 let mut buffer = vec![0_u8; 64 * 1024];
597 for file in v1.files() {
598 cancellation.check()?;
599 if file.is_padding() {
600 buffer.fill(0);
601 let mut remaining = file.length();
602 while remaining > 0 {
603 cancellation.check()?;
604 let take = usize::try_from(remaining.min(buffer.len() as u64))
605 .expect("read buffer length fits usize");
606 stream.append(&buffer[..take]);
607 remaining -= take as u64;
608 }
609 continue;
610 }
611 let relative = path_from_borrowed_components(file.path_components())?;
612 let path = root.display_path(&relative);
613 let snapshot = snapshots
614 .get(&relative)
615 .ok_or_else(|| changed_payload(&path))?;
616 let mut input = match root.open_file(&relative) {
617 Ok(input) if input.snapshot() == *snapshot => input,
618 Ok(_) | Err(SafePathError::Missing | SafePathError::Unsafe) => {
619 return Err(changed_payload(&path));
620 }
621 Err(SafePathError::Io(error)) => return Err(error),
622 };
623 input.rewind(&path)?;
624 let mut consumed = 0_u64;
625 loop {
626 cancellation.check()?;
627 let read = std::io::Read::read(input.file_mut(), &mut buffer)
628 .map_err(|source| Error::io(&path, source))?;
629 if read == 0 {
630 break;
631 }
632 consumed = consumed
633 .checked_add(read as u64)
634 .ok_or_else(|| changed_payload(&path))?;
635 if consumed > file.length() {
636 return Err(changed_payload(&path));
637 }
638 stream.append(&buffer[..read]);
639 bytes_hashed = bytes_hashed.checked_add(read as u64).ok_or_else(|| {
640 Error::metainfo_field("verification progress", "byte count overflowed")
641 })?;
642 progress.on_progress(HashProgress::new(
643 bytes_hashed,
644 total_real,
645 stream.pieces.len() as u64,
646 ));
647 }
648 if consumed != file.length()
649 || !input.unchanged(&path)?
650 || !same_snapshot(root, &relative, snapshot)?
651 {
652 return Err(changed_payload(&path));
653 }
654 }
655 if stream.bytes_in_piece != 0 {
656 stream.pieces.push(stream.hasher.finalize().into());
657 }
658 cancellation.check()?;
659 progress.on_progress(HashProgress::new(
660 bytes_hashed,
661 total_real,
662 stream.pieces.len() as u64,
663 ));
664 Ok(stream.pieces)
665}
666
667struct V1Stream {
668 hasher: sha1::Sha1,
669 piece_length: u64,
670 bytes_in_piece: u64,
671 pieces: Vec<[u8; 20]>,
672}
673
674impl V1Stream {
675 fn new(piece_length: u64) -> Self {
676 Self {
677 hasher: sha1::Sha1::new(),
678 piece_length,
679 bytes_in_piece: 0,
680 pieces: Vec::new(),
681 }
682 }
683
684 fn append(&mut self, mut bytes: &[u8]) {
685 while !bytes.is_empty() {
686 let take =
687 usize::try_from((self.piece_length - self.bytes_in_piece).min(bytes.len() as u64))
688 .expect("read buffer length fits usize");
689 self.hasher.update(&bytes[..take]);
690 self.bytes_in_piece += take as u64;
691 bytes = &bytes[take..];
692 if self.bytes_in_piece == self.piece_length {
693 self.pieces.push(self.hasher.finalize_reset().into());
694 self.bytes_in_piece = 0;
695 }
696 }
697 }
698}
699
700fn changed_payload(path: &Path) -> Error {
701 Error::io(
702 path,
703 std::io::Error::new(
704 std::io::ErrorKind::InvalidData,
705 "payload changed during verification",
706 ),
707 )
708}
709
710fn same_snapshot(root: &SafeRoot, relative: &Path, snapshot: &FileSnapshot) -> Result<bool> {
711 match root.same_file(relative, snapshot) {
712 Ok(same) => Ok(same),
713 Err(SafePathError::Missing | SafePathError::Unsafe) => Ok(false),
714 Err(SafePathError::Io(error)) => Err(error),
715 }
716}
717
718struct VerifyProgress<'a, P> {
719 inner: &'a P,
720 bytes_before: u64,
721 pieces_before: u64,
722 total_bytes: u64,
723}
724
725impl<P: ProgressSink> ProgressSink for VerifyProgress<'_, P> {
726 fn on_progress(&self, progress: HashProgress) {
727 self.inner.on_progress(HashProgress::new(
728 self.bytes_before
729 .checked_add(progress.bytes_hashed())
730 .expect("verified byte progress fits the checked aggregate"),
731 self.total_bytes,
732 self.pieces_before
733 .checked_add(progress.pieces_hashed())
734 .expect("verified piece progress fits the checked aggregate"),
735 ));
736 }
737}
738
739fn path_from_components(components: &[Vec<u8>]) -> Result<PathBuf> {
740 crate::TorrentPath::from_raw(components).to_path_buf()
741}
742
743fn path_from_borrowed_components(components: &[&[u8]]) -> Result<PathBuf> {
744 components
745 .iter()
746 .try_fold(PathBuf::new(), |mut path, component| {
747 path.push(os_string(component));
748 Ok(path)
749 })
750}
751
752#[cfg(unix)]
753fn os_string(bytes: &[u8]) -> OsString {
754 use std::os::unix::ffi::OsStringExt as _;
755 OsString::from_vec(bytes.to_vec())
756}
757
758#[cfg(not(unix))]
759fn os_string(bytes: &[u8]) -> OsString {
760 OsString::from(std::str::from_utf8(bytes).expect("validated platform-compatible path"))
761}
762
763fn push_mismatch(
764 mismatches: &mut Vec<Mismatch>,
765 mode: MismatchMode,
766 kind: MismatchKind,
767 path: PathBuf,
768 piece: Option<u64>,
769) {
770 if mode == MismatchMode::CollectAll || mismatches.is_empty() {
771 mismatches.push(Mismatch { kind, path, piece });
772 }
773}
774
775fn root_mismatches(kind: MismatchKind) -> Vec<Mismatch> {
776 vec![Mismatch {
777 kind,
778 path: PathBuf::new(),
779 piece: None,
780 }]
781}
782
783fn root_report(kind: MismatchKind) -> VerificationReport {
784 finish(root_mismatches(kind))
785}
786
787fn should_stop(mismatches: &[Mismatch], mode: MismatchMode) -> bool {
788 mode == MismatchMode::FailFast && !mismatches.is_empty()
789}
790
791fn finish(mut mismatches: Vec<Mismatch>) -> VerificationReport {
792 mismatches.sort_by_key(Mismatch::sort_key);
793 mismatches.dedup();
794 VerificationReport { mismatches }
795}
796
797#[cfg(test)]
798mod race_tests {
799 use std::fs;
800 use std::sync::atomic::{AtomicBool, Ordering};
801 use std::sync::{Arc, Mutex};
802
803 #[cfg(unix)]
804 use std::os::unix::fs::symlink;
805
806 use crate::Metainfo;
807 use crate::create::{
808 CreateMode, CreateOptions, Creator, HashProgress, NoProgress, PieceLength, ProgressSink,
809 };
810
811 #[cfg(unix)]
812 use super::{ExtraFilePolicy, VerifyOptions};
813 use super::{MismatchKind, TestEvent, Verifier};
814
815 fn torrent(payload: &std::path::Path, mode: CreateMode) -> Metainfo {
816 let options = CreateOptions::builder()
817 .mode(mode)
818 .piece_length(PieceLength::Exact(16_384))
819 .build()
820 .unwrap();
821 let result = Creator::new(payload)
822 .options(options)
823 .create(&NoProgress)
824 .unwrap();
825 Metainfo::from_bytes(result.bytes()).unwrap()
826 }
827
828 struct ReplaceEarlierFile {
829 target: std::path::PathBuf,
830 replaced: AtomicBool,
831 }
832
833 #[cfg(unix)]
834 struct DenyEarlierFile {
835 target: std::path::PathBuf,
836 }
837
838 #[cfg(unix)]
839 impl ProgressSink for DenyEarlierFile {
840 fn on_progress(&self, progress: HashProgress) {
841 use std::os::unix::fs::PermissionsExt as _;
842 if progress.bytes_hashed() == progress.total_bytes() {
843 fs::set_permissions(&self.target, fs::Permissions::from_mode(0o0)).unwrap();
844 }
845 }
846 }
847
848 #[cfg(unix)]
850 #[test]
851 fn fail_fast_returns_the_hash_mismatch_without_final_path_io() {
852 use super::MismatchMode;
853 use std::os::unix::fs::PermissionsExt as _;
854
855 let temp = tempfile::tempdir().unwrap();
856 let payload = temp.path().join("payload");
857 fs::create_dir(&payload).unwrap();
858 let target = payload.join("a");
859 fs::write(&target, b"a").unwrap();
860 fs::write(payload.join("b"), b"b").unwrap();
861 let metainfo = torrent(&payload, CreateMode::V1);
862 fs::write(&target, b"x").unwrap();
863 let progress = DenyEarlierFile {
864 target: target.clone(),
865 };
866 let result = Verifier::new(&metainfo, &payload)
867 .options(
868 VerifyOptions::builder()
869 .mismatch_mode(MismatchMode::FailFast)
870 .build(),
871 )
872 .verify(&progress);
873 fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
874 let report = result.unwrap();
875 assert_eq!(report.mismatches().len(), 1);
876 assert_eq!(report.mismatches()[0].kind(), MismatchKind::V1Hash);
877 }
878
879 impl ProgressSink for ReplaceEarlierFile {
880 fn on_progress(&self, progress: HashProgress) {
881 if progress.bytes_hashed() == progress.total_bytes()
882 && !self.replaced.swap(true, Ordering::SeqCst)
883 {
884 let replacement = self.target.with_extension("replacement");
885 fs::write(&replacement, b"a").unwrap();
886 fs::remove_file(&self.target).unwrap();
887 fs::rename(replacement, &self.target).unwrap();
888 }
889 }
890 }
891
892 #[test]
894 fn replacing_an_already_hashed_file_before_return_is_rejected() {
895 for mode in [CreateMode::V1, CreateMode::V2, CreateMode::Hybrid] {
896 let temp = tempfile::tempdir().unwrap();
897 let payload = temp.path().join("payload");
898 fs::create_dir(&payload).unwrap();
899 fs::write(payload.join("a"), b"a").unwrap();
900 fs::write(payload.join("b"), b"b").unwrap();
901 let metainfo = torrent(&payload, mode);
902 let progress = ReplaceEarlierFile {
903 target: payload.join("a"),
904 replaced: AtomicBool::new(false),
905 };
906 let report = Verifier::new(&metainfo, &payload)
907 .verify(&progress)
908 .unwrap();
909 assert!(progress.replaced.load(Ordering::SeqCst));
910 assert!(
911 report.mismatches().iter().any(|mismatch| {
912 mismatch.kind() == MismatchKind::UnsafePath
913 && mismatch.path() == std::path::Path::new("a")
914 }),
915 "{mode:?}"
916 );
917 }
918 }
919
920 #[cfg(unix)]
921 #[test]
922 fn replacing_verified_file_with_outside_symlink_never_returns_valid() {
923 for mode in [CreateMode::V1, CreateMode::V2, CreateMode::Hybrid] {
924 let temp = tempfile::tempdir().unwrap();
925 let payload = temp.path().join("payload");
926 fs::create_dir(&payload).unwrap();
927 fs::write(payload.join("file"), b"safe payload").unwrap();
928 let metainfo = torrent(&payload, mode);
929 let outside = temp.path().join("outside");
930 fs::write(&outside, b"safe payload").unwrap();
931 let target = payload.join("file");
932 let hook = Arc::new(move |event| {
933 if event == TestEvent::AfterStructure {
934 fs::remove_file(&target).unwrap();
935 symlink(&outside, &target).unwrap();
936 }
937 });
938
939 let report = Verifier::new(&metainfo, &payload)
940 .test_hook(hook)
941 .verify(&NoProgress)
942 .unwrap();
943 assert!(!report.is_valid(), "{mode:?}");
944 assert!(
945 report
946 .mismatches()
947 .iter()
948 .any(|mismatch| mismatch.kind() == MismatchKind::UnsafePath),
949 "{mode:?}: {:?}",
950 report.mismatches()
951 );
952 }
953 }
954
955 #[cfg(unix)]
956 #[test]
957 fn replacing_intermediate_directory_before_open_is_rejected_repeatedly() {
958 for iteration in 0..16 {
959 for mode in [CreateMode::V1, CreateMode::V2, CreateMode::Hybrid] {
960 let temp = tempfile::tempdir().unwrap();
961 let payload = temp.path().join("payload");
962 fs::create_dir_all(payload.join("nested")).unwrap();
963 fs::write(payload.join("nested/file"), b"safe payload").unwrap();
964 let metainfo = torrent(&payload, mode);
965 let outside = temp.path().join("outside");
966 fs::create_dir(&outside).unwrap();
967 fs::write(outside.join("file"), b"safe payload").unwrap();
968 let nested = payload.join("nested");
969 let original = payload.join("original");
970 let replaced = Arc::new(Mutex::new(false));
971 let hook_replaced = Arc::clone(&replaced);
972 let hook = Arc::new(move |event| {
973 if event
974 == TestEvent::BeforeExpectedOpen(std::path::PathBuf::from("nested/file"))
975 && !*hook_replaced.lock().unwrap()
976 {
977 fs::rename(&nested, &original).unwrap();
978 symlink(&outside, &nested).unwrap();
979 *hook_replaced.lock().unwrap() = true;
980 }
981 });
982 let report = Verifier::new(&metainfo, &payload)
983 .test_hook(hook)
984 .verify(&NoProgress)
985 .unwrap();
986 assert!(!report.is_valid(), "iteration {iteration}, {mode:?}");
987 assert!(
988 report
989 .mismatches()
990 .iter()
991 .any(|mismatch| mismatch.kind() == MismatchKind::UnsafePath),
992 "iteration {iteration}, {mode:?}: {:?}",
993 report.mismatches()
994 );
995 }
996 }
997 }
998
999 #[cfg(windows)]
1000 #[test]
1001 fn replacing_intermediate_directory_with_junction_is_rejected_repeatedly() {
1002 for iteration in 0..16 {
1003 for mode in [CreateMode::V1, CreateMode::V2, CreateMode::Hybrid] {
1004 let temp = tempfile::tempdir().unwrap();
1005 let payload = temp.path().join("payload");
1006 fs::create_dir_all(payload.join("nested")).unwrap();
1007 fs::write(payload.join("nested/file"), b"safe payload").unwrap();
1008 let metainfo = torrent(&payload, mode);
1009 let outside = temp.path().join("outside");
1010 fs::create_dir(&outside).unwrap();
1011 fs::write(outside.join("file"), b"safe payload").unwrap();
1012 let nested = payload.join("nested");
1013 let original = payload.join("original");
1014 let replaced = Arc::new(Mutex::new(false));
1015 let hook_replaced = Arc::clone(&replaced);
1016 let hook = Arc::new(move |event| {
1017 if event
1018 == TestEvent::BeforeExpectedOpen(std::path::PathBuf::from("nested/file"))
1019 && !*hook_replaced.lock().unwrap()
1020 {
1021 fs::rename(&nested, &original).unwrap();
1022 junction::create(&outside, &nested).unwrap();
1023 *hook_replaced.lock().unwrap() = true;
1024 }
1025 });
1026 let report = Verifier::new(&metainfo, &payload)
1027 .test_hook(hook)
1028 .verify(&NoProgress)
1029 .unwrap();
1030 assert!(!report.is_valid(), "iteration {iteration}, {mode:?}");
1031 assert!(
1032 report
1033 .mismatches()
1034 .iter()
1035 .any(|mismatch| mismatch.kind() == MismatchKind::UnsafePath),
1036 "iteration {iteration}, {mode:?}: {:?}",
1037 report.mismatches()
1038 );
1039 }
1040 }
1041 }
1042
1043 #[cfg(unix)]
1044 #[test]
1045 fn replacing_enumerated_directory_never_reports_outside_extra_file() {
1046 let temp = tempfile::tempdir().unwrap();
1047 let payload = temp.path().join("payload");
1048 fs::create_dir_all(payload.join("nested")).unwrap();
1049 fs::write(payload.join("expected"), b"expected").unwrap();
1050 fs::write(payload.join("nested/local"), b"local").unwrap();
1051 let metainfo = torrent(&payload, CreateMode::V2);
1052 fs::remove_file(payload.join("nested/local")).unwrap();
1053 let outside = temp.path().join("outside");
1054 fs::create_dir(&outside).unwrap();
1055 fs::write(outside.join("secret"), b"outside").unwrap();
1056 let nested = payload.join("nested");
1057 let replaced = Arc::new(Mutex::new(false));
1058 let hook_replaced = Arc::clone(&replaced);
1059 let hook = Arc::new(move |event| {
1060 if event == TestEvent::BeforeExtraOpen(std::path::PathBuf::from("nested"))
1061 && !*hook_replaced.lock().unwrap()
1062 {
1063 fs::remove_dir(&nested).unwrap();
1064 symlink(&outside, &nested).unwrap();
1065 *hook_replaced.lock().unwrap() = true;
1066 }
1067 });
1068 let options = VerifyOptions::builder()
1069 .extra_files(ExtraFilePolicy::Report)
1070 .build();
1071
1072 let report = Verifier::new(&metainfo, &payload)
1073 .options(options)
1074 .test_hook(hook)
1075 .verify(&NoProgress)
1076 .unwrap();
1077 assert!(!report.is_valid());
1078 assert!(
1079 report
1080 .mismatches()
1081 .iter()
1082 .any(|mismatch| mismatch.kind() == MismatchKind::UnsafePath)
1083 );
1084 assert!(
1085 report
1086 .mismatches()
1087 .iter()
1088 .all(|mismatch| mismatch.path() != std::path::Path::new("nested/secret"))
1089 );
1090 }
1091}