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
60pub 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
66pub 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
89pub 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 PathBuf::from(".local/state/irontide")
107}
108
109pub 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
120pub 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
153pub 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 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 let contents = fs::read(&target).expect("should read target");
229 assert_eq!(contents, b"hello world");
230
231 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 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 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 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}