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`.
61pub fn resume_file_path(dir: &Path, info_hash: &Id20) -> PathBuf {
62    dir.join("torrents")
63        .join(format!("{}.resume", hex::encode(info_hash.as_bytes())))
64}
65
66/// Scan `dir/torrents/` and collect all paths ending in `.resume`.
67///
68/// Returns an empty `Vec` if the directory does not exist or cannot be read.
69pub fn scan_resume_dir(dir: &Path) -> Vec<PathBuf> {
70    let torrents_dir = dir.join("torrents");
71    let entries = match fs::read_dir(&torrents_dir) {
72        Ok(entries) => entries,
73        Err(_) => return Vec::new(),
74    };
75
76    entries
77        .filter_map(|entry| {
78            let entry = entry.ok()?;
79            let path = entry.path();
80            if path.extension().and_then(|e| e.to_str()) == Some("resume") {
81                Some(path)
82            } else {
83                None
84            }
85        })
86        .collect()
87}
88
89/// Return the default resume directory.
90///
91/// Uses `$XDG_STATE_HOME/irontide` when set, otherwise falls back to
92/// `$HOME/.local/state/irontide`.
93pub fn default_resume_dir() -> PathBuf {
94    if let Ok(state_home) = std::env::var("XDG_STATE_HOME")
95        && !state_home.is_empty()
96    {
97        return PathBuf::from(state_home).join("irontide");
98    }
99    if let Ok(home) = std::env::var("HOME") {
100        return PathBuf::from(home)
101            .join(".local")
102            .join("state")
103            .join("irontide");
104    }
105    // Last-resort fallback when HOME is unset (unlikely on real systems).
106    PathBuf::from(".local/state/irontide")
107}
108
109/// Delete the `.resume` file for a torrent identified by `info_hash`.
110///
111/// # Errors
112///
113/// Returns [`io::Error`] if the file cannot be removed (e.g. it does not exist
114/// or the caller lacks permissions).
115pub fn delete_resume_file(dir: &Path, info_hash: &Id20) -> io::Result<()> {
116    let path = resume_file_path(dir, info_hash);
117    fs::remove_file(path)
118}
119
120/// Reconstruct a [`TorrentMetaV1`] from stored resume data.
121///
122/// Uses Decision 1A: the info hash is taken directly from the resume file,
123/// never recomputed. Returns `None` if `rd.info` is `None` (unresolved magnet)
124/// or if the stored info bytes fail to parse as an [`InfoDict`].
125pub fn reconstruct_torrent_meta(rd: &FastResumeData) -> Option<TorrentMetaV1> {
126    let info_bytes = rd.info.as_ref()?;
127    let info: InfoDict = irontide_bencode::from_bytes(info_bytes).ok()?;
128    let info_hash = Id20::from_bytes(&rd.info_hash).ok()?;
129
130    let announce = rd.trackers.first().and_then(|tier| tier.first()).cloned();
131
132    let announce_list = if rd.trackers.is_empty() {
133        None
134    } else {
135        Some(rd.trackers.clone())
136    };
137
138    Some(TorrentMetaV1 {
139        info_hash,
140        announce,
141        announce_list,
142        comment: None,
143        created_by: None,
144        creation_date: None,
145        info,
146        url_list: rd.url_seeds.clone(),
147        httpseeds: rd.http_seeds.clone(),
148        info_bytes: Some(Bytes::from(info_bytes.clone())),
149        ssl_cert: None,
150    })
151}
152
153/// Reconstruct a [`Magnet`] from resume data for unresolved magnets.
154///
155/// Returns `None` if the info hash is malformed.
156pub fn reconstruct_magnet(rd: &FastResumeData) -> Option<Magnet> {
157    let v1 = Id20::from_bytes(&rd.info_hash).ok()?;
158    let v2 = rd
159        .info_hash2
160        .as_ref()
161        .and_then(|ih2| Id32::from_bytes(ih2).ok());
162
163    let info_hashes = InfoHashes { v1: Some(v1), v2 };
164
165    let display_name = if rd.name.is_empty() {
166        None
167    } else {
168        Some(rd.name.clone())
169    };
170
171    let trackers = rd
172        .trackers
173        .iter()
174        .flat_map(|tier| tier.iter().cloned())
175        .collect();
176
177    Some(Magnet {
178        info_hashes,
179        display_name,
180        trackers,
181        peers: Vec::new(),
182        selected_files: None,
183    })
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use pretty_assertions::assert_eq;
190    use tempfile::TempDir;
191
192    /// Helper: build a minimal [`FastResumeData`] for testing.
193    fn sample_resume_data() -> FastResumeData {
194        let mut data =
195            FastResumeData::new(vec![0xAB; 20], "test-torrent".into(), "/downloads".into());
196        data.total_uploaded = 1024;
197        data.total_downloaded = 2048;
198        data.active_time = 300;
199        data.added_time = 1_700_000_000;
200        data.pieces = vec![0xFF; 8];
201        data
202    }
203
204    #[test]
205    fn bencode_round_trip() {
206        let original = sample_resume_data();
207        let bytes = serialize_resume(&original).expect("serialize should succeed");
208        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
209        assert_eq!(original, decoded);
210    }
211
212    #[test]
213    fn empty_resume_data_round_trip() {
214        let original = FastResumeData::new(vec![0x00; 20], "empty".into(), "/tmp".into());
215        let bytes = serialize_resume(&original).expect("serialize should succeed");
216        let decoded = deserialize_resume(&bytes).expect("deserialize should succeed");
217        assert_eq!(original, decoded);
218    }
219
220    #[test]
221    fn atomic_write_no_tmp_remains() {
222        let dir = TempDir::new().expect("failed to create temp dir");
223        let target = dir.path().join("test.resume");
224
225        atomic_write(&target, b"hello world").expect("atomic_write should succeed");
226
227        // The target file must exist with correct contents.
228        let contents = fs::read(&target).expect("should read target");
229        assert_eq!(contents, b"hello world");
230
231        // The temporary file must not remain.
232        let tmp_path = target.with_extension("resume.tmp");
233        assert!(
234            !tmp_path.exists(),
235            ".tmp file should not remain after write"
236        );
237    }
238
239    #[test]
240    fn scan_resume_dir_filters_extensions() {
241        let dir = TempDir::new().expect("failed to create temp dir");
242        let torrents = dir.path().join("torrents");
243        fs::create_dir_all(&torrents).expect("failed to create torrents dir");
244
245        // Create files with various extensions.
246        fs::write(torrents.join("aabb.resume"), b"r1").expect("write");
247        fs::write(torrents.join("ccdd.resume"), b"r2").expect("write");
248        fs::write(torrents.join("eeff.dat"), b"d1").expect("write");
249        fs::write(torrents.join("0011.resume.tmp"), b"t1").expect("write");
250        fs::write(torrents.join("notes.txt"), b"n1").expect("write");
251
252        let mut found = scan_resume_dir(dir.path());
253        // Sort for deterministic comparison.
254        found.sort();
255
256        assert_eq!(found.len(), 2);
257        assert!(found[0].ends_with("aabb.resume") || found[0].ends_with("ccdd.resume"));
258        assert!(found[1].ends_with("aabb.resume") || found[1].ends_with("ccdd.resume"));
259
260        // Double-check none of the non-.resume files snuck in.
261        for path in &found {
262            assert_eq!(
263                path.extension().and_then(|e| e.to_str()),
264                Some("resume"),
265                "only .resume files should be returned"
266            );
267        }
268    }
269}