Skip to main content

irontide_core/
resume_data.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::WebSeedStats;
6
7fn default_neg_one() -> i64 {
8    -1
9}
10
11/// A partial piece that was in progress when the torrent was paused/stopped.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct UnfinishedPiece {
14    /// Piece index.
15    pub piece: i64,
16    /// Bitmask of which blocks within the piece have been downloaded.
17    #[serde(with = "serde_bytes")]
18    pub bitmask: Vec<u8>,
19}
20
21/// libtorrent-compatible fast-resume data in bencode format.
22///
23/// This struct matches libtorrent's resume file format so that resume data
24/// can be read/written by both Torrent and libtorrent-based clients.
25/// Every field uses `#[serde(rename = "...")]` to match libtorrent's exact
26/// bencode dictionary keys.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct FastResumeData {
29    /// Always "libtorrent resume file".
30    #[serde(rename = "file-format")]
31    pub file_format: String,
32
33    /// Always 1.
34    #[serde(rename = "file-version")]
35    pub file_version: i64,
36
37    /// 20-byte SHA1 info hash.
38    #[serde(rename = "info-hash")]
39    #[serde(with = "serde_bytes")]
40    pub info_hash: Vec<u8>,
41
42    /// Torrent name.
43    #[serde(rename = "name")]
44    pub name: String,
45
46    /// Path where files are saved.
47    #[serde(rename = "save_path")]
48    pub save_path: String,
49
50    /// Bitfield indicating which pieces are complete.
51    #[serde(rename = "pieces")]
52    #[serde(with = "serde_bytes")]
53    pub pieces: Vec<u8>,
54
55    /// Partially downloaded pieces.
56    #[serde(rename = "unfinished")]
57    #[serde(skip_serializing_if = "Vec::is_empty", default)]
58    pub unfinished: Vec<UnfinishedPiece>,
59
60    /// Total bytes uploaded.
61    #[serde(rename = "total_uploaded")]
62    pub total_uploaded: i64,
63
64    /// Total bytes downloaded.
65    #[serde(rename = "total_downloaded")]
66    pub total_downloaded: i64,
67
68    /// Total time active in seconds.
69    #[serde(rename = "active_time")]
70    pub active_time: i64,
71
72    /// Total time spent seeding in seconds.
73    #[serde(rename = "seeding_time")]
74    pub seeding_time: i64,
75
76    /// Total time in finished state in seconds.
77    #[serde(rename = "finished_time")]
78    pub finished_time: i64,
79
80    /// POSIX timestamp when the torrent was added.
81    #[serde(rename = "added_time")]
82    pub added_time: i64,
83
84    /// POSIX timestamp when the torrent completed.
85    #[serde(rename = "completed_time")]
86    #[serde(default)]
87    pub completed_time: i64,
88
89    /// POSIX timestamp of last download activity.
90    #[serde(rename = "last_download")]
91    #[serde(default)]
92    pub last_download: i64,
93
94    /// POSIX timestamp of last upload activity.
95    #[serde(rename = "last_upload")]
96    #[serde(default)]
97    pub last_upload: i64,
98
99    /// Whether the torrent is paused (0 or 1).
100    #[serde(rename = "paused")]
101    #[serde(default)]
102    pub paused: i64,
103
104    /// Whether the torrent is queued by auto-manage (0 or 1).
105    #[serde(rename = "queued")]
106    #[serde(default)]
107    pub queued: i64,
108
109    /// Whether the torrent is auto-managed.
110    #[serde(rename = "auto_managed")]
111    #[serde(default)]
112    pub auto_managed: i64,
113
114    /// Queue position (-1 = not queued).
115    #[serde(rename = "queue_position")]
116    #[serde(default = "default_neg_one")]
117    pub queue_position: i64,
118
119    /// Whether sequential download is enabled.
120    #[serde(rename = "sequential_download")]
121    #[serde(default)]
122    pub sequential_download: i64,
123
124    /// M253/ER2: whether first/last-pieces-first ordering is enabled.
125    #[serde(rename = "prioritize_first_last_pieces")]
126    #[serde(default)]
127    pub prioritize_first_last_pieces: i64,
128
129    /// Whether seed mode is enabled.
130    #[serde(rename = "seed_mode")]
131    #[serde(default)]
132    pub seed_mode: i64,
133
134    /// Tracker tiers (list of lists of tracker URLs).
135    #[serde(rename = "trackers")]
136    #[serde(skip_serializing_if = "Vec::is_empty", default)]
137    pub trackers: Vec<Vec<String>>,
138
139    /// Compact IPv4 peers (6 bytes each: 4 IP + 2 port).
140    #[serde(rename = "peers")]
141    #[serde(with = "serde_bytes")]
142    #[serde(default)]
143    pub peers: Vec<u8>,
144
145    /// Compact IPv6 peers (18 bytes each: 16 IP + 2 port).
146    #[serde(rename = "peers6")]
147    #[serde(with = "serde_bytes")]
148    #[serde(default)]
149    pub peers6: Vec<u8>,
150
151    /// Per-file priority values.
152    #[serde(rename = "file_priority")]
153    #[serde(skip_serializing_if = "Vec::is_empty", default)]
154    pub file_priority: Vec<i64>,
155
156    /// Per-piece priority values.
157    #[serde(rename = "piece_priority")]
158    #[serde(skip_serializing_if = "Vec::is_empty", default)]
159    pub piece_priority: Vec<i64>,
160
161    /// Upload rate limit in bytes/sec (-1 = unlimited).
162    #[serde(rename = "upload_rate_limit")]
163    #[serde(default)]
164    pub upload_rate_limit: i64,
165
166    /// Download rate limit in bytes/sec (-1 = unlimited).
167    #[serde(rename = "download_rate_limit")]
168    #[serde(default)]
169    pub download_rate_limit: i64,
170
171    /// Max connections for this torrent (-1 = unlimited).
172    #[serde(rename = "max_connections")]
173    #[serde(default)]
174    pub max_connections: i64,
175
176    /// Max upload slots for this torrent (-1 = unlimited).
177    #[serde(rename = "max_uploads")]
178    #[serde(default)]
179    pub max_uploads: i64,
180
181    /// Raw bencoded info dictionary (for magnet links that have resolved).
182    #[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    /// BEP 16: whether super seeding was enabled.
188    #[serde(rename = "super_seeding")]
189    #[serde(default)]
190    pub super_seeding: i64,
191
192    /// BEP 19 web seed URLs (GetRight-style).
193    #[serde(rename = "url_seeds")]
194    #[serde(skip_serializing_if = "Vec::is_empty", default)]
195    pub url_seeds: Vec<String>,
196
197    /// BEP 17 HTTP seed URLs (Hoffman-style).
198    #[serde(rename = "http_seeds")]
199    #[serde(skip_serializing_if = "Vec::is_empty", default)]
200    pub http_seeds: Vec<String>,
201
202    /// SHA-256 v2 info hash (32 bytes, BEP 52).
203    #[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    /// Cached piece-layer Merkle hashes per file.
209    /// Key: hex-encoded file root hash. Value: concatenated 32-byte piece hashes.
210    /// Allows skipping piece-layer hash requests on resume.
211    #[serde(rename = "trees")]
212    #[serde(skip_serializing_if = "HashMap::is_empty", default)]
213    pub trees: HashMap<String, Vec<u8>>,
214
215    // ── M170: qBt v2 *arr-minimal surface ──
216    //
217    // These three fields persist the per-torrent category label and torrent
218    // metadata (creator + creation timestamp) so that they survive session
219    // restart. All three use `skip_serializing_if = "Option::is_none"` so
220    // older resume files without them deserialize cleanly (missing key →
221    // `None`), and newer resume files only include them when a value is set.
222    /// User-assigned category label (qBt-compat). `None` = uncategorised.
223    #[serde(rename = "category")]
224    #[serde(skip_serializing_if = "Option::is_none", default)]
225    pub category: Option<String>,
226    /// M252/ER5: how the files were materialized on disk. `None` on
227    /// pre-M252 resume files — those were all stored `Original`.
228    #[serde(rename = "content_layout")]
229    #[serde(skip_serializing_if = "Option::is_none", default)]
230    pub content_layout: Option<crate::ContentLayout>,
231    /// Torrent creator string from `TorrentMetaV1.created_by`. `None` if the
232    /// torrent was added via magnet before metadata resolved.
233    #[serde(rename = "created_by")]
234    #[serde(skip_serializing_if = "Option::is_none", default)]
235    pub created_by: Option<String>,
236    /// UNIX timestamp (seconds) when the torrent was authored, from
237    /// `TorrentMetaV1.creation_date`. `None` if not present in the .torrent
238    /// file or if metadata has not yet resolved for a magnet-added torrent.
239    #[serde(rename = "creation_date")]
240    #[serde(skip_serializing_if = "Option::is_none", default)]
241    pub creation_date: Option<i64>,
242
243    // ── M171: qBt v2 parity ──
244    /// User-assigned tags (qBt-compat). Multi-valued per torrent. Empty vec
245    /// when no tags set; `skip_serializing_if = "Vec::is_empty"` keeps older
246    /// resume files (which have no `tags` key) bit-identical on save.
247    #[serde(rename = "tags")]
248    #[serde(skip_serializing_if = "Vec::is_empty", default)]
249    pub tags: Vec<String>,
250
251    // ── M178: web seed stats ──
252    /// Per-URL web-seed stats (BEP 17/19), keyed by URL. Empty map when no
253    /// stats accumulated; `skip_serializing_if` keeps older resume files
254    /// (which have no `web_seed_stats` key) bit-identical on save.
255    /// Backward-compat: `#[serde(default)]` means legacy resume files load
256    /// with an empty map.
257    #[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    /// Create a new `FastResumeData` with format markers pre-filled and all
264    /// other fields zeroed/empty. Rate limits default to -1 (unlimited).
265    #[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            // M170 fields default to None.
307            category: None,
308            content_layout: None,
309            created_by: None,
310            creation_date: None,
311            // M171: tags default to empty vec.
312            tags: Vec::new(),
313            // M178: web seed stats default to empty map.
314            web_seed_stats: HashMap::new(),
315        }
316    }
317}
318
319/// Returns `true` if the `pieces` bitfield has the correct length for
320/// `num_pieces` pieces (i.e. `ceil(num_pieces / 8)` bytes).
321///
322/// This is used to decide whether a resume file's piece bitfield is
323/// trustworthy and hash verification can be skipped on restart.
324///
325/// Lives here (next to [`FastResumeData`]) rather than in session-core's
326/// `persistence` module since M244b: the per-torrent actor (now in
327/// `irontide-engine`) validates restored bitfields, and the engine crate must
328/// not depend back on session-core. A pure piece-geometry leaf belongs in
329/// `irontide-core`, the shared bottom both layers already consume.
330#[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    /// M253/ER2: both ordering flags round-trip through bencode; a legacy
367    /// payload without the new key deserializes to 0 (off).
368    #[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        // Legacy: serialize an unset struct, strip nothing — the absent-key
379        // path is what `#[serde(default)]` covers; 0 must come back as 0.
380        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    /// M252/ER5: `content_layout` round-trips through bencode; absent key
387    /// (legacy resume file) deserializes to `None`.
388    #[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        // Verify default values survived the round-trip.
450        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        // Simulate a raw bencoded info dict.
468        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        // Default should be 0
531        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], // 2 piece hashes
543        );
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        // "5:trees" (bencode key) should not appear in output when empty
568        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        // M170: category, created_by, creation_date must survive a
587        // bencode round-trip exactly.
588        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        // An "old" resume file has no category/created_by/creation_date
605        // keys at all. Deserialising it must produce None for all three
606        // fields, not fail.
607        let mut resume =
608            FastResumeData::new(vec![0xB2; 20], "legacy-torrent".into(), "/downloads".into());
609        // Synthesise the "pre-M170" shape: encode with the new type but
610        // with all M170 fields None (skip_serializing_if strips them), then
611        // decode back. The wire form must not contain the M170 keys.
612        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        // skip_serializing_if must strip each missing field.
619        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        // M171: tags must round-trip through bencode cleanly.
632        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        // Empty tags vec must not appear in the wire form (skip_serializing_if
645        // gate) so older decoders are none the wiser. And a decode round-trip
646        // yields an empty vec on the new-style end.
647        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        // Hybrid torrents store both v1 (SHA-1, 20 bytes) and v2 (SHA-256, 32 bytes)
665        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], // 3 piece hashes
671        );
672
673        let encoded = irontide_bencode::to_bytes(&resume).unwrap();
674        let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
675
676        // Both hashes present and distinct
677        assert_eq!(decoded.info_hash, vec![0x11; 20]);
678        assert_eq!(decoded.info_hash2.as_deref(), Some([0x22; 32].as_ref()));
679
680        // Trees preserved
681        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        // Decode and verify queued defaults to 0
692        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        // 8 pieces -> 1 byte
710        assert!(validate_resume_bitfield(&[0xFF], 8));
711        // 9 pieces -> 2 bytes
712        assert!(validate_resume_bitfield(&[0xFF, 0x80], 9));
713        // 16 pieces -> 2 bytes
714        assert!(validate_resume_bitfield(&[0xFF, 0xFF], 16));
715        // 1 piece -> 1 byte
716        assert!(validate_resume_bitfield(&[0x80], 1));
717    }
718
719    #[test]
720    fn validate_resume_bitfield_wrong_length() {
721        // 8 pieces with 2 bytes -> wrong
722        assert!(!validate_resume_bitfield(&[0xFF, 0x00], 8));
723        // 9 pieces with 1 byte -> wrong
724        assert!(!validate_resume_bitfield(&[0xFF], 9));
725        // 0 pieces with 1 byte of data -> wrong
726        assert!(!validate_resume_bitfield(&[0x00], 0));
727    }
728
729    #[test]
730    fn validate_resume_bitfield_zero_pieces() {
731        // 0 pieces with empty data -> true
732        assert!(validate_resume_bitfield(&[], 0));
733    }
734}