Skip to main content

irontide_engine/
verify_before_download.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_sign_loss,
4    reason = "M205: piece/file counts bounded by torrent metadata (u32 pieces, practical file counts)"
5)]
6
7//! Verify-Before-Download: fast file-size pre-scan (M205).
8//!
9//! Before hashing every piece, stat each file on disk and check its size
10//! against the torrent metadata.  Pieces whose constituent files are all
11//! present with correct sizes are "candidates" for hash verification;
12//! pieces with any missing or short file are immediately skipped.
13//! This turns a full O(total_pieces × read+hash) into a cheap O(num_files)
14//! stat pass followed by hash verification of only the candidate set.
15
16use std::path::{Path, PathBuf};
17
18/// Result of a fast file-size pre-scan.
19pub struct ScanResult {
20    /// Per-piece bitmap: `true` if all constituent files exist with correct sizes.
21    pub candidates: Vec<bool>,
22    /// Number of candidate pieces (count of `true` entries).
23    pub candidate_count: u32,
24    /// Total pieces in the torrent.
25    pub total_pieces: u32,
26    /// Number of files found on disk with correct size.
27    pub files_found: u32,
28    /// Total number of files in the torrent metadata.
29    pub files_total: u32,
30}
31
32/// Stat each file on disk and produce a candidate bitmap for hash verification.
33#[must_use]
34pub fn quick_file_scan(
35    file_infos: &[irontide_core::FileInfo],
36    piece_length: u64,
37    total_pieces: u32,
38    save_path: &Path,
39) -> ScanResult {
40    if total_pieces == 0 || file_infos.is_empty() || piece_length == 0 {
41        return ScanResult {
42            candidates: vec![false; total_pieces as usize],
43            candidate_count: 0,
44            total_pieces,
45            files_found: 0,
46            files_total: file_infos.len() as u32,
47        };
48    }
49
50    let mut file_ok = Vec::with_capacity(file_infos.len());
51    let mut files_found = 0u32;
52
53    for fi in file_infos {
54        let rel_path: PathBuf = fi.path.iter().collect();
55        let full_path = save_path.join(&rel_path);
56        let ok = match std::fs::metadata(&full_path) {
57            Ok(m) => {
58                let size_ok = m.len() >= fi.length;
59                if size_ok {
60                    files_found += 1;
61                }
62                size_ok
63            }
64            Err(_) => false,
65        };
66        file_ok.push(ok);
67    }
68
69    let mut candidates = vec![true; total_pieces as usize];
70    let mut offset = 0u64;
71
72    for (file_idx, fi) in file_infos.iter().enumerate() {
73        if !file_ok[file_idx] {
74            let first_piece = offset / piece_length;
75            let last_byte = offset + fi.length.saturating_sub(1);
76            let last_piece = if fi.length == 0 {
77                first_piece
78            } else {
79                last_byte / piece_length
80            };
81
82            for p in first_piece..=last_piece {
83                if (p as usize) < candidates.len() {
84                    candidates[p as usize] = false;
85                }
86            }
87        }
88        offset += fi.length;
89    }
90
91    let candidate_count = candidates.iter().filter(|&&c| c).count() as u32;
92
93    ScanResult {
94        candidates,
95        candidate_count,
96        total_pieces,
97        files_found,
98        files_total: file_infos.len() as u32,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use irontide_core::FileInfo;
106    use std::fs;
107
108    fn make_file_info(path: &[&str], length: u64) -> FileInfo {
109        FileInfo {
110            path: path.iter().map(|s| (*s).to_string()).collect(),
111            length,
112        }
113    }
114
115    #[test]
116    fn empty_torrent() {
117        let result = quick_file_scan(&[], 256_000, 0, Path::new("/tmp"));
118        assert_eq!(result.candidate_count, 0);
119        assert_eq!(result.total_pieces, 0);
120    }
121
122    #[test]
123    fn all_files_present() {
124        let dir = tempfile::tempdir().unwrap();
125        let sub = dir.path().join("test");
126        fs::create_dir_all(&sub).unwrap();
127        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
128        fs::write(sub.join("b.txt"), vec![0u8; 500]).unwrap();
129
130        let files = vec![
131            make_file_info(&["test", "a.txt"], 1000),
132            make_file_info(&["test", "b.txt"], 500),
133        ];
134
135        let result = quick_file_scan(&files, 1000, 2, dir.path());
136        assert_eq!(result.files_found, 2);
137        assert_eq!(result.candidate_count, 2);
138        assert!(result.candidates[0]);
139        assert!(result.candidates[1]);
140    }
141
142    #[test]
143    fn missing_file_marks_pieces_not_candidate() {
144        let dir = tempfile::tempdir().unwrap();
145        let sub = dir.path().join("test");
146        fs::create_dir_all(&sub).unwrap();
147        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
148
149        let files = vec![
150            make_file_info(&["test", "a.txt"], 1000),
151            make_file_info(&["test", "b.txt"], 500),
152        ];
153
154        let result = quick_file_scan(&files, 1000, 2, dir.path());
155        assert_eq!(result.files_found, 1);
156        assert!(result.candidates[0]);
157        assert!(!result.candidates[1]);
158    }
159
160    #[test]
161    fn short_file_not_candidate() {
162        let dir = tempfile::tempdir().unwrap();
163        let sub = dir.path().join("test");
164        fs::create_dir_all(&sub).unwrap();
165        fs::write(sub.join("a.txt"), vec![0u8; 500]).unwrap();
166
167        let files = vec![make_file_info(&["test", "a.txt"], 1000)];
168
169        let result = quick_file_scan(&files, 1000, 1, dir.path());
170        assert_eq!(result.files_found, 0);
171        assert!(!result.candidates[0]);
172    }
173
174    #[test]
175    fn multi_file_piece_boundary() {
176        let dir = tempfile::tempdir().unwrap();
177        let sub = dir.path().join("test");
178        fs::create_dir_all(&sub).unwrap();
179        fs::write(sub.join("a.txt"), vec![0u8; 600]).unwrap();
180        fs::write(sub.join("b.txt"), vec![0u8; 400]).unwrap();
181
182        let files = vec![
183            make_file_info(&["test", "a.txt"], 600),
184            make_file_info(&["test", "b.txt"], 400),
185        ];
186
187        let result = quick_file_scan(&files, 500, 2, dir.path());
188        assert_eq!(result.files_found, 2);
189        assert_eq!(result.candidate_count, 2);
190    }
191
192    #[test]
193    fn missing_middle_file_invalidates_spanning_pieces() {
194        let dir = tempfile::tempdir().unwrap();
195        let sub = dir.path().join("test");
196        fs::create_dir_all(&sub).unwrap();
197        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
198        fs::write(sub.join("c.txt"), vec![0u8; 1000]).unwrap();
199
200        let files = vec![
201            make_file_info(&["test", "a.txt"], 1000),
202            make_file_info(&["test", "b.txt"], 1000),
203            make_file_info(&["test", "c.txt"], 1000),
204        ];
205
206        let result = quick_file_scan(&files, 1000, 3, dir.path());
207        assert_eq!(result.files_found, 2);
208        assert!(result.candidates[0]);
209        assert!(!result.candidates[1]);
210        assert!(result.candidates[2]);
211    }
212
213    #[test]
214    fn zero_length_file_counted_as_found() {
215        let dir = tempfile::tempdir().unwrap();
216        let sub = dir.path().join("test");
217        fs::create_dir_all(&sub).unwrap();
218        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
219        fs::write(sub.join("empty.txt"), b"").unwrap();
220
221        let files = vec![
222            make_file_info(&["test", "a.txt"], 1000),
223            make_file_info(&["test", "empty.txt"], 0),
224        ];
225
226        let result = quick_file_scan(&files, 1000, 1, dir.path());
227        assert_eq!(result.files_found, 2);
228        assert!(result.candidates[0]);
229    }
230
231    #[test]
232    fn no_files_on_disk() {
233        let dir = tempfile::tempdir().unwrap();
234        let files = vec![
235            make_file_info(&["test", "a.txt"], 1000),
236            make_file_info(&["test", "b.txt"], 500),
237        ];
238
239        let result = quick_file_scan(&files, 1000, 2, dir.path());
240        assert_eq!(result.files_found, 0);
241        assert_eq!(result.candidate_count, 0);
242    }
243
244    #[test]
245    fn single_file_torrent() {
246        let dir = tempfile::tempdir().unwrap();
247        fs::write(dir.path().join("movie.mkv"), vec![0u8; 5000]).unwrap();
248
249        let files = vec![make_file_info(&["movie.mkv"], 5000)];
250
251        let result = quick_file_scan(&files, 1000, 5, dir.path());
252        assert_eq!(result.files_found, 1);
253        assert_eq!(result.candidate_count, 5);
254    }
255
256    #[test]
257    fn scan_result_counts() {
258        let dir = tempfile::tempdir().unwrap();
259        let files = vec![make_file_info(&["a"], 100), make_file_info(&["b"], 100)];
260        let result = quick_file_scan(&files, 100, 2, dir.path());
261        assert_eq!(result.total_pieces, 2);
262        assert_eq!(result.files_total, 2);
263    }
264}