1use bytes::Bytes;
2use serde::de::{self, Deserializer};
3use serde::{Deserialize, Serialize};
4
5use crate::error::Error;
6use crate::hash::Id20;
7
8fn deserialize_similar<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<Id20>, D::Error> {
16 struct SimilarVisitor;
17
18 impl<'de> de::Visitor<'de> for SimilarVisitor {
19 type Value = Vec<Id20>;
20
21 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 f.write_str("a list of 20-byte binary strings")
23 }
24
25 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Id20>, A::Error> {
26 let mut hashes = Vec::new();
27 while let Some(bytes) = seq.next_element::<serde_bytes::ByteBuf>()? {
29 if let Ok(id) = Id20::from_bytes(bytes.as_ref()) {
30 hashes.push(id);
31 }
32 }
34 Ok(hashes)
35 }
36 }
37
38 deserializer.deserialize_seq(SimilarVisitor)
39}
40
41#[derive(Debug, Clone, Default)]
43pub struct UrlList(pub Vec<String>);
44
45impl<'de> Deserialize<'de> for UrlList {
46 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
47 struct UrlListVisitor;
48
49 impl<'de> de::Visitor<'de> for UrlListVisitor {
50 type Value = UrlList;
51
52 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.write_str("a string or list of strings")
54 }
55
56 fn visit_str<E: de::Error>(self, v: &str) -> Result<UrlList, E> {
57 Ok(UrlList(vec![v.to_owned()]))
58 }
59
60 fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<UrlList, E> {
61 let s = std::str::from_utf8(v).map_err(de::Error::custom)?;
62 Ok(UrlList(vec![s.to_owned()]))
63 }
64
65 fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<UrlList, A::Error> {
66 let mut urls = Vec::new();
67 while let Some(url) = seq.next_element::<String>()? {
68 urls.push(url);
69 }
70 Ok(UrlList(urls))
71 }
72 }
73
74 deserializer.deserialize_any(UrlListVisitor)
75 }
76}
77
78#[derive(Debug, Clone)]
80pub struct TorrentMetaV1 {
81 pub info_hash: Id20,
83 pub announce: Option<String>,
85 pub announce_list: Option<Vec<Vec<String>>>,
87 pub comment: Option<String>,
89 pub created_by: Option<String>,
91 pub creation_date: Option<i64>,
93 pub info: InfoDict,
95 pub url_list: Vec<String>,
97 pub httpseeds: Vec<String>,
99 pub info_bytes: Option<Bytes>,
101 pub ssl_cert: Option<Vec<u8>>,
103}
104
105impl TorrentMetaV1 {
106 pub fn to_torrent_bytes(&self) -> Result<Vec<u8>, Error> {
121 fn push_key(out: &mut Vec<u8>, key: &[u8]) {
122 out.extend_from_slice(key.len().to_string().as_bytes());
123 out.push(b':');
124 out.extend_from_slice(key);
125 }
126 fn push_str(out: &mut Vec<u8>, s: &str) {
127 out.extend_from_slice(s.len().to_string().as_bytes());
128 out.push(b':');
129 out.extend_from_slice(s.as_bytes());
130 }
131 fn push_str_list(out: &mut Vec<u8>, items: &[String]) {
132 out.push(b'l');
133 for item in items {
134 push_str(out, item);
135 }
136 out.push(b'e');
137 }
138
139 let info_raw: Vec<u8> = match &self.info_bytes {
140 Some(raw) => raw.to_vec(),
141 None => irontide_bencode::to_bytes(&self.info)?,
142 };
143
144 let mut out = Vec::with_capacity(info_raw.len() + 256);
145 out.push(b'd');
146 if let Some(ref announce) = self.announce {
147 push_key(&mut out, b"announce");
148 push_str(&mut out, announce);
149 }
150 if let Some(ref tiers) = self.announce_list {
151 push_key(&mut out, b"announce-list");
152 out.push(b'l');
153 for tier in tiers {
154 push_str_list(&mut out, tier);
155 }
156 out.push(b'e');
157 }
158 if let Some(ref comment) = self.comment {
159 push_key(&mut out, b"comment");
160 push_str(&mut out, comment);
161 }
162 if let Some(ref created_by) = self.created_by {
163 push_key(&mut out, b"created by");
164 push_str(&mut out, created_by);
165 }
166 if let Some(date) = self.creation_date {
167 push_key(&mut out, b"creation date");
168 out.push(b'i');
169 out.extend_from_slice(date.to_string().as_bytes());
170 out.push(b'e');
171 }
172 if !self.httpseeds.is_empty() {
173 push_key(&mut out, b"httpseeds");
174 push_str_list(&mut out, &self.httpseeds);
175 }
176 push_key(&mut out, b"info");
177 out.extend_from_slice(&info_raw);
178 if !self.url_list.is_empty() {
179 push_key(&mut out, b"url-list");
180 push_str_list(&mut out, &self.url_list);
181 }
182 out.push(b'e');
183 Ok(out)
184 }
185}
186
187#[derive(Debug, Clone, Deserialize, Serialize)]
189pub struct InfoDict {
190 pub name: String,
192 #[serde(rename = "piece length")]
194 pub piece_length: u64,
195 #[serde(with = "serde_bytes")]
197 pub pieces: Vec<u8>,
198 #[serde(skip_serializing_if = "Option::is_none", default)]
200 pub length: Option<u64>,
201 #[serde(skip_serializing_if = "Option::is_none", default)]
203 pub files: Option<Vec<FileEntry>>,
204 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub private: Option<i64>,
207 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub source: Option<String>,
210 #[serde(rename = "ssl-cert", skip_serializing_if = "Option::is_none", default)]
213 #[serde(with = "serde_bytes")]
214 pub ssl_cert: Option<Vec<u8>>,
215 #[serde(
219 default,
220 skip_serializing_if = "Vec::is_empty",
221 deserialize_with = "deserialize_similar"
222 )]
223 pub similar: Vec<Id20>,
224 #[serde(default, skip_serializing_if = "Vec::is_empty")]
226 pub collections: Vec<String>,
227}
228
229#[derive(Debug, Clone, Deserialize, Serialize)]
231pub struct FileEntry {
232 pub length: u64,
234 pub path: Vec<String>,
236 #[serde(skip_serializing_if = "Option::is_none", default)]
238 pub attr: Option<String>,
239 #[serde(skip_serializing_if = "Option::is_none", default)]
241 pub mtime: Option<i64>,
242 #[serde(
244 rename = "symlink path",
245 skip_serializing_if = "Option::is_none",
246 default
247 )]
248 pub symlink_path: Option<Vec<String>>,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct FileInfo {
254 pub path: Vec<String>,
256 pub length: u64,
258}
259
260#[derive(Deserialize)]
262struct RawTorrent {
263 announce: Option<String>,
264 #[serde(rename = "announce-list")]
265 announce_list: Option<Vec<Vec<String>>>,
266 comment: Option<String>,
267 #[serde(rename = "created by")]
268 created_by: Option<String>,
269 #[serde(rename = "creation date")]
270 creation_date: Option<i64>,
271 info: InfoDict,
272 #[serde(rename = "url-list", default)]
274 url_list: UrlList,
275 #[serde(default)]
277 httpseeds: Vec<String>,
278}
279
280pub fn torrent_from_bytes(data: &[u8]) -> Result<TorrentMetaV1, Error> {
289 let info_span = irontide_bencode::find_dict_key_span(data, "info")?;
291 let info_hash = crate::sha1(&data[info_span.clone()]);
292 let info_raw = Bytes::copy_from_slice(&data[info_span]);
293
294 let raw: RawTorrent = irontide_bencode::from_bytes(data)?;
296
297 validate_info(&raw.info)?;
299
300 let ssl_cert = raw.info.ssl_cert.clone();
301
302 Ok(TorrentMetaV1 {
303 info_hash,
304 announce: raw.announce,
305 announce_list: raw.announce_list,
306 comment: raw.comment,
307 created_by: raw.created_by,
308 creation_date: raw.creation_date,
309 info: raw.info,
310 url_list: raw.url_list.0,
311 httpseeds: raw.httpseeds,
312 info_bytes: Some(info_raw),
313 ssl_cert,
314 })
315}
316
317fn validate_info(info: &InfoDict) -> Result<(), Error> {
318 if info.piece_length == 0 {
319 return Err(Error::InvalidTorrent("piece length is 0".into()));
320 }
321
322 if !info.pieces.len().is_multiple_of(20) {
323 return Err(Error::InvalidTorrent(format!(
324 "pieces length {} is not a multiple of 20",
325 info.pieces.len()
326 )));
327 }
328
329 if info.length.is_none() && info.files.is_none() {
330 return Err(Error::InvalidTorrent(
331 "neither 'length' nor 'files' present".into(),
332 ));
333 }
334
335 if info.length.is_some() && info.files.is_some() {
336 return Err(Error::InvalidTorrent(
337 "both 'length' and 'files' present".into(),
338 ));
339 }
340
341 Ok(())
342}
343
344impl InfoDict {
345 #[must_use]
347 pub fn total_length(&self) -> u64 {
348 if let Some(length) = self.length {
349 length
350 } else if let Some(ref files) = self.files {
351 files.iter().map(|f| f.length).sum()
352 } else {
353 0
354 }
355 }
356
357 #[must_use]
359 pub fn num_pieces(&self) -> usize {
360 self.pieces.len() / 20
361 }
362
363 #[must_use]
365 pub fn piece_hash(&self, index: usize) -> Option<Id20> {
366 let start = index * 20;
367 if start + 20 > self.pieces.len() {
368 return None;
369 }
370 let mut hash = [0u8; 20];
371 hash.copy_from_slice(&self.pieces[start..start + 20]);
372 Some(Id20(hash))
373 }
374
375 #[must_use]
377 pub fn files(&self) -> Vec<FileInfo> {
378 if let Some(length) = self.length {
379 vec![FileInfo {
380 path: vec![self.name.clone()],
381 length,
382 }]
383 } else if let Some(ref files) = self.files {
384 files
385 .iter()
386 .map(|f| {
387 let mut path = vec![self.name.clone()];
388 path.extend(f.path.clone());
389 FileInfo {
390 path,
391 length: f.length,
392 }
393 })
394 .collect()
395 } else {
396 vec![]
397 }
398 }
399}
400
401#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
408#[serde(rename_all = "snake_case")]
409pub enum ContentLayout {
410 #[default]
412 Original,
413 Subfolder,
416 NoSubfolder,
419}
420
421impl ContentLayout {
422 #[must_use]
427 pub fn apply_to_files(self, mut files: Vec<FileInfo>) -> Vec<FileInfo> {
428 match self {
429 Self::Original => files,
430 Self::Subfolder => {
431 for f in &mut files {
432 if f.path.len() == 1 {
433 let name = f.path[0].clone();
434 f.path.insert(0, name);
435 }
436 }
437 files
438 }
439 Self::NoSubfolder => {
440 for f in &mut files {
445 if f.path.len() >= 2 {
446 f.path.remove(0);
447 }
448 }
449 files
450 }
451 }
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458
459 fn make_torrent_bytes_sorted(before_info: &[u8], after_info: &[u8]) -> Vec<u8> {
464 let info = b"d6:lengthi1048576e4:name4:test12:piece lengthi262144e6:pieces20:\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00e";
466 let mut buf = Vec::new();
467 buf.push(b'd');
468 buf.extend_from_slice(before_info);
469 buf.extend_from_slice(b"4:info");
470 buf.extend_from_slice(info);
471 buf.extend_from_slice(after_info);
472 buf.push(b'e');
473 buf
474 }
475
476 #[test]
477 fn url_list_single_string() {
478 let data = make_torrent_bytes_sorted(b"", b"8:url-list24:http://example.com/files");
480 let meta = torrent_from_bytes(&data).unwrap();
481 assert_eq!(meta.url_list, vec!["http://example.com/files"]);
482 }
483
484 #[test]
485 fn url_list_multiple() {
486 let data = make_torrent_bytes_sorted(
487 b"",
488 b"8:url-listl24:http://example.com/files26:http://mirror.example.com/e",
489 );
490 let meta = torrent_from_bytes(&data).unwrap();
491 assert_eq!(meta.url_list.len(), 2);
492 assert_eq!(meta.url_list[0], "http://example.com/files");
493 assert_eq!(meta.url_list[1], "http://mirror.example.com/");
494 }
495
496 #[test]
500 fn m254_to_torrent_bytes_round_trips_info_hash() {
501 let data = make_torrent_bytes_sorted(
502 b"8:announce18:http://tr.example/",
503 b"8:url-list24:http://example.com/files",
504 );
505 let meta = torrent_from_bytes(&data).unwrap();
506 let out = meta.to_torrent_bytes().unwrap();
507 let back = torrent_from_bytes(&out).unwrap();
508 assert_eq!(meta.info_hash, back.info_hash, "info-hash must be exact");
509 assert_eq!(back.announce.as_deref(), Some("http://tr.example/"));
510 assert_eq!(back.url_list, vec!["http://example.com/files"]);
511 }
512
513 #[test]
516 fn m254_to_torrent_bytes_fallback_without_raw_info() {
517 let data = make_torrent_bytes_sorted(b"8:announce18:http://tr.example/", b"");
518 let mut meta = torrent_from_bytes(&data).unwrap();
519 meta.info_bytes = None;
520 let out = meta.to_torrent_bytes().unwrap();
521 let back = torrent_from_bytes(&out).unwrap();
522 assert_eq!(meta.info_hash, back.info_hash);
525 assert_eq!(back.info.name, meta.info.name);
526 }
527
528 #[test]
529 fn url_list_absent() {
530 let data = make_torrent_bytes_sorted(b"", b"");
531 let meta = torrent_from_bytes(&data).unwrap();
532 assert!(meta.url_list.is_empty());
533 }
534
535 #[test]
536 fn httpseeds_present() {
537 let data = make_torrent_bytes_sorted(b"9:httpseedsl28:http://seed.example.com/seede", b"");
539 let meta = torrent_from_bytes(&data).unwrap();
540 assert_eq!(meta.httpseeds, vec!["http://seed.example.com/seed"]);
541 }
542
543 #[test]
544 fn httpseeds_absent() {
545 let data = make_torrent_bytes_sorted(b"", b"");
546 let meta = torrent_from_bytes(&data).unwrap();
547 assert!(meta.httpseeds.is_empty());
548 }
549
550 #[test]
551 fn torrent_from_bytes_stores_raw_info_bytes() {
552 let data = make_torrent_bytes_sorted(b"", b"");
553 let meta = torrent_from_bytes(&data).unwrap();
554 assert!(meta.info_bytes.is_some());
555 let info_bytes = meta.info_bytes.unwrap();
556 let rehash = crate::sha1(&info_bytes);
558 assert_eq!(rehash, meta.info_hash);
559 }
560
561 #[test]
562 fn ssl_cert_parsed_from_info_dict() {
563 let cert_pem = b"-----BEGIN CERTIFICATE-----\nMIIBtest\n-----END CERTIFICATE-----\n";
565 let cert_len = cert_pem.len();
566
567 let mut info = Vec::new();
569 info.extend_from_slice(b"d");
570 info.extend_from_slice(b"6:lengthi1048576e");
571 info.extend_from_slice(b"4:name4:test");
572 info.extend_from_slice(b"12:piece lengthi262144e");
573 info.extend_from_slice(b"6:pieces20:");
574 info.extend_from_slice(&[0u8; 20]);
575 info.extend_from_slice(format!("8:ssl-cert{cert_len}:").as_bytes());
576 info.extend_from_slice(cert_pem);
577 info.extend_from_slice(b"e");
578
579 let mut torrent = Vec::new();
580 torrent.extend_from_slice(b"d4:info");
581 torrent.extend_from_slice(&info);
582 torrent.extend_from_slice(b"e");
583
584 let meta = torrent_from_bytes(&torrent).unwrap();
585 assert!(meta.ssl_cert.is_some());
586 assert_eq!(meta.ssl_cert.as_deref().unwrap(), cert_pem);
587 assert_eq!(meta.info.ssl_cert.as_deref().unwrap(), cert_pem);
588 }
589
590 #[test]
591 fn ssl_cert_absent_by_default() {
592 let data = make_torrent_bytes_sorted(b"", b"");
593 let meta = torrent_from_bytes(&data).unwrap();
594 assert!(meta.ssl_cert.is_none());
595 assert!(meta.info.ssl_cert.is_none());
596 }
597
598 fn make_torrent_with_bep38(similar: Option<&[u8]>, collections: Option<&[u8]>) -> Vec<u8> {
601 let mut info = Vec::new();
602 info.extend_from_slice(b"d");
603 if let Some(c) = collections {
605 info.extend_from_slice(b"11:collections");
606 info.extend_from_slice(c);
607 }
608 info.extend_from_slice(b"6:lengthi1048576e");
609 info.extend_from_slice(b"4:name4:test");
610 info.extend_from_slice(b"12:piece lengthi262144e");
611 info.extend_from_slice(b"6:pieces20:");
612 info.extend_from_slice(&[0u8; 20]);
613 if let Some(s) = similar {
615 info.extend_from_slice(b"7:similar");
616 info.extend_from_slice(s);
617 }
618 info.extend_from_slice(b"e");
619
620 let mut torrent = Vec::new();
621 torrent.extend_from_slice(b"d4:info");
622 torrent.extend_from_slice(&info);
623 torrent.extend_from_slice(b"e");
624 torrent
625 }
626
627 #[test]
628 fn parse_similar_torrents_from_info() {
629 let hash_a = [0xAAu8; 20];
630 let hash_b = [0xBBu8; 20];
631
632 let mut similar_list = Vec::new();
634 similar_list.extend_from_slice(b"l");
635 similar_list.extend_from_slice(b"20:");
636 similar_list.extend_from_slice(&hash_a);
637 similar_list.extend_from_slice(b"20:");
638 similar_list.extend_from_slice(&hash_b);
639 similar_list.extend_from_slice(b"e");
640
641 let data = make_torrent_with_bep38(Some(&similar_list), None);
642 let meta = torrent_from_bytes(&data).expect("parse should succeed");
643
644 assert_eq!(meta.info.similar.len(), 2);
645 assert_eq!(meta.info.similar[0], Id20(hash_a));
646 assert_eq!(meta.info.similar[1], Id20(hash_b));
647 }
648
649 #[test]
650 fn parse_collections_from_info() {
651 let collections_list = b"l6:movies6:sci-fie";
653
654 let data = make_torrent_with_bep38(None, Some(collections_list));
655 let meta = torrent_from_bytes(&data).expect("parse should succeed");
656
657 assert_eq!(meta.info.collections.len(), 2);
658 assert_eq!(meta.info.collections[0], "movies");
659 assert_eq!(meta.info.collections[1], "sci-fi");
660 }
661
662 #[test]
663 fn similar_empty_when_absent() {
664 let data = make_torrent_bytes_sorted(b"", b"");
665 let meta = torrent_from_bytes(&data).expect("parse should succeed");
666 assert!(meta.info.similar.is_empty());
667 assert!(meta.info.collections.is_empty());
668 }
669
670 #[test]
671 fn similar_ignores_wrong_length_hashes() {
672 let valid_hash = [0xCCu8; 20];
673 let too_short = [0xDDu8; 19];
674 let too_long = [0xEEu8; 21];
675
676 let mut similar_list = Vec::new();
678 similar_list.extend_from_slice(b"l");
679 similar_list.extend_from_slice(b"19:");
681 similar_list.extend_from_slice(&too_short);
682 similar_list.extend_from_slice(b"20:");
684 similar_list.extend_from_slice(&valid_hash);
685 similar_list.extend_from_slice(b"21:");
687 similar_list.extend_from_slice(&too_long);
688 similar_list.extend_from_slice(b"e");
689
690 let data = make_torrent_with_bep38(Some(&similar_list), None);
691 let meta = torrent_from_bytes(&data).expect("parse should succeed");
692
693 assert_eq!(meta.info.similar.len(), 1);
695 assert_eq!(meta.info.similar[0], Id20(valid_hash));
696 }
697
698 #[test]
699 fn m252_content_layout_original_is_identity() {
700 let files = vec![
701 FileInfo {
702 path: vec!["root".into(), "a.bin".into()],
703 length: 1,
704 },
705 FileInfo {
706 path: vec!["root".into(), "sub".into(), "b.bin".into()],
707 length: 2,
708 },
709 ];
710 let out = ContentLayout::Original.apply_to_files(files.clone());
711 assert_eq!(out, files);
712 }
713
714 #[test]
715 fn m252_content_layout_subfolder_wraps_single_file_only() {
716 let single = vec![FileInfo {
717 path: vec!["movie.mkv".into()],
718 length: 9,
719 }];
720 let out = ContentLayout::Subfolder.apply_to_files(single);
721 assert_eq!(
722 out[0].path,
723 vec!["movie.mkv".to_string(), "movie.mkv".to_string()]
724 );
725
726 let multi = vec![FileInfo {
727 path: vec!["root".into(), "a.bin".into()],
728 length: 1,
729 }];
730 let out = ContentLayout::Subfolder.apply_to_files(multi.clone());
731 assert_eq!(
732 out, multi,
733 "multi-file already has a root — Subfolder is a no-op"
734 );
735 }
736
737 #[test]
738 fn m252_content_layout_nosubfolder_strips_root_per_entry() {
739 let multi = vec![
740 FileInfo {
741 path: vec!["root".into(), "a.bin".into()],
742 length: 1,
743 },
744 FileInfo {
745 path: vec!["root".into(), "sub".into(), "b.bin".into()],
746 length: 2,
747 },
748 ];
749 let out = ContentLayout::NoSubfolder.apply_to_files(multi);
750 assert_eq!(out[0].path, vec!["a.bin".to_string()]);
751 assert_eq!(out[1].path, vec!["sub".to_string(), "b.bin".to_string()]);
752
753 let single = vec![FileInfo {
754 path: vec!["movie.mkv".into()],
755 length: 9,
756 }];
757 let out = ContentLayout::NoSubfolder.apply_to_files(single.clone());
758 assert_eq!(
759 out, single,
760 "single-file is already flat — NoSubfolder is a no-op"
761 );
762
763 let one_entry_multi = vec![FileInfo {
766 path: vec!["root".into(), "only.bin".into()],
767 length: 3,
768 }];
769 let out = ContentLayout::NoSubfolder.apply_to_files(one_entry_multi);
770 assert_eq!(out[0].path, vec!["only.bin".to_string()]);
771 }
772
773 #[test]
774 fn m252_content_layout_serde_snake_case_round_trip() {
775 let j = serde_json::to_string(&ContentLayout::NoSubfolder).unwrap();
776 assert_eq!(j, "\"no_subfolder\"");
777 let back: ContentLayout = serde_json::from_str(&j).unwrap();
778 assert_eq!(back, ContentLayout::NoSubfolder);
779 assert_eq!(ContentLayout::default(), ContentLayout::Original);
780 }
781}