btpc_core/
verify.rs

1//! Safe, deterministic payload verification.
2
3mod 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/// Whether verification stops after its first mismatch.
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
29pub enum MismatchMode {
30    /// Return the first deterministic mismatch.
31    FailFast,
32    /// Return every mismatch found by enabled checks.
33    #[default]
34    CollectAll,
35}
36
37/// Policy for payload files absent from metainfo.
38#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39pub enum ExtraFilePolicy {
40    /// Do not enumerate unrelated payload files.
41    #[default]
42    Ignore,
43    /// Report unrelated regular files.
44    Report,
45}
46
47/// Payload mismatch category.
48#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
49pub enum MismatchKind {
50    /// Expected path does not exist.
51    Missing,
52    /// Expected path has a different byte length.
53    WrongSize,
54    /// Payload contains an unrelated file.
55    Extra,
56    /// Path crosses a symlink or otherwise escapes safe mapping.
57    UnsafePath,
58    /// V1 SHA-1 piece data differs.
59    V1Hash,
60    /// V2 SHA-256 file Merkle root differs.
61    V2Hash,
62}
63
64/// One deterministic verification mismatch.
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct Mismatch {
67    kind: MismatchKind,
68    path: PathBuf,
69    piece: Option<u64>,
70}
71
72impl Mismatch {
73    /// Returns the mismatch category.
74    #[must_use]
75    pub const fn kind(&self) -> MismatchKind {
76        self.kind
77    }
78
79    /// Returns the payload-relative path, or the selected root for root-level issues.
80    #[must_use]
81    pub fn path(&self) -> &Path {
82        &self.path
83    }
84
85    /// Returns the v1 piece index when available.
86    #[must_use]
87    pub const fn piece(&self) -> Option<u64> {
88        self.piece
89    }
90
91    /// Returns the stable ordering key used by reports.
92    #[must_use]
93    pub fn sort_key(&self) -> (PathBuf, MismatchKind, Option<u64>) {
94        (self.path.clone(), self.kind, self.piece)
95    }
96}
97
98/// Completed verification report.
99#[derive(Clone, Debug, Default, Eq, PartialEq)]
100pub struct VerificationReport {
101    mismatches: Vec<Mismatch>,
102}
103
104impl VerificationReport {
105    /// Returns true when every enabled check passed.
106    #[must_use]
107    pub fn is_valid(&self) -> bool {
108        self.mismatches.is_empty()
109    }
110
111    /// Returns deterministic mismatches.
112    #[must_use]
113    pub fn mismatches(&self) -> &[Mismatch] {
114        &self.mismatches
115    }
116}
117
118/// Verification configuration.
119#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
120pub struct VerifyOptions {
121    mismatch_mode: MismatchMode,
122    extra_files: ExtraFilePolicy,
123}
124
125impl VerifyOptions {
126    /// Starts an options builder.
127    #[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/// Builder for [`VerifyOptions`].
137#[derive(Clone, Copy, Debug)]
138pub struct VerifyOptionsBuilder(VerifyOptions);
139
140impl VerifyOptionsBuilder {
141    /// Selects fail-fast or collect-all reporting.
142    #[must_use]
143    pub const fn mismatch_mode(mut self, mode: MismatchMode) -> Self {
144        self.0.mismatch_mode = mode;
145        self
146    }
147
148    /// Selects extra-file handling.
149    #[must_use]
150    pub const fn extra_files(mut self, policy: ExtraFilePolicy) -> Self {
151        self.0.extra_files = policy;
152        self
153    }
154
155    /// Builds verification options.
156    #[must_use]
157    pub const fn build(self) -> VerifyOptions {
158        self.0
159    }
160}
161
162/// Verifies one validated metainfo object against a payload root.
163pub 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    /// Creates a verifier using safe default policies.
180    #[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    /// Replaces verification options.
193    #[must_use]
194    pub const fn options(mut self, options: VerifyOptions) -> Self {
195        self.options = options;
196        self
197    }
198
199    /// Replaces the cancellation token.
200    #[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    /// Verifies structural and applicable hash domains.
213    ///
214    /// Relative payload roots use the current directory. Expected paths must match
215    /// actual directory entries exactly and must not cross symlinks. Verification
216    /// keeps a bounded number of files open and checks saved file identity and
217    /// state before hashing, after hashing, and before reporting successful
218    /// verification. These checks do not provide an atomic filesystem snapshot.
219    ///
220    /// # Errors
221    ///
222    /// Returns cancellation, metainfo reparse, or operational filesystem errors.
223    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    // Spec: VERIFY-REPORT-001
849    #[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    // Spec: VERIFY-PATH-001
893    #[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}