Skip to main content

irontide_core/
create.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_precision_loss,
4    clippy::cast_possible_wrap,
5    clippy::cast_sign_loss,
6    reason = "M175: file/piece sizes bounded by piece_length (u32 by construction in Lengths::new); creation_date follows BEP 3 i64 wire format"
7)]
8
9//! Torrent creation (BEP 3, 12, 17, 19, 27, 47).
10//!
11//! Create `.torrent` files from local files or directories using a builder API.
12
13use std::collections::HashMap;
14use std::fs;
15use std::io::Read;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use serde::Serialize;
20
21use bytes::Bytes;
22
23use crate::detect::TorrentMeta;
24use crate::error::{Error, Result};
25use crate::file_tree::{FileTreeNode, V2FileAttr};
26use crate::hash::{Id20, Id32};
27use crate::info_hashes::InfoHashes;
28use crate::merkle::MerkleTree;
29use crate::metainfo::{FileEntry, InfoDict, TorrentMetaV1};
30use crate::metainfo_v2::{InfoDictV2, TorrentMetaV2};
31use crate::torrent_version::TorrentVersion;
32
33/// Auto-select piece size based on total content size (libtorrent-style).
34#[must_use]
35pub fn auto_piece_size(total: u64) -> u64 {
36    if total <= 10_485_760 {
37        32 * 1024 // ≤ 10 MiB → 32 KiB
38    } else if total <= 104_857_600 {
39        64 * 1024 // ≤ 100 MiB → 64 KiB
40    } else if total <= 1_073_741_824 {
41        256 * 1024 // ≤ 1 GiB → 256 KiB
42    } else if total <= 10_737_418_240 {
43        512 * 1024 // ≤ 10 GiB → 512 KiB
44    } else if total <= 107_374_182_400 {
45        1024 * 1024 // ≤ 100 GiB → 1 MiB
46    } else if total <= 1_099_511_627_776 {
47        2 * 1024 * 1024 // ≤ 1 TiB → 2 MiB
48    } else {
49        4 * 1024 * 1024 // > 1 TiB → 4 MiB
50    }
51}
52
53/// Collected info about a file to include in the torrent.
54struct InputFile {
55    /// Absolute path on disk (empty for pad files).
56    disk_path: PathBuf,
57    /// Path components within the torrent.
58    torrent_path: Vec<String>,
59    /// File length in bytes.
60    length: u64,
61    /// Modification time (unix timestamp).
62    mtime: Option<i64>,
63    /// BEP 47 file attributes.
64    attr: Option<String>,
65    /// Symlink target path components.
66    symlink_path: Option<Vec<String>>,
67    /// Whether this is a pad file.
68    is_pad: bool,
69}
70
71/// Result of torrent creation.
72#[derive(Debug)]
73pub struct CreateTorrentResult {
74    /// Parsed torrent metadata (V1, V2, or Hybrid depending on `set_version()`).
75    pub meta: TorrentMeta,
76    /// Raw `.torrent` file bytes.
77    pub bytes: Vec<u8>,
78}
79
80/// Serializable wrapper for the outer `.torrent` dict.
81#[derive(Serialize)]
82struct TorrentOutput {
83    #[serde(skip_serializing_if = "Option::is_none")]
84    announce: Option<String>,
85    #[serde(rename = "announce-list", skip_serializing_if = "Option::is_none")]
86    announce_list: Option<Vec<Vec<String>>>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    comment: Option<String>,
89    #[serde(rename = "created by", skip_serializing_if = "Option::is_none")]
90    created_by: Option<String>,
91    #[serde(rename = "creation date", skip_serializing_if = "Option::is_none")]
92    creation_date: Option<i64>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    httpseeds: Option<Vec<String>>,
95    info: InfoDict,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    nodes: Option<Vec<(String, u16)>>,
98    #[serde(rename = "url-list", skip_serializing_if = "Option::is_none")]
99    url_list: Option<Vec<String>>,
100}
101
102/// Builder for creating `.torrent` files.
103pub struct CreateTorrent {
104    files: Vec<InputFile>,
105    name: Option<String>,
106    piece_size: Option<u64>,
107    comment: Option<String>,
108    creator: Option<String>,
109    creation_date: Option<i64>,
110    private: bool,
111    source: Option<String>,
112    trackers: Vec<(String, usize)>,
113    web_seeds: Vec<String>,
114    http_seeds: Vec<String>,
115    dht_nodes: Vec<(String, u16)>,
116    pad_file_limit: Option<u64>,
117    include_mtime: bool,
118    include_symlinks: bool,
119    pre_hashes: HashMap<u32, Id20>,
120    version: TorrentVersion,
121    ssl_cert: Option<Vec<u8>>,
122}
123
124impl CreateTorrent {
125    /// Create a new torrent builder.
126    #[must_use]
127    pub fn new() -> Self {
128        Self {
129            files: Vec::new(),
130            name: None,
131            piece_size: None,
132            comment: None,
133            creator: None,
134            creation_date: None,
135            private: false,
136            source: None,
137            trackers: Vec::new(),
138            web_seeds: Vec::new(),
139            http_seeds: Vec::new(),
140            dht_nodes: Vec::new(),
141            pad_file_limit: None,
142            include_mtime: false,
143            include_symlinks: false,
144            pre_hashes: HashMap::new(),
145            version: TorrentVersion::V1Only,
146            ssl_cert: None,
147        }
148    }
149
150    /// Add a single file to the torrent.
151    #[must_use]
152    pub fn add_file(mut self, path: impl AsRef<Path>) -> Self {
153        let path = path.as_ref();
154        if let Ok(canonical) = fs::canonicalize(path)
155            && let Ok(meta) = fs::metadata(&canonical)
156        {
157            let file_name = canonical
158                .file_name()
159                .unwrap_or_default()
160                .to_string_lossy()
161                .into_owned();
162            let mtime = if self.include_mtime {
163                meta.modified().ok().and_then(|t| {
164                    t.duration_since(UNIX_EPOCH)
165                        .ok()
166                        .map(|d| d.as_secs() as i64)
167                })
168            } else {
169                None
170            };
171            let attr = detect_attr(&canonical, &meta);
172            self.files.push(InputFile {
173                disk_path: canonical,
174                torrent_path: vec![file_name],
175                length: meta.len(),
176                mtime,
177                attr,
178                symlink_path: None,
179                is_pad: false,
180            });
181        }
182        self
183    }
184
185    /// Add all files from a directory recursively.
186    #[must_use]
187    pub fn add_directory(mut self, path: impl AsRef<Path>) -> Self {
188        let path = path.as_ref();
189        if let Ok(canonical) = fs::canonicalize(path) {
190            let mut files = Vec::new();
191            walk_directory(
192                &canonical,
193                &[],
194                &mut files,
195                self.include_mtime,
196                self.include_symlinks,
197            );
198            files.sort_by(|a, b| a.torrent_path.cmp(&b.torrent_path));
199            self.files.extend(files);
200        }
201        self
202    }
203
204    /// Set the torrent name (defaults to the file/directory name).
205    #[must_use]
206    pub fn set_name(mut self, name: impl Into<String>) -> Self {
207        self.name = Some(name.into());
208        self
209    }
210
211    /// Set the piece size in bytes (must be a power of 2, ≥ 16384).
212    #[must_use]
213    pub fn set_piece_size(mut self, bytes: u64) -> Self {
214        self.piece_size = Some(bytes);
215        self
216    }
217
218    /// Set the comment field.
219    #[must_use]
220    pub fn set_comment(mut self, s: impl Into<String>) -> Self {
221        self.comment = Some(s.into());
222        self
223    }
224
225    /// Set the creator field.
226    #[must_use]
227    pub fn set_creator(mut self, s: impl Into<String>) -> Self {
228        self.creator = Some(s.into());
229        self
230    }
231
232    /// Set the creation date (unix timestamp). Defaults to current time.
233    #[must_use]
234    pub fn set_creation_date(mut self, ts: i64) -> Self {
235        self.creation_date = Some(ts);
236        self
237    }
238
239    /// Set the private flag (BEP 27).
240    #[must_use]
241    pub fn set_private(mut self, private: bool) -> Self {
242        self.private = private;
243        self
244    }
245
246    /// Set the source tag (private tracker identification).
247    #[must_use]
248    pub fn set_source(mut self, s: impl Into<String>) -> Self {
249        self.source = Some(s.into());
250        self
251    }
252
253    /// Add a tracker URL at the given tier.
254    #[must_use]
255    pub fn add_tracker(mut self, url: impl Into<String>, tier: usize) -> Self {
256        self.trackers.push((url.into(), tier));
257        self
258    }
259
260    /// Add a BEP 19 web seed URL (GetRight-style).
261    #[must_use]
262    pub fn add_web_seed(mut self, url: impl Into<String>) -> Self {
263        self.web_seeds.push(url.into());
264        self
265    }
266
267    /// Add a BEP 17 HTTP seed URL (Hoffman-style).
268    #[must_use]
269    pub fn add_http_seed(mut self, url: impl Into<String>) -> Self {
270        self.http_seeds.push(url.into());
271        self
272    }
273
274    /// Add a DHT bootstrap node (BEP 5).
275    #[must_use]
276    pub fn add_dht_node(mut self, host: impl Into<String>, port: u16) -> Self {
277        self.dht_nodes.push((host.into(), port));
278        self
279    }
280
281    /// Set pad file alignment (BEP 47).
282    ///
283    /// - `None` — no padding (default)
284    /// - `Some(0)` — pad after every file
285    /// - `Some(n)` — pad after files with length > n
286    #[must_use]
287    pub fn set_pad_file_limit(mut self, limit: Option<u64>) -> Self {
288        self.pad_file_limit = limit;
289        self
290    }
291
292    /// Include file modification times in the torrent.
293    #[must_use]
294    pub fn include_mtime(mut self, enabled: bool) -> Self {
295        self.include_mtime = enabled;
296        self
297    }
298
299    /// Follow and record symlinks.
300    #[must_use]
301    pub fn include_symlinks(mut self, enabled: bool) -> Self {
302        self.include_symlinks = enabled;
303        self
304    }
305
306    /// Set a pre-computed SHA1 hash for a piece (skips disk read during generation).
307    #[must_use]
308    pub fn set_hash(mut self, piece: u32, hash: Id20) -> Self {
309        self.pre_hashes.insert(piece, hash);
310        self
311    }
312
313    /// Set the torrent version to create.
314    ///
315    /// - `V1Only` (default) — standard SHA-1 .torrent (BEP 3)
316    /// - `Hybrid` — combined v1+v2 with both SHA-1 pieces and SHA-256 Merkle trees (BEP 52)
317    /// - `V2Only` — pure v2 with SHA-256 Merkle trees only (BEP 52)
318    #[must_use]
319    pub fn set_version(mut self, version: TorrentVersion) -> Self {
320        self.version = version;
321        self
322    }
323
324    /// Set the SSL CA certificate (PEM-encoded) for SSL torrent creation.
325    /// When set, the `ssl-cert` key is written into the info dict.
326    #[must_use]
327    pub fn set_ssl_cert(mut self, cert_pem: Vec<u8>) -> Self {
328        self.ssl_cert = Some(cert_pem);
329        self
330    }
331
332    /// Generate the `.torrent` file.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if no files have been added, the piece size is invalid,
337    /// or file I/O fails during hashing.
338    pub fn generate(self) -> Result<CreateTorrentResult> {
339        self.generate_with_progress(|_, _| {})
340    }
341
342    /// Generate the `.torrent` file with a progress callback `(current_piece, total_pieces)`.
343    ///
344    /// # Errors
345    ///
346    /// Returns an error if no files have been added, the piece size is invalid,
347    /// or file I/O fails during hashing.
348    pub fn generate_with_progress(
349        self,
350        mut cb: impl FnMut(usize, usize),
351    ) -> Result<CreateTorrentResult> {
352        if self.files.is_empty() {
353            return Err(Error::CreateTorrent("no files added".into()));
354        }
355
356        // Validate piece size if explicitly set
357        if let Some(ps) = self.piece_size
358            && (ps < 16384 || !ps.is_power_of_two())
359        {
360            return Err(Error::CreateTorrent(
361                "piece size must be a power of 2 and at least 16384".into(),
362            ));
363        }
364
365        // Determine name
366        let name = self.name.unwrap_or_else(|| {
367            self.files[0]
368                .torrent_path
369                .first()
370                .cloned()
371                .unwrap_or_else(|| "torrent".into())
372        });
373
374        let is_single_file = self.files.len() == 1 && !self.files[0].is_pad;
375
376        // Build file list with pad files
377        let files_with_pads = if is_single_file {
378            self.files
379        } else {
380            insert_pad_files(self.files, self.pad_file_limit, self.piece_size)
381        };
382
383        // Compute total size and piece size
384        let total_size: u64 = files_with_pads.iter().map(|f| f.length).sum();
385        let piece_size = self
386            .piece_size
387            .unwrap_or_else(|| auto_piece_size(total_size));
388
389        // Build announce / announce-list (needed by all paths)
390        let (announce, announce_list) = build_tracker_lists(&self.trackers);
391
392        // Creation date
393        let creation_date = self.creation_date.or_else(|| {
394            SystemTime::now()
395                .duration_since(UNIX_EPOCH)
396                .ok()
397                .map(|d| d.as_secs() as i64)
398        });
399
400        match self.version {
401            TorrentVersion::V2Only => {
402                // ── V2-only output (BEP 52) ──
403                // Skip SHA-1 entirely — v2 uses SHA-256 Merkle trees only
404                let v2_out = build_v2_output(
405                    &files_with_pads,
406                    piece_size,
407                    &name,
408                    self.private,
409                    self.source.as_ref(),
410                    self.ssl_cert.as_ref(),
411                )?;
412
413                // Build outer torrent dict
414                let mut outer = build_outer_dict(
415                    announce.as_ref(),
416                    announce_list.as_ref(),
417                    self.comment.as_ref(),
418                    self.creator.as_ref(),
419                    creation_date,
420                    &self.http_seeds,
421                    &v2_out.info_bytes,
422                    &self.dht_nodes,
423                    &self.web_seeds,
424                )?;
425
426                // piece layers
427                if !v2_out.piece_layers_raw.is_empty() {
428                    let mut pl_dict = std::collections::BTreeMap::new();
429                    for (root, layer) in &v2_out.piece_layers_raw {
430                        pl_dict.insert(
431                            root.clone(),
432                            irontide_bencode::BencodeValue::Bytes(layer.clone()),
433                        );
434                    }
435                    outer.insert(
436                        b"piece layers".to_vec(),
437                        irontide_bencode::BencodeValue::Dict(pl_dict),
438                    );
439                }
440
441                let bytes =
442                    irontide_bencode::to_bytes(&irontide_bencode::BencodeValue::Dict(outer))
443                        .map_err(|e| Error::CreateTorrent(format!("serialize v2 torrent: {e}")))?;
444
445                let ssl_cert = self.ssl_cert;
446                let meta_v2 = TorrentMetaV2 {
447                    info_hashes: InfoHashes::v2_only(v2_out.info_hash_v2),
448                    info_bytes: Some(Bytes::from(v2_out.info_bytes)),
449                    announce: self.trackers.first().map(|(url, _)| url.clone()),
450                    announce_list,
451                    comment: self.comment,
452                    created_by: self.creator,
453                    creation_date,
454                    info: v2_out.info_dict_v2,
455                    piece_layers: v2_out.piece_layers,
456                    ssl_cert,
457                };
458
459                Ok(CreateTorrentResult {
460                    meta: TorrentMeta::V2(meta_v2),
461                    bytes,
462                })
463            }
464
465            TorrentVersion::V1Only => {
466                // ── Standard v1 output ──
467                let num_pieces = if total_size == 0 {
468                    0
469                } else {
470                    total_size.div_ceil(piece_size) as usize
471                };
472                let pieces = hash_sha1_pieces(
473                    &files_with_pads,
474                    piece_size,
475                    total_size,
476                    num_pieces,
477                    &self.pre_hashes,
478                    &mut cb,
479                )?;
480                let info = build_v1_info_dict(
481                    &files_with_pads,
482                    &name,
483                    piece_size,
484                    &pieces,
485                    is_single_file,
486                    self.private,
487                    self.source.as_ref(),
488                    self.ssl_cert.as_ref(),
489                );
490
491                let info_bytes = irontide_bencode::to_bytes(&info)
492                    .map_err(|e| Error::CreateTorrent(format!("serialize info: {e}")))?;
493                let info_hash = crate::sha1(&info_bytes);
494
495                let output = TorrentOutput {
496                    announce,
497                    announce_list,
498                    comment: self.comment.clone(),
499                    created_by: self.creator.clone(),
500                    creation_date,
501                    httpseeds: if self.http_seeds.is_empty() {
502                        None
503                    } else {
504                        Some(self.http_seeds.clone())
505                    },
506                    info,
507                    nodes: if self.dht_nodes.is_empty() {
508                        None
509                    } else {
510                        Some(self.dht_nodes.clone())
511                    },
512                    url_list: if self.web_seeds.is_empty() {
513                        None
514                    } else {
515                        Some(self.web_seeds.clone())
516                    },
517                };
518
519                let bytes = irontide_bencode::to_bytes(&output)
520                    .map_err(|e| Error::CreateTorrent(format!("serialize torrent: {e}")))?;
521
522                let ssl_cert = self.ssl_cert;
523                let meta_v1 = TorrentMetaV1 {
524                    info_hash,
525                    announce: self.trackers.first().map(|(url, _)| url.clone()),
526                    announce_list: output.announce_list,
527                    comment: self.comment,
528                    created_by: self.creator,
529                    creation_date,
530                    info: output.info,
531                    url_list: self.web_seeds,
532                    httpseeds: self.http_seeds,
533                    info_bytes: Some(Bytes::from(info_bytes)),
534                    ssl_cert,
535                };
536
537                Ok(CreateTorrentResult {
538                    meta: TorrentMeta::V1(meta_v1),
539                    bytes,
540                })
541            }
542
543            TorrentVersion::Hybrid => {
544                // ── Hybrid v1+v2 output ──
545                // Step 1: SHA-1 piece hashing (v1 component)
546                let num_pieces = if total_size == 0 {
547                    0
548                } else {
549                    total_size.div_ceil(piece_size) as usize
550                };
551                let pieces = hash_sha1_pieces(
552                    &files_with_pads,
553                    piece_size,
554                    total_size,
555                    num_pieces,
556                    &self.pre_hashes,
557                    &mut cb,
558                )?;
559                let info = build_v1_info_dict(
560                    &files_with_pads,
561                    &name,
562                    piece_size,
563                    &pieces,
564                    is_single_file,
565                    self.private,
566                    self.source.as_ref(),
567                    self.ssl_cert.as_ref(),
568                );
569
570                // Step 2: v2 Merkle data
571                let v2_out = build_v2_output(
572                    &files_with_pads,
573                    piece_size,
574                    &name,
575                    self.private,
576                    self.source.as_ref(),
577                    self.ssl_cert.as_ref(),
578                )?;
579
580                // Step 3: Build merged info dict with both v1 and v2 keys
581                let mut merged =
582                    std::collections::BTreeMap::<Vec<u8>, irontide_bencode::BencodeValue>::new();
583
584                // v2: file tree
585                merged.insert(
586                    b"file tree".to_vec(),
587                    v2_out.info_dict_v2.file_tree.to_bencode(),
588                );
589
590                // v1: files list or single-file length
591                if is_single_file {
592                    merged.insert(
593                        b"length".to_vec(),
594                        irontide_bencode::BencodeValue::Integer(
595                            info.length.expect("single-file info dict must have length") as i64,
596                        ),
597                    );
598                } else {
599                    let file_list: Vec<irontide_bencode::BencodeValue> = info
600                        .files
601                        .as_ref()
602                        .expect("multi-file info dict must have files")
603                        .iter()
604                        .map(|f| {
605                            let mut d = std::collections::BTreeMap::new();
606                            d.insert(
607                                b"length".to_vec(),
608                                irontide_bencode::BencodeValue::Integer(f.length as i64),
609                            );
610                            let path: Vec<irontide_bencode::BencodeValue> = f
611                                .path
612                                .iter()
613                                .map(|p| {
614                                    irontide_bencode::BencodeValue::Bytes(p.as_bytes().to_vec())
615                                })
616                                .collect();
617                            d.insert(b"path".to_vec(), irontide_bencode::BencodeValue::List(path));
618                            if let Some(ref attr) = f.attr {
619                                d.insert(
620                                    b"attr".to_vec(),
621                                    irontide_bencode::BencodeValue::Bytes(attr.as_bytes().to_vec()),
622                                );
623                            }
624                            if let Some(mtime) = f.mtime {
625                                d.insert(
626                                    b"mtime".to_vec(),
627                                    irontide_bencode::BencodeValue::Integer(mtime),
628                                );
629                            }
630                            if let Some(ref sl) = f.symlink_path {
631                                let sl_list: Vec<irontide_bencode::BencodeValue> = sl
632                                    .iter()
633                                    .map(|s| {
634                                        irontide_bencode::BencodeValue::Bytes(s.as_bytes().to_vec())
635                                    })
636                                    .collect();
637                                d.insert(
638                                    b"symlink path".to_vec(),
639                                    irontide_bencode::BencodeValue::List(sl_list),
640                                );
641                            }
642                            irontide_bencode::BencodeValue::Dict(d)
643                        })
644                        .collect();
645                    merged.insert(
646                        b"files".to_vec(),
647                        irontide_bencode::BencodeValue::List(file_list),
648                    );
649                }
650
651                // v2: meta version
652                merged.insert(
653                    b"meta version".to_vec(),
654                    irontide_bencode::BencodeValue::Integer(2),
655                );
656
657                // shared: name
658                merged.insert(
659                    b"name".to_vec(),
660                    irontide_bencode::BencodeValue::Bytes(info.name.as_bytes().to_vec()),
661                );
662
663                // shared: piece length
664                merged.insert(
665                    b"piece length".to_vec(),
666                    irontide_bencode::BencodeValue::Integer(piece_size as i64),
667                );
668
669                // v1: pieces (concatenated SHA-1 hashes)
670                merged.insert(
671                    b"pieces".to_vec(),
672                    irontide_bencode::BencodeValue::Bytes(pieces),
673                );
674
675                // optional: private
676                if self.private {
677                    merged.insert(
678                        b"private".to_vec(),
679                        irontide_bencode::BencodeValue::Integer(1),
680                    );
681                }
682
683                // optional: source
684                if let Some(ref source) = self.source {
685                    merged.insert(
686                        b"source".to_vec(),
687                        irontide_bencode::BencodeValue::Bytes(source.as_bytes().to_vec()),
688                    );
689                }
690
691                // optional: ssl-cert
692                if let Some(ref cert) = self.ssl_cert {
693                    merged.insert(
694                        b"ssl-cert".to_vec(),
695                        irontide_bencode::BencodeValue::Bytes(cert.clone()),
696                    );
697                }
698
699                // Serialize merged info dict -> compute both hashes
700                let merged_info_bytes =
701                    irontide_bencode::to_bytes(&irontide_bencode::BencodeValue::Dict(merged))
702                        .map_err(|e| Error::CreateTorrent(format!("serialize hybrid info: {e}")))?;
703                let info_hash_v1 = crate::sha1(&merged_info_bytes);
704                let info_hash_v2 = crate::sha256(&merged_info_bytes);
705
706                // Build outer torrent dict
707                let mut outer = build_outer_dict(
708                    announce.as_ref(),
709                    announce_list.as_ref(),
710                    self.comment.as_ref(),
711                    self.creator.as_ref(),
712                    creation_date,
713                    &self.http_seeds,
714                    &merged_info_bytes,
715                    &self.dht_nodes,
716                    &self.web_seeds,
717                )?;
718
719                // piece layers
720                if !v2_out.piece_layers_raw.is_empty() {
721                    let mut pl_dict = std::collections::BTreeMap::new();
722                    for (root, layer) in &v2_out.piece_layers_raw {
723                        pl_dict.insert(
724                            root.clone(),
725                            irontide_bencode::BencodeValue::Bytes(layer.clone()),
726                        );
727                    }
728                    outer.insert(
729                        b"piece layers".to_vec(),
730                        irontide_bencode::BencodeValue::Dict(pl_dict),
731                    );
732                }
733
734                let bytes =
735                    irontide_bencode::to_bytes(&irontide_bencode::BencodeValue::Dict(outer))
736                        .map_err(|e| {
737                            Error::CreateTorrent(format!("serialize hybrid torrent: {e}"))
738                        })?;
739
740                // Build TorrentMetaV1 component
741                let ssl_cert = self.ssl_cert;
742                let meta_v1 = TorrentMetaV1 {
743                    info_hash: info_hash_v1,
744                    announce: self.trackers.first().map(|(url, _)| url.clone()),
745                    announce_list,
746                    comment: self.comment.clone(),
747                    created_by: self.creator.clone(),
748                    creation_date,
749                    info,
750                    url_list: self.web_seeds.clone(),
751                    httpseeds: self.http_seeds.clone(),
752                    info_bytes: Some(Bytes::from(merged_info_bytes.clone())),
753                    ssl_cert: ssl_cert.clone(),
754                };
755
756                // Build TorrentMetaV2 component
757                let meta_v2 = TorrentMetaV2 {
758                    info_hashes: InfoHashes::hybrid(info_hash_v1, info_hash_v2),
759                    info_bytes: Some(Bytes::from(merged_info_bytes)),
760                    announce: meta_v1.announce.clone(),
761                    announce_list: meta_v1.announce_list.clone(),
762                    comment: self.comment,
763                    created_by: self.creator,
764                    creation_date,
765                    info: v2_out.info_dict_v2,
766                    piece_layers: v2_out.piece_layers,
767                    ssl_cert,
768                };
769
770                Ok(CreateTorrentResult {
771                    meta: TorrentMeta::Hybrid(Box::new(meta_v1), Box::new(meta_v2)),
772                    bytes,
773                })
774            }
775        }
776    }
777}
778
779impl Default for CreateTorrent {
780    fn default() -> Self {
781        Self::new()
782    }
783}
784
785/// Compute SHA-1 piece hashes for v1 torrents.
786///
787/// Reads files sequentially, filling piece-sized buffers and hashing each with SHA-1.
788/// Supports pre-computed hashes that skip disk reads.
789fn hash_sha1_pieces(
790    files: &[InputFile],
791    piece_size: u64,
792    total_size: u64,
793    num_pieces: usize,
794    pre_hashes: &HashMap<u32, Id20>,
795    cb: &mut impl FnMut(usize, usize),
796) -> Result<Vec<u8>> {
797    let mut pieces = Vec::with_capacity(num_pieces * 20);
798    let mut piece_buf = vec![0u8; piece_size as usize];
799    let mut current_file_idx = 0;
800    let mut current_file_offset = 0u64;
801    let mut current_file_handle: Option<fs::File> = None;
802    let mut piece_index = 0u32;
803
804    while (piece_index as usize) < num_pieces {
805        // Check for pre-computed hash
806        if let Some(&hash) = pre_hashes.get(&piece_index) {
807            let remaining_in_piece = if (piece_index as usize) == num_pieces - 1 {
808                (total_size - u64::from(piece_index) * piece_size) as usize
809            } else {
810                piece_size as usize
811            };
812            advance_cursors(
813                files,
814                remaining_in_piece,
815                &mut current_file_idx,
816                &mut current_file_offset,
817                &mut current_file_handle,
818            );
819            pieces.extend_from_slice(hash.as_bytes());
820            cb(piece_index as usize + 1, num_pieces);
821            piece_index += 1;
822            continue;
823        }
824
825        // Fill piece buffer from files
826        let mut buf_offset = 0;
827        let piece_end = if (piece_index as usize) == num_pieces - 1 {
828            (total_size - u64::from(piece_index) * piece_size) as usize
829        } else {
830            piece_size as usize
831        };
832
833        while buf_offset < piece_end {
834            if current_file_idx >= files.len() {
835                break;
836            }
837            let file = &files[current_file_idx];
838            let remaining_in_file = file.length - current_file_offset;
839            let to_read = (piece_end - buf_offset).min(remaining_in_file as usize);
840
841            if file.is_pad {
842                piece_buf[buf_offset..buf_offset + to_read].fill(0);
843            } else {
844                if current_file_handle.is_none() {
845                    current_file_handle = Some(fs::File::open(&file.disk_path)?);
846                    if current_file_offset > 0 {
847                        use std::io::Seek;
848                        current_file_handle
849                            .as_mut()
850                            .expect("file handle just opened")
851                            .seek(std::io::SeekFrom::Start(current_file_offset))?;
852                    }
853                }
854                let handle = current_file_handle
855                    .as_mut()
856                    .expect("file handle just opened or already open");
857                handle.read_exact(&mut piece_buf[buf_offset..buf_offset + to_read])?;
858            }
859
860            buf_offset += to_read;
861            current_file_offset += to_read as u64;
862
863            if current_file_offset >= file.length {
864                current_file_idx += 1;
865                current_file_offset = 0;
866                current_file_handle = None;
867            }
868        }
869
870        let hash = crate::sha1(&piece_buf[..piece_end]);
871        pieces.extend_from_slice(hash.as_bytes());
872        cb(piece_index as usize + 1, num_pieces);
873        piece_index += 1;
874    }
875
876    Ok(pieces)
877}
878
879/// Build a v1 `InfoDict` from the given parameters.
880#[allow(clippy::too_many_arguments)]
881fn build_v1_info_dict(
882    files: &[InputFile],
883    name: &str,
884    piece_size: u64,
885    pieces: &[u8],
886    is_single_file: bool,
887    private: bool,
888    source: Option<&String>,
889    ssl_cert: Option<&Vec<u8>>,
890) -> InfoDict {
891    if is_single_file {
892        InfoDict {
893            name: name.to_owned(),
894            piece_length: piece_size,
895            pieces: pieces.to_vec(),
896            length: Some(files[0].length),
897            files: None,
898            private: if private { Some(1) } else { None },
899            source: source.cloned(),
900            ssl_cert: ssl_cert.cloned(),
901            similar: Vec::new(),
902            collections: Vec::new(),
903        }
904    } else {
905        let file_entries: Vec<FileEntry> = files
906            .iter()
907            .map(|f| FileEntry {
908                length: f.length,
909                path: f.torrent_path.clone(),
910                attr: f.attr.clone(),
911                mtime: f.mtime,
912                symlink_path: f.symlink_path.clone(),
913            })
914            .collect();
915        InfoDict {
916            name: name.to_owned(),
917            piece_length: piece_size,
918            pieces: pieces.to_vec(),
919            length: None,
920            files: Some(file_entries),
921            private: if private { Some(1) } else { None },
922            source: source.cloned(),
923            ssl_cert: ssl_cert.cloned(),
924            similar: Vec::new(),
925            collections: Vec::new(),
926        }
927    }
928}
929
930/// Output from building v2 torrent components.
931struct V2Output {
932    /// The v2 file tree node.
933    info_dict_v2: InfoDictV2,
934    /// Piece layers as `Id32 -> concatenated hashes` (for `TorrentMetaV2`).
935    piece_layers: std::collections::BTreeMap<Id32, Vec<u8>>,
936    /// Piece layers as raw bytes `(root_bytes -> concat_hashes)` (for outer dict serialization).
937    piece_layers_raw: std::collections::BTreeMap<Vec<u8>, Vec<u8>>,
938    /// SHA-256 hash of the serialized v2 info dict.
939    info_hash_v2: Id32,
940    /// Serialized v2 info dict bytes.
941    info_bytes: Vec<u8>,
942}
943
944/// Build v2 torrent components: Merkle trees, file tree, info dict, piece layers.
945///
946/// Used by both `V2Only` and Hybrid paths. For Hybrid, the caller merges v1 keys
947/// into the info dict and re-hashes; the `info_hash_v2` and `info_bytes` from this
948/// output are only used directly by the `V2Only` path.
949fn build_v2_output(
950    files: &[InputFile],
951    piece_size: u64,
952    name: &str,
953    private: bool,
954    source: Option<&String>,
955    ssl_cert: Option<&Vec<u8>>,
956) -> Result<V2Output> {
957    // Compute SHA-256 Merkle trees per file
958    let v2_data = compute_v2_merkle_data(files, piece_size)?;
959
960    // Build file tree
961    let file_tree = build_v2_file_tree(&v2_data);
962
963    // Build v2 info dict as BencodeValue
964    let mut info_map = std::collections::BTreeMap::<Vec<u8>, irontide_bencode::BencodeValue>::new();
965
966    info_map.insert(b"file tree".to_vec(), file_tree.to_bencode());
967    info_map.insert(
968        b"meta version".to_vec(),
969        irontide_bencode::BencodeValue::Integer(2),
970    );
971    info_map.insert(
972        b"name".to_vec(),
973        irontide_bencode::BencodeValue::Bytes(name.as_bytes().to_vec()),
974    );
975    info_map.insert(
976        b"piece length".to_vec(),
977        irontide_bencode::BencodeValue::Integer(piece_size as i64),
978    );
979
980    if private {
981        info_map.insert(
982            b"private".to_vec(),
983            irontide_bencode::BencodeValue::Integer(1),
984        );
985    }
986    if let Some(src) = source {
987        info_map.insert(
988            b"source".to_vec(),
989            irontide_bencode::BencodeValue::Bytes(src.as_bytes().to_vec()),
990        );
991    }
992    if let Some(cert) = ssl_cert {
993        info_map.insert(
994            b"ssl-cert".to_vec(),
995            irontide_bencode::BencodeValue::Bytes(cert.clone()),
996        );
997    }
998
999    // Serialize and hash
1000    let info_bytes = irontide_bencode::to_bytes(&irontide_bencode::BencodeValue::Dict(info_map))
1001        .map_err(|e| Error::CreateTorrent(format!("serialize v2 info: {e}")))?;
1002    let info_hash_v2 = crate::sha256(&info_bytes);
1003
1004    // Build piece layers (both typed and raw forms)
1005    let mut piece_layers = std::collections::BTreeMap::<Id32, Vec<u8>>::new();
1006    let mut piece_layers_raw = std::collections::BTreeMap::<Vec<u8>, Vec<u8>>::new();
1007    for fd in &v2_data {
1008        if let Some(root) = &fd.pieces_root
1009            && !fd.piece_layer.is_empty()
1010        {
1011            let concat: Vec<u8> = fd
1012                .piece_layer
1013                .iter()
1014                .flat_map(super::hash::Id32::as_bytes)
1015                .copied()
1016                .collect();
1017            piece_layers_raw.insert(root.as_bytes().to_vec(), concat.clone());
1018            piece_layers.insert(*root, concat);
1019        }
1020    }
1021
1022    let info_dict_v2 = InfoDictV2 {
1023        name: name.to_owned(),
1024        piece_length: piece_size,
1025        meta_version: 2,
1026        file_tree,
1027        ssl_cert: ssl_cert.cloned(),
1028    };
1029
1030    Ok(V2Output {
1031        info_dict_v2,
1032        piece_layers,
1033        piece_layers_raw,
1034        info_hash_v2,
1035        info_bytes,
1036    })
1037}
1038
1039/// Build the outer torrent dict shared by `V2Only` and Hybrid paths.
1040///
1041/// Does NOT include "piece layers" — the caller adds those after this returns,
1042/// because the piece layers dict is path-specific.
1043#[allow(clippy::too_many_arguments)]
1044fn build_outer_dict(
1045    announce: Option<&String>,
1046    announce_list: Option<&Vec<Vec<String>>>,
1047    comment: Option<&String>,
1048    creator: Option<&String>,
1049    creation_date: Option<i64>,
1050    http_seeds: &[String],
1051    info_bytes: &[u8],
1052    dht_nodes: &[(String, u16)],
1053    web_seeds: &[String],
1054) -> Result<std::collections::BTreeMap<Vec<u8>, irontide_bencode::BencodeValue>> {
1055    let mut outer = std::collections::BTreeMap::<Vec<u8>, irontide_bencode::BencodeValue>::new();
1056
1057    if let Some(url) = announce {
1058        outer.insert(
1059            b"announce".to_vec(),
1060            irontide_bencode::BencodeValue::Bytes(url.as_bytes().to_vec()),
1061        );
1062    }
1063    if let Some(al) = announce_list {
1064        let al_val: Vec<irontide_bencode::BencodeValue> = al
1065            .iter()
1066            .map(|tier| {
1067                let t: Vec<irontide_bencode::BencodeValue> = tier
1068                    .iter()
1069                    .map(|u| irontide_bencode::BencodeValue::Bytes(u.as_bytes().to_vec()))
1070                    .collect();
1071                irontide_bencode::BencodeValue::List(t)
1072            })
1073            .collect();
1074        outer.insert(
1075            b"announce-list".to_vec(),
1076            irontide_bencode::BencodeValue::List(al_val),
1077        );
1078    }
1079    if let Some(c) = comment {
1080        outer.insert(
1081            b"comment".to_vec(),
1082            irontide_bencode::BencodeValue::Bytes(c.as_bytes().to_vec()),
1083        );
1084    }
1085    if let Some(cr) = creator {
1086        outer.insert(
1087            b"created by".to_vec(),
1088            irontide_bencode::BencodeValue::Bytes(cr.as_bytes().to_vec()),
1089        );
1090    }
1091    if let Some(cd) = creation_date {
1092        outer.insert(
1093            b"creation date".to_vec(),
1094            irontide_bencode::BencodeValue::Integer(cd),
1095        );
1096    }
1097    if !http_seeds.is_empty() {
1098        let seeds: Vec<irontide_bencode::BencodeValue> = http_seeds
1099            .iter()
1100            .map(|s| irontide_bencode::BencodeValue::Bytes(s.as_bytes().to_vec()))
1101            .collect();
1102        outer.insert(
1103            b"httpseeds".to_vec(),
1104            irontide_bencode::BencodeValue::List(seeds),
1105        );
1106    }
1107    // Info dict as re-parsed BencodeValue (preserves exact serialization for hash consistency)
1108    outer.insert(
1109        b"info".to_vec(),
1110        irontide_bencode::from_bytes::<irontide_bencode::BencodeValue>(info_bytes)
1111            .map_err(|e| Error::CreateTorrent(format!("re-parse info: {e}")))?,
1112    );
1113    if !dht_nodes.is_empty() {
1114        let nodes: Vec<irontide_bencode::BencodeValue> = dht_nodes
1115            .iter()
1116            .map(|(h, p)| {
1117                irontide_bencode::BencodeValue::List(vec![
1118                    irontide_bencode::BencodeValue::Bytes(h.as_bytes().to_vec()),
1119                    irontide_bencode::BencodeValue::Integer(i64::from(*p)),
1120                ])
1121            })
1122            .collect();
1123        outer.insert(
1124            b"nodes".to_vec(),
1125            irontide_bencode::BencodeValue::List(nodes),
1126        );
1127    }
1128    if !web_seeds.is_empty() {
1129        let seeds: Vec<irontide_bencode::BencodeValue> = web_seeds
1130            .iter()
1131            .map(|s| irontide_bencode::BencodeValue::Bytes(s.as_bytes().to_vec()))
1132            .collect();
1133        outer.insert(
1134            b"url-list".to_vec(),
1135            irontide_bencode::BencodeValue::List(seeds),
1136        );
1137    }
1138
1139    Ok(outer)
1140}
1141
1142/// Advance file cursors by `bytes` without reading.
1143fn advance_cursors(
1144    files: &[InputFile],
1145    mut bytes: usize,
1146    file_idx: &mut usize,
1147    file_offset: &mut u64,
1148    file_handle: &mut Option<fs::File>,
1149) {
1150    while bytes > 0 && *file_idx < files.len() {
1151        let remaining = files[*file_idx].length - *file_offset;
1152        let skip = bytes.min(remaining as usize);
1153        *file_offset += skip as u64;
1154        bytes -= skip;
1155        if *file_offset >= files[*file_idx].length {
1156            *file_idx += 1;
1157            *file_offset = 0;
1158            *file_handle = None;
1159        }
1160    }
1161}
1162
1163/// Detect BEP 47 file attributes from filesystem metadata.
1164fn detect_attr(path: &Path, meta: &fs::Metadata) -> Option<String> {
1165    let mut attr = String::new();
1166
1167    // Hidden: file name starts with "."
1168    if let Some(name) = path.file_name()
1169        && name.to_string_lossy().starts_with('.')
1170    {
1171        attr.push('h');
1172    }
1173
1174    // Executable: any execute bit set
1175    #[cfg(unix)]
1176    {
1177        use std::os::unix::fs::PermissionsExt;
1178        if meta.permissions().mode() & 0o111 != 0 {
1179            attr.push('x');
1180        }
1181    }
1182    let _ = meta; // suppress unused warning on non-unix
1183
1184    if attr.is_empty() { None } else { Some(attr) }
1185}
1186
1187/// Recursively walk a directory, collecting `InputFile` entries.
1188fn walk_directory(
1189    base: &Path,
1190    prefix: &[String],
1191    out: &mut Vec<InputFile>,
1192    include_mtime: bool,
1193    include_symlinks: bool,
1194) {
1195    let mut entries: Vec<_> = match fs::read_dir(base) {
1196        Ok(rd) => rd.filter_map(std::result::Result::ok).collect(),
1197        Err(_) => return,
1198    };
1199    entries.sort_by_key(std::fs::DirEntry::file_name);
1200
1201    for entry in entries {
1202        let file_name = entry.file_name().to_string_lossy().into_owned();
1203        let mut path_components = prefix.to_vec();
1204        path_components.push(file_name);
1205
1206        let entry_path = entry.path();
1207        let Ok(file_type) = entry.file_type() else {
1208            continue;
1209        };
1210
1211        if file_type.is_dir() {
1212            walk_directory(
1213                &entry_path,
1214                &path_components,
1215                out,
1216                include_mtime,
1217                include_symlinks,
1218            );
1219        } else if file_type.is_symlink() && include_symlinks {
1220            // Follow symlink for size, record target
1221            if let Ok(meta) = fs::metadata(&entry_path) {
1222                let target = fs::read_link(&entry_path).ok().map(|t| {
1223                    t.components()
1224                        .map(|c| c.as_os_str().to_string_lossy().into_owned())
1225                        .collect::<Vec<_>>()
1226                });
1227                let mtime = if include_mtime {
1228                    meta.modified().ok().and_then(|t| {
1229                        t.duration_since(UNIX_EPOCH)
1230                            .ok()
1231                            .map(|d| d.as_secs() as i64)
1232                    })
1233                } else {
1234                    None
1235                };
1236                out.push(InputFile {
1237                    disk_path: fs::canonicalize(&entry_path).unwrap_or(entry_path),
1238                    torrent_path: path_components,
1239                    length: meta.len(),
1240                    mtime,
1241                    attr: Some("l".into()),
1242                    symlink_path: target,
1243                    is_pad: false,
1244                });
1245            }
1246        } else if file_type.is_file()
1247            && let Ok(meta) = fs::metadata(&entry_path)
1248        {
1249            let mtime = if include_mtime {
1250                meta.modified().ok().and_then(|t| {
1251                    t.duration_since(UNIX_EPOCH)
1252                        .ok()
1253                        .map(|d| d.as_secs() as i64)
1254                })
1255            } else {
1256                None
1257            };
1258            let attr = detect_attr(&entry_path, &meta);
1259            out.push(InputFile {
1260                disk_path: fs::canonicalize(&entry_path).unwrap_or(entry_path),
1261                torrent_path: path_components,
1262                length: meta.len(),
1263                mtime,
1264                attr,
1265                symlink_path: None,
1266                is_pad: false,
1267            });
1268        }
1269    }
1270}
1271
1272/// Insert pad files after eligible files to align to piece boundaries.
1273fn insert_pad_files(
1274    files: Vec<InputFile>,
1275    limit: Option<u64>,
1276    piece_size: Option<u64>,
1277) -> Vec<InputFile> {
1278    let Some(limit) = limit else {
1279        return files;
1280    };
1281
1282    let total_size: u64 = files.iter().map(|f| f.length).sum();
1283    let ps = piece_size.unwrap_or_else(|| auto_piece_size(total_size));
1284
1285    let mut result = Vec::new();
1286    let mut offset = 0u64;
1287    let last_idx = files.len() - 1;
1288
1289    for (i, file) in files.into_iter().enumerate() {
1290        let should_pad = i < last_idx && (limit == 0 || file.length > limit);
1291        offset += file.length;
1292        result.push(file);
1293
1294        if should_pad {
1295            let remainder = offset % ps;
1296            if remainder != 0 {
1297                let padding = ps - remainder;
1298                result.push(InputFile {
1299                    disk_path: PathBuf::new(),
1300                    torrent_path: vec![".pad".into(), padding.to_string()],
1301                    length: padding,
1302                    mtime: None,
1303                    attr: Some("p".into()),
1304                    symlink_path: None,
1305                    is_pad: true,
1306                });
1307                offset += padding;
1308            }
1309        }
1310    }
1311
1312    result
1313}
1314
1315/// Build announce URL and announce-list from tracker list.
1316fn build_tracker_lists(trackers: &[(String, usize)]) -> (Option<String>, Option<Vec<Vec<String>>>) {
1317    if trackers.is_empty() {
1318        return (None, None);
1319    }
1320
1321    let announce = Some(trackers[0].0.clone());
1322
1323    // Group by tier
1324    let mut max_tier = 0;
1325    for &(_, tier) in trackers {
1326        if tier > max_tier {
1327            max_tier = tier;
1328        }
1329    }
1330
1331    let mut tiers: Vec<Vec<String>> = vec![Vec::new(); max_tier + 1];
1332    for (url, tier) in trackers {
1333        tiers[*tier].push(url.clone());
1334    }
1335    let tiers: Vec<Vec<String>> = tiers.into_iter().filter(|t| !t.is_empty()).collect();
1336
1337    let announce_list = if tiers.len() > 1 || tiers.first().is_some_and(|t| t.len() > 1) {
1338        Some(tiers)
1339    } else {
1340        None
1341    };
1342
1343    (announce, announce_list)
1344}
1345
1346/// Per-file v2 Merkle data computed during hybrid torrent creation.
1347struct V2FileData {
1348    /// Torrent path components (excluding pad files).
1349    torrent_path: Vec<String>,
1350    /// File length in bytes.
1351    length: u64,
1352    /// Merkle tree root hash (None for empty files).
1353    pieces_root: Option<Id32>,
1354    /// Piece-layer hashes (only for files larger than `piece_length`).
1355    piece_layer: Vec<Id32>,
1356}
1357
1358/// Compute SHA-256 Merkle trees for each non-pad file.
1359///
1360/// This is a second pass through the file data — the first pass computed SHA-1
1361/// piece hashes for v1. We re-read each file and hash 16 KiB blocks for v2.
1362fn compute_v2_merkle_data(files: &[InputFile], piece_size: u64) -> Result<Vec<V2FileData>> {
1363    let block_size = 16384u64;
1364    let blocks_per_piece = (piece_size / block_size) as usize;
1365    let mut result = Vec::new();
1366
1367    for file in files {
1368        if file.is_pad {
1369            continue;
1370        }
1371
1372        if file.length == 0 {
1373            result.push(V2FileData {
1374                torrent_path: file.torrent_path.clone(),
1375                length: 0,
1376                pieces_root: None,
1377                piece_layer: Vec::new(),
1378            });
1379            continue;
1380        }
1381
1382        // Read file and SHA-256 each 16 KiB block
1383        let num_blocks = file.length.div_ceil(block_size) as usize;
1384        let mut block_hashes = Vec::with_capacity(num_blocks);
1385        let mut handle = fs::File::open(&file.disk_path)?;
1386        let mut buf = vec![0u8; block_size as usize];
1387        let mut remaining = file.length;
1388
1389        while remaining > 0 {
1390            let to_read = remaining.min(block_size) as usize;
1391            handle.read_exact(&mut buf[..to_read])?;
1392            // Hash only the actual data (last block may be shorter)
1393            block_hashes.push(crate::sha256(&buf[..to_read]));
1394            remaining -= to_read as u64;
1395        }
1396
1397        let tree = MerkleTree::from_leaves(&block_hashes);
1398        let root = tree.root();
1399
1400        // Piece layer only needed for files spanning multiple pieces
1401        let piece_layer = if file.length > piece_size {
1402            tree.piece_layer(blocks_per_piece).to_vec()
1403        } else {
1404            Vec::new()
1405        };
1406
1407        result.push(V2FileData {
1408            torrent_path: file.torrent_path.clone(),
1409            length: file.length,
1410            pieces_root: Some(root),
1411            piece_layer,
1412        });
1413    }
1414
1415    Ok(result)
1416}
1417
1418/// Build a v2 `FileTreeNode` from computed Merkle data.
1419///
1420/// Single-file torrents produce a single-entry directory.
1421/// Multi-file torrents build the nested path structure.
1422fn build_v2_file_tree(v2_data: &[V2FileData]) -> FileTreeNode {
1423    use std::collections::BTreeMap;
1424
1425    let mut root = BTreeMap::new();
1426
1427    for fd in v2_data {
1428        let attr = V2FileAttr {
1429            length: fd.length,
1430            pieces_root: fd.pieces_root,
1431        };
1432        let file_node = FileTreeNode::File(attr);
1433
1434        // Walk path components to build nested directory structure
1435        let mut current = &mut root;
1436        for (i, component) in fd.torrent_path.iter().enumerate() {
1437            if i == fd.torrent_path.len() - 1 {
1438                // Last component: insert the file
1439                current.insert(component.clone(), file_node);
1440                break;
1441            }
1442            // Intermediate component: create/get directory
1443            current = match current
1444                .entry(component.clone())
1445                .or_insert_with(|| FileTreeNode::Directory(BTreeMap::new()))
1446            {
1447                FileTreeNode::Directory(children) => children,
1448                FileTreeNode::File(_) => unreachable!("path conflict in file tree"),
1449            };
1450        }
1451    }
1452
1453    FileTreeNode::Directory(root)
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458    use super::*;
1459    use crate::metainfo::torrent_from_bytes;
1460    use std::io::Write;
1461
1462    /// Create a temp directory with test files.
1463    fn make_test_dir() -> tempfile::TempDir {
1464        let dir = tempfile::tempdir().unwrap();
1465        // Create files with known content
1466        let file_a = dir.path().join("aaa.txt");
1467        fs::write(&file_a, b"hello world\n").unwrap();
1468        let sub = dir.path().join("subdir");
1469        fs::create_dir(&sub).unwrap();
1470        let file_b = sub.join("bbb.bin");
1471        fs::write(&file_b, vec![0u8; 1000]).unwrap();
1472        dir
1473    }
1474
1475    /// Create a single test file.
1476    fn make_test_file() -> tempfile::NamedTempFile {
1477        let mut f = tempfile::NamedTempFile::new().unwrap();
1478        f.write_all(&vec![0xAB; 65536]).unwrap();
1479        f.flush().unwrap();
1480        f
1481    }
1482
1483    #[test]
1484    fn auto_piece_size_thresholds() {
1485        // ≤ 10 MiB → 32 KiB
1486        assert_eq!(auto_piece_size(0), 32 * 1024);
1487        assert_eq!(auto_piece_size(10 * 1024 * 1024), 32 * 1024);
1488        // ≤ 100 MiB → 64 KiB
1489        assert_eq!(auto_piece_size(10 * 1024 * 1024 + 1), 64 * 1024);
1490        assert_eq!(auto_piece_size(100 * 1024 * 1024), 64 * 1024);
1491        // ≤ 1 GiB → 256 KiB
1492        assert_eq!(auto_piece_size(100 * 1024 * 1024 + 1), 256 * 1024);
1493        assert_eq!(auto_piece_size(1024 * 1024 * 1024), 256 * 1024);
1494        // ≤ 10 GiB → 512 KiB
1495        assert_eq!(auto_piece_size(1024 * 1024 * 1024 + 1), 512 * 1024);
1496        assert_eq!(auto_piece_size(10 * 1024 * 1024 * 1024), 512 * 1024);
1497        // ≤ 100 GiB → 1 MiB
1498        assert_eq!(auto_piece_size(10 * 1024 * 1024 * 1024 + 1), 1024 * 1024);
1499        assert_eq!(auto_piece_size(100 * 1024 * 1024 * 1024), 1024 * 1024);
1500        // ≤ 1 TiB → 2 MiB
1501        assert_eq!(
1502            auto_piece_size(100 * 1024 * 1024 * 1024 + 1),
1503            2 * 1024 * 1024
1504        );
1505        assert_eq!(auto_piece_size(1024 * 1024 * 1024 * 1024), 2 * 1024 * 1024);
1506        // > 1 TiB → 4 MiB
1507        assert_eq!(
1508            auto_piece_size(1024 * 1024 * 1024 * 1024 + 1),
1509            4 * 1024 * 1024
1510        );
1511    }
1512
1513    #[test]
1514    fn single_file_round_trip() {
1515        let f = make_test_file();
1516        let result = CreateTorrent::new()
1517            .add_file(f.path())
1518            .set_piece_size(32768)
1519            .set_creation_date(1_000_000)
1520            .generate()
1521            .unwrap();
1522
1523        assert_eq!(result.meta.as_v1().unwrap().info.total_length(), 65536);
1524        assert!(result.meta.as_v1().unwrap().info.length.is_some());
1525        assert!(result.meta.as_v1().unwrap().info.files.is_none());
1526
1527        // Round-trip: re-parse the generated bytes
1528        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1529        assert_eq!(parsed.info_hash, result.meta.as_v1().unwrap().info_hash);
1530        assert_eq!(parsed.info.total_length(), 65536);
1531        assert_eq!(parsed.info.piece_length, 32768);
1532        assert_eq!(parsed.info.num_pieces(), 2);
1533    }
1534
1535    #[test]
1536    fn multi_file_round_trip() {
1537        let dir = make_test_dir();
1538        let result = CreateTorrent::new()
1539            .add_directory(dir.path())
1540            .set_name("test-torrent")
1541            .set_piece_size(32768)
1542            .set_creation_date(1_000_000)
1543            .generate()
1544            .unwrap();
1545
1546        assert!(result.meta.as_v1().unwrap().info.files.is_some());
1547        let files = result.meta.as_v1().unwrap().info.files.as_ref().unwrap();
1548        assert_eq!(files.len(), 2);
1549        // Files should be sorted: aaa.txt before subdir/bbb.bin
1550        assert_eq!(files[0].path, vec!["aaa.txt"]);
1551        assert_eq!(files[1].path, vec!["subdir", "bbb.bin"]);
1552        assert_eq!(result.meta.as_v1().unwrap().info.total_length(), 12 + 1000);
1553
1554        // Round-trip
1555        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1556        assert_eq!(parsed.info_hash, result.meta.as_v1().unwrap().info_hash);
1557        assert_eq!(parsed.info.name, "test-torrent");
1558    }
1559
1560    #[test]
1561    fn private_torrent_with_source() {
1562        let f = make_test_file();
1563        let result = CreateTorrent::new()
1564            .add_file(f.path())
1565            .set_piece_size(65536)
1566            .set_private(true)
1567            .set_source("MyTracker")
1568            .set_creation_date(1_000_000)
1569            .generate()
1570            .unwrap();
1571
1572        assert_eq!(result.meta.as_v1().unwrap().info.private, Some(1));
1573        assert_eq!(
1574            result.meta.as_v1().unwrap().info.source.as_deref(),
1575            Some("MyTracker")
1576        );
1577
1578        // Round-trip
1579        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1580        assert_eq!(parsed.info.private, Some(1));
1581        assert_eq!(parsed.info.source.as_deref(), Some("MyTracker"));
1582    }
1583
1584    #[test]
1585    fn tracker_tiers() {
1586        let f = make_test_file();
1587        let result = CreateTorrent::new()
1588            .add_file(f.path())
1589            .set_piece_size(65536)
1590            .add_tracker("http://tracker1.example.com/announce", 0)
1591            .add_tracker("http://tracker2.example.com/announce", 0)
1592            .add_tracker("http://tracker3.example.com/announce", 1)
1593            .set_creation_date(1_000_000)
1594            .generate()
1595            .unwrap();
1596
1597        assert_eq!(
1598            result.meta.as_v1().unwrap().announce.as_deref(),
1599            Some("http://tracker1.example.com/announce")
1600        );
1601        let al = result.meta.as_v1().unwrap().announce_list.as_ref().unwrap();
1602        assert_eq!(al.len(), 2);
1603        assert_eq!(al[0].len(), 2); // tier 0: two trackers
1604        assert_eq!(al[1].len(), 1); // tier 1: one tracker
1605
1606        // Round-trip
1607        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1608        assert_eq!(parsed.announce_list.as_ref().unwrap().len(), 2);
1609    }
1610
1611    #[test]
1612    fn web_and_http_seeds() {
1613        let f = make_test_file();
1614        let result = CreateTorrent::new()
1615            .add_file(f.path())
1616            .set_piece_size(65536)
1617            .add_web_seed("http://web.example.com/files")
1618            .add_http_seed("http://http.example.com/seed")
1619            .set_creation_date(1_000_000)
1620            .generate()
1621            .unwrap();
1622
1623        assert_eq!(
1624            result.meta.as_v1().unwrap().url_list,
1625            vec!["http://web.example.com/files"]
1626        );
1627        assert_eq!(
1628            result.meta.as_v1().unwrap().httpseeds,
1629            vec!["http://http.example.com/seed"]
1630        );
1631
1632        // Round-trip
1633        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1634        assert_eq!(parsed.url_list, vec!["http://web.example.com/files"]);
1635        assert_eq!(parsed.httpseeds, vec!["http://http.example.com/seed"]);
1636    }
1637
1638    #[test]
1639    fn pad_files_all() {
1640        let dir = make_test_dir();
1641        let result = CreateTorrent::new()
1642            .add_directory(dir.path())
1643            .set_name("padded")
1644            .set_piece_size(32768)
1645            .set_pad_file_limit(Some(0))
1646            .set_creation_date(1_000_000)
1647            .generate()
1648            .unwrap();
1649
1650        let files = result.meta.as_v1().unwrap().info.files.as_ref().unwrap();
1651        // Should have pad files after all files except the last
1652        let pad_count = files
1653            .iter()
1654            .filter(|f| f.attr.as_deref() == Some("p"))
1655            .count();
1656        // aaa.txt (12 bytes) → pad, subdir/bbb.bin (1000 bytes) → no pad (last)
1657        assert_eq!(pad_count, 1);
1658        // Verify pad file attributes
1659        let pad = files
1660            .iter()
1661            .find(|f| f.attr.as_deref() == Some("p"))
1662            .unwrap();
1663        assert_eq!(pad.path[0], ".pad");
1664    }
1665
1666    #[test]
1667    fn pad_file_limit_threshold() {
1668        let dir = make_test_dir();
1669        // Only pad files > 500 bytes: bbb.bin (1000) gets padded, aaa.txt (12) does not
1670        // But aaa.txt comes first, so: aaa.txt (no pad, 12 ≤ 500), bbb.bin (last, no pad)
1671        let result = CreateTorrent::new()
1672            .add_directory(dir.path())
1673            .set_name("threshold")
1674            .set_piece_size(32768)
1675            .set_pad_file_limit(Some(500))
1676            .set_creation_date(1_000_000)
1677            .generate()
1678            .unwrap();
1679
1680        let files = result.meta.as_v1().unwrap().info.files.as_ref().unwrap();
1681        // aaa.txt (12 bytes, ≤ 500, no pad), subdir/bbb.bin (1000 bytes, last file, no pad)
1682        let pad_count = files
1683            .iter()
1684            .filter(|f| f.attr.as_deref() == Some("p"))
1685            .count();
1686        assert_eq!(pad_count, 0);
1687
1688        // Now with limit=5: aaa.txt (12 > 5) → pad, bbb.bin is last → no pad
1689        let result2 = CreateTorrent::new()
1690            .add_directory(dir.path())
1691            .set_name("threshold2")
1692            .set_piece_size(32768)
1693            .set_pad_file_limit(Some(5))
1694            .set_creation_date(1_000_000)
1695            .generate()
1696            .unwrap();
1697
1698        let files2 = result2.meta.as_v1().unwrap().info.files.as_ref().unwrap();
1699        let pad_count2 = files2
1700            .iter()
1701            .filter(|f| f.attr.as_deref() == Some("p"))
1702            .count();
1703        assert_eq!(pad_count2, 1);
1704    }
1705
1706    #[test]
1707    fn progress_callback() {
1708        let f = make_test_file();
1709        let mut calls = Vec::new();
1710        CreateTorrent::new()
1711            .add_file(f.path())
1712            .set_piece_size(32768)
1713            .set_creation_date(1_000_000)
1714            .generate_with_progress(|current, total| {
1715                calls.push((current, total));
1716            })
1717            .unwrap();
1718
1719        // 65536 / 32768 = 2 pieces
1720        assert_eq!(calls.len(), 2);
1721        assert_eq!(calls[0], (1, 2));
1722        assert_eq!(calls[1], (2, 2));
1723    }
1724
1725    #[test]
1726    fn empty_input_error() {
1727        let result = CreateTorrent::new().generate();
1728        assert!(result.is_err());
1729        let err = result.unwrap_err().to_string();
1730        assert!(err.contains("no files"), "error: {err}");
1731    }
1732
1733    #[test]
1734    fn round_trip_info_hash() {
1735        let f = make_test_file();
1736        let result = CreateTorrent::new()
1737            .add_file(f.path())
1738            .set_piece_size(65536)
1739            .set_creation_date(1_000_000)
1740            .generate()
1741            .unwrap();
1742
1743        // Re-parse
1744        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1745        assert_eq!(parsed.info_hash, result.meta.as_v1().unwrap().info_hash);
1746
1747        // Manual SHA1 of serialized info dict
1748        let info_bytes = irontide_bencode::to_bytes(&result.meta.as_v1().unwrap().info).unwrap();
1749        let manual_hash = crate::sha1(&info_bytes);
1750        assert_eq!(manual_hash, result.meta.as_v1().unwrap().info_hash);
1751    }
1752
1753    #[test]
1754    fn dht_nodes() {
1755        let f = make_test_file();
1756        let result = CreateTorrent::new()
1757            .add_file(f.path())
1758            .set_piece_size(65536)
1759            .add_dht_node("router.bittorrent.com", 6881)
1760            .add_dht_node("dht.example.com", 6882)
1761            .set_creation_date(1_000_000)
1762            .generate()
1763            .unwrap();
1764
1765        // Verify nodes are in the raw bytes by re-parsing with bencode
1766        let value: irontide_bencode::BencodeValue =
1767            irontide_bencode::from_bytes(&result.bytes).unwrap();
1768        if let irontide_bencode::BencodeValue::Dict(ref d) = value {
1769            let nodes = d.get(b"nodes".as_ref()).unwrap();
1770            if let irontide_bencode::BencodeValue::List(list) = nodes {
1771                assert_eq!(list.len(), 2);
1772            } else {
1773                panic!("nodes should be a list");
1774            }
1775        } else {
1776            panic!("top-level should be a dict");
1777        }
1778    }
1779
1780    #[test]
1781    fn file_mtime() {
1782        let dir = make_test_dir();
1783        let result = CreateTorrent::new()
1784            .include_mtime(true)
1785            .add_directory(dir.path())
1786            .set_name("mtime-test")
1787            .set_piece_size(32768)
1788            .set_creation_date(1_000_000)
1789            .generate()
1790            .unwrap();
1791
1792        let files = result.meta.as_v1().unwrap().info.files.as_ref().unwrap();
1793        // All real files should have mtime set
1794        for f in files {
1795            if f.attr.as_deref() != Some("p") {
1796                assert!(f.mtime.is_some(), "file {:?} should have mtime", f.path);
1797                assert!(f.mtime.unwrap() > 0);
1798            }
1799        }
1800    }
1801
1802    #[test]
1803    fn pre_computed_hashes() {
1804        let f = make_test_file();
1805
1806        // First, generate normally to get the real hashes
1807        let normal = CreateTorrent::new()
1808            .add_file(f.path())
1809            .set_piece_size(65536)
1810            .set_creation_date(1_000_000)
1811            .generate()
1812            .unwrap();
1813
1814        let piece0_hash = normal.meta.as_v1().unwrap().info.piece_hash(0).unwrap();
1815
1816        // Now generate with pre-computed hash for piece 0
1817        let result = CreateTorrent::new()
1818            .add_file(f.path())
1819            .set_piece_size(65536)
1820            .set_hash(0, piece0_hash)
1821            .set_creation_date(1_000_000)
1822            .generate()
1823            .unwrap();
1824
1825        // Should produce identical output
1826        assert_eq!(
1827            result.meta.as_v1().unwrap().info_hash,
1828            normal.meta.as_v1().unwrap().info_hash
1829        );
1830        assert_eq!(
1831            result.meta.as_v1().unwrap().info.piece_hash(0),
1832            normal.meta.as_v1().unwrap().info.piece_hash(0)
1833        );
1834    }
1835
1836    // ── Hybrid torrent creation tests ────────────────────────────────────
1837
1838    #[test]
1839    fn hybrid_single_file_round_trip() {
1840        let f = make_test_file();
1841        let result = CreateTorrent::new()
1842            .add_file(f.path())
1843            .set_piece_size(32768)
1844            .set_creation_date(1_000_000)
1845            .set_version(TorrentVersion::Hybrid)
1846            .generate()
1847            .unwrap();
1848
1849        // Should be a Hybrid variant
1850        assert!(result.meta.is_hybrid());
1851        assert!(result.meta.version().is_hybrid());
1852
1853        // v1 component
1854        let v1 = result.meta.as_v1().unwrap();
1855        assert_eq!(v1.info.total_length(), 65536);
1856        assert!(v1.info.length.is_some());
1857        assert!(v1.info.files.is_none());
1858
1859        // v2 component
1860        let v2 = result.meta.as_v2().unwrap();
1861        assert_eq!(v2.info.meta_version, 2);
1862        assert_eq!(v2.info.piece_length, 32768);
1863        // File tree should have one file
1864        let files = v2.info.files();
1865        assert_eq!(files.len(), 1);
1866        assert_eq!(files[0].attr.length, 65536);
1867        // Merkle root should exist
1868        assert!(files[0].attr.pieces_root.is_some());
1869
1870        // Both hashes should come from the same info bytes
1871        assert!(v2.info_hashes.v1.is_some());
1872        assert!(v2.info_hashes.v2.is_some());
1873        assert_eq!(v2.info_hashes.v1.unwrap(), v1.info_hash);
1874
1875        // Round-trip: re-parse the generated bytes as v1
1876        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1877        assert_eq!(parsed.info_hash, v1.info_hash);
1878
1879        // Re-parse as v2 (detect.rs should detect hybrid)
1880        let detected = crate::detect::torrent_from_bytes_any(&result.bytes).unwrap();
1881        assert!(detected.is_hybrid());
1882    }
1883
1884    #[test]
1885    fn hybrid_multi_file_round_trip() {
1886        let dir = make_test_dir();
1887        let result = CreateTorrent::new()
1888            .add_directory(dir.path())
1889            .set_name("hybrid-test")
1890            .set_piece_size(32768)
1891            .set_creation_date(1_000_000)
1892            .set_version(TorrentVersion::Hybrid)
1893            .generate()
1894            .unwrap();
1895
1896        assert!(result.meta.is_hybrid());
1897
1898        let v1 = result.meta.as_v1().unwrap();
1899        assert!(v1.info.files.is_some());
1900        let v1_files = v1.info.files.as_ref().unwrap();
1901        assert_eq!(v1_files.len(), 2);
1902        assert_eq!(v1_files[0].path, vec!["aaa.txt"]);
1903        assert_eq!(v1_files[1].path, vec!["subdir", "bbb.bin"]);
1904
1905        let v2 = result.meta.as_v2().unwrap();
1906        let v2_files = v2.info.files();
1907        assert_eq!(v2_files.len(), 2);
1908
1909        // Round-trip
1910        let parsed = torrent_from_bytes(&result.bytes).unwrap();
1911        assert_eq!(parsed.info_hash, v1.info_hash);
1912    }
1913
1914    #[test]
1915    fn hybrid_has_piece_layers_for_large_file() {
1916        // Create a file larger than piece_size so piece_layers should be present
1917        let mut f = tempfile::NamedTempFile::new().unwrap();
1918        f.write_all(&vec![0xCD; 65536]).unwrap();
1919        f.flush().unwrap();
1920
1921        let result = CreateTorrent::new()
1922            .add_file(f.path())
1923            .set_piece_size(16384) // 4 pieces
1924            .set_creation_date(1_000_000)
1925            .set_version(TorrentVersion::Hybrid)
1926            .generate()
1927            .unwrap();
1928
1929        assert!(result.meta.is_hybrid());
1930        let v2 = result.meta.as_v2().unwrap();
1931        // piece_layers should have an entry for this file (larger than piece_size)
1932        assert!(
1933            !v2.piece_layers.is_empty(),
1934            "piece_layers should not be empty for large file"
1935        );
1936    }
1937
1938    #[test]
1939    fn hybrid_info_hash_differs_from_v1_only() {
1940        let f = make_test_file();
1941
1942        let v1_result = CreateTorrent::new()
1943            .add_file(f.path())
1944            .set_piece_size(32768)
1945            .set_creation_date(1_000_000)
1946            .generate()
1947            .unwrap();
1948
1949        let hybrid_result = CreateTorrent::new()
1950            .add_file(f.path())
1951            .set_piece_size(32768)
1952            .set_creation_date(1_000_000)
1953            .set_version(TorrentVersion::Hybrid)
1954            .generate()
1955            .unwrap();
1956
1957        // Hybrid info dict has extra keys (file tree, meta version) so hashes differ
1958        let v1_hash = v1_result.meta.as_v1().unwrap().info_hash;
1959        let hybrid_v1_hash = hybrid_result.meta.as_v1().unwrap().info_hash;
1960        assert_ne!(
1961            v1_hash, hybrid_v1_hash,
1962            "hybrid info dict should differ from v1-only"
1963        );
1964    }
1965
1966    // ── V2-only torrent creation tests ─────────────────────────────────
1967
1968    #[test]
1969    fn create_v2_only_single_file() {
1970        let f = make_test_file();
1971        let result = CreateTorrent::new()
1972            .add_file(f.path())
1973            .set_piece_size(32768)
1974            .set_creation_date(1_000_000)
1975            .set_version(TorrentVersion::V2Only)
1976            .generate()
1977            .expect("v2-only single file creation should succeed");
1978
1979        assert!(result.meta.is_v2(), "should be V2 variant");
1980        let v2 = result.meta.as_v2().expect("as_v2 should succeed");
1981        assert_eq!(v2.info.meta_version, 2);
1982        assert_eq!(v2.info.piece_length, 32768);
1983        let files = v2.info.files();
1984        assert_eq!(files.len(), 1);
1985        assert_eq!(files[0].attr.length, 65536);
1986    }
1987
1988    #[test]
1989    fn create_v2_only_multi_file() {
1990        let dir = make_test_dir();
1991        let result = CreateTorrent::new()
1992            .add_directory(dir.path())
1993            .set_name("v2-multi")
1994            .set_piece_size(32768)
1995            .set_creation_date(1_000_000)
1996            .set_version(TorrentVersion::V2Only)
1997            .generate()
1998            .expect("v2-only multi file creation should succeed");
1999
2000        assert!(result.meta.is_v2(), "should be V2 variant");
2001        let v2 = result.meta.as_v2().expect("as_v2 should succeed");
2002        let files = v2.info.files();
2003        assert_eq!(files.len(), 2);
2004        // Files should be sorted: aaa.txt before subdir/bbb.bin
2005        assert_eq!(files[0].path, vec!["aaa.txt"]);
2006        assert_eq!(files[1].path, vec!["subdir", "bbb.bin"]);
2007        assert_eq!(files[0].attr.length, 12);
2008        assert_eq!(files[1].attr.length, 1000);
2009    }
2010
2011    #[test]
2012    fn create_v2_only_has_piece_layers() {
2013        // Create a file larger than piece_size so piece_layers should be populated.
2014        let mut f = tempfile::NamedTempFile::new().expect("create temp file");
2015        f.write_all(&vec![0xCD; 65536]).expect("write temp file");
2016        f.flush().expect("flush temp file");
2017
2018        let result = CreateTorrent::new()
2019            .add_file(f.path())
2020            .set_piece_size(16384) // 4 pieces
2021            .set_creation_date(1_000_000)
2022            .set_version(TorrentVersion::V2Only)
2023            .generate()
2024            .expect("v2-only creation should succeed");
2025
2026        let v2 = result.meta.as_v2().expect("as_v2 should succeed");
2027        assert!(
2028            !v2.piece_layers.is_empty(),
2029            "piece_layers should not be empty for a file spanning multiple pieces"
2030        );
2031        // The single file's Merkle root should be the key in piece_layers.
2032        let files = v2.info.files();
2033        assert_eq!(files.len(), 1);
2034        let root = files[0]
2035            .attr
2036            .pieces_root
2037            .expect("file should have a pieces_root");
2038        assert!(
2039            v2.piece_layers.contains_key(&root),
2040            "piece_layers should contain an entry for the file's Merkle root"
2041        );
2042    }
2043
2044    #[test]
2045    fn create_v2_only_no_v1_keys() {
2046        let f = make_test_file();
2047        let result = CreateTorrent::new()
2048            .add_file(f.path())
2049            .set_piece_size(65536)
2050            .set_creation_date(1_000_000)
2051            .set_version(TorrentVersion::V2Only)
2052            .generate()
2053            .expect("v2-only creation should succeed");
2054
2055        // Re-parse the raw bytes as a generic BencodeValue to inspect keys.
2056        let value: irontide_bencode::BencodeValue =
2057            irontide_bencode::from_bytes(&result.bytes).expect("bencode parse");
2058        let root = value.as_dict().expect("root should be a dict");
2059        let info = root
2060            .get(b"info".as_ref())
2061            .expect("should have info key")
2062            .as_dict()
2063            .expect("info should be a dict");
2064
2065        // v1-only keys must NOT be present.
2066        assert!(
2067            !info.contains_key(b"pieces".as_ref()),
2068            "v2-only should not have 'pieces'"
2069        );
2070        assert!(
2071            !info.contains_key(b"length".as_ref()),
2072            "v2-only should not have 'length'"
2073        );
2074        assert!(
2075            !info.contains_key(b"files".as_ref()),
2076            "v2-only should not have 'files'"
2077        );
2078
2079        // v2 keys MUST be present.
2080        assert!(
2081            info.contains_key(b"file tree".as_ref()),
2082            "v2-only must have 'file tree'"
2083        );
2084        assert!(
2085            info.contains_key(b"meta version".as_ref()),
2086            "v2-only must have 'meta version'"
2087        );
2088        assert!(
2089            info.contains_key(b"name".as_ref()),
2090            "v2-only must have 'name'"
2091        );
2092        assert!(
2093            info.contains_key(b"piece length".as_ref()),
2094            "v2-only must have 'piece length'"
2095        );
2096    }
2097
2098    #[test]
2099    fn create_v2_only_round_trip() {
2100        let f = make_test_file();
2101        let result = CreateTorrent::new()
2102            .add_file(f.path())
2103            .set_piece_size(32768)
2104            .set_creation_date(1_000_000)
2105            .set_version(TorrentVersion::V2Only)
2106            .generate()
2107            .expect("v2-only creation should succeed");
2108
2109        // Round-trip through the auto-detect parser.
2110        let detected = crate::detect::torrent_from_bytes_any(&result.bytes)
2111            .expect("round-trip parse should succeed");
2112
2113        assert!(
2114            detected.is_v2(),
2115            "detected should be V2 (not hybrid, not v1)"
2116        );
2117        assert!(!detected.is_hybrid(), "detected should NOT be hybrid");
2118
2119        let orig = result.meta.as_v2().expect("original as_v2");
2120        let rt = detected.as_v2().expect("round-trip as_v2");
2121
2122        assert_eq!(rt.info.name, orig.info.name);
2123        assert_eq!(rt.info.piece_length, orig.info.piece_length);
2124        assert_eq!(rt.info.meta_version, orig.info.meta_version);
2125        assert_eq!(rt.info.files().len(), orig.info.files().len());
2126        assert_eq!(
2127            rt.info.files()[0].attr.length,
2128            orig.info.files()[0].attr.length
2129        );
2130    }
2131
2132    #[test]
2133    fn create_v2_only_info_hash() {
2134        let f = make_test_file();
2135        let result = CreateTorrent::new()
2136            .add_file(f.path())
2137            .set_piece_size(65536)
2138            .set_creation_date(1_000_000)
2139            .set_version(TorrentVersion::V2Only)
2140            .generate()
2141            .expect("v2-only creation should succeed");
2142
2143        let v2 = result.meta.as_v2().expect("as_v2 should succeed");
2144        let hashes = &v2.info_hashes;
2145
2146        // Pure v2 should have no v1 hash and a v2 hash.
2147        assert!(hashes.v1.is_none(), "v2-only should have no v1 hash");
2148        assert!(hashes.v2.is_some(), "v2-only should have a v2 hash");
2149
2150        // best_v1() should still return something (truncated from SHA-256).
2151        let _best = hashes.best_v1();
2152
2153        // Manually compute SHA-256 of the info dict bytes and compare.
2154        let info_bytes = v2
2155            .info_bytes
2156            .as_ref()
2157            .expect("info_bytes should be present");
2158        let manual_hash = crate::sha256(info_bytes);
2159        assert_eq!(
2160            manual_hash,
2161            hashes.v2.expect("v2 hash present"),
2162            "manually computed SHA-256 of info dict should match info_hashes.v2"
2163        );
2164    }
2165
2166    #[test]
2167    fn create_torrent_with_ssl_cert() {
2168        let cert_pem = b"-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n";
2169        let dir = tempfile::tempdir().unwrap();
2170        let file_path = dir.path().join("test.bin");
2171        std::fs::write(&file_path, vec![0u8; 65536]).unwrap();
2172
2173        let result = CreateTorrent::new()
2174            .add_file(&file_path)
2175            .set_ssl_cert(cert_pem.to_vec())
2176            .generate()
2177            .unwrap();
2178
2179        // Parse back and verify ssl-cert round-trips
2180        let parsed = torrent_from_bytes(&result.bytes).unwrap();
2181        assert_eq!(parsed.ssl_cert.as_deref().unwrap(), cert_pem.as_slice());
2182        assert_eq!(
2183            parsed.info.ssl_cert.as_deref().unwrap(),
2184            cert_pem.as_slice()
2185        );
2186    }
2187}