irontide-session 1.0.1

BitTorrent session management: peers, torrents, and piece selection
Documentation
#![allow(
    clippy::cast_possible_truncation,
    clippy::cast_sign_loss,
    reason = "M205: piece/file counts bounded by torrent metadata (u32 pieces, practical file counts)"
)]

//! Verify-Before-Download: fast file-size pre-scan (M205).
//!
//! Before hashing every piece, stat each file on disk and check its size
//! against the torrent metadata.  Pieces whose constituent files are all
//! present with correct sizes are "candidates" for hash verification;
//! pieces with any missing or short file are immediately skipped.
//! This turns a full O(total_pieces × read+hash) into a cheap O(num_files)
//! stat pass followed by hash verification of only the candidate set.

use std::path::{Path, PathBuf};

/// Result of a fast file-size pre-scan.
pub struct ScanResult {
    /// Per-piece bitmap: `true` if all constituent files exist with correct sizes.
    pub candidates: Vec<bool>,
    /// Number of candidate pieces (count of `true` entries).
    pub candidate_count: u32,
    /// Total pieces in the torrent.
    pub total_pieces: u32,
    /// Number of files found on disk with correct size.
    pub files_found: u32,
    /// Total number of files in the torrent metadata.
    pub files_total: u32,
}

/// Stat each file on disk and produce a candidate bitmap for hash verification.
#[must_use]
pub fn quick_file_scan(
    file_infos: &[irontide_core::FileInfo],
    piece_length: u64,
    total_pieces: u32,
    save_path: &Path,
) -> ScanResult {
    if total_pieces == 0 || file_infos.is_empty() || piece_length == 0 {
        return ScanResult {
            candidates: vec![false; total_pieces as usize],
            candidate_count: 0,
            total_pieces,
            files_found: 0,
            files_total: file_infos.len() as u32,
        };
    }

    let mut file_ok = Vec::with_capacity(file_infos.len());
    let mut files_found = 0u32;

    for fi in file_infos {
        let rel_path: PathBuf = fi.path.iter().collect();
        let full_path = save_path.join(&rel_path);
        let ok = match std::fs::metadata(&full_path) {
            Ok(m) => {
                let size_ok = m.len() >= fi.length;
                if size_ok {
                    files_found += 1;
                }
                size_ok
            }
            Err(_) => false,
        };
        file_ok.push(ok);
    }

    let mut candidates = vec![true; total_pieces as usize];
    let mut offset = 0u64;

    for (file_idx, fi) in file_infos.iter().enumerate() {
        if !file_ok[file_idx] {
            let first_piece = offset / piece_length;
            let last_byte = offset + fi.length.saturating_sub(1);
            let last_piece = if fi.length == 0 {
                first_piece
            } else {
                last_byte / piece_length
            };

            for p in first_piece..=last_piece {
                if (p as usize) < candidates.len() {
                    candidates[p as usize] = false;
                }
            }
        }
        offset += fi.length;
    }

    let candidate_count = candidates.iter().filter(|&&c| c).count() as u32;

    ScanResult {
        candidates,
        candidate_count,
        total_pieces,
        files_found,
        files_total: file_infos.len() as u32,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use irontide_core::FileInfo;
    use std::fs;

    fn make_file_info(path: &[&str], length: u64) -> FileInfo {
        FileInfo {
            path: path.iter().map(|s| (*s).to_string()).collect(),
            length,
        }
    }

    #[test]
    fn empty_torrent() {
        let result = quick_file_scan(&[], 256_000, 0, Path::new("/tmp"));
        assert_eq!(result.candidate_count, 0);
        assert_eq!(result.total_pieces, 0);
    }

    #[test]
    fn all_files_present() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
        fs::write(sub.join("b.txt"), vec![0u8; 500]).unwrap();

        let files = vec![
            make_file_info(&["test", "a.txt"], 1000),
            make_file_info(&["test", "b.txt"], 500),
        ];

        let result = quick_file_scan(&files, 1000, 2, dir.path());
        assert_eq!(result.files_found, 2);
        assert_eq!(result.candidate_count, 2);
        assert!(result.candidates[0]);
        assert!(result.candidates[1]);
    }

    #[test]
    fn missing_file_marks_pieces_not_candidate() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();

        let files = vec![
            make_file_info(&["test", "a.txt"], 1000),
            make_file_info(&["test", "b.txt"], 500),
        ];

        let result = quick_file_scan(&files, 1000, 2, dir.path());
        assert_eq!(result.files_found, 1);
        assert!(result.candidates[0]);
        assert!(!result.candidates[1]);
    }

    #[test]
    fn short_file_not_candidate() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 500]).unwrap();

        let files = vec![make_file_info(&["test", "a.txt"], 1000)];

        let result = quick_file_scan(&files, 1000, 1, dir.path());
        assert_eq!(result.files_found, 0);
        assert!(!result.candidates[0]);
    }

    #[test]
    fn multi_file_piece_boundary() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 600]).unwrap();
        fs::write(sub.join("b.txt"), vec![0u8; 400]).unwrap();

        let files = vec![
            make_file_info(&["test", "a.txt"], 600),
            make_file_info(&["test", "b.txt"], 400),
        ];

        let result = quick_file_scan(&files, 500, 2, dir.path());
        assert_eq!(result.files_found, 2);
        assert_eq!(result.candidate_count, 2);
    }

    #[test]
    fn missing_middle_file_invalidates_spanning_pieces() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
        fs::write(sub.join("c.txt"), vec![0u8; 1000]).unwrap();

        let files = vec![
            make_file_info(&["test", "a.txt"], 1000),
            make_file_info(&["test", "b.txt"], 1000),
            make_file_info(&["test", "c.txt"], 1000),
        ];

        let result = quick_file_scan(&files, 1000, 3, dir.path());
        assert_eq!(result.files_found, 2);
        assert!(result.candidates[0]);
        assert!(!result.candidates[1]);
        assert!(result.candidates[2]);
    }

    #[test]
    fn zero_length_file_counted_as_found() {
        let dir = tempfile::tempdir().unwrap();
        let sub = dir.path().join("test");
        fs::create_dir_all(&sub).unwrap();
        fs::write(sub.join("a.txt"), vec![0u8; 1000]).unwrap();
        fs::write(sub.join("empty.txt"), b"").unwrap();

        let files = vec![
            make_file_info(&["test", "a.txt"], 1000),
            make_file_info(&["test", "empty.txt"], 0),
        ];

        let result = quick_file_scan(&files, 1000, 1, dir.path());
        assert_eq!(result.files_found, 2);
        assert!(result.candidates[0]);
    }

    #[test]
    fn no_files_on_disk() {
        let dir = tempfile::tempdir().unwrap();
        let files = vec![
            make_file_info(&["test", "a.txt"], 1000),
            make_file_info(&["test", "b.txt"], 500),
        ];

        let result = quick_file_scan(&files, 1000, 2, dir.path());
        assert_eq!(result.files_found, 0);
        assert_eq!(result.candidate_count, 0);
    }

    #[test]
    fn single_file_torrent() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join("movie.mkv"), vec![0u8; 5000]).unwrap();

        let files = vec![make_file_info(&["movie.mkv"], 5000)];

        let result = quick_file_scan(&files, 1000, 5, dir.path());
        assert_eq!(result.files_found, 1);
        assert_eq!(result.candidate_count, 5);
    }

    #[test]
    fn scan_result_counts() {
        let dir = tempfile::tempdir().unwrap();
        let files = vec![make_file_info(&["a"], 100), make_file_info(&["b"], 100)];
        let result = quick_file_scan(&files, 100, 2, dir.path());
        assert_eq!(result.total_pieces, 2);
        assert_eq!(result.files_total, 2);
    }
}