irontide_session/
resume_file.rs1use 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#[derive(Debug, thiserror::Error)]
15pub enum ResumeFileError {
16 #[error("bencode error: {0}")]
18 Bencode(#[from] irontide_bencode::Error),
19
20 #[error("I/O error: {0}")]
22 Io(#[from] io::Error),
23}
24
25pub fn serialize_resume(data: &FastResumeData) -> Result<Vec<u8>, ResumeFileError> {
31 irontide_bencode::to_bytes(data).map_err(ResumeFileError::from)
32}
33
34pub fn deserialize_resume(data: &[u8]) -> Result<FastResumeData, ResumeFileError> {
41 irontide_bencode::from_bytes(data).map_err(ResumeFileError::from)
42}
43
44pub 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#[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#[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#[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 PathBuf::from(".local/state/irontide")
109}
110
111pub 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#[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#[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 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 #[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 #[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 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 #[test]
273 fn legacy_resume_file_loads_with_empty_web_seed_stats() {
274 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 let contents = fs::read(&target).expect("should read target");
293 assert_eq!(contents, b"hello world");
294
295 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 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 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 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}