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 = "seed_mode")]
126 #[serde(default)]
127 pub seed_mode: i64,
128
129 #[serde(rename = "trackers")]
131 #[serde(skip_serializing_if = "Vec::is_empty", default)]
132 pub trackers: Vec<Vec<String>>,
133
134 #[serde(rename = "peers")]
136 #[serde(with = "serde_bytes")]
137 #[serde(default)]
138 pub peers: Vec<u8>,
139
140 #[serde(rename = "peers6")]
142 #[serde(with = "serde_bytes")]
143 #[serde(default)]
144 pub peers6: Vec<u8>,
145
146 #[serde(rename = "file_priority")]
148 #[serde(skip_serializing_if = "Vec::is_empty", default)]
149 pub file_priority: Vec<i64>,
150
151 #[serde(rename = "piece_priority")]
153 #[serde(skip_serializing_if = "Vec::is_empty", default)]
154 pub piece_priority: Vec<i64>,
155
156 #[serde(rename = "upload_rate_limit")]
158 #[serde(default)]
159 pub upload_rate_limit: i64,
160
161 #[serde(rename = "download_rate_limit")]
163 #[serde(default)]
164 pub download_rate_limit: i64,
165
166 #[serde(rename = "max_connections")]
168 #[serde(default)]
169 pub max_connections: i64,
170
171 #[serde(rename = "max_uploads")]
173 #[serde(default)]
174 pub max_uploads: i64,
175
176 #[serde(rename = "info")]
178 #[serde(with = "serde_bytes")]
179 #[serde(skip_serializing_if = "Option::is_none", default)]
180 pub info: Option<Vec<u8>>,
181
182 #[serde(rename = "super_seeding")]
184 #[serde(default)]
185 pub super_seeding: i64,
186
187 #[serde(rename = "url_seeds")]
189 #[serde(skip_serializing_if = "Vec::is_empty", default)]
190 pub url_seeds: Vec<String>,
191
192 #[serde(rename = "http_seeds")]
194 #[serde(skip_serializing_if = "Vec::is_empty", default)]
195 pub http_seeds: Vec<String>,
196
197 #[serde(rename = "info-hash2")]
199 #[serde(with = "serde_bytes")]
200 #[serde(skip_serializing_if = "Option::is_none", default)]
201 pub info_hash2: Option<Vec<u8>>,
202
203 #[serde(rename = "trees")]
207 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
208 pub trees: HashMap<String, Vec<u8>>,
209
210 #[serde(rename = "category")]
219 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub category: Option<String>,
221 #[serde(rename = "created_by")]
224 #[serde(skip_serializing_if = "Option::is_none", default)]
225 pub created_by: Option<String>,
226 #[serde(rename = "creation_date")]
230 #[serde(skip_serializing_if = "Option::is_none", default)]
231 pub creation_date: Option<i64>,
232
233 #[serde(rename = "tags")]
238 #[serde(skip_serializing_if = "Vec::is_empty", default)]
239 pub tags: Vec<String>,
240
241 #[serde(rename = "web_seed_stats")]
248 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
249 pub web_seed_stats: HashMap<String, WebSeedStats>,
250}
251
252impl FastResumeData {
253 #[must_use]
256 pub fn new(info_hash: Vec<u8>, name: String, save_path: String) -> Self {
257 Self {
258 file_format: "libtorrent resume file".into(),
259 file_version: 1,
260 info_hash,
261 name,
262 save_path,
263 pieces: Vec::new(),
264 unfinished: Vec::new(),
265 total_uploaded: 0,
266 total_downloaded: 0,
267 active_time: 0,
268 seeding_time: 0,
269 finished_time: 0,
270 added_time: 0,
271 completed_time: 0,
272 last_download: 0,
273 last_upload: 0,
274 paused: 0,
275 queued: 0,
276 auto_managed: 0,
277 queue_position: -1,
278 sequential_download: 0,
279 seed_mode: 0,
280 trackers: Vec::new(),
281 peers: Vec::new(),
282 peers6: Vec::new(),
283 file_priority: Vec::new(),
284 piece_priority: Vec::new(),
285 upload_rate_limit: -1,
286 download_rate_limit: -1,
287 max_connections: -1,
288 max_uploads: -1,
289 super_seeding: 0,
290 info: None,
291 url_seeds: Vec::new(),
292 http_seeds: Vec::new(),
293 info_hash2: None,
294 trees: HashMap::new(),
295 category: None,
297 created_by: None,
298 creation_date: None,
299 tags: Vec::new(),
301 web_seed_stats: HashMap::new(),
303 }
304 }
305}
306
307#[must_use]
319pub fn validate_resume_bitfield(pieces: &[u8], num_pieces: u32) -> bool {
320 if num_pieces == 0 {
321 return pieces.is_empty();
322 }
323 let expected = num_pieces.div_ceil(8) as usize;
324 pieces.len() == expected
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use pretty_assertions::assert_eq;
331
332 #[test]
333 fn fast_resume_data_bencode_round_trip() {
334 let mut resume =
335 FastResumeData::new(vec![0xAA; 20], "test-torrent".into(), "/downloads".into());
336 resume.total_uploaded = 1024 * 1024;
337 resume.total_downloaded = 2048 * 1024;
338 resume.active_time = 3600;
339 resume.added_time = 1_700_000_000;
340 resume.trackers = vec![
341 vec!["http://tracker1.example.com/announce".into()],
342 vec![
343 "http://tracker2.example.com/announce".into(),
344 "http://tracker3.example.com/announce".into(),
345 ],
346 ];
347 resume.pieces = vec![0xFF; 10];
348
349 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
350 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
351 assert_eq!(resume, decoded);
352 }
353
354 #[test]
355 fn unfinished_piece_bencode_round_trip() {
356 let piece = UnfinishedPiece {
357 piece: 42,
358 bitmask: vec![0b1010_1010, 0b0101_0101],
359 };
360
361 let encoded = irontide_bencode::to_bytes(&piece).unwrap();
362 let decoded: UnfinishedPiece = irontide_bencode::from_bytes(&encoded).unwrap();
363 assert_eq!(piece, decoded);
364 }
365
366 #[test]
367 fn resume_data_with_unfinished_pieces() {
368 let mut resume = FastResumeData::new(
369 vec![0xBB; 20],
370 "partial-torrent".into(),
371 "/downloads".into(),
372 );
373 resume.unfinished = vec![
374 UnfinishedPiece {
375 piece: 5,
376 bitmask: vec![0xFF, 0x0F],
377 },
378 UnfinishedPiece {
379 piece: 12,
380 bitmask: vec![0xF0],
381 },
382 ];
383
384 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
385 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
386 assert_eq!(resume, decoded);
387 }
388
389 #[test]
390 fn default_fields_serialize_correctly() {
391 let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
392
393 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
394 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
395 assert_eq!(resume, decoded);
396
397 assert_eq!(decoded.total_uploaded, 0);
399 assert_eq!(decoded.total_downloaded, 0);
400 assert_eq!(decoded.paused, 0);
401 assert_eq!(decoded.upload_rate_limit, -1);
402 assert_eq!(decoded.download_rate_limit, -1);
403 assert_eq!(decoded.max_connections, -1);
404 assert_eq!(decoded.max_uploads, -1);
405 assert!(decoded.trackers.is_empty());
406 assert!(decoded.unfinished.is_empty());
407 assert!(decoded.file_priority.is_empty());
408 assert!(decoded.info.is_none());
409 }
410
411 #[test]
412 fn info_dict_embedding_round_trip() {
413 let mut resume =
414 FastResumeData::new(vec![0xCC; 20], "with-info".into(), "/downloads".into());
415 resume.info = Some(
417 b"d4:name10:test-torte12:piece lengthi262144e6:pieces20:AAAAAAAAAAAAAAAAAAAAe".to_vec(),
418 );
419
420 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
421 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
422 assert_eq!(resume, decoded);
423 assert!(decoded.info.is_some());
424 assert_eq!(decoded.info.unwrap().len(), resume.info.unwrap().len());
425 }
426
427 #[test]
428 fn resume_data_queue_position_default() {
429 let rd = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
430 assert_eq!(rd.queue_position, -1);
431 }
432
433 #[test]
434 fn format_markers_correct() {
435 let resume = FastResumeData::new(vec![0x00; 20], "test".into(), "/tmp".into());
436 assert_eq!(resume.file_format, "libtorrent resume file");
437 assert_eq!(resume.file_version, 1);
438 }
439
440 #[test]
441 fn resume_data_url_seeds_round_trip() {
442 let mut resume =
443 FastResumeData::new(vec![0xDD; 20], "web-seed-test".into(), "/downloads".into());
444 resume.url_seeds = vec![
445 "http://example.com/files".into(),
446 "http://mirror.example.com/".into(),
447 ];
448
449 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
450 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
451 assert_eq!(decoded.url_seeds, resume.url_seeds);
452 }
453
454 #[test]
455 fn resume_data_http_seeds_round_trip() {
456 let mut resume =
457 FastResumeData::new(vec![0xEE; 20], "http-seed-test".into(), "/downloads".into());
458 resume.http_seeds = vec!["http://seed.example.com/seed".into()];
459
460 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
461 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
462 assert_eq!(decoded.http_seeds, resume.http_seeds);
463 }
464
465 #[test]
466 fn resume_data_super_seeding_round_trip() {
467 let mut resume = FastResumeData::new(
468 vec![0xFF; 20],
469 "super-seed-test".into(),
470 "/downloads".into(),
471 );
472 resume.super_seeding = 1;
473
474 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
475 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
476 assert_eq!(decoded.super_seeding, 1);
477
478 let default_resume = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
480 assert_eq!(default_resume.super_seeding, 0);
481 }
482
483 #[test]
484 fn resume_data_v2_fields_round_trip() {
485 let mut resume =
486 FastResumeData::new(vec![0xAA; 20], "v2-torrent".into(), "/downloads".into());
487 resume.info_hash2 = Some(vec![0xBB; 32]);
488 resume.trees.insert(
489 hex::encode([0xCC; 32]),
490 vec![0xDD; 64], );
492
493 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
494 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
495 assert_eq!(decoded.info_hash2, Some(vec![0xBB; 32]));
496 assert_eq!(decoded.trees.len(), 1);
497 }
498
499 #[test]
500 fn resume_data_v1_backward_compat() {
501 let resume = FastResumeData::new(vec![0x00; 20], "v1-torrent".into(), "/tmp".into());
502 assert!(resume.info_hash2.is_none());
503 assert!(resume.trees.is_empty());
504
505 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
506 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
507 assert!(decoded.info_hash2.is_none());
508 assert!(decoded.trees.is_empty());
509 }
510
511 #[test]
512 fn resume_data_v2_empty_trees_not_serialized() {
513 let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
514 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
515 let encoded_str = String::from_utf8_lossy(&encoded);
517 assert!(!encoded_str.contains("5:trees"));
518 }
519
520 #[test]
521 fn resume_data_empty_seeds_not_serialized() {
522 let resume = FastResumeData::new(vec![0x00; 20], "no-seeds".into(), "/tmp".into());
523 assert!(resume.url_seeds.is_empty());
524 assert!(resume.http_seeds.is_empty());
525
526 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
527 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
528 assert!(decoded.url_seeds.is_empty());
529 assert!(decoded.http_seeds.is_empty());
530 }
531
532 #[test]
533 fn resume_data_m170_fields_round_trip() {
534 let mut resume =
537 FastResumeData::new(vec![0xA1; 20], "m170-torrent".into(), "/downloads".into());
538 resume.category = Some("sonarr".into());
539 resume.created_by = Some("irontide/0.170".into());
540 resume.creation_date = Some(1_700_000_000);
541
542 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
543 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
544 assert_eq!(decoded.category.as_deref(), Some("sonarr"));
545 assert_eq!(decoded.created_by.as_deref(), Some("irontide/0.170"));
546 assert_eq!(decoded.creation_date, Some(1_700_000_000));
547 assert_eq!(resume, decoded);
548 }
549
550 #[test]
551 fn resume_data_m170_backward_compat_missing_fields() {
552 let mut resume =
556 FastResumeData::new(vec![0xB2; 20], "legacy-torrent".into(), "/downloads".into());
557 resume.category = None;
561 resume.created_by = None;
562 resume.creation_date = None;
563
564 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
565 let encoded_str = String::from_utf8_lossy(&encoded);
566 assert!(!encoded_str.contains("8:category"));
568 assert!(!encoded_str.contains("10:created_by"));
569 assert!(!encoded_str.contains("13:creation_date"));
570
571 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
572 assert!(decoded.category.is_none());
573 assert!(decoded.created_by.is_none());
574 assert!(decoded.creation_date.is_none());
575 }
576
577 #[test]
578 fn resume_data_m171_tags_round_trip() {
579 let mut resume =
581 FastResumeData::new(vec![0xC3; 20], "m171-tags".into(), "/downloads".into());
582 resume.tags = vec!["sonarr".into(), "kids".into()];
583
584 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
585 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
586 assert_eq!(decoded.tags, vec!["sonarr".to_string(), "kids".to_string()]);
587 assert_eq!(resume, decoded);
588 }
589
590 #[test]
591 fn resume_data_m171_tags_backward_compat_missing_field() {
592 let resume =
596 FastResumeData::new(vec![0xD4; 20], "legacy-no-tags".into(), "/downloads".into());
597 assert!(resume.tags.is_empty());
598
599 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
600 let encoded_str = String::from_utf8_lossy(&encoded);
601 assert!(
602 !encoded_str.contains("4:tags"),
603 "empty tags vec must not serialize: got {encoded_str:?}",
604 );
605
606 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
607 assert!(decoded.tags.is_empty());
608 }
609
610 #[test]
611 fn resume_data_hybrid_both_hashes() {
612 let mut resume =
614 FastResumeData::new(vec![0x11; 20], "hybrid-torrent".into(), "/downloads".into());
615 resume.info_hash2 = Some(vec![0x22; 32]);
616 resume.trees.insert(
617 hex::encode([0x33; 32]),
618 vec![0x44; 96], );
620
621 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
622 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
623
624 assert_eq!(decoded.info_hash, vec![0x11; 20]);
626 assert_eq!(decoded.info_hash2.as_deref(), Some([0x22; 32].as_ref()));
627
628 assert_eq!(decoded.trees.len(), 1);
630 let layer = decoded.trees.values().next().unwrap();
631 assert_eq!(layer.len(), 96);
632 }
633
634 #[test]
635 fn resume_data_missing_queued_field_defaults_to_zero() {
636 let resume = FastResumeData::new(vec![0xaa; 20], "test".into(), "/tmp".into());
637 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
638
639 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
641 assert_eq!(decoded.queued, 0);
642 }
643
644 #[test]
645 fn resume_data_queued_field_round_trips() {
646 let mut resume = FastResumeData::new(vec![0xbb; 20], "queued-test".into(), "/dl".into());
647 resume.queued = 1;
648
649 let encoded = irontide_bencode::to_bytes(&resume).unwrap();
650 let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
651 assert_eq!(decoded.queued, 1);
652 assert_eq!(decoded.paused, 0);
653 }
654
655 #[test]
656 fn validate_resume_bitfield_correct_length() {
657 assert!(validate_resume_bitfield(&[0xFF], 8));
659 assert!(validate_resume_bitfield(&[0xFF, 0x80], 9));
661 assert!(validate_resume_bitfield(&[0xFF, 0xFF], 16));
663 assert!(validate_resume_bitfield(&[0x80], 1));
665 }
666
667 #[test]
668 fn validate_resume_bitfield_wrong_length() {
669 assert!(!validate_resume_bitfield(&[0xFF, 0x00], 8));
671 assert!(!validate_resume_bitfield(&[0xFF], 9));
673 assert!(!validate_resume_bitfield(&[0x00], 0));
675 }
676
677 #[test]
678 fn validate_resume_bitfield_zero_pieces() {
679 assert!(validate_resume_bitfield(&[], 0));
681 }
682}