btpc_core/metainfo/
mod.rs

1//! Borrowed top-level metainfo views.
2
3mod path_types;
4
5pub use path_types::{TorrentBytes, TorrentPath};
6
7use sha1::{Digest as _, Sha1};
8use sha2::Sha256;
9
10use crate::bencode::{ByteString, Span, Value, ValueKind, parse_with_budget};
11use crate::limits::AllocationBudget;
12use crate::metadata::{
13    DhtNode, OptionalMetadata, validate_creation_date, validate_nodes, validate_tracker_tiers,
14    validate_web_seeds,
15};
16use crate::{Error, ErrorCategory, ParseOptions, Result};
17
18const INFO: &[u8] = b"info";
19const KNOWN_TOP_LEVEL_FIELDS: &[&[u8]] = &[
20    b"announce",
21    b"announce-list",
22    b"url-list",
23    b"nodes",
24    b"comment",
25    b"created by",
26    b"creation date",
27    INFO,
28];
29
30/// Borrowed, syntax-level view of a `BitTorrent` metainfo dictionary.
31#[derive(Debug)]
32pub struct RawMetainfo<'a> {
33    original: &'a [u8],
34    root: Value<'a>,
35    info_index: usize,
36}
37
38impl<'a> RawMetainfo<'a> {
39    /// Parses a top-level metainfo dictionary while preserving original bytes.
40    ///
41    /// # Errors
42    ///
43    /// Returns a syntax/resource error from bencode parsing, or a metainfo error
44    /// when the root is not a dictionary or `info` is missing, duplicated, or not
45    /// a dictionary.
46    pub fn from_bytes(original: &'a [u8]) -> Result<Self> {
47        Self::from_bytes_with_options(original, ParseOptions::default())
48    }
49
50    /// Parses a top-level metainfo dictionary using caller-provided limits.
51    ///
52    /// # Errors
53    ///
54    /// Returns a syntax/resource error from bencode parsing, or a metainfo error
55    /// when the root is not a dictionary or `info` is invalid.
56    pub fn from_bytes_with_options(original: &'a [u8], options: ParseOptions) -> Result<Self> {
57        let mut budget = AllocationBudget::new(options.limits());
58        let root = parse_with_budget(original, options.limits(), &mut budget)?;
59        Self::from_parsed(original, root)
60    }
61
62    fn from_parsed(original: &'a [u8], root: Value<'a>) -> Result<Self> {
63        let ValueKind::Dictionary(entries) = root.kind() else {
64            return Err(Error::metainfo_field(
65                "<root>",
66                "top-level metainfo must be a dictionary",
67            ));
68        };
69
70        let mut info_indices = entries
71            .iter()
72            .enumerate()
73            .filter_map(|(index, (key, _))| (key.bytes() == INFO).then_some(index));
74        let Some(info_index) = info_indices.next() else {
75            return Err(Error::metainfo_field("info", "missing required dictionary"));
76        };
77        if info_indices.next().is_some() {
78            return Err(Error::metainfo_field("info", "duplicate top-level key"));
79        }
80        if !matches!(entries[info_index].1.kind(), ValueKind::Dictionary(_)) {
81            return Err(Error::metainfo_field("info", "value must be a dictionary"));
82        }
83
84        Ok(Self {
85            original,
86            root,
87            info_index,
88        })
89    }
90
91    /// Reports whether the original source uses canonical bencode.
92    #[must_use]
93    pub fn canonicality(&self) -> Canonicality {
94        canonicality(self.original, &self.root)
95    }
96
97    /// Returns the complete original metainfo bytes.
98    #[must_use]
99    pub const fn original_bytes(&self) -> &'a [u8] {
100        self.original
101    }
102
103    /// Returns the exact original encoded `info` bytes.
104    #[must_use]
105    pub fn info_bytes(&self) -> &'a [u8] {
106        let span = self.info_span();
107        &self.original[span.range()]
108    }
109
110    /// Returns the exact source span of the encoded `info` dictionary.
111    #[must_use]
112    pub fn info_span(&self) -> Span {
113        self.info_value().span()
114    }
115
116    /// Returns the parsed `info` dictionary value.
117    #[must_use]
118    pub fn info_value(&self) -> &Value<'a> {
119        &self.entries()[self.info_index].1
120    }
121
122    pub(crate) const fn root(&self) -> &Value<'a> {
123        &self.root
124    }
125
126    /// Returns the first `announce` field, when present.
127    #[must_use]
128    pub fn announce(&self) -> Option<&Value<'a>> {
129        self.field(b"announce")
130    }
131
132    /// Returns the first `announce-list` field, when present.
133    #[must_use]
134    pub fn announce_list(&self) -> Option<&Value<'a>> {
135        self.field(b"announce-list")
136    }
137
138    /// Returns the first `url-list` field, when present.
139    #[must_use]
140    pub fn url_list(&self) -> Option<&Value<'a>> {
141        self.field(b"url-list")
142    }
143
144    /// Returns the first `nodes` field, when present.
145    #[must_use]
146    pub fn nodes(&self) -> Option<&Value<'a>> {
147        self.field(b"nodes")
148    }
149
150    /// Returns the first `comment` field, when present.
151    #[must_use]
152    pub fn comment(&self) -> Option<&Value<'a>> {
153        self.field(b"comment")
154    }
155
156    /// Returns the first `created by` field, when present.
157    #[must_use]
158    pub fn created_by(&self) -> Option<&Value<'a>> {
159        self.field(b"created by")
160    }
161
162    /// Returns the first `creation date` field, when present.
163    #[must_use]
164    pub fn creation_date(&self) -> Option<&Value<'a>> {
165        self.field(b"creation date")
166    }
167
168    /// Returns top-level entries whose keys are not recognized by this view.
169    #[must_use]
170    pub fn unknown_fields(&self) -> Vec<(ByteString<'a>, &Value<'a>)> {
171        self.entries()
172            .iter()
173            .filter(|(key, _)| !KNOWN_TOP_LEVEL_FIELDS.contains(&key.bytes()))
174            .map(|(key, value)| (*key, value))
175            .collect()
176    }
177
178    /// Computes SHA-1 over the exact original `info` bytes.
179    #[must_use]
180    pub fn info_hash_sha1(&self) -> [u8; 20] {
181        Sha1::digest(self.info_bytes()).into()
182    }
183
184    /// Computes SHA-256 over the exact original `info` bytes.
185    #[must_use]
186    pub fn info_hash_sha256(&self) -> [u8; 32] {
187        Sha256::digest(self.info_bytes()).into()
188    }
189
190    pub(crate) fn field(&self, name: &[u8]) -> Option<&Value<'a>> {
191        self.entries()
192            .iter()
193            .find_map(|(key, value)| (key.bytes() == name).then_some(value))
194    }
195
196    pub(crate) fn entries(&self) -> &[(ByteString<'a>, Value<'a>)] {
197        let ValueKind::Dictionary(entries) = self.root.kind() else {
198            unreachable!("constructor requires a top-level dictionary")
199        };
200        entries
201    }
202}
203
204/// Protocol representation identified for validated metainfo.
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
206#[non_exhaustive]
207pub enum TorrentMode {
208    /// BEP 3 / v1 metainfo only.
209    V1,
210    /// BEP 52 / v2 metainfo only.
211    V2,
212    /// Both compatible v1 and v2 representations.
213    Hybrid,
214}
215
216/// A validated v1 payload file entry.
217#[derive(Debug)]
218pub struct V1File<'a> {
219    length: u64,
220    path: Vec<&'a [u8]>,
221    attributes: &'a [u8],
222}
223
224impl<'a> V1File<'a> {
225    /// Returns the file length in bytes.
226    #[must_use]
227    pub const fn length(&self) -> u64 {
228        self.length
229    }
230
231    /// Returns raw torrent path components.
232    #[must_use]
233    pub fn path_components(&self) -> &[&'a [u8]] {
234        &self.path
235    }
236
237    /// Returns UTF-8 path components when all raw components are valid UTF-8.
238    #[must_use]
239    pub fn path_utf8(&self) -> Option<Vec<&'a str>> {
240        self.path
241            .iter()
242            .map(|component| std::str::from_utf8(component).ok())
243            .collect()
244    }
245
246    /// Returns BEP 47 file attribute bytes.
247    #[must_use]
248    pub const fn attributes(&self) -> &'a [u8] {
249        self.attributes
250    }
251
252    /// Returns whether this entry is a validated hybrid alignment padding file.
253    #[must_use]
254    pub fn is_padding(&self) -> bool {
255        self.attributes.contains(&b'p') && is_padding_path(&self.path, self.length)
256    }
257}
258
259/// Validated typed view of v1 metainfo.
260#[derive(Debug)]
261pub struct V1Metainfo<'a> {
262    name: &'a [u8],
263    piece_length: u64,
264    pieces: &'a [u8],
265    files: Vec<V1File<'a>>,
266    total_length: u64,
267    single_file: bool,
268    warnings: Vec<String>,
269}
270
271impl<'a> V1Metainfo<'a> {
272    /// Validates and constructs a typed v1 view from raw metainfo.
273    ///
274    /// # Errors
275    ///
276    /// Returns a metainfo error for missing or incorrectly typed fields, invalid
277    /// paths or lengths, arithmetic overflow, or an inconsistent piece count.
278    pub fn from_raw(raw: &'a RawMetainfo<'a>) -> Result<Self> {
279        let info = dictionary_entries(raw.info_value(), "info")?;
280        let name = required_bytes(info, b"name", "info.name")?;
281        validate_path_component(name, "info.name")?;
282        let piece_length = positive_integer(info, b"piece length", "info.piece length")?;
283        let pieces = required_bytes(info, b"pieces", "info.pieces")?;
284        if pieces.len() % 20 != 0 {
285            return Err(Error::metainfo_field(
286                "info.pieces",
287                "length must be divisible by 20",
288            ));
289        }
290
291        let (files, total_length, single_file) = parse_v1_files(info, name)?;
292
293        let expected_piece_count = if total_length == 0 {
294            0
295        } else {
296            1 + (total_length - 1) / piece_length
297        };
298        let actual_piece_count = u64::try_from(pieces.len() / 20).map_err(|_| {
299            Error::metainfo_field("info.pieces", "piece count cannot be represented")
300        })?;
301        if actual_piece_count != expected_piece_count {
302            return Err(Error::metainfo_field(
303                "info.pieces",
304                format!(
305                    "piece count {actual_piece_count} does not match expected {expected_piece_count}"
306                ),
307            ));
308        }
309
310        Ok(Self {
311            name,
312            piece_length,
313            pieces,
314            files,
315            total_length,
316            single_file,
317            warnings: Vec::new(),
318        })
319    }
320
321    /// Returns the validated torrent mode.
322    #[must_use]
323    pub const fn mode(&self) -> TorrentMode {
324        TorrentMode::V1
325    }
326
327    /// Returns the raw v1 name bytes.
328    #[must_use]
329    pub const fn name(&self) -> &'a [u8] {
330        self.name
331    }
332
333    /// Returns the name as UTF-8 when valid.
334    #[must_use]
335    pub fn name_utf8(&self) -> Option<&'a str> {
336        std::str::from_utf8(self.name).ok()
337    }
338
339    /// Returns the piece length in bytes.
340    #[must_use]
341    pub const fn piece_length(&self) -> u64 {
342        self.piece_length
343    }
344
345    /// Returns the concatenated 20-byte SHA-1 piece hashes.
346    #[must_use]
347    pub const fn pieces(&self) -> &'a [u8] {
348        self.pieces
349    }
350
351    /// Returns validated payload files in torrent order.
352    #[must_use]
353    pub fn files(&self) -> &[V1File<'a>] {
354        &self.files
355    }
356
357    /// Returns total payload bytes.
358    #[must_use]
359    pub const fn total_length(&self) -> u64 {
360        self.total_length
361    }
362
363    /// Returns whether this uses the v1 single-file representation.
364    #[must_use]
365    pub const fn is_single_file(&self) -> bool {
366        self.single_file
367    }
368
369    /// Returns non-fatal compatibility warnings.
370    #[must_use]
371    pub fn warnings(&self) -> &[String] {
372        &self.warnings
373    }
374}
375
376fn parse_v1_files<'a>(
377    info: &'a [(ByteString<'a>, Value<'a>)],
378    name: &'a [u8],
379) -> Result<(Vec<V1File<'a>>, u64, bool)> {
380    let length_value = unique_field(info, b"length", "info.length")?;
381    let files_value = unique_field(info, b"files", "info.files")?;
382    match (length_value, files_value) {
383        (Some(length), None) => {
384            let length = non_negative_value(length, "info.length")?;
385            Ok((
386                vec![V1File {
387                    length,
388                    path: vec![name],
389                    attributes: unique_field(info, b"attr", "info.attr")?
390                        .map_or(Ok(&[][..]), |value| value_bytes(value, "info.attr"))?,
391                }],
392                length,
393                true,
394            ))
395        }
396        (None, Some(files)) => parse_v1_file_list(files),
397        (Some(_), Some(_)) | (None, None) => Err(Error::metainfo_field(
398            "info",
399            "exactly one of length or files is required",
400        )),
401    }
402}
403
404fn parse_v1_file_list<'a>(files: &'a Value<'a>) -> Result<(Vec<V1File<'a>>, u64, bool)> {
405    let values = list_values(files, "info.files")?;
406    if values.is_empty() {
407        return Err(Error::metainfo_field(
408            "info.files",
409            "multi-file torrents require at least one file",
410        ));
411    }
412    let mut parsed = Vec::with_capacity(values.len());
413    let mut total = 0_u64;
414    for value in values {
415        let entry = dictionary_entries(value, "info.files")?;
416        let length = non_negative_value(
417            required_field(entry, b"length", "info.files.length")?,
418            "info.files.length",
419        )?;
420        total = total.checked_add(length).ok_or_else(|| {
421            Error::metainfo_field("info.files.length", "total payload length overflowed")
422        })?;
423        let components = list_values(
424            required_field(entry, b"path", "info.files.path")?,
425            "info.files.path",
426        )?;
427        if components.is_empty() {
428            return Err(Error::metainfo_field(
429                "info.files.path",
430                "path must contain at least one component",
431            ));
432        }
433        let mut path = Vec::with_capacity(components.len());
434        for component in components {
435            let component = value_bytes(component, "info.files.path")?;
436            validate_path_component(component, "info.files.path")?;
437            path.push(component);
438        }
439        let attributes = unique_field(entry, b"attr", "info.files.attr")?
440            .map_or(Ok(&[][..]), |value| value_bytes(value, "info.files.attr"))?;
441        parsed.push(V1File {
442            length,
443            path,
444            attributes,
445        });
446    }
447    validate_v1_path_graph(&parsed)?;
448    Ok((parsed, total, false))
449}
450
451fn validate_v1_path_graph(files: &[V1File<'_>]) -> Result<()> {
452    validate_torrent_path_graph(
453        files
454            .iter()
455            .map(|file| file.path_components().to_vec())
456            .collect(),
457        "info.files.path",
458    )
459}
460
461pub(crate) fn validate_torrent_path_graph(
462    mut paths: Vec<Vec<&[u8]>>,
463    field: &'static str,
464) -> Result<()> {
465    paths.sort_unstable();
466    for pair in paths.windows(2) {
467        if pair[0] == pair[1] || pair[1].starts_with(&pair[0]) {
468            return Err(Error::metainfo_field(
469                field,
470                "duplicate or prefix-colliding file path",
471            ));
472        }
473    }
474    #[cfg(windows)]
475    {
476        let mut mapped = paths
477            .iter()
478            .map(|path| windows_path_key(path))
479            .collect::<Vec<_>>();
480        mapped.sort_unstable();
481        if mapped
482            .windows(2)
483            .any(|pair| pair[0] == pair[1] || pair[1].starts_with(&pair[0]))
484        {
485            return Err(Error::metainfo_field(
486                field,
487                "paths collide after Windows filesystem mapping",
488            ));
489        }
490    }
491    Ok(())
492}
493
494#[cfg(windows)]
495fn windows_path_key(path: &[&[u8]]) -> Vec<Vec<u8>> {
496    path.iter()
497        .map(|component| component.iter().map(u8::to_ascii_lowercase).collect())
498        .collect()
499}
500
501fn is_padding_path(path: &[&[u8]], length: u64) -> bool {
502    let [directory, name] = path else {
503        return false;
504    };
505    if *directory != b".pad" {
506        return false;
507    }
508    let Some((_, encoded_length)) = parse_padding_name(name) else {
509        return false;
510    };
511    encoded_length == length
512}
513
514fn parse_padding_name(value: &[u8]) -> Option<(Option<u64>, u64)> {
515    if let Some(separator) = value.iter().position(|byte| *byte == b'-') {
516        let (left, right_with_separator) = value.split_at(separator);
517        let right = right_with_separator.get(1..)?;
518        if left.is_empty() || right.is_empty() {
519            return None;
520        }
521        let offset = std::str::from_utf8(left).ok()?.parse().ok()?;
522        let length = std::str::from_utf8(right).ok()?.parse().ok()?;
523        return Some((Some(offset), length));
524    }
525    let length = std::str::from_utf8(value).ok()?.parse().ok()?;
526    Some((None, length))
527}
528
529fn dictionary_entries<'a>(
530    value: &'a Value<'a>,
531    field: &'static str,
532) -> Result<&'a [(ByteString<'a>, Value<'a>)]> {
533    match value.kind() {
534        ValueKind::Dictionary(entries) => Ok(entries),
535        _ => Err(Error::metainfo_field(field, "must be a dictionary")),
536    }
537}
538
539fn list_values<'a>(value: &'a Value<'a>, field: &'static str) -> Result<&'a [Value<'a>]> {
540    match value.kind() {
541        ValueKind::List(values) => Ok(values),
542        _ => Err(Error::metainfo_field(field, "must be a list")),
543    }
544}
545
546fn value_bytes<'a>(value: &'a Value<'a>, field: &'static str) -> Result<&'a [u8]> {
547    value
548        .as_bytes()
549        .ok_or_else(|| Error::metainfo_field(field, "must be a byte string"))
550}
551
552fn unique_field<'a>(
553    entries: &'a [(ByteString<'a>, Value<'a>)],
554    key: &[u8],
555    field: &'static str,
556) -> Result<Option<&'a Value<'a>>> {
557    let mut matching = entries
558        .iter()
559        .filter_map(|(candidate, value)| (candidate.bytes() == key).then_some(value));
560    let value = matching.next();
561    if matching.next().is_some() {
562        return Err(Error::metainfo_field(field, "duplicate field"));
563    }
564    Ok(value)
565}
566
567fn required_field<'a>(
568    entries: &'a [(ByteString<'a>, Value<'a>)],
569    key: &[u8],
570    field: &'static str,
571) -> Result<&'a Value<'a>> {
572    unique_field(entries, key, field)?
573        .ok_or_else(|| Error::metainfo_field(field, "missing required field"))
574}
575
576fn required_bytes<'a>(
577    entries: &'a [(ByteString<'a>, Value<'a>)],
578    key: &[u8],
579    field: &'static str,
580) -> Result<&'a [u8]> {
581    value_bytes(required_field(entries, key, field)?, field)
582}
583
584fn positive_integer(
585    entries: &[(ByteString<'_>, Value<'_>)],
586    key: &[u8],
587    field: &'static str,
588) -> Result<u64> {
589    let value = required_field(entries, key, field)?;
590    let integer = value
591        .integer()
592        .ok_or_else(|| Error::metainfo_field(field, "must be an integer"))?;
593    let Some(integer) = integer.to_u64() else {
594        return Err(Error::metainfo_field(field, "is out of range"));
595    };
596    if integer == 0 {
597        return Err(Error::metainfo_field(field, "must be positive"));
598    }
599    Ok(integer)
600}
601
602fn non_negative_value(value: &Value<'_>, field: &'static str) -> Result<u64> {
603    let integer = value
604        .integer()
605        .ok_or_else(|| Error::metainfo_field(field, "must be an integer"))?;
606    integer
607        .to_u64()
608        .ok_or_else(|| Error::metainfo_field(field, "must be non-negative and fit in u64"))
609}
610
611fn validate_path_component(component: &[u8], field: &'static str) -> Result<()> {
612    if component.is_empty()
613        || component == b"."
614        || component == b".."
615        || component.contains(&b'/')
616        || component.contains(&b'\\')
617        || component.contains(&0)
618    {
619        return Err(Error::metainfo_field(
620            field,
621            "contains an unsafe path component",
622        ));
623    }
624    Ok(())
625}
626
627/// A validated v2 payload file entry.
628#[derive(Debug)]
629pub struct V2File<'a> {
630    length: u64,
631    path: Vec<&'a [u8]>,
632    pieces_root: Option<&'a [u8; 32]>,
633    attributes: &'a [u8],
634    piece_layer: Option<&'a [u8]>,
635    properties: &'a Value<'a>,
636}
637
638impl<'a> V2File<'a> {
639    /// Returns the file length in bytes.
640    #[must_use]
641    pub const fn length(&self) -> u64 {
642        self.length
643    }
644
645    /// Returns raw torrent path components.
646    #[must_use]
647    pub fn path_components(&self) -> &[&'a [u8]] {
648        &self.path
649    }
650
651    /// Returns UTF-8 path components when all components are valid UTF-8.
652    #[must_use]
653    pub fn path_utf8(&self) -> Option<Vec<&'a str>> {
654        self.path
655            .iter()
656            .map(|component| std::str::from_utf8(component).ok())
657            .collect()
658    }
659
660    /// Returns the 32-byte pieces root for a non-empty file.
661    #[must_use]
662    pub const fn pieces_root(&self) -> Option<&'a [u8; 32]> {
663        self.pieces_root
664    }
665
666    /// Returns file attribute bytes, including unknown attributes.
667    #[must_use]
668    pub const fn attributes(&self) -> &'a [u8] {
669        self.attributes
670    }
671
672    /// Returns the validated piece-layer bytes for files larger than one piece.
673    #[must_use]
674    pub const fn piece_layer(&self) -> Option<&'a [u8]> {
675        self.piece_layer
676    }
677
678    /// Returns the raw file properties dictionary for extension access.
679    #[must_use]
680    pub const fn properties(&self) -> &'a Value<'a> {
681        self.properties
682    }
683}
684
685/// Validated typed view of v2 or hybrid metainfo.
686#[derive(Debug)]
687pub struct V2Metainfo<'a> {
688    name: &'a [u8],
689    piece_length: u64,
690    files: Vec<V2File<'a>>,
691    total_length: u64,
692    piece_count: u64,
693    mode: TorrentMode,
694}
695
696impl<'a> V2Metainfo<'a> {
697    /// Validates and constructs a typed v2 or hybrid view.
698    ///
699    /// # Errors
700    ///
701    /// Returns a metainfo error for malformed trees, invalid roots or layers,
702    /// unsupported meta versions, or inconsistent hybrid representations.
703    pub fn from_raw(raw: &'a RawMetainfo<'a>) -> Result<Self> {
704        let info = dictionary_entries(raw.info_value(), "info")?;
705        let meta_version = required_field(info, b"meta version", "info.meta version")?
706            .integer()
707            .ok_or_else(|| Error::metainfo_field("info.meta version", "must be an integer"))?;
708        let meta_version = meta_version.to_i64().ok_or_else(|| {
709            Error::metainfo_field("info.meta version", "is outside the supported range")
710        })?;
711        if meta_version != 2 {
712            return Err(Error::metainfo_field(
713                "info.meta version",
714                "unsupported meta version",
715            ));
716        }
717        let name = required_bytes(info, b"name", "info.name")?;
718        validate_path_component(name, "info.name")?;
719        let piece_length = positive_integer(info, b"piece length", "info.piece length")?;
720        validate_v2_piece_length(piece_length)?;
721        let tree = required_field(info, b"file tree", "info.file tree")?;
722        let root_entries = dictionary_entries(tree, "info.file tree")?;
723        if root_entries.iter().any(|(key, _)| key.bytes().is_empty()) {
724            return Err(Error::metainfo_field(
725                "info.file tree",
726                "root must not be a file",
727            ));
728        }
729        let mut pending = Vec::new();
730        let mut path = Vec::new();
731        walk_file_tree(root_entries, &mut path, &mut pending)?;
732        validate_torrent_path_graph(
733            pending.iter().map(|file| file.path.clone()).collect(),
734            "info.file tree",
735        )?;
736        if pending.is_empty() {
737            return Err(Error::metainfo_field(
738                "info.file tree",
739                "must contain at least one file",
740            ));
741        }
742        let layer_entries = raw
743            .field(b"piece layers")
744            .map(|layers| dictionary_entries(layers, "piece layers"))
745            .transpose()?
746            .unwrap_or_default();
747        let files = validate_piece_layers(pending, layer_entries, piece_length)?;
748        let total_length =
749            checked_total_length(files.iter().map(V2File::length), "info.file tree.length")?;
750        let piece_count = checked_piece_count(
751            files.iter().map(V2File::length),
752            piece_length,
753            "info.file tree.piece count",
754        )?;
755        let has_v1 = unique_field(info, b"pieces", "info.pieces")?.is_some()
756            || unique_field(info, b"length", "info.length")?.is_some()
757            || unique_field(info, b"files", "info.files")?.is_some();
758        let mode = if has_v1 {
759            let v1 = V1Metainfo::from_raw(raw)?;
760            validate_hybrid(&v1, &files, piece_length)?;
761            TorrentMode::Hybrid
762        } else {
763            TorrentMode::V2
764        };
765        Ok(Self {
766            name,
767            piece_length,
768            files,
769            total_length,
770            piece_count,
771            mode,
772        })
773    }
774
775    /// Returns the validated torrent mode.
776    #[must_use]
777    pub const fn mode(&self) -> TorrentMode {
778        self.mode
779    }
780
781    /// Returns the raw display name.
782    #[must_use]
783    pub const fn name(&self) -> &'a [u8] {
784        self.name
785    }
786
787    /// Returns the piece length in bytes.
788    #[must_use]
789    pub const fn piece_length(&self) -> u64 {
790        self.piece_length
791    }
792
793    /// Returns v2 payload files in raw-byte file-tree order.
794    #[must_use]
795    pub fn files(&self) -> &[V2File<'a>] {
796        &self.files
797    }
798
799    /// Returns the checked aggregate payload length.
800    #[must_use]
801    pub const fn total_length(&self) -> u64 {
802        self.total_length
803    }
804
805    /// Returns the checked aggregate number of file pieces.
806    #[must_use]
807    pub const fn piece_count(&self) -> u64 {
808        self.piece_count
809    }
810}
811
812pub(crate) fn checked_total_length(
813    lengths: impl IntoIterator<Item = u64>,
814    field: &'static str,
815) -> Result<u64> {
816    lengths.into_iter().try_fold(0_u64, |total, length| {
817        total
818            .checked_add(length)
819            .ok_or_else(|| Error::metainfo_field(field, "total payload length overflowed"))
820    })
821}
822
823pub(crate) fn checked_piece_count(
824    lengths: impl IntoIterator<Item = u64>,
825    piece_length: u64,
826    field: &'static str,
827) -> Result<u64> {
828    lengths.into_iter().try_fold(0_u64, |total, length| {
829        total
830            .checked_add(length.div_ceil(piece_length))
831            .ok_or_else(|| Error::metainfo_field(field, "aggregate piece count overflowed"))
832    })
833}
834
835struct PendingV2File<'a> {
836    length: u64,
837    path: Vec<&'a [u8]>,
838    pieces_root: Option<&'a [u8; 32]>,
839    attributes: &'a [u8],
840    properties: &'a Value<'a>,
841}
842
843fn walk_file_tree<'a>(
844    entries: &'a [(ByteString<'a>, Value<'a>)],
845    path: &mut Vec<&'a [u8]>,
846    files: &mut Vec<PendingV2File<'a>>,
847) -> Result<()> {
848    for (key, value) in entries {
849        if key.bytes().is_empty() {
850            return Err(Error::metainfo_field(
851                "info.file tree",
852                "file marker cannot be a sibling of child entries",
853            ));
854        }
855        validate_path_component(key.bytes(), "info.file tree")?;
856        path.push(key.bytes());
857        let node = dictionary_entries(value, "info.file tree")?;
858        if let Some(properties) = unique_field(node, b"", "info.file tree")? {
859            if node.len() != 1 {
860                return Err(Error::metainfo_field(
861                    "info.file tree",
862                    "file properties cannot have sibling child entries",
863                ));
864            }
865            files.push(parse_v2_file(properties, path)?);
866        } else {
867            if node.is_empty() {
868                return Err(Error::metainfo_field(
869                    "info.file tree",
870                    "directory node must not be empty",
871                ));
872            }
873            walk_file_tree(node, path, files)?;
874        }
875        path.pop();
876    }
877    Ok(())
878}
879
880fn parse_v2_file<'a>(properties: &'a Value<'a>, path: &[&'a [u8]]) -> Result<PendingV2File<'a>> {
881    let entries = dictionary_entries(properties, "info.file tree")?;
882    let length = non_negative_value(
883        required_field(entries, b"length", "info.file tree.length")?,
884        "info.file tree.length",
885    )?;
886    let root = unique_field(entries, b"pieces root", "info.file tree.pieces root")?
887        .map(|value| value_bytes(value, "info.file tree.pieces root"))
888        .transpose()?;
889    let pieces_root = match (length, root) {
890        (0, None) => None,
891        (0, Some(_)) => {
892            return Err(Error::metainfo_field(
893                "info.file tree.pieces root",
894                "empty files must not have a pieces root",
895            ));
896        }
897        (_, None) => {
898            return Err(Error::metainfo_field(
899                "info.file tree.pieces root",
900                "non-empty files require a pieces root",
901            ));
902        }
903        (_, Some(root)) => Some(<&[u8; 32]>::try_from(root).map_err(|_| {
904            Error::metainfo_field("info.file tree.pieces root", "must be exactly 32 bytes")
905        })?),
906    };
907    let attributes = unique_field(entries, b"attr", "info.file tree.attr")?
908        .map_or(Ok(&[][..]), |value| {
909            value_bytes(value, "info.file tree.attr")
910        })?;
911    Ok(PendingV2File {
912        length,
913        path: path.to_vec(),
914        pieces_root,
915        attributes,
916        properties,
917    })
918}
919
920pub(crate) fn validate_v2_piece_length(piece_length: u64) -> Result<u64> {
921    if piece_length < 16_384 || !piece_length.is_power_of_two() {
922        return Err(Error::metainfo_field(
923            "info.piece length",
924            "must be a power of two and at least 16384",
925        ));
926    }
927    Ok(piece_length)
928}
929
930fn validate_piece_layers<'a>(
931    pending: Vec<PendingV2File<'a>>,
932    layer_entries: &'a [(ByteString<'a>, Value<'a>)],
933    piece_length: u64,
934) -> Result<Vec<V2File<'a>>> {
935    let mut layers = std::collections::BTreeMap::new();
936    for (key, value) in layer_entries {
937        if layers.insert(key.bytes(), (value, false)).is_some() {
938            return Err(Error::metainfo_field(
939                "piece layers",
940                "duplicate pieces root",
941            ));
942        }
943    }
944    let mut files = Vec::with_capacity(pending.len());
945    for file in pending {
946        let piece_count = file.length.div_ceil(piece_length);
947        let piece_layer = if piece_count > 1 {
948            let root = file.pieces_root.expect("non-empty files have roots");
949            let Some((value, validated)) = layers.get_mut(root.as_slice()) else {
950                return Err(Error::metainfo_field(
951                    "piece layers",
952                    "missing layer for a multi-piece file",
953                ));
954            };
955            let bytes = value_bytes(value, "piece layers")?;
956            let expected_len = usize::try_from(piece_count)
957                .ok()
958                .and_then(|count| count.checked_mul(32))
959                .ok_or_else(|| Error::metainfo_field("piece layers", "length overflowed"))?;
960            if bytes.len() != expected_len {
961                return Err(Error::metainfo_field(
962                    "piece layers",
963                    "layer length does not match file piece count",
964                ));
965            }
966            if !*validated {
967                if merkle_root_from_piece_layer(bytes, piece_length)? != *root {
968                    return Err(Error::metainfo_field(
969                        "piece layers",
970                        "layer hashes do not match the pieces root",
971                    ));
972                }
973                *validated = true;
974            }
975            Some(bytes)
976        } else {
977            None
978        };
979        files.push(V2File {
980            length: file.length,
981            path: file.path,
982            pieces_root: file.pieces_root,
983            attributes: file.attributes,
984            piece_layer,
985            properties: file.properties,
986        });
987    }
988    if layers.values().any(|(_, validated)| !validated) {
989        return Err(Error::metainfo_field(
990            "piece layers",
991            "contains a layer not required by the file tree",
992        ));
993    }
994    Ok(files)
995}
996
997fn merkle_root_from_piece_layer(bytes: &[u8], piece_length: u64) -> Result<[u8; 32]> {
998    #[cfg(test)]
999    PIECE_LAYER_VALIDATIONS.with(|count| count.set(count.get() + 1));
1000    let mut hashes = bytes
1001        .chunks_exact(32)
1002        .map(|chunk| <[u8; 32]>::try_from(chunk).expect("exact chunks"))
1003        .collect::<Vec<_>>();
1004    let target = hashes.len().next_power_of_two();
1005    hashes.resize(target, zero_hash_for_piece_length(piece_length));
1006    while hashes.len() > 1 {
1007        hashes = hashes
1008            .chunks_exact(2)
1009            .map(|pair| hash_pair(pair[0], pair[1]))
1010            .collect();
1011    }
1012    hashes
1013        .pop()
1014        .ok_or_else(|| Error::metainfo_field("piece layers", "layer must not be empty"))
1015}
1016
1017#[cfg(test)]
1018thread_local! {
1019    static PIECE_LAYER_VALIDATIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1020}
1021
1022#[cfg(test)]
1023mod piece_layer_work_tests {
1024    use super::{PIECE_LAYER_VALIDATIONS, RawMetainfo, V2Metainfo, hash_pair};
1025    use crate::bencode::OwnedValue;
1026
1027    // Spec: SEC-PARSE-001
1028    #[test]
1029    fn shared_piece_layer_is_hashed_once() {
1030        let hashes = [[1; 32], [2; 32]];
1031        let root = hash_pair(hashes[0], hashes[1]);
1032        let properties = OwnedValue::dictionary([
1033            (b"length".to_vec(), OwnedValue::integer(32_768)),
1034            (b"pieces root".to_vec(), OwnedValue::bytes(root.to_vec())),
1035        ])
1036        .unwrap();
1037        let leaf = OwnedValue::dictionary([(Vec::new(), properties)]).unwrap();
1038        let tree = OwnedValue::dictionary(
1039            (0..100).map(|index| (format!("file-{index:03}").into_bytes(), leaf.clone())),
1040        )
1041        .unwrap();
1042        let info = OwnedValue::dictionary([
1043            (b"file tree".to_vec(), tree),
1044            (b"meta version".to_vec(), OwnedValue::integer(2)),
1045            (b"name".to_vec(), OwnedValue::bytes(b"payload".to_vec())),
1046            (b"piece length".to_vec(), OwnedValue::integer(16_384)),
1047        ])
1048        .unwrap();
1049        let layers =
1050            OwnedValue::dictionary([(root.to_vec(), OwnedValue::bytes(hashes.concat()))]).unwrap();
1051        let bytes =
1052            OwnedValue::dictionary([(b"info".to_vec(), info), (b"piece layers".to_vec(), layers)])
1053                .unwrap()
1054                .to_vec()
1055                .unwrap();
1056        let raw = RawMetainfo::from_bytes(&bytes).unwrap();
1057        PIECE_LAYER_VALIDATIONS.with(|count| count.set(0));
1058        assert_eq!(V2Metainfo::from_raw(&raw).unwrap().files().len(), 100);
1059        assert_eq!(PIECE_LAYER_VALIDATIONS.with(std::cell::Cell::get), 1);
1060    }
1061}
1062
1063fn zero_hash_for_piece_length(piece_length: u64) -> [u8; 32] {
1064    let mut hash = [0; 32];
1065    let mut covered = 16_384;
1066    while covered < piece_length {
1067        hash = hash_pair(hash, hash);
1068        covered *= 2;
1069    }
1070    hash
1071}
1072
1073fn hash_pair(left: [u8; 32], right: [u8; 32]) -> [u8; 32] {
1074    use sha2::Digest as _;
1075    let mut hasher = Sha256::new();
1076    hasher.update(left);
1077    hasher.update(right);
1078    hasher.finalize().into()
1079}
1080
1081fn validate_hybrid(v1: &V1Metainfo<'_>, v2: &[V2File<'_>], piece_length: u64) -> Result<()> {
1082    if v1.piece_length() != piece_length {
1083        return Err(Error::metainfo_field(
1084            "info.piece length",
1085            "v1 and v2 piece lengths differ",
1086        ));
1087    }
1088    let mut real_files = Vec::new();
1089    let mut offset = 0_u64;
1090    let mut previous_was_padding = false;
1091    for (index, file) in v1.files().iter().enumerate() {
1092        if file.attributes().contains(&b'p') {
1093            if !file.is_padding() {
1094                return Err(Error::metainfo_field(
1095                    "info.files.path",
1096                    "padding file must use .pad/<length> or .pad/<offset>-<length>",
1097                ));
1098            }
1099            let required = (piece_length - (offset % piece_length)) % piece_length;
1100            let [_, name] = file.path_components() else {
1101                unreachable!("validated padding paths have two components");
1102            };
1103            let (encoded_offset, encoded_length) =
1104                parse_padding_name(name).expect("validated padding path has decimal length");
1105            if index == 0
1106                || index + 1 == v1.files().len()
1107                || previous_was_padding
1108                || required == 0
1109                || file.length() != required
1110                || encoded_offset.is_some_and(|encoded_offset| encoded_offset != offset)
1111                || encoded_length != file.length()
1112            {
1113                return Err(Error::metainfo_field(
1114                    "info.files.attr",
1115                    "padding file has invalid placement, path, or alignment length",
1116                ));
1117            }
1118            offset = offset.checked_add(file.length()).ok_or_else(|| {
1119                Error::metainfo_field("info.files.length", "hybrid offset overflowed")
1120            })?;
1121            previous_was_padding = true;
1122            continue;
1123        }
1124        if offset % piece_length != 0 {
1125            return Err(Error::metainfo_field(
1126                "info.files",
1127                "hybrid real file is not piece-aligned",
1128            ));
1129        }
1130        if file.path_components().first() == Some(&b".pad".as_slice()) {
1131            return Err(Error::metainfo_field(
1132                "info.files.path",
1133                "hybrid real files must not use the reserved .pad directory",
1134            ));
1135        }
1136        real_files.push(file);
1137        offset = offset.checked_add(file.length()).ok_or_else(|| {
1138            Error::metainfo_field("info.files.length", "hybrid offset overflowed")
1139        })?;
1140        previous_was_padding = false;
1141    }
1142    if real_files.len() != v2.len() {
1143        return Err(Error::metainfo_field(
1144            "info.files",
1145            "v1 and v2 file counts differ",
1146        ));
1147    }
1148    for (v1_file, v2_file) in real_files.into_iter().zip(v2) {
1149        if v1_file.length() != v2_file.length()
1150            || v1_file.path_components() != v2_file.path_components()
1151        {
1152            return Err(Error::metainfo_field(
1153                "info.files",
1154                "v1 and v2 paths or lengths differ",
1155            ));
1156        }
1157    }
1158    Ok(())
1159}
1160
1161/// Owned hash value for a v1 SHA-1 info hash.
1162#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1163pub struct InfoHashV1([u8; 20]);
1164
1165impl InfoHashV1 {
1166    pub(crate) const fn new(bytes: [u8; 20]) -> Self {
1167        Self(bytes)
1168    }
1169
1170    /// Returns the raw 20-byte digest.
1171    #[must_use]
1172    pub const fn as_bytes(&self) -> &[u8; 20] {
1173        &self.0
1174    }
1175
1176    /// Returns lowercase hexadecimal text.
1177    #[must_use]
1178    pub fn hex(&self) -> String {
1179        encode_hex(&self.0)
1180    }
1181}
1182
1183impl std::fmt::Display for InfoHashV1 {
1184    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185        formatter.write_str(&self.hex())
1186    }
1187}
1188
1189/// Owned hash value for a v2 SHA-256 info hash.
1190#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
1191pub struct InfoHashV2([u8; 32]);
1192
1193impl InfoHashV2 {
1194    pub(crate) const fn new(bytes: [u8; 32]) -> Self {
1195        Self(bytes)
1196    }
1197
1198    /// Returns the raw 32-byte digest.
1199    #[must_use]
1200    pub const fn as_bytes(&self) -> &[u8; 32] {
1201        &self.0
1202    }
1203
1204    /// Returns lowercase hexadecimal text.
1205    #[must_use]
1206    pub fn hex(&self) -> String {
1207        encode_hex(&self.0)
1208    }
1209}
1210
1211impl std::fmt::Display for InfoHashV2 {
1212    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1213        formatter.write_str(&self.hex())
1214    }
1215}
1216
1217/// Owned payload file entry exposed by the inspection API.
1218#[derive(Clone, Debug, Eq, PartialEq)]
1219pub struct TorrentFile {
1220    length: u64,
1221    path: Vec<Vec<u8>>,
1222    attributes: Vec<u8>,
1223    pieces_root: Option<[u8; 32]>,
1224}
1225
1226impl TorrentFile {
1227    /// Returns the payload file length.
1228    #[must_use]
1229    pub const fn length(&self) -> u64 {
1230        self.length
1231    }
1232
1233    /// Returns raw torrent path components.
1234    #[must_use]
1235    pub fn path_components(&self) -> &[Vec<u8>] {
1236        &self.path
1237    }
1238
1239    /// Returns an owned raw-identity torrent path value.
1240    #[must_use]
1241    pub fn torrent_path(&self) -> TorrentPath {
1242        TorrentPath::from_raw(&self.path)
1243    }
1244
1245    /// Returns UTF-8 path components when every component is valid UTF-8.
1246    #[must_use]
1247    pub fn path_utf8(&self) -> Option<Vec<&str>> {
1248        self.path
1249            .iter()
1250            .map(|component| std::str::from_utf8(component).ok())
1251            .collect()
1252    }
1253
1254    /// Returns file attribute bytes.
1255    #[must_use]
1256    pub fn attributes(&self) -> &[u8] {
1257        &self.attributes
1258    }
1259
1260    /// Returns whether this owned entry represents validated hybrid padding.
1261    #[must_use]
1262    pub fn is_padding(&self) -> bool {
1263        let path = self.path.iter().map(Vec::as_slice).collect::<Vec<_>>();
1264        self.attributes.contains(&b'p') && is_padding_path(&path, self.length)
1265    }
1266
1267    /// Returns the v2 pieces root when applicable.
1268    #[must_use]
1269    pub const fn pieces_root(&self) -> Option<&[u8; 32]> {
1270        self.pieces_root.as_ref()
1271    }
1272}
1273
1274/// An owned unknown top-level metainfo field.
1275#[derive(Clone, Debug, Eq, PartialEq)]
1276pub struct UnknownField {
1277    key: Vec<u8>,
1278    value: crate::bencode::OwnedValue,
1279    span: Span,
1280}
1281
1282impl UnknownField {
1283    /// Returns the raw field key.
1284    #[must_use]
1285    pub fn key(&self) -> &[u8] {
1286        &self.key
1287    }
1288
1289    /// Returns the owned bencode value.
1290    #[must_use]
1291    pub const fn value(&self) -> &crate::bencode::OwnedValue {
1292        &self.value
1293    }
1294
1295    /// Returns the exact source span covering the encoded key and value.
1296    #[must_use]
1297    pub const fn span(&self) -> Span {
1298        self.span
1299    }
1300}
1301
1302/// Result of metainfo-only validation.
1303/// Canonical encoding status for parseable source bytes.
1304#[derive(Clone, Debug, Default, Eq, PartialEq)]
1305#[non_exhaustive]
1306pub enum Canonicality {
1307    /// The original bytes are canonical bencode.
1308    #[default]
1309    Canonical,
1310    /// The original bytes are parseable but not canonical.
1311    NonCanonical {
1312        /// Byte offset where canonical validation detected the issue.
1313        offset: usize,
1314        /// Human-readable canonicality explanation.
1315        message: String,
1316    },
1317}
1318
1319impl Canonicality {
1320    /// Returns true when the original source is canonically encoded.
1321    #[must_use]
1322    pub const fn is_canonical(&self) -> bool {
1323        matches!(self, Self::Canonical)
1324    }
1325
1326    /// Returns the canonicality issue offset, when non-canonical.
1327    #[must_use]
1328    pub const fn offset(&self) -> Option<usize> {
1329        match self {
1330            Self::Canonical => None,
1331            Self::NonCanonical { offset, .. } => Some(*offset),
1332        }
1333    }
1334
1335    /// Returns the canonicality explanation, when non-canonical.
1336    #[must_use]
1337    pub fn message(&self) -> Option<&str> {
1338        match self {
1339            Self::Canonical => None,
1340            Self::NonCanonical { message, .. } => Some(message),
1341        }
1342    }
1343}
1344
1345fn canonicality(bytes: &[u8], root: &Value<'_>) -> Canonicality {
1346    match crate::bencode::validate_parsed_canonical(bytes, root) {
1347        Ok(()) => Canonicality::Canonical,
1348        Err(error) if error.category() == ErrorCategory::BencodeCanonical => {
1349            let offset = error.offset().unwrap_or_default();
1350            let Error::BencodeCanonical { message, .. } = error else {
1351                unreachable!("canonical validation returns canonical errors");
1352            };
1353            Canonicality::NonCanonical { offset, message }
1354        }
1355        Err(_) => unreachable!("already parsed bytes remain syntactically valid"),
1356    }
1357}
1358
1359/// Structured non-fatal compatibility warning.
1360#[derive(Clone, Debug, Eq, PartialEq)]
1361#[non_exhaustive]
1362pub struct ValidationWarning {
1363    message: String,
1364    field: Option<String>,
1365    offset: Option<usize>,
1366}
1367
1368impl ValidationWarning {
1369    /// Returns the warning message.
1370    #[must_use]
1371    pub fn message(&self) -> &str {
1372        &self.message
1373    }
1374
1375    /// Returns the related field path, when applicable.
1376    #[must_use]
1377    pub fn field(&self) -> Option<&str> {
1378        self.field.as_deref()
1379    }
1380
1381    /// Returns the related source offset, when applicable.
1382    #[must_use]
1383    pub const fn offset(&self) -> Option<usize> {
1384        self.offset
1385    }
1386}
1387
1388#[derive(Clone, Debug, Default, Eq, PartialEq)]
1389pub struct ValidationReport {
1390    warnings: Vec<String>,
1391    warning_details: Vec<ValidationWarning>,
1392    canonicality: Canonicality,
1393}
1394
1395impl ValidationReport {
1396    /// Returns true when no structural error was found.
1397    #[must_use]
1398    pub const fn is_valid(&self) -> bool {
1399        true
1400    }
1401
1402    /// Returns non-fatal compatibility warnings.
1403    #[must_use]
1404    pub fn warnings(&self) -> &[String] {
1405        &self.warnings
1406    }
1407
1408    /// Returns structured non-fatal compatibility warnings.
1409    #[must_use]
1410    pub fn warning_details(&self) -> &[ValidationWarning] {
1411        &self.warning_details
1412    }
1413
1414    /// Returns canonicality separately from protocol validity.
1415    #[must_use]
1416    pub const fn canonicality(&self) -> &Canonicality {
1417        &self.canonicality
1418    }
1419}
1420
1421/// Owned, validated metainfo inspection object.
1422///
1423/// Input bytes are copied so this value does not borrow its source. The exact
1424/// original bytes remain available, while [`Metainfo::to_bytes`] and
1425/// [`Metainfo::write_canonical`] emit a canonical re-encoding.
1426///
1427/// # Examples
1428///
1429/// ```
1430/// use btpc_core::{Metainfo, TorrentMode};
1431///
1432/// let bytes = b"d4:infod6:lengthi0e4:name5:empty12:piece lengthi16e6:pieces0:ee";
1433/// let torrent = Metainfo::from_bytes(bytes)?;
1434/// assert_eq!(torrent.mode(), TorrentMode::V1);
1435/// assert_eq!(torrent.name_utf8(), Some("empty"));
1436/// assert_eq!(torrent.original_bytes(), bytes);
1437/// # Ok::<(), btpc_core::Error>(())
1438/// ```
1439#[derive(Clone, Debug)]
1440pub struct Metainfo {
1441    original: Vec<u8>,
1442    canonical_root: crate::bencode::OwnedValue,
1443    canonical: std::sync::OnceLock<Vec<u8>>,
1444    mode: TorrentMode,
1445    name: Vec<u8>,
1446    piece_length: u64,
1447    total_length: u64,
1448    piece_count: u64,
1449    files: Vec<TorrentFile>,
1450    optional_metadata: OptionalMetadata,
1451    private: Option<bool>,
1452    info_hash_v1: Option<InfoHashV1>,
1453    info_hash_v2: Option<InfoHashV2>,
1454    unknown_fields: Vec<UnknownField>,
1455    validation: ValidationReport,
1456    parse_options: ParseOptions,
1457}
1458
1459impl Metainfo {
1460    /// Generates a deterministic magnet URI.
1461    #[must_use]
1462    pub fn magnet(&self, options: &crate::magnet::MagnetOptions) -> String {
1463        crate::magnet::generate(self, options)
1464    }
1465
1466    /// Parses and validates metainfo from bytes, copying the input.
1467    ///
1468    /// # Errors
1469    ///
1470    /// Returns a bencode, resource-limit, or protocol validation error.
1471    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
1472        Self::from_bytes_with_options(bytes, ParseOptions::default())
1473    }
1474
1475    /// Parses and validates metainfo using caller-provided limits.
1476    ///
1477    /// # Errors
1478    ///
1479    /// Returns a bencode, resource-limit, or protocol validation error.
1480    pub fn from_bytes_with_options(bytes: &[u8], options: ParseOptions) -> Result<Self> {
1481        Self::from_vec_with_options(bytes.to_vec(), options)
1482    }
1483
1484    /// Parses and validates owned metainfo bytes without copying the input buffer.
1485    ///
1486    /// # Errors
1487    ///
1488    /// Returns a bencode, resource-limit, or protocol validation error.
1489    pub fn from_vec(bytes: Vec<u8>) -> Result<Self> {
1490        Self::from_vec_with_options(bytes, ParseOptions::default())
1491    }
1492
1493    /// Parses and validates owned metainfo bytes using caller-provided limits.
1494    ///
1495    /// The returned object retains the supplied buffer as its exact original
1496    /// representation, including the raw bencoded `info` slice used for hashes.
1497    ///
1498    /// # Errors
1499    ///
1500    /// Returns a bencode, resource-limit, or protocol validation error.
1501    pub fn from_vec_with_options(bytes: Vec<u8>, options: ParseOptions) -> Result<Self> {
1502        let limits = options.limits();
1503        limits.check_total_input(bytes.len())?;
1504        let mut budget = AllocationBudget::new(limits);
1505        let root = parse_with_budget(&bytes, limits, &mut budget)?;
1506        budget.charge(owned_load_cost(&root, bytes.len(), true)?)?;
1507        let raw = RawMetainfo::from_parsed(&bytes, root)?;
1508        let source_canonicality = raw.canonicality();
1509        let info_entries = dictionary_entries(raw.info_value(), "info")?;
1510        let mut snapshot = inspection_snapshot(&raw, info_entries)?;
1511        let canonical_root = value_to_owned(raw.root())?;
1512        let (optional_metadata, optional_warnings) = parse_optional_metadata(&raw, info_entries)?;
1513        snapshot.warnings.extend(optional_warnings);
1514        let private = unique_field(info_entries, b"private", "info.private")?
1515            .map(|value| {
1516                value
1517                    .integer()
1518                    .ok_or_else(|| Error::metainfo_field("info.private", "must be an integer"))
1519                    .and_then(|value| match value.to_i64() {
1520                        Some(0) => Ok(false),
1521                        Some(1) => Ok(true),
1522                        _ => Err(Error::metainfo_field("info.private", "must be 0 or 1")),
1523                    })
1524            })
1525            .transpose()?;
1526        let unknown_fields = raw
1527            .unknown_fields()
1528            .into_iter()
1529            .map(|(key, value)| {
1530                Ok(UnknownField {
1531                    key: key.bytes().to_vec(),
1532                    value: value_to_owned(value)?,
1533                    span: Span::new(key.span().start(), value.span().end()),
1534                })
1535            })
1536            .collect::<Result<Vec<_>>>()?;
1537        let info_hash_v1 = matches!(snapshot.mode, TorrentMode::V1 | TorrentMode::Hybrid)
1538            .then(|| InfoHashV1(raw.info_hash_sha1()));
1539        let info_hash_v2 = matches!(snapshot.mode, TorrentMode::V2 | TorrentMode::Hybrid)
1540            .then(|| InfoHashV2(raw.info_hash_sha256()));
1541        drop(raw);
1542        Ok(Self {
1543            original: bytes,
1544            canonical_root,
1545            canonical: std::sync::OnceLock::new(),
1546            mode: snapshot.mode,
1547            name: snapshot.name,
1548            piece_length: snapshot.piece_length,
1549            total_length: snapshot.total_length,
1550            piece_count: snapshot.piece_count,
1551            files: snapshot.files,
1552            optional_metadata,
1553            private,
1554            info_hash_v1,
1555            info_hash_v2,
1556            unknown_fields,
1557            validation: ValidationReport {
1558                warnings: snapshot
1559                    .warnings
1560                    .iter()
1561                    .map(|warning| warning.message.clone())
1562                    .collect(),
1563                warning_details: snapshot.warnings,
1564                canonicality: source_canonicality,
1565            },
1566            parse_options: options,
1567        })
1568    }
1569
1570    /// Reads, parses, and validates metainfo from a filesystem path.
1571    ///
1572    /// # Errors
1573    ///
1574    /// Returns a contextual I/O error or any error from [`Metainfo::from_bytes`].
1575    pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
1576        Self::from_path_with_options(path, ParseOptions::default())
1577    }
1578
1579    /// Reads, parses, and validates metainfo using caller-provided limits.
1580    ///
1581    /// Regular files are checked before allocation. The actual read is capped at
1582    /// one byte beyond the configured maximum so growth races and streams are
1583    /// still rejected without unbounded allocation.
1584    ///
1585    /// # Errors
1586    ///
1587    /// Returns a contextual I/O, resource-limit, bencode, or validation error.
1588    pub fn from_path_with_options(
1589        path: impl AsRef<std::path::Path>,
1590        options: ParseOptions,
1591    ) -> Result<Self> {
1592        use std::io::Read as _;
1593
1594        let path = path.as_ref();
1595        let limits = options.limits();
1596        let file = std::fs::File::open(path).map_err(|source| Error::io(path, source))?;
1597        let metadata = file.metadata().map_err(|source| Error::io(path, source))?;
1598        if metadata.is_file() {
1599            limits.check_total_input(usize::try_from(metadata.len()).unwrap_or(usize::MAX))?;
1600        }
1601        let maximum = limits.max_total_input();
1602        let initial = usize::try_from(metadata.len())
1603            .unwrap_or(maximum)
1604            .min(maximum);
1605        let mut bytes = Vec::with_capacity(initial);
1606        let read_limit = maximum.saturating_add(1);
1607        file.take(u64::try_from(read_limit).unwrap_or(u64::MAX))
1608            .read_to_end(&mut bytes)
1609            .map_err(|source| Error::io(path, source))?;
1610        limits.check_total_input(bytes.len())?;
1611        Self::from_vec_with_options(bytes, options)
1612    }
1613
1614    /// Returns the resource limits used to load this object.
1615    #[must_use]
1616    pub const fn parse_options(&self) -> ParseOptions {
1617        self.parse_options
1618    }
1619
1620    /// Returns the validated protocol mode.
1621    #[must_use]
1622    pub const fn mode(&self) -> TorrentMode {
1623        self.mode
1624    }
1625
1626    /// Returns the raw torrent name bytes.
1627    #[must_use]
1628    pub fn name(&self) -> &[u8] {
1629        &self.name
1630    }
1631
1632    /// Returns the torrent name as UTF-8 when valid.
1633    #[must_use]
1634    pub fn name_utf8(&self) -> Option<&str> {
1635        std::str::from_utf8(&self.name).ok()
1636    }
1637
1638    /// Returns payload files.
1639    #[must_use]
1640    pub fn files(&self) -> &[TorrentFile] {
1641        &self.files
1642    }
1643
1644    /// Returns the piece length in bytes.
1645    #[must_use]
1646    pub const fn piece_length(&self) -> u64 {
1647        self.piece_length
1648    }
1649
1650    /// Returns total payload bytes.
1651    #[must_use]
1652    pub const fn total_length(&self) -> u64 {
1653        self.total_length
1654    }
1655
1656    /// Returns the logical piece count for the active representation.
1657    #[must_use]
1658    pub const fn piece_count(&self) -> u64 {
1659        self.piece_count
1660    }
1661
1662    /// Returns tracker tiers as raw byte strings.
1663    #[must_use]
1664    pub fn trackers(&self) -> &[Vec<Vec<u8>>] {
1665        self.optional_metadata.trackers()
1666    }
1667
1668    /// Returns web seed URLs as raw byte strings.
1669    #[must_use]
1670    pub fn web_seeds(&self) -> &[Vec<u8>] {
1671        self.optional_metadata.web_seeds()
1672    }
1673
1674    /// Returns all validated optional metadata in one lossless owned model.
1675    #[must_use]
1676    pub const fn optional_metadata(&self) -> &OptionalMetadata {
1677        &self.optional_metadata
1678    }
1679
1680    /// Returns validated DHT bootstrap nodes.
1681    #[must_use]
1682    pub fn nodes(&self) -> &[DhtNode] {
1683        self.optional_metadata.nodes()
1684    }
1685
1686    /// Returns the private flag when explicitly present.
1687    #[must_use]
1688    pub const fn private(&self) -> Option<bool> {
1689        self.private
1690    }
1691
1692    /// Returns raw source bytes when explicitly present in the info dictionary.
1693    #[must_use]
1694    pub fn source(&self) -> Option<&[u8]> {
1695        self.optional_metadata.source()
1696    }
1697
1698    /// Returns raw top-level comment bytes when present.
1699    #[must_use]
1700    pub fn comment(&self) -> Option<&[u8]> {
1701        self.optional_metadata.comment()
1702    }
1703
1704    /// Returns raw top-level creator bytes when present.
1705    #[must_use]
1706    pub fn created_by(&self) -> Option<&[u8]> {
1707        self.optional_metadata.created_by()
1708    }
1709
1710    /// Returns the top-level creation timestamp when representable as `i64`.
1711    #[must_use]
1712    pub const fn creation_date(&self) -> Option<i64> {
1713        self.optional_metadata.creation_date()
1714    }
1715
1716    /// Returns the v1 info hash when applicable.
1717    #[must_use]
1718    pub const fn info_hash_v1(&self) -> Option<InfoHashV1> {
1719        self.info_hash_v1
1720    }
1721
1722    /// Returns the v2 info hash when applicable.
1723    #[must_use]
1724    pub const fn info_hash_v2(&self) -> Option<InfoHashV2> {
1725        self.info_hash_v2
1726    }
1727
1728    /// Returns exact input bytes.
1729    #[must_use]
1730    pub fn original_bytes(&self) -> &[u8] {
1731        &self.original
1732    }
1733
1734    /// Returns unknown top-level extension fields.
1735    #[must_use]
1736    pub fn unknown_fields(&self) -> &[UnknownField] {
1737        &self.unknown_fields
1738    }
1739
1740    /// Returns the exact source bytes for an unknown top-level field.
1741    #[must_use]
1742    pub fn unknown_field_bytes(&self, field: &UnknownField) -> &[u8] {
1743        &self.original[field.span().range()]
1744    }
1745
1746    /// Returns the successful validation report retained at construction.
1747    #[must_use]
1748    pub const fn validate(&self) -> &ValidationReport {
1749        &self.validation
1750    }
1751
1752    /// Writes exact original input bytes.
1753    ///
1754    /// # Errors
1755    ///
1756    /// Returns an I/O error preserving the writer's error as its source.
1757    pub fn write_original(&self, writer: &mut impl std::io::Write) -> Result<()> {
1758        writer
1759            .write_all(&self.original)
1760            .map_err(|source| Error::io("<writer>", source))
1761    }
1762
1763    /// Writes canonical bencode for this unchanged parsed object.
1764    ///
1765    /// # Errors
1766    ///
1767    /// Returns an I/O error preserving the writer's error as its source.
1768    pub fn write_canonical(&self, writer: &mut impl std::io::Write) -> Result<()> {
1769        writer
1770            .write_all(self.canonical_bytes()?)
1771            .map_err(|source| Error::io("<writer>", source))
1772    }
1773
1774    /// Returns canonical bencode bytes.
1775    ///
1776    /// # Errors
1777    ///
1778    /// This unchanged owned snapshot is already validated, so this method does
1779    /// not currently fail; the result type preserves serialization API stability.
1780    pub fn to_bytes(&self) -> Result<Vec<u8>> {
1781        Ok(self.canonical_bytes()?.clone())
1782    }
1783
1784    /// Returns whether canonical bytes have been materialized.
1785    #[doc(hidden)]
1786    #[must_use]
1787    pub fn canonical_bytes_cached(&self) -> bool {
1788        self.canonical.get().is_some()
1789    }
1790
1791    fn canonical_bytes(&self) -> Result<&Vec<u8>> {
1792        if let Some(bytes) = self.canonical.get() {
1793            return Ok(bytes);
1794        }
1795        let bytes = self.canonical_root.to_vec()?;
1796        let _ = self.canonical.set(bytes);
1797        Ok(self
1798            .canonical
1799            .get()
1800            .expect("canonical bytes were initialized"))
1801    }
1802}
1803
1804fn encode_hex(bytes: &[u8]) -> String {
1805    const HEX: &[u8; 16] = b"0123456789abcdef";
1806    let mut output = String::with_capacity(bytes.len() * 2);
1807    for byte in bytes {
1808        output.push(char::from(HEX[usize::from(byte >> 4)]));
1809        output.push(char::from(HEX[usize::from(byte & 0x0f)]));
1810    }
1811    output
1812}
1813
1814fn owned_load_cost(root: &Value<'_>, input_length: usize, copy_original: bool) -> Result<usize> {
1815    let tree = owned_tree_cost(root)?;
1816    let repeated_tree = tree.checked_mul(2).ok_or_else(owned_allocation_overflow)?;
1817    let original = if copy_original { input_length } else { 0 };
1818    original
1819        .checked_add(repeated_tree)
1820        .ok_or_else(owned_allocation_overflow)
1821}
1822
1823fn owned_tree_cost(value: &Value<'_>) -> Result<usize> {
1824    let base = std::mem::size_of::<crate::bencode::OwnedValue>();
1825    match value.kind() {
1826        ValueKind::Integer(value) => base
1827            .checked_add(value.encoded().len())
1828            .ok_or_else(owned_allocation_overflow),
1829        ValueKind::Bytes(bytes) => base
1830            .checked_add(bytes.len())
1831            .ok_or_else(owned_allocation_overflow),
1832        ValueKind::List(values) => values.iter().try_fold(
1833            base.checked_add(
1834                values
1835                    .len()
1836                    .checked_mul(std::mem::size_of::<crate::bencode::OwnedValue>())
1837                    .ok_or_else(owned_allocation_overflow)?,
1838            )
1839            .ok_or_else(owned_allocation_overflow)?,
1840            |total, value| {
1841                total
1842                    .checked_add(owned_tree_cost(value)?)
1843                    .ok_or_else(owned_allocation_overflow)
1844            },
1845        ),
1846        ValueKind::Dictionary(entries) => entries.iter().try_fold(
1847            base.checked_add(
1848                entries
1849                    .len()
1850                    .checked_mul(
1851                        std::mem::size_of::<(Vec<u8>, crate::bencode::OwnedValue)>()
1852                            + 3 * std::mem::size_of::<usize>(),
1853                    )
1854                    .ok_or_else(owned_allocation_overflow)?,
1855            )
1856            .ok_or_else(owned_allocation_overflow)?,
1857            |total, (key, value)| {
1858                total
1859                    .checked_add(key.bytes().len())
1860                    .and_then(|total| total.checked_add(owned_tree_cost(value).ok()?))
1861                    .ok_or_else(owned_allocation_overflow)
1862            },
1863        ),
1864    }
1865}
1866
1867fn owned_allocation_overflow() -> Error {
1868    Error::resource_limit("owned allocation", usize::MAX, usize::MAX)
1869}
1870
1871fn value_to_owned(value: &Value<'_>) -> Result<crate::bencode::OwnedValue> {
1872    use crate::bencode::OwnedValue;
1873    match value.kind() {
1874        ValueKind::Integer(value) => OwnedValue::integer_bytes(value.canonical_bytes()),
1875        ValueKind::Bytes(bytes) => Ok(OwnedValue::bytes(bytes.to_vec())),
1876        ValueKind::List(values) => values
1877            .iter()
1878            .map(value_to_owned)
1879            .collect::<Result<Vec<_>>>()
1880            .map(OwnedValue::list),
1881        ValueKind::Dictionary(entries) => OwnedValue::dictionary(
1882            entries
1883                .iter()
1884                .map(|(key, value)| Ok((key.bytes().to_vec(), value_to_owned(value)?)))
1885                .collect::<Result<Vec<_>>>()?,
1886        ),
1887    }
1888}
1889
1890fn parse_optional_metadata(
1891    raw: &RawMetainfo<'_>,
1892    info: &[(ByteString<'_>, Value<'_>)],
1893) -> Result<(OptionalMetadata, Vec<ValidationWarning>)> {
1894    let root = dictionary_entries(raw.root(), "<root>")?;
1895    let announce = unique_field(root, b"announce", "announce")?
1896        .map(|value| value_bytes(value, "announce").map(ToOwned::to_owned))
1897        .transpose()?;
1898    if announce.as_ref().is_some_and(Vec::is_empty) {
1899        return Err(Error::metainfo_field("announce", "tracker URL is empty"));
1900    }
1901    let announce_list = unique_field(root, b"announce-list", "announce-list")?;
1902    let mut warnings = Vec::new();
1903    let trackers = if let Some(value) = announce_list {
1904        let tiers = list_values(value, "announce-list")?;
1905        if tiers.is_empty() {
1906            warnings.push(ValidationWarning {
1907                message: "empty announce-list ignored in favor of announce".into(),
1908                field: Some("announce-list".into()),
1909                offset: Some(value.span().start()),
1910            });
1911            announce.into_iter().map(|url| vec![url]).collect()
1912        } else {
1913            tiers
1914                .iter()
1915                .map(|tier| {
1916                    list_values(tier, "announce-list")?
1917                        .iter()
1918                        .map(|tracker| Ok(value_bytes(tracker, "announce-list")?.to_vec()))
1919                        .collect()
1920                })
1921                .collect::<Result<Vec<_>>>()?
1922        }
1923    } else {
1924        announce.into_iter().map(|url| vec![url]).collect()
1925    };
1926    validate_tracker_tiers(&trackers)?;
1927
1928    let web_seeds = match unique_field(root, b"url-list", "url-list")? {
1929        None => Vec::new(),
1930        Some(value) if value.as_bytes().is_some() => {
1931            vec![value_bytes(value, "url-list")?.to_vec()]
1932        }
1933        Some(value) => list_values(value, "url-list")?
1934            .iter()
1935            .map(|seed| Ok(value_bytes(seed, "url-list")?.to_vec()))
1936            .collect::<Result<Vec<_>>>()?,
1937    };
1938    validate_web_seeds(&web_seeds)?;
1939
1940    let nodes = parse_nodes(unique_field(root, b"nodes", "nodes")?)?;
1941    validate_nodes(&nodes)?;
1942
1943    let source = unique_field(info, b"source", "info.source")?
1944        .map(|value| value_bytes(value, "info.source").map(ToOwned::to_owned))
1945        .transpose()?;
1946    let comment = unique_field(root, b"comment", "comment")?
1947        .map(|value| value_bytes(value, "comment").map(ToOwned::to_owned))
1948        .transpose()?;
1949    let created_by = unique_field(root, b"created by", "created by")?
1950        .map(|value| value_bytes(value, "created by").map(ToOwned::to_owned))
1951        .transpose()?;
1952    let creation_date = unique_field(root, b"creation date", "creation date")?
1953        .map(|value| {
1954            value
1955                .integer()
1956                .and_then(crate::bencode::Integer::to_i64)
1957                .ok_or_else(|| Error::metainfo_field("creation date", "must be an i64 integer"))
1958        })
1959        .transpose()?;
1960    validate_creation_date(creation_date)?;
1961    Ok((
1962        OptionalMetadata::new(
1963            trackers,
1964            web_seeds,
1965            nodes
1966                .into_iter()
1967                .map(|(host, port)| DhtNode::new(host, port))
1968                .collect(),
1969            source,
1970            comment,
1971            created_by,
1972            creation_date,
1973        ),
1974        warnings,
1975    ))
1976}
1977
1978fn parse_nodes(value: Option<&Value<'_>>) -> Result<Vec<(Vec<u8>, u16)>> {
1979    let Some(value) = value else {
1980        return Ok(Vec::new());
1981    };
1982    list_values(value, "nodes")?
1983        .iter()
1984        .map(|node| {
1985            let pair = list_values(node, "nodes")?;
1986            if pair.len() != 2 {
1987                return Err(Error::metainfo_field(
1988                    "nodes",
1989                    "each node must be [host, port]",
1990                ));
1991            }
1992            let host = value_bytes(&pair[0], "nodes.host")?.to_vec();
1993            let port = pair[1]
1994                .integer()
1995                .and_then(crate::bencode::Integer::to_i64)
1996                .and_then(|value| u16::try_from(value).ok())
1997                .ok_or_else(|| {
1998                    Error::metainfo_field("nodes.port", "must be an integer in 1..=65535")
1999                })?;
2000            Ok((host, port))
2001        })
2002        .collect()
2003}
2004
2005struct InspectionSnapshot {
2006    mode: TorrentMode,
2007    name: Vec<u8>,
2008    piece_length: u64,
2009    total_length: u64,
2010    piece_count: u64,
2011    files: Vec<TorrentFile>,
2012    warnings: Vec<ValidationWarning>,
2013}
2014
2015fn inspection_snapshot(
2016    raw: &RawMetainfo<'_>,
2017    info: &[(ByteString<'_>, Value<'_>)],
2018) -> Result<InspectionSnapshot> {
2019    if unique_field(info, b"meta version", "info.meta version")?.is_some() {
2020        let typed = V2Metainfo::from_raw(raw)?;
2021        let mut warnings = Vec::new();
2022        if raw
2023            .field(b"piece layers")
2024            .map(|layers| dictionary_entries(layers, "piece layers"))
2025            .transpose()?
2026            .is_some_and(<[_]>::is_empty)
2027        {
2028            warnings.push(ValidationWarning {
2029                message: "piece layers is present but empty; canonical output omits it".into(),
2030                field: Some("piece layers".into()),
2031                offset: raw.field(b"piece layers").map(|value| value.span().start()),
2032            });
2033        }
2034        Ok(InspectionSnapshot {
2035            mode: typed.mode(),
2036            name: typed.name().to_vec(),
2037            piece_length: typed.piece_length(),
2038            total_length: typed.total_length(),
2039            piece_count: typed.piece_count(),
2040            files: if typed.mode() == TorrentMode::Hybrid {
2041                V1Metainfo::from_raw(raw)?
2042                    .files()
2043                    .iter()
2044                    .map(|file| TorrentFile {
2045                        length: file.length(),
2046                        path: file
2047                            .path_components()
2048                            .iter()
2049                            .map(|part| part.to_vec())
2050                            .collect(),
2051                        attributes: file.attributes().to_vec(),
2052                        pieces_root: typed
2053                            .files()
2054                            .iter()
2055                            .find(|candidate| candidate.path_components() == file.path_components())
2056                            .and_then(V2File::pieces_root)
2057                            .copied(),
2058                    })
2059                    .collect()
2060            } else {
2061                typed
2062                    .files()
2063                    .iter()
2064                    .map(|file| TorrentFile {
2065                        length: file.length(),
2066                        path: file
2067                            .path_components()
2068                            .iter()
2069                            .map(|part| part.to_vec())
2070                            .collect(),
2071                        attributes: file.attributes().to_vec(),
2072                        pieces_root: file.pieces_root().copied(),
2073                    })
2074                    .collect()
2075            },
2076            warnings,
2077        })
2078    } else {
2079        let typed = V1Metainfo::from_raw(raw)?;
2080        Ok(InspectionSnapshot {
2081            mode: typed.mode(),
2082            name: typed.name().to_vec(),
2083            piece_length: typed.piece_length(),
2084            total_length: typed.total_length(),
2085            piece_count: u64::try_from(typed.pieces().len() / 20)
2086                .expect("usize values fit in u64 on supported targets"),
2087            files: typed
2088                .files()
2089                .iter()
2090                .map(|file| TorrentFile {
2091                    length: file.length(),
2092                    path: file
2093                        .path_components()
2094                        .iter()
2095                        .map(|part| part.to_vec())
2096                        .collect(),
2097                    attributes: file.attributes().to_vec(),
2098                    pieces_root: None,
2099                })
2100                .collect(),
2101            warnings: typed
2102                .warnings()
2103                .iter()
2104                .map(|message| ValidationWarning {
2105                    message: message.clone(),
2106                    field: None,
2107                    offset: None,
2108                })
2109                .collect(),
2110        })
2111    }
2112}
2113
2114#[cfg(test)]
2115mod checked_arithmetic_tests {
2116    use super::{checked_piece_count, checked_total_length};
2117    use crate::ErrorCategory;
2118
2119    // Spec: META-V1-001
2120    // Spec: META-V2-001
2121    #[test]
2122    fn platform_mapping_checks_case_collisions_separated_by_raw_byte_order() {
2123        let cases: [Vec<Vec<&[u8]>>; 2] = [
2124            vec![vec![b"A"], vec![b"B"], vec![b"a"]],
2125            vec![vec![b"A"], vec![b"B"], vec![b"a", b"child"]],
2126        ];
2127        for paths in cases {
2128            let result = super::validate_torrent_path_graph(paths, "info.files");
2129            if cfg!(windows) {
2130                assert_eq!(result.unwrap_err().category(), ErrorCategory::Metainfo);
2131            } else {
2132                assert!(result.is_ok());
2133            }
2134        }
2135    }
2136
2137    #[test]
2138    fn aggregate_boundaries_return_structured_errors() {
2139        assert_eq!(
2140            checked_total_length([u64::MAX], "length").unwrap(),
2141            u64::MAX
2142        );
2143        let length_error = checked_total_length([u64::MAX, 1], "length").unwrap_err();
2144        assert_eq!(length_error.category(), ErrorCategory::Metainfo);
2145        assert_eq!(length_error.field(), Some("length"));
2146
2147        assert_eq!(
2148            checked_piece_count([u64::MAX], 1, "pieces").unwrap(),
2149            u64::MAX
2150        );
2151        let piece_error = checked_piece_count([u64::MAX, 1], 1, "pieces").unwrap_err();
2152        assert_eq!(piece_error.category(), ErrorCategory::Metainfo);
2153        assert_eq!(piece_error.field(), Some("pieces"));
2154    }
2155}