Skip to main content

irontide_session/
resume_file.rs

1//! Resume file persistence: serialize, deserialize, atomic write, and directory helpers.
2//!
3//! Resume files store [`FastResumeData`] as bencode on disk, one file per
4//! torrent, named by hex-encoded info hash with a `.resume` extension.
5
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9
10use bytes::Bytes;
11use irontide_core::{FastResumeData, Id20, Id32, InfoDict, InfoHashes, Magnet, TorrentMetaV1};
12
13/// Errors that can occur during resume file operations.
14#[derive(Debug, thiserror::Error)]
15pub enum ResumeFileError {
16    /// Bencode serialization or deserialization failed.
17    #[error("bencode error: {0}")]
18    Bencode(#[from] irontide_bencode::Error),
19
20    /// Filesystem I/O failed.
21    #[error("I/O error: {0}")]
22    Io(#[from] io::Error),
23}
24
25/// Serialize [`FastResumeData`] to bencode bytes.
26///
27/// # Errors
28///
29/// Returns [`ResumeFileError::Bencode`] if serialization fails.
30pub fn serialize_resume(data: &FastResumeData) -> Result<Vec<u8>, ResumeFileError> {
31    irontide_bencode::to_bytes(data).map_err(ResumeFileError::from)
32}
33
34/// Deserialize [`FastResumeData`] from bencode bytes.
35///
36/// # Errors
37///
38/// Returns [`ResumeFileError::Bencode`] if the input is not valid bencode
39/// or does not match the [`FastResumeData`] schema.
40pub fn deserialize_resume(data: &[u8]) -> Result<FastResumeData, ResumeFileError> {
41    irontide_bencode::from_bytes(data).map_err(ResumeFileError::from)
42}
43
44/// Atomically write `data` to `path` by writing to a temporary file first,
45/// then renaming.
46///
47/// The temporary file is placed at `path.with_extension("resume.tmp")` so it
48/// resides on the same filesystem, guaranteeing that `fs::rename` is atomic.
49///
50/// # Errors
51///
52/// Returns [`io::Error`] if writing or renaming fails.
53pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> {
54    let tmp_path = path.with_extension("resume.tmp");
55    fs::write(&tmp_path, data)?;
56    fs::rename(&tmp_path, path)?;
57    Ok(())
58}
59
60/// Compute the path for a resume file: `dir/torrents/{hex}.resume`.
61#[must_use]
62pub fn resume_file_path(dir: &Path, info_hash: &Id20) -> PathBuf {
63    dir.join("torrents")
64        .join(format!("{}.resume", hex::encode(info_hash.as_bytes())))
65}
66
67/// Scan `dir/torrents/` and collect all paths ending in `.resume`.
68///
69/// Returns an empty `Vec` if the directory does not exist or cannot be read.
70#[must_use]
71pub fn scan_resume_dir(dir: &Path) -> Vec<PathBuf> {
72    let torrents_dir = dir.join("torrents");
73    let Ok(entries) = fs::read_dir(&torrents_dir) else {
74        return Vec::new();
75    };
76
77    entries
78        .filter_map(|entry| {
79            let entry = entry.ok()?;
80            let path = entry.path();
81            if path.extension().and_then(|e| e.to_str()) == Some("resume") {
82                Some(path)
83            } else {
84                None
85            }
86        })
87        .collect()
88}
89
90/// Return the default resume directory.
91///
92/// Uses `$XDG_STATE_HOME/irontide` when set, otherwise falls back to
93/// `$HOME/.local/state/irontide`.
94#[must_use]
95pub fn default_resume_dir() -> PathBuf {
96    if let Ok(state_home) = std::env::var("XDG_STATE_HOME")
97        && !state_home.is_empty()
98    {
99        return PathBuf::from(state_home).join("irontide");
100    }
101    if let Ok(home) = std::env::var("HOME") {
102        return PathBuf::from(home)
103            .join(".local")
104            .join("state")
105            .join("irontide");
106    }
107    // Last-resort fallback when HOME is unset (unlikely on real systems).
108    PathBuf::from(".local/state/irontide")
109}
110
111/// Delete the `.resume` file for a torrent identified by `info_hash`.
112///
113/// # Errors
114///
115/// Returns [`io::Error`] if the file cannot be removed (e.g. it does not exist
116/// or the caller lacks permissions).
117pub fn delete_resume_file(dir: &Path, info_hash: &Id20) -> io::Result<()> {
118    let path = resume_file_path(dir, info_hash);
119    fs::remove_file(path)
120}
121
122/// Reconstruct a [`TorrentMetaV1`] from stored resume data.
123///
124/// Uses Decision 1A: the info hash is taken directly from the resume file,
125/// never recomputed. Returns `None` if `rd.info` is `None` (unresolved magnet)
126/// or if the stored info bytes fail to parse as an [`InfoDict`].
127#[must_use]
128pub fn reconstruct_torrent_meta(rd: &FastResumeData) -> Option<TorrentMetaV1> {
129    let info_bytes = rd.info.as_ref()?;
130    let info: InfoDict = irontide_bencode::from_bytes(info_bytes).ok()?;
131    let info_hash = Id20::from_bytes(&rd.info_hash).ok()?;
132
133    let announce = rd.trackers.first().and_then(|tier| tier.first()).cloned();
134
135    let announce_list = if rd.trackers.is_empty() {
136        None
137    } else {
138        Some(rd.trackers.clone())
139    };
140
141    Some(TorrentMetaV1 {
142        info_hash,
143        announce,
144        announce_list,
145        comment: None,
146        created_by: None,
147        creation_date: None,
148        info,
149        url_list: rd.url_seeds.clone(),
150        httpseeds: rd.http_seeds.clone(),
151        info_bytes: Some(Bytes::from(info_bytes.clone())),
152        ssl_cert: None,
153    })
154}
155
156/// Reconstruct a [`Magnet`] from resume data for unresolved magnets.
157///
158/// Returns `None` if the info hash is malformed.
159#[must_use]
160pub fn reconstruct_magnet(rd: &FastResumeData) -> Option<Magnet> {
161    let v1 = Id20::from_bytes(&rd.info_hash).ok()?;
162    let v2 = rd
163        .info_hash2
164        .as_ref()
165        .and_then(|ih2| Id32::from_bytes(ih2).ok());
166
167    let info_hashes = InfoHashes { v1: Some(v1), v2 };
168
169    let display_name = if rd.name.is_empty() {
170        None
171    } else {
172        Some(rd.name.clone())
173    };
174
175    let trackers = rd
176        .trackers
177        .iter()
178        .flat_map(|tier| tier.iter().cloned())
179        .collect();
180
181    Some(Magnet {
182        info_hashes,
183        display_name,
184        trackers,
185        peers: Vec::new(),
186        selected_files: None,
187    })
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use pretty_assertions::assert_eq;
194    use tempfile::TempDir;
195
196    /// Helper: build a minimal [`FastResumeData`] for testing.
197    fn sample_resume_data() -> FastResumeData {
198        let mut data =
199            FastResumeData::new(vec![0xAB; 20], "test-torrent".into(), "/downloads".into());
200        data.total_uploaded = 1024;
201        data.total_downloaded = 2048;
202        data.active_time = 300;
203        data.added_time = 1_700_000_000;
204        data.pieces = vec![0xFF; 8];
205        data
206    }
207
208    #[test]
209    fn bencode_round_trip() {
210        let original = sample_resume_data();
211        let bytes = serialize_resume(&original).expect("serialize should succeed");
212        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
213        assert_eq!(original, decoded);
214    }
215
216    #[test]
217    fn empty_resume_data_round_trip() {
218        let original = FastResumeData::new(vec![0x00; 20], "empty".into(), "/tmp".into());
219        let bytes = serialize_resume(&original).expect("serialize should succeed");
220        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
221        assert_eq!(original, decoded);
222    }
223
224    /// M178 Lane B2: web seed stats survive a bencode round-trip.
225    #[test]
226    fn web_seed_stats_round_trip() {
227        let mut original = sample_resume_data();
228        let stats = irontide_core::WebSeedStats {
229            url: "http://seed.example.com/file".into(),
230            state: irontide_core::WebSeedState::Active,
231            downloaded_bytes: 1024 * 1024,
232            last_rate_bps: 524_288,
233            last_error: Some("flaked once".into()),
234            consecutive_failures: 0,
235            last_attempt_unix_secs: 1_700_000_000,
236            next_retry_unix_secs: None,
237        };
238        original.web_seed_stats.insert(stats.url.clone(), stats);
239
240        let bytes = serialize_resume(&original).expect("serialize");
241        let decoded = deserialize_resume(&bytes).expect("deserialize");
242        assert_eq!(original, decoded);
243        assert_eq!(decoded.web_seed_stats.len(), 1);
244        let recovered = decoded
245            .web_seed_stats
246            .get("http://seed.example.com/file")
247            .expect("entry");
248        assert_eq!(recovered.downloaded_bytes, 1024 * 1024);
249        assert_eq!(recovered.state, irontide_core::WebSeedState::Active);
250        assert_eq!(recovered.last_error.as_deref(), Some("flaked once"));
251    }
252
253    /// M178 Lane B2: empty `web_seed_stats` round-trips and the bencoded
254    /// payload is bit-identical to a resume file written before M178
255    /// (forward-compat with `skip_serializing_if`).
256    #[test]
257    fn empty_web_seed_stats_skipped_on_serialize() {
258        let original = sample_resume_data();
259        assert!(original.web_seed_stats.is_empty(), "default has empty map");
260        let bytes = serialize_resume(&original).expect("serialize");
261        // The bencoded payload should NOT contain the "web_seed_stats" key
262        // when the map is empty, so legacy resume files stay byte-identical.
263        let needle = b"web_seed_stats";
264        assert!(
265            !bytes.windows(needle.len()).any(|w| w == needle),
266            "empty web_seed_stats key must be skipped on serialize"
267        );
268    }
269
270    /// M178 Lane B2: a resume file written before M178 (no `web_seed_stats`
271    /// key) deserializes cleanly with an empty map.
272    #[test]
273    fn legacy_resume_file_loads_with_empty_web_seed_stats() {
274        // Build a bencoded resume file from the M177 schema (no
275        // web_seed_stats key). We do that by serializing a fresh resume
276        // and confirming the no-key invariant, then deserialise to ensure
277        // the new field defaults to empty.
278        let original = sample_resume_data();
279        let bytes = serialize_resume(&original).expect("serialize");
280        let decoded = deserialize_resume(&bytes).expect("deserialize");
281        assert!(decoded.web_seed_stats.is_empty());
282    }
283
284    #[test]
285    fn atomic_write_no_tmp_remains() {
286        let dir = TempDir::new().expect("failed to create temp dir");
287        let target = dir.path().join("test.resume");
288
289        atomic_write(&target, b"hello world").expect("atomic_write should succeed");
290
291        // The target file must exist with correct contents.
292        let contents = fs::read(&target).expect("should read target");
293        assert_eq!(contents, b"hello world");
294
295        // The temporary file must not remain.
296        let tmp_path = target.with_extension("resume.tmp");
297        assert!(
298            !tmp_path.exists(),
299            ".tmp file should not remain after write"
300        );
301    }
302
303    #[test]
304    fn scan_resume_dir_filters_extensions() {
305        let dir = TempDir::new().expect("failed to create temp dir");
306        let torrents = dir.path().join("torrents");
307        fs::create_dir_all(&torrents).expect("failed to create torrents dir");
308
309        // Create files with various extensions.
310        fs::write(torrents.join("aabb.resume"), b"r1").expect("write");
311        fs::write(torrents.join("ccdd.resume"), b"r2").expect("write");
312        fs::write(torrents.join("eeff.dat"), b"d1").expect("write");
313        fs::write(torrents.join("0011.resume.tmp"), b"t1").expect("write");
314        fs::write(torrents.join("notes.txt"), b"n1").expect("write");
315
316        let mut found = scan_resume_dir(dir.path());
317        // Sort for deterministic comparison.
318        found.sort();
319
320        assert_eq!(found.len(), 2);
321        assert!(found[0].ends_with("aabb.resume") || found[0].ends_with("ccdd.resume"));
322        assert!(found[1].ends_with("aabb.resume") || found[1].ends_with("ccdd.resume"));
323
324        // Double-check none of the non-.resume files snuck in.
325        for path in &found {
326            assert_eq!(
327                path.extension().and_then(|e| e.to_str()),
328                Some("resume"),
329                "only .resume files should be returned"
330            );
331        }
332    }
333}