Skip to main content

irontide_core/
metainfo.rs

1use bytes::Bytes;
2use serde::de::{self, Deserializer};
3use serde::{Deserialize, Serialize};
4
5use crate::error::Error;
6use crate::hash::Id20;
7
8/// Deserialize a list of raw 20-byte binary strings into `Vec<Id20>`, silently
9/// dropping entries that are not exactly 20 bytes.
10///
11/// BEP 38 `similar` is a list of info hashes (raw 20-byte binary).  Rather than
12/// rejecting the entire torrent when a single entry has the wrong length, we
13/// keep only the valid ones — a robustness-over-strictness choice consistent
14/// with Postel's law.
15fn deserialize_similar<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Id20>, D::Error> {
16    struct SimilarVisitor;
17
18    impl<'de> de::Visitor<'de> for SimilarVisitor {
19        type Value = Vec<Id20>;
20
21        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22            f.write_str("a list of 20-byte binary strings")
23        }
24
25        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Id20>, A::Error> {
26            let mut hashes = Vec::new();
27            // Each element is a raw byte string; accept via serde_bytes.
28            while let Some(bytes) = seq.next_element::<serde_bytes::ByteBuf>()? {
29                if let Ok(id) = Id20::from_bytes(bytes.as_ref()) {
30                    hashes.push(id);
31                }
32                // Silently drop entries that are not exactly 20 bytes.
33            }
34            Ok(hashes)
35        }
36    }
37
38    deserializer.deserialize_seq(SimilarVisitor)
39}
40
41/// Wrapper for `url-list` that handles both a single string and a list of strings.
42#[derive(Debug, Clone, Default)]
43pub struct UrlList(pub Vec<String>);
44
45impl<'de> Deserialize<'de> for UrlList {
46    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
47        struct UrlListVisitor;
48
49        impl<'de> de::Visitor<'de> for UrlListVisitor {
50            type Value = UrlList;
51
52            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53                f.write_str("a string or list of strings")
54            }
55
56            fn visit_str<E: de::Error>(self, v: &str) -> Result<UrlList, E> {
57                Ok(UrlList(vec![v.to_owned()]))
58            }
59
60            fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<UrlList, E> {
61                let s = std::str::from_utf8(v).map_err(de::Error::custom)?;
62                Ok(UrlList(vec![s.to_owned()]))
63            }
64
65            fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<UrlList, A::Error> {
66                let mut urls = Vec::new();
67                while let Some(url) = seq.next_element::<String>()? {
68                    urls.push(url);
69                }
70                Ok(UrlList(urls))
71            }
72        }
73
74        deserializer.deserialize_any(UrlListVisitor)
75    }
76}
77
78/// Parsed .torrent file (BEP 3 metainfo, v1).
79#[derive(Debug, Clone)]
80pub struct TorrentMetaV1 {
81    /// The info hash (SHA1 of the raw "info" dict bytes).
82    pub info_hash: Id20,
83    /// Primary announce URL.
84    pub announce: Option<String>,
85    /// Announce list (BEP 12) — list of tracker tiers.
86    pub announce_list: Option<Vec<Vec<String>>>,
87    /// Comment.
88    pub comment: Option<String>,
89    /// Created by.
90    pub created_by: Option<String>,
91    /// Creation date (unix timestamp).
92    pub creation_date: Option<i64>,
93    /// Info dictionary.
94    pub info: InfoDict,
95    /// BEP 19 web seed URLs (GetRight-style).
96    pub url_list: Vec<String>,
97    /// BEP 17 HTTP seed URLs (Hoffman-style).
98    pub httpseeds: Vec<String>,
99    /// Raw info dict bytes for BEP 9 metadata serving.
100    pub info_bytes: Option<Bytes>,
101    /// PEM-encoded SSL CA certificate from the info dict, if present.
102    pub ssl_cert: Option<Vec<u8>>,
103}
104
105impl TorrentMetaV1 {
106    /// M254: serialize back to `.torrent` file bytes.
107    ///
108    /// The raw `info_bytes` are spliced VERBATIM (parsing and
109    /// re-serializing would sort dict keys and change the info-hash for
110    /// non-canonical torrents in the wild) — hash-exact by construction.
111    /// Falls back to serde-serializing [`InfoDict`] (canonical, sorted)
112    /// when the raw bytes are absent. The outer dict is assembled
113    /// manually in bencode-sorted key order:
114    /// `announce` < `announce-list` < `comment` < `created by` <
115    /// `creation date` < `httpseeds` < `info` < `url-list`.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if the [`InfoDict`] fallback serialization fails.
120    pub fn to_torrent_bytes(&self) -> Result<Vec<u8>, Error> {
121        fn push_key(out: &mut Vec<u8>, key: &[u8]) {
122            out.extend_from_slice(key.len().to_string().as_bytes());
123            out.push(b':');
124            out.extend_from_slice(key);
125        }
126        fn push_str(out: &mut Vec<u8>, s: &str) {
127            out.extend_from_slice(s.len().to_string().as_bytes());
128            out.push(b':');
129            out.extend_from_slice(s.as_bytes());
130        }
131        fn push_str_list(out: &mut Vec<u8>, items: &[String]) {
132            out.push(b'l');
133            for item in items {
134                push_str(out, item);
135            }
136            out.push(b'e');
137        }
138
139        let info_raw: Vec<u8> = match &self.info_bytes {
140            Some(raw) => raw.to_vec(),
141            None => irontide_bencode::to_bytes(&self.info)?,
142        };
143
144        let mut out = Vec::with_capacity(info_raw.len() + 256);
145        out.push(b'd');
146        if let Some(ref announce) = self.announce {
147            push_key(&mut out, b"announce");
148            push_str(&mut out, announce);
149        }
150        if let Some(ref tiers) = self.announce_list {
151            push_key(&mut out, b"announce-list");
152            out.push(b'l');
153            for tier in tiers {
154                push_str_list(&mut out, tier);
155            }
156            out.push(b'e');
157        }
158        if let Some(ref comment) = self.comment {
159            push_key(&mut out, b"comment");
160            push_str(&mut out, comment);
161        }
162        if let Some(ref created_by) = self.created_by {
163            push_key(&mut out, b"created by");
164            push_str(&mut out, created_by);
165        }
166        if let Some(date) = self.creation_date {
167            push_key(&mut out, b"creation date");
168            out.push(b'i');
169            out.extend_from_slice(date.to_string().as_bytes());
170            out.push(b'e');
171        }
172        if !self.httpseeds.is_empty() {
173            push_key(&mut out, b"httpseeds");
174            push_str_list(&mut out, &self.httpseeds);
175        }
176        push_key(&mut out, b"info");
177        out.extend_from_slice(&info_raw);
178        if !self.url_list.is_empty() {
179            push_key(&mut out, b"url-list");
180            push_str_list(&mut out, &self.url_list);
181        }
182        out.push(b'e');
183        Ok(out)
184    }
185}
186
187/// The "info" dictionary from a .torrent file.
188#[derive(Debug, Clone, Deserialize, Serialize)]
189pub struct InfoDict {
190    /// Suggested file/directory name.
191    pub name: String,
192    /// Piece length in bytes.
193    #[serde(rename = "piece length")]
194    pub piece_length: u64,
195    /// Concatenated SHA1 hashes of each piece (20 bytes each).
196    #[serde(with = "serde_bytes")]
197    pub pieces: Vec<u8>,
198    /// Length in bytes (single-file mode).
199    #[serde(skip_serializing_if = "Option::is_none", default)]
200    pub length: Option<u64>,
201    /// Files (multi-file mode).
202    #[serde(skip_serializing_if = "Option::is_none", default)]
203    pub files: Option<Vec<FileEntry>>,
204    /// Private flag.
205    #[serde(skip_serializing_if = "Option::is_none", default)]
206    pub private: Option<i64>,
207    /// Source tag (private tracker identification).
208    #[serde(skip_serializing_if = "Option::is_none", default)]
209    pub source: Option<String>,
210    /// BEP 35 / SSL torrent: PEM-encoded X.509 CA certificate.
211    /// When present, all peer connections must use TLS with certs chaining to this CA.
212    #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none", default)]
213    #[serde(with = "serde_bytes")]
214    pub ssl_cert: Option<Vec<u8>>,
215    /// BEP 38: info hashes of similar/related torrents (raw 20-byte binary strings).
216    ///
217    /// Entries that are not exactly 20 bytes are silently dropped during parsing.
218    #[serde(
219        default,
220        skip_serializing_if = "Vec::is_empty",
221        deserialize_with = "deserialize_similar"
222    )]
223    pub similar: Vec<Id20>,
224    /// BEP 38: collection names this torrent belongs to.
225    #[serde(default, skip_serializing_if = "Vec::is_empty")]
226    pub collections: Vec<String>,
227}
228
229/// A file entry in multi-file mode.
230#[derive(Debug, Clone, Deserialize, Serialize)]
231pub struct FileEntry {
232    /// File length in bytes.
233    pub length: u64,
234    /// Path components (e.g., `["dir", "file.txt"]`).
235    pub path: Vec<String>,
236    /// BEP 47 file attributes ("p"=pad, "h"=hidden, "x"=executable, "l"=symlink).
237    #[serde(skip_serializing_if = "Option::is_none", default)]
238    pub attr: Option<String>,
239    /// File modification time (unix timestamp).
240    #[serde(skip_serializing_if = "Option::is_none", default)]
241    pub mtime: Option<i64>,
242    /// Symlink target path components.
243    #[serde(
244        rename = "symlink path",
245        skip_serializing_if = "Option::is_none",
246        default
247    )]
248    pub symlink_path: Option<Vec<String>>,
249}
250
251/// High-level file info (unified from single-file and multi-file modes).
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct FileInfo {
254    /// Relative path components.
255    pub path: Vec<String>,
256    /// File length in bytes.
257    pub length: u64,
258}
259
260/// Raw top-level torrent structure for serde deserialization.
261#[derive(Deserialize)]
262struct RawTorrent {
263    announce: Option<String>,
264    #[serde(rename = "announce-list")]
265    announce_list: Option<Vec<Vec<String>>>,
266    comment: Option<String>,
267    #[serde(rename = "created by")]
268    created_by: Option<String>,
269    #[serde(rename = "creation date")]
270    creation_date: Option<i64>,
271    info: InfoDict,
272    /// BEP 19: web seed URL(s) — single string or list.
273    #[serde(rename = "url-list", default)]
274    url_list: UrlList,
275    /// BEP 17: HTTP seed URLs.
276    #[serde(default)]
277    httpseeds: Vec<String>,
278}
279
280/// Parse a .torrent file from raw bytes.
281///
282/// Computes the info-hash by finding the raw byte span of the "info" key
283/// and SHA1-hashing it directly (not the re-serialized form).
284///
285/// # Errors
286///
287/// Returns an error if the data is not a valid v1 torrent file.
288pub fn torrent_from_bytes(data: &[u8]) -> Result<TorrentMetaV1, Error> {
289    // Step 1: Find the raw info dict span for hashing
290    let info_span = irontide_bencode::find_dict_key_span(data, "info")?;
291    let info_hash = crate::sha1(&data[info_span.clone()]);
292    let info_raw = Bytes::copy_from_slice(&data[info_span]);
293
294    // Step 2: Deserialize the full structure
295    let raw: RawTorrent = irontide_bencode::from_bytes(data)?;
296
297    // Step 3: Validate the info dict
298    validate_info(&raw.info)?;
299
300    let ssl_cert = raw.info.ssl_cert.clone();
301
302    Ok(TorrentMetaV1 {
303        info_hash,
304        announce: raw.announce,
305        announce_list: raw.announce_list,
306        comment: raw.comment,
307        created_by: raw.created_by,
308        creation_date: raw.creation_date,
309        info: raw.info,
310        url_list: raw.url_list.0,
311        httpseeds: raw.httpseeds,
312        info_bytes: Some(info_raw),
313        ssl_cert,
314    })
315}
316
317fn validate_info(info: &InfoDict) -> Result<(), Error> {
318    if info.piece_length == 0 {
319        return Err(Error::InvalidTorrent("piece length is 0".into()));
320    }
321
322    if !info.pieces.len().is_multiple_of(20) {
323        return Err(Error::InvalidTorrent(format!(
324            "pieces length {} is not a multiple of 20",
325            info.pieces.len()
326        )));
327    }
328
329    if info.length.is_none() && info.files.is_none() {
330        return Err(Error::InvalidTorrent(
331            "neither 'length' nor 'files' present".into(),
332        ));
333    }
334
335    if info.length.is_some() && info.files.is_some() {
336        return Err(Error::InvalidTorrent(
337            "both 'length' and 'files' present".into(),
338        ));
339    }
340
341    Ok(())
342}
343
344impl InfoDict {
345    /// Total size of all files in bytes.
346    #[must_use]
347    pub fn total_length(&self) -> u64 {
348        if let Some(length) = self.length {
349            length
350        } else if let Some(ref files) = self.files {
351            files.iter().map(|f| f.length).sum()
352        } else {
353            0
354        }
355    }
356
357    /// Number of pieces.
358    #[must_use]
359    pub fn num_pieces(&self) -> usize {
360        self.pieces.len() / 20
361    }
362
363    /// Get the SHA1 hash for a specific piece.
364    #[must_use]
365    pub fn piece_hash(&self, index: usize) -> Option<Id20> {
366        let start = index * 20;
367        if start + 20 > self.pieces.len() {
368            return None;
369        }
370        let mut hash = [0u8; 20];
371        hash.copy_from_slice(&self.pieces[start..start + 20]);
372        Some(Id20(hash))
373    }
374
375    /// Get file info in a unified format.
376    #[must_use]
377    pub fn files(&self) -> Vec<FileInfo> {
378        if let Some(length) = self.length {
379            vec![FileInfo {
380                path: vec![self.name.clone()],
381                length,
382            }]
383        } else if let Some(ref files) = self.files {
384            files
385                .iter()
386                .map(|f| {
387                    let mut path = vec![self.name.clone()];
388                    path.extend(f.path.clone());
389                    FileInfo {
390                        path,
391                        length: f.length,
392                    }
393                })
394                .collect()
395        } else {
396            vec![]
397        }
398    }
399}
400
401/// How a torrent's file tree is materialized under the download directory
402/// (M252/ER5; mirrors qBittorrent's "Content layout").
403///
404/// Transforms operate on [`InfoDict::files()`] output, whose canonical shape
405/// is `[name]` for single-file torrents and `[name, …components]` for
406/// multi-file torrents (root always first).
407#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum ContentLayout {
410    /// Keep the torrent's own structure exactly as the metainfo describes it.
411    #[default]
412    Original,
413    /// Always create a subfolder named after the torrent — wraps single-file
414    /// torrents in `name/`; multi-file torrents already have one (no-op).
415    Subfolder,
416    /// Never create a subfolder — strips the root component from multi-file
417    /// torrents; single-file torrents are already flat (no-op).
418    NoSubfolder,
419}
420
421impl ContentLayout {
422    /// Apply this layout to canonical [`InfoDict::files()`] output.
423    ///
424    /// Never produces an empty path: stripping requires `len >= 2`,
425    /// wrapping only ever prepends.
426    #[must_use]
427    pub fn apply_to_files(self, mut files: Vec<FileInfo>) -> Vec<FileInfo> {
428        match self {
429            Self::Original => files,
430            Self::Subfolder => {
431                for f in &mut files {
432                    if f.path.len() == 1 {
433                        let name = f.path[0].clone();
434                        f.path.insert(0, name);
435                    }
436                }
437                files
438            }
439            Self::NoSubfolder => {
440                // Per-entry rule, NOT a list-length gate: a multi-file torrent
441                // containing exactly one file still has `[name, component]`
442                // paths and must strip. Single-file = `[name]` (len 1) is
443                // untouched by the `>= 2` guard.
444                for f in &mut files {
445                    if f.path.len() >= 2 {
446                        f.path.remove(0);
447                    }
448                }
449                files
450            }
451        }
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    /// Build a minimal torrent bencoded dict with extra keys sorted correctly.
460    ///
461    /// `before_info` contains keys that sort before "info" (e.g., "httpseeds").
462    /// `after_info` contains keys that sort after "info" (e.g., "url-list").
463    fn make_torrent_bytes_sorted(before_info: &[u8], after_info: &[u8]) -> Vec<u8> {
464        // Minimal info dict: name, piece length, pieces (20 zero bytes), length
465        let info = b"d6:lengthi1048576e4:name4:test12:piece lengthi262144e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00e";
466        let mut buf = Vec::new();
467        buf.push(b'd');
468        buf.extend_from_slice(before_info);
469        buf.extend_from_slice(b"4:info");
470        buf.extend_from_slice(info);
471        buf.extend_from_slice(after_info);
472        buf.push(b'e');
473        buf
474    }
475
476    #[test]
477    fn url_list_single_string() {
478        // url-list sorts after info
479        let data = make_torrent_bytes_sorted(b"", b"8:url-list24:http://example.com/files");
480        let meta = torrent_from_bytes(&data).unwrap();
481        assert_eq!(meta.url_list, vec!["http://example.com/files"]);
482    }
483
484    #[test]
485    fn url_list_multiple() {
486        let data = make_torrent_bytes_sorted(
487            b"",
488            b"8:url-listl24:http://example.com/files26:http://mirror.example.com/e",
489        );
490        let meta = torrent_from_bytes(&data).unwrap();
491        assert_eq!(meta.url_list.len(), 2);
492        assert_eq!(meta.url_list[0], "http://example.com/files");
493        assert_eq!(meta.url_list[1], "http://mirror.example.com/");
494    }
495
496    /// M254: `to_torrent_bytes` round-trips through the parser with an
497    /// identical info-hash (raw `info_bytes` path) and carries announce +
498    /// url-list.
499    #[test]
500    fn m254_to_torrent_bytes_round_trips_info_hash() {
501        let data = make_torrent_bytes_sorted(
502            b"8:announce18:http://tr.example/",
503            b"8:url-list24:http://example.com/files",
504        );
505        let meta = torrent_from_bytes(&data).unwrap();
506        let out = meta.to_torrent_bytes().unwrap();
507        let back = torrent_from_bytes(&out).unwrap();
508        assert_eq!(meta.info_hash, back.info_hash, "info-hash must be exact");
509        assert_eq!(back.announce.as_deref(), Some("http://tr.example/"));
510        assert_eq!(back.url_list, vec!["http://example.com/files"]);
511    }
512
513    /// M254: the `InfoDict` serde fallback (`info_bytes` = None) produces
514    /// parseable output with the same info-hash for a canonical source.
515    #[test]
516    fn m254_to_torrent_bytes_fallback_without_raw_info() {
517        let data = make_torrent_bytes_sorted(b"8:announce18:http://tr.example/", b"");
518        let mut meta = torrent_from_bytes(&data).unwrap();
519        meta.info_bytes = None;
520        let out = meta.to_torrent_bytes().unwrap();
521        let back = torrent_from_bytes(&out).unwrap();
522        // The fixture is canonical (sorted keys), so the serde
523        // re-serialization reproduces the same info-hash.
524        assert_eq!(meta.info_hash, back.info_hash);
525        assert_eq!(back.info.name, meta.info.name);
526    }
527
528    #[test]
529    fn url_list_absent() {
530        let data = make_torrent_bytes_sorted(b"", b"");
531        let meta = torrent_from_bytes(&data).unwrap();
532        assert!(meta.url_list.is_empty());
533    }
534
535    #[test]
536    fn httpseeds_present() {
537        // httpseeds sorts before info
538        let data = make_torrent_bytes_sorted(b"9:httpseedsl28:http://seed.example.com/seede", b"");
539        let meta = torrent_from_bytes(&data).unwrap();
540        assert_eq!(meta.httpseeds, vec!["http://seed.example.com/seed"]);
541    }
542
543    #[test]
544    fn httpseeds_absent() {
545        let data = make_torrent_bytes_sorted(b"", b"");
546        let meta = torrent_from_bytes(&data).unwrap();
547        assert!(meta.httpseeds.is_empty());
548    }
549
550    #[test]
551    fn torrent_from_bytes_stores_raw_info_bytes() {
552        let data = make_torrent_bytes_sorted(b"", b"");
553        let meta = torrent_from_bytes(&data).unwrap();
554        assert!(meta.info_bytes.is_some());
555        let info_bytes = meta.info_bytes.unwrap();
556        // Re-hashing the stored bytes should produce the same info hash
557        let rehash = crate::sha1(&info_bytes);
558        assert_eq!(rehash, meta.info_hash);
559    }
560
561    #[test]
562    fn ssl_cert_parsed_from_info_dict() {
563        // Build a torrent with ssl-cert in the info dict.
564        let cert_pem = b"-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n";
565        let cert_len = cert_pem.len();
566
567        // Minimal info dict with ssl-cert inserted (keys must be sorted)
568        let mut info = Vec::new();
569        info.extend_from_slice(b"d");
570        info.extend_from_slice(b"6:lengthi1048576e");
571        info.extend_from_slice(b"4:name4:test");
572        info.extend_from_slice(b"12:piece lengthi262144e");
573        info.extend_from_slice(b"6:pieces20:");
574        info.extend_from_slice(&[0u8; 20]);
575        info.extend_from_slice(format!("8:ssl-cert{cert_len}:").as_bytes());
576        info.extend_from_slice(cert_pem);
577        info.extend_from_slice(b"e");
578
579        let mut torrent = Vec::new();
580        torrent.extend_from_slice(b"d4:info");
581        torrent.extend_from_slice(&info);
582        torrent.extend_from_slice(b"e");
583
584        let meta = torrent_from_bytes(&torrent).unwrap();
585        assert!(meta.ssl_cert.is_some());
586        assert_eq!(meta.ssl_cert.as_deref().unwrap(), cert_pem);
587        assert_eq!(meta.info.ssl_cert.as_deref().unwrap(), cert_pem);
588    }
589
590    #[test]
591    fn ssl_cert_absent_by_default() {
592        let data = make_torrent_bytes_sorted(b"", b"");
593        let meta = torrent_from_bytes(&data).unwrap();
594        assert!(meta.ssl_cert.is_none());
595        assert!(meta.info.ssl_cert.is_none());
596    }
597
598    /// Build a minimal info dict with optional `similar` and `collections` entries,
599    /// wrapped in an outer torrent dict.  Keys are kept in bencode-sorted order.
600    fn make_torrent_with_bep38(similar: Option<&[u8]>, collections: Option<&[u8]>) -> Vec<u8> {
601        let mut info = Vec::new();
602        info.extend_from_slice(b"d");
603        // "collections" < "length" — insert first if present.
604        if let Some(c) = collections {
605            info.extend_from_slice(b"11:collections");
606            info.extend_from_slice(c);
607        }
608        info.extend_from_slice(b"6:lengthi1048576e");
609        info.extend_from_slice(b"4:name4:test");
610        info.extend_from_slice(b"12:piece lengthi262144e");
611        info.extend_from_slice(b"6:pieces20:");
612        info.extend_from_slice(&[0u8; 20]);
613        // "similar" > "pieces" and < "source"/"ssl-cert"
614        if let Some(s) = similar {
615            info.extend_from_slice(b"7:similar");
616            info.extend_from_slice(s);
617        }
618        info.extend_from_slice(b"e");
619
620        let mut torrent = Vec::new();
621        torrent.extend_from_slice(b"d4:info");
622        torrent.extend_from_slice(&info);
623        torrent.extend_from_slice(b"e");
624        torrent
625    }
626
627    #[test]
628    fn parse_similar_torrents_from_info() {
629        let hash_a = [0xAAu8; 20];
630        let hash_b = [0xBBu8; 20];
631
632        // Build bencode list: l20:<hash_a>20:<hash_b>e
633        let mut similar_list = Vec::new();
634        similar_list.extend_from_slice(b"l");
635        similar_list.extend_from_slice(b"20:");
636        similar_list.extend_from_slice(&hash_a);
637        similar_list.extend_from_slice(b"20:");
638        similar_list.extend_from_slice(&hash_b);
639        similar_list.extend_from_slice(b"e");
640
641        let data = make_torrent_with_bep38(Some(&similar_list), None);
642        let meta = torrent_from_bytes(&data).expect("parse should succeed");
643
644        assert_eq!(meta.info.similar.len(), 2);
645        assert_eq!(meta.info.similar[0], Id20(hash_a));
646        assert_eq!(meta.info.similar[1], Id20(hash_b));
647    }
648
649    #[test]
650    fn parse_collections_from_info() {
651        // Build bencode list: l6:movies6:sci-fie
652        let collections_list = b"l6:movies6:sci-fie";
653
654        let data = make_torrent_with_bep38(None, Some(collections_list));
655        let meta = torrent_from_bytes(&data).expect("parse should succeed");
656
657        assert_eq!(meta.info.collections.len(), 2);
658        assert_eq!(meta.info.collections[0], "movies");
659        assert_eq!(meta.info.collections[1], "sci-fi");
660    }
661
662    #[test]
663    fn similar_empty_when_absent() {
664        let data = make_torrent_bytes_sorted(b"", b"");
665        let meta = torrent_from_bytes(&data).expect("parse should succeed");
666        assert!(meta.info.similar.is_empty());
667        assert!(meta.info.collections.is_empty());
668    }
669
670    #[test]
671    fn similar_ignores_wrong_length_hashes() {
672        let valid_hash = [0xCCu8; 20];
673        let too_short = [0xDDu8; 19];
674        let too_long = [0xEEu8; 21];
675
676        // Build bencode list with mixed entries: 19-byte, 20-byte valid, 21-byte
677        let mut similar_list = Vec::new();
678        similar_list.extend_from_slice(b"l");
679        // 19 bytes — invalid
680        similar_list.extend_from_slice(b"19:");
681        similar_list.extend_from_slice(&too_short);
682        // 20 bytes — valid
683        similar_list.extend_from_slice(b"20:");
684        similar_list.extend_from_slice(&valid_hash);
685        // 21 bytes — invalid
686        similar_list.extend_from_slice(b"21:");
687        similar_list.extend_from_slice(&too_long);
688        similar_list.extend_from_slice(b"e");
689
690        let data = make_torrent_with_bep38(Some(&similar_list), None);
691        let meta = torrent_from_bytes(&data).expect("parse should succeed");
692
693        // Only the 20-byte entry survives.
694        assert_eq!(meta.info.similar.len(), 1);
695        assert_eq!(meta.info.similar[0], Id20(valid_hash));
696    }
697
698    #[test]
699    fn m252_content_layout_original_is_identity() {
700        let files = vec![
701            FileInfo {
702                path: vec!["root".into(), "a.bin".into()],
703                length: 1,
704            },
705            FileInfo {
706                path: vec!["root".into(), "sub".into(), "b.bin".into()],
707                length: 2,
708            },
709        ];
710        let out = ContentLayout::Original.apply_to_files(files.clone());
711        assert_eq!(out, files);
712    }
713
714    #[test]
715    fn m252_content_layout_subfolder_wraps_single_file_only() {
716        let single = vec![FileInfo {
717            path: vec!["movie.mkv".into()],
718            length: 9,
719        }];
720        let out = ContentLayout::Subfolder.apply_to_files(single);
721        assert_eq!(
722            out[0].path,
723            vec!["movie.mkv".to_string(), "movie.mkv".to_string()]
724        );
725
726        let multi = vec![FileInfo {
727            path: vec!["root".into(), "a.bin".into()],
728            length: 1,
729        }];
730        let out = ContentLayout::Subfolder.apply_to_files(multi.clone());
731        assert_eq!(
732            out, multi,
733            "multi-file already has a root — Subfolder is a no-op"
734        );
735    }
736
737    #[test]
738    fn m252_content_layout_nosubfolder_strips_root_per_entry() {
739        let multi = vec![
740            FileInfo {
741                path: vec!["root".into(), "a.bin".into()],
742                length: 1,
743            },
744            FileInfo {
745                path: vec!["root".into(), "sub".into(), "b.bin".into()],
746                length: 2,
747            },
748        ];
749        let out = ContentLayout::NoSubfolder.apply_to_files(multi);
750        assert_eq!(out[0].path, vec!["a.bin".to_string()]);
751        assert_eq!(out[1].path, vec!["sub".to_string(), "b.bin".to_string()]);
752
753        let single = vec![FileInfo {
754            path: vec!["movie.mkv".into()],
755            length: 9,
756        }];
757        let out = ContentLayout::NoSubfolder.apply_to_files(single.clone());
758        assert_eq!(
759            out, single,
760            "single-file is already flat — NoSubfolder is a no-op"
761        );
762
763        // OV F1: a multi-file torrent with exactly ONE file entry still
764        // carries the root component and must strip.
765        let one_entry_multi = vec![FileInfo {
766            path: vec!["root".into(), "only.bin".into()],
767            length: 3,
768        }];
769        let out = ContentLayout::NoSubfolder.apply_to_files(one_entry_multi);
770        assert_eq!(out[0].path, vec!["only.bin".to_string()]);
771    }
772
773    #[test]
774    fn m252_content_layout_serde_snake_case_round_trip() {
775        let j = serde_json::to_string(&ContentLayout::NoSubfolder).unwrap();
776        assert_eq!(j, "\"no_subfolder\"");
777        let back: ContentLayout = serde_json::from_str(&j).unwrap();
778        assert_eq!(back, ContentLayout::NoSubfolder);
779        assert_eq!(ContentLayout::default(), ContentLayout::Original);
780    }
781}