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
9use 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#[must_use]
35pub fn auto_piece_size(total: u64) -> u64 {
36 if total <= 10_485_760 {
37 32 * 1024 } else if total <= 104_857_600 {
39 64 * 1024 } else if total <= 1_073_741_824 {
41 256 * 1024 } else if total <= 10_737_418_240 {
43 512 * 1024 } else if total <= 107_374_182_400 {
45 1024 * 1024 } else if total <= 1_099_511_627_776 {
47 2 * 1024 * 1024 } else {
49 4 * 1024 * 1024 }
51}
52
53struct InputFile {
55 disk_path: PathBuf,
57 torrent_path: Vec<String>,
59 length: u64,
61 mtime: Option<i64>,
63 attr: Option<String>,
65 symlink_path: Option<Vec<String>>,
67 is_pad: bool,
69}
70
71#[derive(Debug)]
73pub struct CreateTorrentResult {
74 pub meta: TorrentMeta,
76 pub bytes: Vec<u8>,
78}
79
80#[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
102pub 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 #[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 #[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 #[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 #[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 #[must_use]
213 pub fn set_piece_size(mut self, bytes: u64) -> Self {
214 self.piece_size = Some(bytes);
215 self
216 }
217
218 #[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 #[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 #[must_use]
234 pub fn set_creation_date(mut self, ts: i64) -> Self {
235 self.creation_date = Some(ts);
236 self
237 }
238
239 #[must_use]
241 pub fn set_private(mut self, private: bool) -> Self {
242 self.private = private;
243 self
244 }
245
246 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
294 pub fn include_mtime(mut self, enabled: bool) -> Self {
295 self.include_mtime = enabled;
296 self
297 }
298
299 #[must_use]
301 pub fn include_symlinks(mut self, enabled: bool) -> Self {
302 self.include_symlinks = enabled;
303 self
304 }
305
306 #[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 #[must_use]
319 pub fn set_version(mut self, version: TorrentVersion) -> Self {
320 self.version = version;
321 self
322 }
323
324 #[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 pub fn generate(self) -> Result<CreateTorrentResult> {
339 self.generate_with_progress(|_, _| {})
340 }
341
342 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 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 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 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 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 let (announce, announce_list) = build_tracker_lists(&self.trackers);
391
392 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 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 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 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 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 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 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 let mut merged =
582 std::collections::BTreeMap::<Vec<u8>, irontide_bencode::BencodeValue>::new();
583
584 merged.insert(
586 b"file tree".to_vec(),
587 v2_out.info_dict_v2.file_tree.to_bencode(),
588 );
589
590 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 merged.insert(
653 b"meta version".to_vec(),
654 irontide_bencode::BencodeValue::Integer(2),
655 );
656
657 merged.insert(
659 b"name".to_vec(),
660 irontide_bencode::BencodeValue::Bytes(info.name.as_bytes().to_vec()),
661 );
662
663 merged.insert(
665 b"piece length".to_vec(),
666 irontide_bencode::BencodeValue::Integer(piece_size as i64),
667 );
668
669 merged.insert(
671 b"pieces".to_vec(),
672 irontide_bencode::BencodeValue::Bytes(pieces),
673 );
674
675 if self.private {
677 merged.insert(
678 b"private".to_vec(),
679 irontide_bencode::BencodeValue::Integer(1),
680 );
681 }
682
683 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 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 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 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 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 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 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
785fn 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 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 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#[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
930struct V2Output {
932 info_dict_v2: InfoDictV2,
934 piece_layers: std::collections::BTreeMap<Id32, Vec<u8>>,
936 piece_layers_raw: std::collections::BTreeMap<Vec<u8>, Vec<u8>>,
938 info_hash_v2: Id32,
940 info_bytes: Vec<u8>,
942}
943
944fn 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 let v2_data = compute_v2_merkle_data(files, piece_size)?;
959
960 let file_tree = build_v2_file_tree(&v2_data);
962
963 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 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 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#[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 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
1142fn 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
1163fn detect_attr(path: &Path, meta: &fs::Metadata) -> Option<String> {
1165 let mut attr = String::new();
1166
1167 if let Some(name) = path.file_name()
1169 && name.to_string_lossy().starts_with('.')
1170 {
1171 attr.push('h');
1172 }
1173
1174 #[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; if attr.is_empty() { None } else { Some(attr) }
1185}
1186
1187fn 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 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
1272fn 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
1315fn 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 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
1346struct V2FileData {
1348 torrent_path: Vec<String>,
1350 length: u64,
1352 pieces_root: Option<Id32>,
1354 piece_layer: Vec<Id32>,
1356}
1357
1358fn 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 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 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 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
1418fn 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 let mut current = &mut root;
1436 for (i, component) in fd.torrent_path.iter().enumerate() {
1437 if i == fd.torrent_path.len() - 1 {
1438 current.insert(component.clone(), file_node);
1440 break;
1441 }
1442 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 fn make_test_dir() -> tempfile::TempDir {
1464 let dir = tempfile::tempdir().unwrap();
1465 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 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 assert_eq!(auto_piece_size(0), 32 * 1024);
1487 assert_eq!(auto_piece_size(10 * 1024 * 1024), 32 * 1024);
1488 assert_eq!(auto_piece_size(10 * 1024 * 1024 + 1), 64 * 1024);
1490 assert_eq!(auto_piece_size(100 * 1024 * 1024), 64 * 1024);
1491 assert_eq!(auto_piece_size(100 * 1024 * 1024 + 1), 256 * 1024);
1493 assert_eq!(auto_piece_size(1024 * 1024 * 1024), 256 * 1024);
1494 assert_eq!(auto_piece_size(1024 * 1024 * 1024 + 1), 512 * 1024);
1496 assert_eq!(auto_piece_size(10 * 1024 * 1024 * 1024), 512 * 1024);
1497 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 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 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 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 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 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 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); assert_eq!(al[1].len(), 1); 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 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 let pad_count = files
1653 .iter()
1654 .filter(|f| f.attr.as_deref() == Some("p"))
1655 .count();
1656 assert_eq!(pad_count, 1);
1658 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 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 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 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 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 let parsed = torrent_from_bytes(&result.bytes).unwrap();
1745 assert_eq!(parsed.info_hash, result.meta.as_v1().unwrap().info_hash);
1746
1747 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 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 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 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 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 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 #[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 assert!(result.meta.is_hybrid());
1851 assert!(result.meta.version().is_hybrid());
1852
1853 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 let v2 = result.meta.as_v2().unwrap();
1861 assert_eq!(v2.info.meta_version, 2);
1862 assert_eq!(v2.info.piece_length, 32768);
1863 let files = v2.info.files();
1865 assert_eq!(files.len(), 1);
1866 assert_eq!(files[0].attr.length, 65536);
1867 assert!(files[0].attr.pieces_root.is_some());
1869
1870 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 let parsed = torrent_from_bytes(&result.bytes).unwrap();
1877 assert_eq!(parsed.info_hash, v1.info_hash);
1878
1879 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 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 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) .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 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 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 #[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 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 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) .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 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 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 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 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 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 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 let _best = hashes.best_v1();
2152
2153 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 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}