1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::WebSeedStats;
6
7fn default_neg_one() -> i64 {
8 -1
9}
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct UnfinishedPiece {
14 pub piece: i64,
16 #[serde(with = "serde_bytes")]
18 pub bitmask: Vec<u8>,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct FastResumeData {
29 #[serde(rename = "file-format")]
31 pub file_format: String,
32
33 #[serde(rename = "file-version")]
35 pub file_version: i64,
36
37 #[serde(rename = "info-hash")]
39 #[serde(with = "serde_bytes")]
40 pub info_hash: Vec<u8>,
41
42 #[serde(rename = "name")]
44 pub name: String,
45
46 #[serde(rename = "save_path")]
48 pub save_path: String,
49
50 #[serde(rename = "pieces")]
52 #[serde(with = "serde_bytes")]
53 pub pieces: Vec<u8>,
54
55 #[serde(rename = "unfinished")]
57 #[serde(skip_serializing_if = "Vec::is_empty", default)]
58 pub unfinished: Vec<UnfinishedPiece>,
59
60 #[serde(rename = "total_uploaded")]
62 pub total_uploaded: i64,
63
64 #[serde(rename = "total_downloaded")]
66 pub total_downloaded: i64,
67
68 #[serde(rename = "active_time")]
70 pub active_time: i64,
71
72 #[serde(rename = "seeding_time")]
74 pub seeding_time: i64,
75
76 #[serde(rename = "finished_time")]
78 pub finished_time: i64,
79
80 #[serde(rename = "added_time")]
82 pub added_time: i64,
83
84 #[serde(rename = "completed_time")]
86 #[serde(default)]
87 pub completed_time: i64,
88
89 #[serde(rename = "last_download")]
91 #[serde(default)]
92 pub last_download: i64,
93
94 #[serde(rename = "last_upload")]
96 #[serde(default)]
97 pub last_upload: i64,
98
99 #[serde(rename = "paused")]
101 #[serde(default)]
102 pub paused: i64,
103
104 #[serde(rename = "queued")]
106 #[serde(default)]
107 pub queued: i64,
108
109 #[serde(rename = "auto_managed")]
111 #[serde(default)]
112 pub auto_managed: i64,
113
114 #[serde(rename = "queue_position")]
116 #[serde(default = "default_neg_one")]
117 pub queue_position: i64,
118
119 #[serde(rename = "sequential_download")]
121 #[serde(default)]
122 pub sequential_download: i64,
123
124 #[serde(rename = "prioritize_first_last_pieces")]
126 #[serde(default)]
127 pub prioritize_first_last_pieces: i64,
128
129 #[serde(rename = "seed_mode")]
131 #[serde(default)]
132 pub seed_mode: i64,
133
134 #[serde(rename = "trackers")]
136 #[serde(skip_serializing_if = "Vec::is_empty", default)]
137 pub trackers: Vec<Vec<String>>,
138
139 #[serde(rename = "peers")]
141 #[serde(with = "serde_bytes")]
142 #[serde(default)]
143 pub peers: Vec<u8>,
144
145 #[serde(rename = "peers6")]
147 #[serde(with = "serde_bytes")]
148 #[serde(default)]
149 pub peers6: Vec<u8>,
150
151 #[serde(rename = "file_priority")]
153 #[serde(skip_serializing_if = "Vec::is_empty", default)]
154 pub file_priority: Vec<i64>,
155
156 #[serde(rename = "piece_priority")]
158 #[serde(skip_serializing_if = "Vec::is_empty", default)]
159 pub piece_priority: Vec<i64>,
160
161 #[serde(rename = "upload_rate_limit")]
163 #[serde(default)]
164 pub upload_rate_limit: i64,
165
166 #[serde(rename = "download_rate_limit")]
168 #[serde(default)]
169 pub download_rate_limit: i64,
170
171 #[serde(rename = "max_connections")]
173 #[serde(default)]
174 pub max_connections: i64,
175
176 #[serde(rename = "max_uploads")]
178 #[serde(default)]
179 pub max_uploads: i64,
180
181 #[serde(rename = "info")]
183 #[serde(with = "serde_bytes")]
184 #[serde(skip_serializing_if = "Option::is_none", default)]
185 pub info: Option<Vec<u8>>,
186
187 #[serde(rename = "super_seeding")]
189 #[serde(default)]
190 pub super_seeding: i64,
191
192 #[serde(rename = "url_seeds")]
194 #[serde(skip_serializing_if = "Vec::is_empty", default)]
195 pub url_seeds: Vec<String>,
196
197 #[serde(rename = "http_seeds")]
199 #[serde(skip_serializing_if = "Vec::is_empty", default)]
200 pub http_seeds: Vec<String>,
201
202 #[serde(rename = "info-hash2")]
204 #[serde(with = "serde_bytes")]
205 #[serde(skip_serializing_if = "Option::is_none", default)]
206 pub info_hash2: Option<Vec<u8>>,
207
208 #[serde(rename = "trees")]
212 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
213 pub trees: HashMap<String, Vec<u8>>,
214
215 #[serde(rename = "category")]
224 #[serde(skip_serializing_if = "Option::is_none", default)]
225 pub category: Option<String>,
226 #[serde(rename = "content_layout")]
229 #[serde(skip_serializing_if = "Option::is_none", default)]
230 pub content_layout: Option<crate::ContentLayout>,
231 #[serde(rename = "created_by")]
234 #[serde(skip_serializing_if = "Option::is_none", default)]
235 pub created_by: Option<String>,
236 #[serde(rename = "creation_date")]
240 #[serde(skip_serializing_if = "Option::is_none", default)]
241 pub creation_date: Option<i64>,
242
243 #[serde(rename = "tags")]
248 #[serde(skip_serializing_if = "Vec::is_empty", default)]
249 pub tags: Vec<String>,
250
251 #[serde(rename = "web_seed_stats")]
258 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
259 pub web_seed_stats: HashMap<String, WebSeedStats>,
260}
261
262impl FastResumeData {
263 #[must_use]
266 pub fn new(info_hash: Vec<u8>, name: String, save_path: String) -> Self {
267 Self {
268 file_format: "libtorrent resume file".into(),
269 file_version: 1,
270 info_hash,
271 name,
272 save_path,
273 pieces: Vec::new(),
274 unfinished: Vec::new(),
275 total_uploaded: 0,
276 total_downloaded: 0,
277 active_time: 0,
278 seeding_time: 0,
279 finished_time: 0,
280 added_time: 0,
281 completed_time: 0,
282 last_download: 0,
283 last_upload: 0,
284 paused: 0,
285 queued: 0,
286 auto_managed: 0,
287 queue_position: -1,
288 sequential_download: 0,
289 prioritize_first_last_pieces: 0,
290 seed_mode: 0,
291 trackers: Vec::new(),
292 peers: Vec::new(),
293 peers6: Vec::new(),
294 file_priority: Vec::new(),
295 piece_priority: Vec::new(),
296 upload_rate_limit: -1,
297 download_rate_limit: -1,
298 max_connections: -1,
299 max_uploads: -1,
300 super_seeding: 0,
301 info: None,
302 url_seeds: Vec::new(),
303 http_seeds: Vec::new(),
304 info_hash2: None,
305 trees: HashMap::new(),
306 category: None,
308 content_layout: None,
309 created_by: None,
310 creation_date: None,
311 tags: Vec::new(),
313 web_seed_stats: HashMap::new(),
315 }
316 }
317}
318
319#[must_use]
331pub fn validate_resume_bitfield(pieces: &[u8], num_pieces: u32) -> bool {
332 if num_pieces == 0 {
333 return pieces.is_empty();
334 }
335 let expected = num_pieces.div_ceil(8) as usize;
336 pieces.len() == expected
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use pretty_assertions::assert_eq;
343
344 #[test]
345 fn fast_resume_data_bencode_round_trip() {
346 let mut resume =
347 FastResumeData::new(vec![0xAA; 20], "test-torrent".into(), "/downloads".into());
348 resume.total_uploaded = 1024 * 1024;
349 resume.total_downloaded = 2048 * 1024;
350 resume.active_time = 3600;
351 resume.added_time = 1_700_000_000;
352 resume.trackers = vec![
353 vec!["http://tracker1.example.com/announce".into()],
354 vec![
355 "http://tracker2.example.com/announce".into(),
356 "http://tracker3.example.com/announce".into(),
357 ],
358 ];
359 resume.pieces = vec![0xFF; 10];
360
361 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
362 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
363 assert_eq!(resume, decoded);
364 }
365
366 #[test]
369 fn m253_ordering_flags_round_trip_and_legacy_default() {
370 let mut rd = FastResumeData::new(vec![0xAA; 20], "m253".into(), "/dl".into());
371 rd.sequential_download = 1;
372 rd.prioritize_first_last_pieces = 1;
373 let bytes = irontide_bencode::to_bytes(&rd).unwrap();
374 let back: FastResumeData = irontide_bencode::from_bytes(&bytes).unwrap();
375 assert_eq!(back.sequential_download, 1);
376 assert_eq!(back.prioritize_first_last_pieces, 1);
377
378 let legacy = FastResumeData::new(vec![0xBB; 20], "legacy".into(), "/dl".into());
381 let bytes = irontide_bencode::to_bytes(&legacy).unwrap();
382 let back: FastResumeData = irontide_bencode::from_bytes(&bytes).unwrap();
383 assert_eq!(back.prioritize_first_last_pieces, 0);
384 }
385
386 #[test]
389 fn m252_resume_content_layout_round_trips_and_legacy_defaults_none() {
390 let mut rd = FastResumeData::new(vec![0xBB; 20], "m252".into(), "/dl".into());
391 rd.content_layout = Some(crate::ContentLayout::NoSubfolder);
392 let bytes = irontide_bencode::to_bytes(&rd).unwrap();
393 let back: FastResumeData = irontide_bencode::from_bytes(&bytes).unwrap();
394 assert_eq!(back.content_layout, Some(crate::ContentLayout::NoSubfolder));
395
396 let mut legacy = FastResumeData::new(vec![0xCC; 20], "legacy".into(), "/dl".into());
397 legacy.content_layout = None;
398 let bytes = irontide_bencode::to_bytes(&legacy).unwrap();
399 let back: FastResumeData = irontide_bencode::from_bytes(&bytes).unwrap();
400 assert_eq!(
401 back.content_layout, None,
402 "skip_serializing_if keeps the legacy wire shape"
403 );
404 }
405
406 #[test]
407 fn unfinished_piece_bencode_round_trip() {
408 let piece = UnfinishedPiece {
409 piece: 42,
410 bitmask: vec![0b1010_1010, 0b0101_0101],
411 };
412
413 let encoded = irontide_bencode::to_bytes(&piece).unwrap();
414 let decoded: UnfinishedPiece = irontide_bencode::from_bytes(&encoded).unwrap();
415 assert_eq!(piece, decoded);
416 }
417
418 #[test]
419 fn resume_data_with_unfinished_pieces() {
420 let mut resume = FastResumeData::new(
421 vec![0xBB; 20],
422 "partial-torrent".into(),
423 "/downloads".into(),
424 );
425 resume.unfinished = vec![
426 UnfinishedPiece {
427 piece: 5,
428 bitmask: vec![0xFF, 0x0F],
429 },
430 UnfinishedPiece {
431 piece: 12,
432 bitmask: vec![0xF0],
433 },
434 ];
435
436 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
437 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
438 assert_eq!(resume, decoded);
439 }
440
441 #[test]
442 fn default_fields_serialize_correctly() {
443 let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
444
445 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
446 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
447 assert_eq!(resume, decoded);
448
449 assert_eq!(decoded.total_uploaded, 0);
451 assert_eq!(decoded.total_downloaded, 0);
452 assert_eq!(decoded.paused, 0);
453 assert_eq!(decoded.upload_rate_limit, -1);
454 assert_eq!(decoded.download_rate_limit, -1);
455 assert_eq!(decoded.max_connections, -1);
456 assert_eq!(decoded.max_uploads, -1);
457 assert!(decoded.trackers.is_empty());
458 assert!(decoded.unfinished.is_empty());
459 assert!(decoded.file_priority.is_empty());
460 assert!(decoded.info.is_none());
461 }
462
463 #[test]
464 fn info_dict_embedding_round_trip() {
465 let mut resume =
466 FastResumeData::new(vec![0xCC; 20], "with-info".into(), "/downloads".into());
467 resume.info = Some(
469 b"d4:name10:test-torte12:piece lengthi262144e6:pieces20:AAAAAAAAAAAAAAAAAAAAe".to_vec(),
470 );
471
472 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
473 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
474 assert_eq!(resume, decoded);
475 assert!(decoded.info.is_some());
476 assert_eq!(decoded.info.unwrap().len(), resume.info.unwrap().len());
477 }
478
479 #[test]
480 fn resume_data_queue_position_default() {
481 let rd = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
482 assert_eq!(rd.queue_position, -1);
483 }
484
485 #[test]
486 fn format_markers_correct() {
487 let resume = FastResumeData::new(vec![0x00; 20], "test".into(), "/tmp".into());
488 assert_eq!(resume.file_format, "libtorrent resume file");
489 assert_eq!(resume.file_version, 1);
490 }
491
492 #[test]
493 fn resume_data_url_seeds_round_trip() {
494 let mut resume =
495 FastResumeData::new(vec![0xDD; 20], "web-seed-test".into(), "/downloads".into());
496 resume.url_seeds = vec![
497 "http://example.com/files".into(),
498 "http://mirror.example.com/".into(),
499 ];
500
501 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
502 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
503 assert_eq!(decoded.url_seeds, resume.url_seeds);
504 }
505
506 #[test]
507 fn resume_data_http_seeds_round_trip() {
508 let mut resume =
509 FastResumeData::new(vec![0xEE; 20], "http-seed-test".into(), "/downloads".into());
510 resume.http_seeds = vec!["http://seed.example.com/seed".into()];
511
512 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
513 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
514 assert_eq!(decoded.http_seeds, resume.http_seeds);
515 }
516
517 #[test]
518 fn resume_data_super_seeding_round_trip() {
519 let mut resume = FastResumeData::new(
520 vec![0xFF; 20],
521 "super-seed-test".into(),
522 "/downloads".into(),
523 );
524 resume.super_seeding = 1;
525
526 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
527 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
528 assert_eq!(decoded.super_seeding, 1);
529
530 let default_resume = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
532 assert_eq!(default_resume.super_seeding, 0);
533 }
534
535 #[test]
536 fn resume_data_v2_fields_round_trip() {
537 let mut resume =
538 FastResumeData::new(vec![0xAA; 20], "v2-torrent".into(), "/downloads".into());
539 resume.info_hash2 = Some(vec![0xBB; 32]);
540 resume.trees.insert(
541 hex::encode([0xCC; 32]),
542 vec![0xDD; 64], );
544
545 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
546 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
547 assert_eq!(decoded.info_hash2, Some(vec![0xBB; 32]));
548 assert_eq!(decoded.trees.len(), 1);
549 }
550
551 #[test]
552 fn resume_data_v1_backward_compat() {
553 let resume = FastResumeData::new(vec![0x00; 20], "v1-torrent".into(), "/tmp".into());
554 assert!(resume.info_hash2.is_none());
555 assert!(resume.trees.is_empty());
556
557 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
558 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
559 assert!(decoded.info_hash2.is_none());
560 assert!(decoded.trees.is_empty());
561 }
562
563 #[test]
564 fn resume_data_v2_empty_trees_not_serialized() {
565 let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
566 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
567 let encoded_str = String::from_utf8_lossy(&encoded);
569 assert!(!encoded_str.contains("5:trees"));
570 }
571
572 #[test]
573 fn resume_data_empty_seeds_not_serialized() {
574 let resume = FastResumeData::new(vec![0x00; 20], "no-seeds".into(), "/tmp".into());
575 assert!(resume.url_seeds.is_empty());
576 assert!(resume.http_seeds.is_empty());
577
578 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
579 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
580 assert!(decoded.url_seeds.is_empty());
581 assert!(decoded.http_seeds.is_empty());
582 }
583
584 #[test]
585 fn resume_data_m170_fields_round_trip() {
586 let mut resume =
589 FastResumeData::new(vec![0xA1; 20], "m170-torrent".into(), "/downloads".into());
590 resume.category = Some("sonarr".into());
591 resume.created_by = Some("irontide/0.170".into());
592 resume.creation_date = Some(1_700_000_000);
593
594 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
595 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
596 assert_eq!(decoded.category.as_deref(), Some("sonarr"));
597 assert_eq!(decoded.created_by.as_deref(), Some("irontide/0.170"));
598 assert_eq!(decoded.creation_date, Some(1_700_000_000));
599 assert_eq!(resume, decoded);
600 }
601
602 #[test]
603 fn resume_data_m170_backward_compat_missing_fields() {
604 let mut resume =
608 FastResumeData::new(vec![0xB2; 20], "legacy-torrent".into(), "/downloads".into());
609 resume.category = None;
613 resume.created_by = None;
614 resume.creation_date = None;
615
616 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
617 let encoded_str = String::from_utf8_lossy(&encoded);
618 assert!(!encoded_str.contains("8:category"));
620 assert!(!encoded_str.contains("10:created_by"));
621 assert!(!encoded_str.contains("13:creation_date"));
622
623 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
624 assert!(decoded.category.is_none());
625 assert!(decoded.created_by.is_none());
626 assert!(decoded.creation_date.is_none());
627 }
628
629 #[test]
630 fn resume_data_m171_tags_round_trip() {
631 let mut resume =
633 FastResumeData::new(vec![0xC3; 20], "m171-tags".into(), "/downloads".into());
634 resume.tags = vec!["sonarr".into(), "kids".into()];
635
636 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
637 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
638 assert_eq!(decoded.tags, vec!["sonarr".to_string(), "kids".to_string()]);
639 assert_eq!(resume, decoded);
640 }
641
642 #[test]
643 fn resume_data_m171_tags_backward_compat_missing_field() {
644 let resume =
648 FastResumeData::new(vec![0xD4; 20], "legacy-no-tags".into(), "/downloads".into());
649 assert!(resume.tags.is_empty());
650
651 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
652 let encoded_str = String::from_utf8_lossy(&encoded);
653 assert!(
654 !encoded_str.contains("4:tags"),
655 "empty tags vec must not serialize: got {encoded_str:?}",
656 );
657
658 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
659 assert!(decoded.tags.is_empty());
660 }
661
662 #[test]
663 fn resume_data_hybrid_both_hashes() {
664 let mut resume =
666 FastResumeData::new(vec![0x11; 20], "hybrid-torrent".into(), "/downloads".into());
667 resume.info_hash2 = Some(vec![0x22; 32]);
668 resume.trees.insert(
669 hex::encode([0x33; 32]),
670 vec![0x44; 96], );
672
673 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
674 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
675
676 assert_eq!(decoded.info_hash, vec![0x11; 20]);
678 assert_eq!(decoded.info_hash2.as_deref(), Some([0x22; 32].as_ref()));
679
680 assert_eq!(decoded.trees.len(), 1);
682 let layer = decoded.trees.values().next().unwrap();
683 assert_eq!(layer.len(), 96);
684 }
685
686 #[test]
687 fn resume_data_missing_queued_field_defaults_to_zero() {
688 let resume = FastResumeData::new(vec![0xaa; 20], "test".into(), "/tmp".into());
689 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
690
691 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
693 assert_eq!(decoded.queued, 0);
694 }
695
696 #[test]
697 fn resume_data_queued_field_round_trips() {
698 let mut resume = FastResumeData::new(vec![0xbb; 20], "queued-test".into(), "/dl".into());
699 resume.queued = 1;
700
701 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
702 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
703 assert_eq!(decoded.queued, 1);
704 assert_eq!(decoded.paused, 0);
705 }
706
707 #[test]
708 fn validate_resume_bitfield_correct_length() {
709 assert!(validate_resume_bitfield(&[0xFF], 8));
711 assert!(validate_resume_bitfield(&[0xFF, 0x80], 9));
713 assert!(validate_resume_bitfield(&[0xFF, 0xFF], 16));
715 assert!(validate_resume_bitfield(&[0x80], 1));
717 }
718
719 #[test]
720 fn validate_resume_bitfield_wrong_length() {
721 assert!(!validate_resume_bitfield(&[0xFF, 0x00], 8));
723 assert!(!validate_resume_bitfield(&[0xFF], 9));
725 assert!(!validate_resume_bitfield(&[0x00], 0));
727 }
728
729 #[test]
730 fn validate_resume_bitfield_zero_pieces() {
731 assert!(validate_resume_bitfield(&[], 0));
733 }
734}