irontide-storage 1.0.1

Piece storage, verification, and disk I/O for BitTorrent
Documentation
#![allow(
    clippy::cast_possible_truncation,
    reason = "M175: file mapping — chunk lengths bounded by chunk_size (u32 by construction in Lengths::new)"
)]

use irontide_core::Lengths;
use smallvec::SmallVec;

/// A contiguous segment within a single file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileSegment {
    /// Index into the file list.
    pub file_index: usize,
    /// Byte offset within the file.
    pub file_offset: u64,
    /// Length of this segment in bytes.
    pub len: u32,
}

/// Maps piece/chunk coordinates to file segments using pre-computed cumulative offsets.
///
/// Binary search gives O(log n) file lookup instead of linear scan.
#[derive(Debug, Clone)]
pub struct FileMap {
    /// Cumulative start offset of each file in the torrent's byte space.
    file_offsets: Vec<u64>,
    /// Length of each file.
    file_lengths: Vec<u64>,
    /// Piece/chunk arithmetic.
    lengths: Lengths,
}

impl FileMap {
    /// Create a new `FileMap` from file lengths and piece arithmetic.
    #[must_use]
    pub fn new(file_lengths: Vec<u64>, lengths: Lengths) -> Self {
        let mut file_offsets = Vec::with_capacity(file_lengths.len());
        let mut cumulative = 0u64;
        for &len in &file_lengths {
            file_offsets.push(cumulative);
            cumulative += len;
        }
        Self {
            file_offsets,
            file_lengths,
            lengths,
        }
    }

    /// Map an absolute byte range to file segments.
    #[must_use]
    pub fn byte_range_to_segments(&self, offset: u64, length: u32) -> SmallVec<[FileSegment; 4]> {
        if length == 0 || self.file_lengths.is_empty() {
            return SmallVec::new();
        }

        let mut segments = SmallVec::new();
        let mut remaining = u64::from(length);
        let mut pos = offset;

        while remaining > 0 {
            // Binary search: find the file containing `pos`.
            let file_idx = match self.file_offsets.binary_search(&pos) {
                Ok(i) => i,
                Err(i) => i.saturating_sub(1),
            };

            if file_idx >= self.file_lengths.len() {
                break;
            }

            let file_start = self.file_offsets[file_idx];
            let file_len = self.file_lengths[file_idx];
            let file_offset = pos - file_start;

            // How much of this file can we use from `file_offset`?
            let available = file_len - file_offset;
            let take = remaining.min(available);

            if take > 0 {
                segments.push(FileSegment {
                    file_index: file_idx,
                    file_offset,
                    len: take as u32,
                });
            }

            pos += take;
            remaining -= take;
        }

        segments
    }

    /// Map a chunk (piece, begin, length) to file segments.
    #[must_use]
    pub fn chunk_segments(
        &self,
        piece: u32,
        begin: u32,
        length: u32,
    ) -> SmallVec<[FileSegment; 4]> {
        let abs_offset = self.lengths.piece_offset(piece) + u64::from(begin);
        self.byte_range_to_segments(abs_offset, length)
    }

    /// Size in bytes of the given piece.
    #[must_use]
    pub fn piece_size(&self, piece: u32) -> u32 {
        self.lengths.piece_size(piece)
    }

    /// Map an entire piece to file segments.
    #[must_use]
    pub fn piece_segments(&self, piece: u32) -> SmallVec<[FileSegment; 4]> {
        let abs_offset = self.lengths.piece_offset(piece);
        let piece_size = self.lengths.piece_size(piece);
        self.byte_range_to_segments(abs_offset, piece_size)
    }

    /// Number of files.
    #[must_use]
    pub fn num_files(&self) -> usize {
        self.file_lengths.len()
    }

    /// Length of a specific file.
    #[must_use]
    pub fn file_length(&self, index: usize) -> u64 {
        self.file_lengths[index]
    }

    /// Return the inclusive `(first_piece, last_piece)` range that covers
    /// a given file's bytes (M170 — qBt `/torrents/files` `piece_range`).
    ///
    /// Semantics:
    /// - Returns `(0, 0)` when `file_idx >= num_files()`.
    /// - For a zero-length file the range is `(first_piece, first_piece)`,
    ///   where `first_piece` is the piece index containing the file's
    ///   offset. This matches qBt's behaviour: a file with no bytes still
    ///   reports a valid (degenerate) piece range, never a negative one.
    /// - For a non-empty file, `last_piece` is derived from the final
    ///   byte `(file_offset + file_len - 1)`.
    /// - Both values are clamped into `[0, num_pieces - 1]`.
    ///
    /// Cost is O(1) using the pre-computed cumulative `file_offsets`.
    #[must_use]
    pub fn piece_range(&self, file_idx: usize) -> (u32, u32) {
        if file_idx >= self.file_lengths.len() {
            return (0, 0);
        }
        let piece_length = self.lengths.piece_length();
        let num_pieces = self.lengths.num_pieces();
        // Degenerate torrent: no pieces at all — return the conventional
        // zeroed range rather than panic.
        if num_pieces == 0 || piece_length == 0 {
            return (0, 0);
        }
        let max_piece = num_pieces.saturating_sub(1);
        let file_start = self.file_offsets[file_idx];
        let file_len = self.file_lengths[file_idx];

        // Integer division on u64 cannot overflow; cast via u64::min before
        // narrowing to u32.
        let first_piece_u64 = file_start / piece_length;
        let first_piece = u32::try_from(first_piece_u64)
            .unwrap_or(max_piece)
            .min(max_piece);

        if file_len == 0 {
            return (first_piece, first_piece);
        }

        // Use `saturating_sub(1)` in case a file_start overflow made the
        // sum wrap — this is defensive; on a well-formed torrent
        // `file_start + file_len <= total_length`.
        let last_byte = file_start.saturating_add(file_len).saturating_sub(1);
        let last_piece_u64 = last_byte / piece_length;
        let last_piece = u32::try_from(last_piece_u64)
            .unwrap_or(max_piece)
            .min(max_piece);
        (first_piece, last_piece)
    }
}

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

    #[test]
    fn single_file() {
        // 1 MiB single file, 256 KiB pieces, 16 KiB chunks
        let lengths = Lengths::new(1_048_576, 262_144, 16384);
        let fm = FileMap::new(vec![1_048_576], lengths);

        let segs = fm.piece_segments(0);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].file_index, 0);
        assert_eq!(segs[0].file_offset, 0);
        assert_eq!(segs[0].len, 262_144);
    }

    #[test]
    fn multi_file_no_span() {
        // Two files of 262_144 each (exactly 1 piece each)
        let lengths = Lengths::new(524_288, 262_144, 16384);
        let fm = FileMap::new(vec![262_144, 262_144], lengths);

        let segs0 = fm.piece_segments(0);
        assert_eq!(segs0.len(), 1);
        assert_eq!(segs0[0].file_index, 0);

        let segs1 = fm.piece_segments(1);
        assert_eq!(segs1.len(), 1);
        assert_eq!(segs1[0].file_index, 1);
    }

    #[test]
    fn chunk_spans_boundary() {
        // Two files: 100 bytes and 200 bytes, piece size 300, chunk size 150
        let lengths = Lengths::new(300, 300, 150);
        let fm = FileMap::new(vec![100, 200], lengths);

        // Chunk at begin=0, length=150 → first 100 in file 0, next 50 in file 1
        let segs = fm.chunk_segments(0, 0, 150);
        assert_eq!(segs.len(), 2);
        assert_eq!(
            segs[0],
            FileSegment {
                file_index: 0,
                file_offset: 0,
                len: 100
            }
        );
        assert_eq!(
            segs[1],
            FileSegment {
                file_index: 1,
                file_offset: 0,
                len: 50
            }
        );
    }

    #[test]
    fn piece_spans_three_files() {
        // Three files: 100, 50, 150 bytes. Piece size = 300 (one piece, spans all files)
        let lengths = Lengths::new(300, 300, 16384);
        let fm = FileMap::new(vec![100, 50, 150], lengths);

        let segs = fm.piece_segments(0);
        assert_eq!(segs.len(), 3);
        assert_eq!(
            segs[0],
            FileSegment {
                file_index: 0,
                file_offset: 0,
                len: 100
            }
        );
        assert_eq!(
            segs[1],
            FileSegment {
                file_index: 1,
                file_offset: 0,
                len: 50
            }
        );
        assert_eq!(
            segs[2],
            FileSegment {
                file_index: 2,
                file_offset: 0,
                len: 150
            }
        );
    }

    #[test]
    fn last_piece_shorter() {
        // 500 bytes total, 300 byte pieces → piece 0 = 300, piece 1 = 200
        let lengths = Lengths::new(500, 300, 16384);
        let fm = FileMap::new(vec![500], lengths);

        let segs = fm.piece_segments(1);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].file_offset, 300);
        assert_eq!(segs[0].len, 200);
    }

    #[test]
    fn zero_length_file() {
        // Files: 0 bytes, 100 bytes. Total = 100, one piece.
        let lengths = Lengths::new(100, 100, 16384);
        let fm = FileMap::new(vec![0, 100], lengths);

        let segs = fm.piece_segments(0);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].file_index, 1);
        assert_eq!(segs[0].file_offset, 0);
        assert_eq!(segs[0].len, 100);
    }

    #[test]
    fn byte_range_single() {
        let lengths = Lengths::new(1000, 500, 16384);
        let fm = FileMap::new(vec![1000], lengths);

        let segs = fm.byte_range_to_segments(100, 50);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].file_offset, 100);
        assert_eq!(segs[0].len, 50);
    }

    #[test]
    fn piece_segments_second_piece_multi_file() {
        // Files: 400, 600. Piece size 500. Piece 1 starts at offset 500.
        // Piece 1: file 0 bytes 400..400 (0 bytes) → actually starts in file 1 offset 100
        let lengths = Lengths::new(1000, 500, 16384);
        let fm = FileMap::new(vec![400, 600], lengths);

        let segs = fm.piece_segments(1);
        assert_eq!(segs.len(), 1);
        assert_eq!(segs[0].file_index, 1);
        assert_eq!(segs[0].file_offset, 100);
        assert_eq!(segs[0].len, 500);
    }

    #[test]
    fn piece_range_single_file_multiple_pieces() {
        // 4 pieces of 256 bytes = 1024 bytes, single file.
        let lengths = Lengths::new(1024, 256, 64);
        let fm = FileMap::new(vec![1024], lengths);
        assert_eq!(fm.piece_range(0), (0, 3));
    }

    #[test]
    fn piece_range_multi_file_span() {
        // Files [400, 300, 200]. piece_length = 256. Total = 900 -> 4 pieces.
        // File 0: bytes 0..400 -> pieces 0..1 (400/256 = 1)
        // File 1: bytes 400..700 -> pieces 1..2 (400/256=1 .. 699/256=2)
        // File 2: bytes 700..900 -> pieces 2..3 (700/256=2 .. 899/256=3)
        let lengths = Lengths::new(900, 256, 64);
        let fm = FileMap::new(vec![400, 300, 200], lengths);
        assert_eq!(fm.piece_range(0), (0, 1));
        assert_eq!(fm.piece_range(1), (1, 2));
        assert_eq!(fm.piece_range(2), (2, 3));
    }

    #[test]
    fn piece_range_zero_length_file_collapses() {
        // Files [100, 0, 200]. piece_length = 100. Total = 300 -> 3 pieces.
        // File 0: bytes 0..100 -> pieces (0, 0)
        // File 1: bytes 100..100 (empty) -> collapsed to (1, 1)
        // File 2: bytes 100..300 -> pieces (1, 2)
        let lengths = Lengths::new(300, 100, 50);
        let fm = FileMap::new(vec![100, 0, 200], lengths);
        assert_eq!(fm.piece_range(0), (0, 0));
        assert_eq!(fm.piece_range(1), (1, 1));
        assert_eq!(fm.piece_range(2), (1, 2));
    }

    #[test]
    fn piece_range_out_of_bounds_returns_zero_zero() {
        let lengths = Lengths::new(100, 100, 50);
        let fm = FileMap::new(vec![100], lengths);
        assert_eq!(fm.piece_range(99), (0, 0));
    }

    #[test]
    fn piece_range_last_piece_short_tail() {
        // 250 bytes / 100-byte pieces = 3 pieces (the last is 50 bytes).
        // File 0 covers all 250 bytes -> pieces 0..2.
        let lengths = Lengths::new(250, 100, 50);
        let fm = FileMap::new(vec![250], lengths);
        assert_eq!(fm.piece_range(0), (0, 2));
    }

    #[test]
    fn smallvec_spills_on_many_files() {
        // 6 files of 50 bytes each, total 300, one piece spanning all 6.
        // This forces >4 segments, exercising the SmallVec heap-spill path.
        let lengths = Lengths::new(300, 300, 16384);
        let fm = FileMap::new(vec![50, 50, 50, 50, 50, 50], lengths);

        let segs = fm.piece_segments(0);
        assert_eq!(segs.len(), 6);
        for (i, seg) in segs.iter().enumerate() {
            assert_eq!(seg.file_index, i, "segment {i} wrong file_index");
            assert_eq!(seg.file_offset, 0, "segment {i} wrong file_offset");
            assert_eq!(seg.len, 50, "segment {i} wrong len");
        }
    }
}