Skip to main content

irontide_core/
metainfo_v2.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_possible_wrap,
4    clippy::cast_sign_loss,
5    reason = "M175: BEP 52 metainfo — Merkle tree geometry uses fixed widths per spec"
6)]
7
8//! `BitTorrent` v2 metainfo types (BEP 52).
9//!
10//! v2 uses a nested file tree instead of a flat file list, SHA-256 instead
11//! of SHA-1, and per-file Merkle hash trees with piece alignment.
12
13use std::collections::BTreeMap;
14
15use bytes::Bytes;
16use irontide_bencode::BencodeValue;
17
18use crate::error::Error;
19use crate::file_tree::{FileTreeNode, V2FileInfo};
20use crate::hash::{Id20, Id32};
21use crate::info_hashes::InfoHashes;
22
23/// v2 info dictionary (BEP 52).
24///
25/// Key difference from v1: files are represented as a nested tree, and `piece_length`
26/// is a global value (power of 2, ≥ 16 KiB). Files are aligned to piece boundaries —
27/// each file starts on a new piece, and the last piece of each file may be shorter.
28#[derive(Debug, Clone)]
29pub struct InfoDictV2 {
30    /// Suggested name for the torrent.
31    pub name: String,
32    /// Global piece length in bytes. Must be a power of 2, ≥ 16384.
33    pub piece_length: u64,
34    /// Meta version — always 2 for v2 torrents.
35    pub meta_version: u64,
36    /// The nested file tree.
37    pub file_tree: FileTreeNode,
38    /// BEP 35 / SSL torrent: PEM-encoded X.509 CA certificate.
39    /// When present, all peer connections must use TLS with certs chaining to this CA.
40    pub ssl_cert: Option<Vec<u8>>,
41}
42
43impl InfoDictV2 {
44    /// Get all files as a flat list.
45    #[must_use]
46    pub fn files(&self) -> Vec<V2FileInfo> {
47        self.file_tree.flatten()
48    }
49
50    /// Total size of all files in bytes.
51    #[must_use]
52    pub fn total_length(&self) -> u64 {
53        self.files().iter().map(|f| f.attr.length).sum()
54    }
55
56    /// Total number of pieces across all files.
57    ///
58    /// In v2, each file starts on a new piece boundary, so the total is the
59    /// sum of per-file piece counts (not simply `ceil(total_length / piece_length)`).
60    #[must_use]
61    pub fn num_pieces(&self) -> u32 {
62        self.files()
63            .iter()
64            .map(|f| file_piece_count(f.attr.length, self.piece_length))
65            .sum()
66    }
67
68    /// Per-file piece ranges: `(file_info, global_piece_offset, file_piece_count)`.
69    ///
70    /// Each file starts at a new global piece offset due to v2 alignment.
71    #[must_use]
72    pub fn file_piece_ranges(&self) -> Vec<(V2FileInfo, u32, u32)> {
73        let files = self.files();
74        let mut result = Vec::with_capacity(files.len());
75        let mut offset = 0u32;
76
77        for file in files {
78            let count = file_piece_count(file.attr.length, self.piece_length);
79            result.push((file, offset, count));
80            offset += count;
81        }
82
83        result
84    }
85}
86
87/// Number of pieces for a single file with v2 alignment.
88fn file_piece_count(file_length: u64, piece_length: u64) -> u32 {
89    if file_length == 0 {
90        return 0;
91    }
92    file_length.div_ceil(piece_length) as u32
93}
94
95/// Validate a v2 info dict.
96pub fn validate_info_v2(info: &InfoDictV2) -> Result<(), Error> {
97    if info.meta_version != 2 {
98        return Err(Error::InvalidTorrent(format!(
99            "expected meta version 2, got {}",
100            info.meta_version
101        )));
102    }
103
104    if info.piece_length < 16384 {
105        return Err(Error::InvalidTorrent(format!(
106            "piece length {} is less than minimum 16384",
107            info.piece_length
108        )));
109    }
110
111    if !info.piece_length.is_power_of_two() {
112        return Err(Error::InvalidTorrent(format!(
113            "piece length {} is not a power of 2",
114            info.piece_length
115        )));
116    }
117
118    Ok(())
119}
120
121/// Parsed v2 .torrent file (BEP 52).
122#[derive(Debug, Clone)]
123pub struct TorrentMetaV2 {
124    /// Unified info hashes (v2 SHA-256, optionally truncated v1 for compat).
125    pub info_hashes: InfoHashes,
126    /// Raw info dict bytes for BEP 9 metadata serving.
127    pub info_bytes: Option<Bytes>,
128    /// Primary announce URL.
129    pub announce: Option<String>,
130    /// Announce list (BEP 12).
131    pub announce_list: Option<Vec<Vec<String>>>,
132    /// Comment.
133    pub comment: Option<String>,
134    /// Created by.
135    pub created_by: Option<String>,
136    /// Creation date (unix timestamp).
137    pub creation_date: Option<i64>,
138    /// v2 info dictionary.
139    pub info: InfoDictV2,
140    /// Piece layers: `pieces_root → concatenated SHA-256 hashes`.
141    ///
142    /// Each entry maps a file's Merkle root to the concatenated piece-level
143    /// hashes. Only present for files larger than `piece_length`.
144    pub piece_layers: BTreeMap<Id32, Vec<u8>>,
145    /// PEM-encoded SSL CA certificate from the info dict, if present.
146    pub ssl_cert: Option<Vec<u8>>,
147}
148
149impl TorrentMetaV2 {
150    /// Validate that piece layers match the file tree.
151    ///
152    /// Each file larger than `piece_length` must have a corresponding piece layer
153    /// with the correct number of hashes.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if a file is missing its piece layer or has the wrong hash count.
158    pub fn validate_piece_layers(&self) -> Result<(), Error> {
159        for file in self.info.files() {
160            if file.attr.length <= self.info.piece_length {
161                continue; // Small files don't need piece layers
162            }
163
164            let root = file.attr.pieces_root.ok_or_else(|| {
165                Error::InvalidTorrent(format!(
166                    "file {:?} has length {} but no pieces_root",
167                    file.path, file.attr.length
168                ))
169            })?;
170
171            let layer = self.piece_layers.get(&root).ok_or_else(|| {
172                Error::InvalidTorrent(format!(
173                    "missing piece layer for file {:?} (root: {})",
174                    file.path, root
175                ))
176            })?;
177
178            let expected_pieces =
179                file_piece_count(file.attr.length, self.info.piece_length) as usize;
180            let actual_hashes = layer.len() / 32;
181
182            if layer.len() % 32 != 0 {
183                return Err(Error::InvalidTorrent(format!(
184                    "piece layer for {:?} has length {} which is not a multiple of 32",
185                    file.path,
186                    layer.len()
187                )));
188            }
189
190            if actual_hashes != expected_pieces {
191                return Err(Error::InvalidTorrent(format!(
192                    "piece layer for {:?} has {} hashes, expected {}",
193                    file.path, actual_hashes, expected_pieces
194                )));
195            }
196        }
197        Ok(())
198    }
199
200    /// Get piece hashes for a file by its Merkle root.
201    pub fn file_piece_hashes(&self, pieces_root: &Id32) -> Option<Vec<Id32>> {
202        let layer = self.piece_layers.get(pieces_root)?;
203        Some(
204            layer
205                .chunks_exact(32)
206                .map(|chunk| {
207                    let mut hash = [0u8; 32];
208                    hash.copy_from_slice(chunk);
209                    Id32(hash)
210                })
211                .collect(),
212        )
213    }
214
215    /// Access piece layer by file index (libtorrent parity).
216    pub fn piece_layer_for_file(&self, file_index: usize) -> Option<Vec<Id32>> {
217        let files = self.info.files();
218        let file = files.get(file_index)?;
219        let root = file.attr.pieces_root.as_ref()?;
220        self.file_piece_hashes(root)
221    }
222
223    /// Take and clear piece layers (memory optimization, libtorrent parity).
224    ///
225    /// Returns the layers and clears them from the struct. Useful after
226    /// verification is complete to free memory.
227    pub fn take_piece_layers(&mut self) -> BTreeMap<Id32, Vec<u8>> {
228        std::mem::take(&mut self.piece_layers)
229    }
230}
231
232/// Parse a v2 .torrent file from raw bytes.
233///
234/// # Errors
235///
236/// Returns an error if the data is not a valid v2 torrent file.
237pub fn torrent_v2_from_bytes(data: &[u8]) -> Result<TorrentMetaV2, Error> {
238    // Step 1: Find raw info dict span for hashing
239    let info_span = irontide_bencode::find_dict_key_span(data, "info")?;
240    let info_hash_v2 = crate::sha256(&data[info_span.clone()]);
241    let info_raw = Bytes::copy_from_slice(&data[info_span]);
242
243    // Truncated v1 hash for tracker/DHT compat
244    let mut v1_truncated = [0u8; 20];
245    v1_truncated.copy_from_slice(&info_hash_v2.0[..20]);
246    let info_hashes = InfoHashes {
247        v1: Some(Id20(v1_truncated)),
248        v2: Some(info_hash_v2),
249    };
250
251    // Step 2: Parse the full structure as BencodeValue (can't use serde for v2 file tree)
252    let root: BencodeValue = irontide_bencode::from_bytes(data)?;
253    let root_dict = root
254        .as_dict()
255        .ok_or_else(|| Error::InvalidTorrent("torrent must be a dict".into()))?;
256
257    // Step 3: Parse info dict
258    let info_value = root_dict
259        .get(b"info".as_ref())
260        .ok_or_else(|| Error::InvalidTorrent("missing 'info' key".into()))?;
261    let info_dict = info_value
262        .as_dict()
263        .ok_or_else(|| Error::InvalidTorrent("'info' must be a dict".into()))?;
264
265    let name = info_dict
266        .get(b"name".as_ref())
267        .and_then(|v| v.as_bytes_raw())
268        .and_then(|b| std::str::from_utf8(b).ok())
269        .ok_or_else(|| Error::InvalidTorrent("missing or invalid 'name' in info".into()))?
270        .to_owned();
271
272    let piece_length = info_dict
273        .get(b"piece length".as_ref())
274        .and_then(irontide_bencode::BencodeValue::as_int)
275        .ok_or_else(|| Error::InvalidTorrent("missing 'piece length' in info".into()))?
276        as u64;
277
278    let meta_version = info_dict
279        .get(b"meta version".as_ref())
280        .and_then(irontide_bencode::BencodeValue::as_int)
281        .ok_or_else(|| Error::InvalidTorrent("missing 'meta version' in info".into()))?
282        as u64;
283
284    let file_tree_value = info_dict
285        .get(b"file tree".as_ref())
286        .ok_or_else(|| Error::InvalidTorrent("missing 'file tree' in info".into()))?;
287    let file_tree = FileTreeNode::from_bencode(file_tree_value)?;
288
289    let ssl_cert = info_dict
290        .get(b"ssl-cert".as_ref())
291        .and_then(|v| v.as_bytes_raw())
292        .map(<[u8]>::to_vec);
293
294    let info = InfoDictV2 {
295        name,
296        piece_length,
297        meta_version,
298        file_tree,
299        ssl_cert: ssl_cert.clone(),
300    };
301
302    validate_info_v2(&info)?;
303
304    // Step 4: Parse optional top-level keys
305    let announce = root_dict
306        .get(b"announce".as_ref())
307        .and_then(|v| v.as_bytes_raw())
308        .and_then(|b| std::str::from_utf8(b).ok())
309        .map(std::borrow::ToOwned::to_owned);
310
311    let announce_list = root_dict.get(b"announce-list".as_ref()).and_then(|v| {
312        v.as_list().map(|tiers| {
313            tiers
314                .iter()
315                .filter_map(|tier| {
316                    tier.as_list().map(|urls| {
317                        urls.iter()
318                            .filter_map(|u| {
319                                u.as_bytes_raw()
320                                    .and_then(|b| std::str::from_utf8(b).ok())
321                                    .map(std::borrow::ToOwned::to_owned)
322                            })
323                            .collect()
324                    })
325                })
326                .collect()
327        })
328    });
329
330    let comment = root_dict
331        .get(b"comment".as_ref())
332        .and_then(|v| v.as_bytes_raw())
333        .and_then(|b| std::str::from_utf8(b).ok())
334        .map(std::borrow::ToOwned::to_owned);
335
336    let created_by = root_dict
337        .get(b"created by".as_ref())
338        .and_then(|v| v.as_bytes_raw())
339        .and_then(|b| std::str::from_utf8(b).ok())
340        .map(std::borrow::ToOwned::to_owned);
341
342    let creation_date = root_dict
343        .get(b"creation date".as_ref())
344        .and_then(irontide_bencode::BencodeValue::as_int);
345
346    // Step 5: Parse piece layers
347    let piece_layers = parse_piece_layers(root_dict)?;
348
349    Ok(TorrentMetaV2 {
350        info_hashes,
351        info_bytes: Some(info_raw),
352        announce,
353        announce_list,
354        comment,
355        created_by,
356        creation_date,
357        info,
358        piece_layers,
359        ssl_cert,
360    })
361}
362
363/// Parse the "piece layers" dict from the top-level torrent dict.
364fn parse_piece_layers(
365    root_dict: &BTreeMap<Vec<u8>, BencodeValue>,
366) -> Result<BTreeMap<Id32, Vec<u8>>, Error> {
367    let mut layers = BTreeMap::new();
368
369    let Some(layers_value) = root_dict.get(b"piece layers".as_ref()) else {
370        return Ok(layers);
371    };
372
373    let layers_dict = layers_value
374        .as_dict()
375        .ok_or_else(|| Error::InvalidTorrent("'piece layers' must be a dict".into()))?;
376
377    for (key, value) in layers_dict {
378        let root = Id32::from_bytes(key)?;
379        let hashes = value
380            .as_bytes_raw()
381            .ok_or_else(|| Error::InvalidTorrent("piece layer value must be bytes".into()))?;
382        layers.insert(root, hashes.to_vec());
383    }
384
385    Ok(layers)
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    /// Build a minimal v2 torrent as raw bencode bytes.
393    fn make_v2_torrent_bytes(
394        name: &str,
395        piece_length: u64,
396        files: &[(&str, u64, Option<[u8; 32]>)],
397        piece_layers: &[([u8; 32], Vec<u8>)],
398    ) -> Vec<u8> {
399        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
400
401        // Build info dict
402        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
403
404        // Build file tree using BencodeValue
405        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
406        for &(fname, length, ref root) in files {
407            let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
408            attr_map.insert(b"length".to_vec(), BencodeValue::Integer(length as i64));
409            if let Some(root_bytes) = root {
410                attr_map.insert(
411                    b"pieces root".to_vec(),
412                    BencodeValue::Bytes(root_bytes.to_vec()),
413                );
414            }
415
416            let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
417            file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
418
419            ft_map.insert(fname.as_bytes().to_vec(), BencodeValue::Dict(file_node));
420        }
421
422        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
423        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
424        info_map.insert(
425            b"name".to_vec(),
426            BencodeValue::Bytes(name.as_bytes().to_vec()),
427        );
428        info_map.insert(
429            b"piece length".to_vec(),
430            BencodeValue::Integer(piece_length as i64),
431        );
432
433        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
434
435        // Add piece layers if any
436        if !piece_layers.is_empty() {
437            let mut pl_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
438            for (root_hash, hashes) in piece_layers {
439                pl_map.insert(root_hash.to_vec(), BencodeValue::Bytes(hashes.clone()));
440            }
441            root_map.insert(b"piece layers".to_vec(), BencodeValue::Dict(pl_map));
442        }
443
444        irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap()
445    }
446
447    #[test]
448    fn parse_minimal_v2_torrent() {
449        let data = make_v2_torrent_bytes("test", 16384, &[("file.txt", 1024, None)], &[]);
450        let torrent = torrent_v2_from_bytes(&data).unwrap();
451
452        assert_eq!(torrent.info.name, "test");
453        assert_eq!(torrent.info.piece_length, 16384);
454        assert_eq!(torrent.info.meta_version, 2);
455        assert_eq!(torrent.info.total_length(), 1024);
456        assert!(torrent.info_hashes.has_v2());
457    }
458
459    #[test]
460    fn parse_with_piece_layers() {
461        let root = [0xABu8; 32];
462        // 2 pieces worth of hashes (2 * 32 bytes)
463        let hashes = vec![0xCDu8; 64];
464        let data = make_v2_torrent_bytes(
465            "test",
466            16384,
467            &[("big.dat", 32768, Some(root))],
468            &[(root, hashes)],
469        );
470
471        let torrent = torrent_v2_from_bytes(&data).unwrap();
472        assert_eq!(torrent.piece_layers.len(), 1);
473        let layer = torrent.piece_layers.get(&Id32(root)).unwrap();
474        assert_eq!(layer.len(), 64);
475    }
476
477    #[test]
478    fn reject_bad_meta_version() {
479        // Build manually with meta_version = 1
480        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
481        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
482        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
483        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(100));
484        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
485        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
486        ft_map.insert(b"f.txt".to_vec(), BencodeValue::Dict(file_node));
487
488        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
489        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(1));
490        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
491        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(16384));
492
493        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
494        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
495
496        let data = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
497        assert!(torrent_v2_from_bytes(&data).is_err());
498    }
499
500    #[test]
501    fn info_bytes_populated() {
502        let data = make_v2_torrent_bytes("test", 16384, &[("f.txt", 100, None)], &[]);
503        let torrent = torrent_v2_from_bytes(&data).unwrap();
504        assert!(torrent.info_bytes.is_some());
505        // Re-hashing should produce the same v2 hash
506        let rehash = crate::sha256(&torrent.info_bytes.unwrap());
507        assert_eq!(rehash, torrent.info_hashes.v2.unwrap());
508    }
509
510    #[test]
511    fn piece_layer_for_file_indexed() {
512        let root = [0xABu8; 32];
513        let hashes = vec![0xCDu8; 64]; // 2 hashes
514        let data = make_v2_torrent_bytes(
515            "test",
516            16384,
517            &[("a.txt", 100, None), ("big.dat", 32768, Some(root))],
518            &[(root, hashes)],
519        );
520
521        let torrent = torrent_v2_from_bytes(&data).unwrap();
522
523        // File 0 (small, no piece layer)
524        assert!(torrent.piece_layer_for_file(0).is_none());
525        // File 1 (has piece layer)
526        let pieces = torrent.piece_layer_for_file(1).unwrap();
527        assert_eq!(pieces.len(), 2);
528    }
529
530    // === InfoDictV2 validation tests ===
531
532    #[test]
533    fn valid_info_dict_v2() {
534        let data = make_v2_torrent_bytes("test", 16384, &[("f.txt", 1000, None)], &[]);
535        let torrent = torrent_v2_from_bytes(&data).unwrap();
536        assert_eq!(torrent.info.meta_version, 2);
537    }
538
539    #[test]
540    fn reject_piece_length_not_power_of_two() {
541        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
542        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
543        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
544        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(100));
545        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
546        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
547        ft_map.insert(b"f.txt".to_vec(), BencodeValue::Dict(file_node));
548
549        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
550        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
551        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
552        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(30000));
553
554        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
555        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
556
557        let data = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
558        assert!(torrent_v2_from_bytes(&data).is_err());
559    }
560
561    #[test]
562    fn reject_piece_length_too_small() {
563        let mut info_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
564        let mut ft_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
565        let mut attr_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
566        attr_map.insert(b"length".to_vec(), BencodeValue::Integer(100));
567        let mut file_node: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
568        file_node.insert(b"".to_vec(), BencodeValue::Dict(attr_map));
569        ft_map.insert(b"f.txt".to_vec(), BencodeValue::Dict(file_node));
570
571        info_map.insert(b"file tree".to_vec(), BencodeValue::Dict(ft_map));
572        info_map.insert(b"meta version".to_vec(), BencodeValue::Integer(2));
573        info_map.insert(b"name".to_vec(), BencodeValue::Bytes(b"test".to_vec()));
574        info_map.insert(b"piece length".to_vec(), BencodeValue::Integer(8192));
575
576        let mut root_map: BTreeMap<Vec<u8>, BencodeValue> = BTreeMap::new();
577        root_map.insert(b"info".to_vec(), BencodeValue::Dict(info_map));
578
579        let data = irontide_bencode::to_bytes(&BencodeValue::Dict(root_map)).unwrap();
580        assert!(torrent_v2_from_bytes(&data).is_err());
581    }
582
583    #[test]
584    fn files_list_and_total_length() {
585        let data = make_v2_torrent_bytes(
586            "test",
587            16384,
588            &[("a.txt", 100, None), ("b.txt", 200, None)],
589            &[],
590        );
591        let torrent = torrent_v2_from_bytes(&data).unwrap();
592        let files = torrent.info.files();
593        assert_eq!(files.len(), 2);
594        assert_eq!(torrent.info.total_length(), 300);
595    }
596
597    #[test]
598    fn num_pieces_with_per_file_alignment() {
599        // Two files, each 16384 bytes = 1 piece each = 2 total pieces
600        let data = make_v2_torrent_bytes(
601            "test",
602            16384,
603            &[("a.dat", 16384, None), ("b.dat", 16384, None)],
604            &[],
605        );
606        let torrent = torrent_v2_from_bytes(&data).unwrap();
607        assert_eq!(torrent.info.num_pieces(), 2);
608
609        // Two files of 1 byte each = 1 piece each = 2 total
610        // (v2 aligns each file to piece boundary)
611        let data2 = make_v2_torrent_bytes(
612            "test",
613            16384,
614            &[("a.dat", 1, None), ("b.dat", 1, None)],
615            &[],
616        );
617        let torrent2 = torrent_v2_from_bytes(&data2).unwrap();
618        assert_eq!(torrent2.info.num_pieces(), 2);
619    }
620
621    // === Piece layer validation tests ===
622
623    #[test]
624    fn correct_layers_pass_validation() {
625        let root = [0xABu8; 32];
626        // File is 32768 bytes with piece_length 16384 = 2 pieces
627        let hashes = vec![0xCDu8; 64]; // 2 * 32 bytes
628        let data = make_v2_torrent_bytes(
629            "test",
630            16384,
631            &[("big.dat", 32768, Some(root))],
632            &[(root, hashes)],
633        );
634        let torrent = torrent_v2_from_bytes(&data).unwrap();
635        assert!(torrent.validate_piece_layers().is_ok());
636    }
637
638    #[test]
639    fn missing_layer_fails_validation() {
640        let root = [0xABu8; 32];
641        // File needs piece layer but none provided
642        let data = make_v2_torrent_bytes(
643            "test",
644            16384,
645            &[("big.dat", 32768, Some(root))],
646            &[], // no layers!
647        );
648        let torrent = torrent_v2_from_bytes(&data).unwrap();
649        assert!(torrent.validate_piece_layers().is_err());
650    }
651
652    #[test]
653    fn small_file_no_layer_needed() {
654        // File smaller than piece_length doesn't need a layer
655        let data = make_v2_torrent_bytes("test", 16384, &[("small.txt", 100, None)], &[]);
656        let torrent = torrent_v2_from_bytes(&data).unwrap();
657        assert!(torrent.validate_piece_layers().is_ok());
658    }
659
660    #[test]
661    fn wrong_hash_count_fails_validation() {
662        let root = [0xABu8; 32];
663        // File is 32768 bytes = 2 pieces, but we provide 3 hashes
664        let hashes = vec![0xCDu8; 96]; // 3 * 32 bytes (wrong!)
665        let data = make_v2_torrent_bytes(
666            "test",
667            16384,
668            &[("big.dat", 32768, Some(root))],
669            &[(root, hashes)],
670        );
671        let torrent = torrent_v2_from_bytes(&data).unwrap();
672        assert!(torrent.validate_piece_layers().is_err());
673    }
674}