Skip to main content

gosh_dl/storage/
file.rs

1//! File-based storage: JSON sidecar files, one per download.
2//!
3//! This is the moral equivalent of aria2's `.aria2` control files: each
4//! download gets a `<id>.json` record holding its status, segment state and
5//! runtime metadata (plus a raw `<id>.torrent` sidecar for torrent
6//! metainfo), so downloads can resume across process restarts without a
7//! database. All writes go through a temp-file-plus-rename so a crash never
8//! leaves a half-written record.
9
10use super::{Segment, Storage};
11use crate::error::Result;
12#[cfg(feature = "recursive-http")]
13use crate::types::TrackedRecursiveJob;
14use crate::types::{DownloadId, DownloadStatus};
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20/// On-disk JSON record for a single download.
21#[derive(Debug, Default, Serialize, Deserialize)]
22struct DownloadRecord {
23    #[serde(default)]
24    status: Option<DownloadStatus>,
25    #[serde(default)]
26    segments: Vec<Segment>,
27    #[serde(default)]
28    runtime_metadata: Option<String>,
29}
30
31/// File-based [`Storage`] implementation using one JSON sidecar per download.
32///
33/// ```no_run
34/// use std::sync::Arc;
35/// use gosh_dl::{DownloadEngine, EngineConfig, FileStorage};
36///
37/// # async fn example() -> gosh_dl::Result<()> {
38/// let storage = Arc::new(FileStorage::new("/var/lib/myapp/downloads-state").await?);
39/// let engine = DownloadEngine::with_storage(EngineConfig::default(), storage).await?;
40/// # Ok(())
41/// # }
42/// ```
43#[derive(Debug)]
44pub struct FileStorage {
45    dir: PathBuf,
46    /// Serializes read-modify-write cycles on the record files.
47    write_lock: tokio::sync::Mutex<()>,
48}
49
50const RECURSIVE_PREFIX: &str = "recursive-";
51
52impl FileStorage {
53    /// Create a file storage rooted at `dir`, creating the directory if needed.
54    pub async fn new(dir: impl Into<PathBuf>) -> Result<Self> {
55        let dir = dir.into();
56        tokio::fs::create_dir_all(&dir).await?;
57        Ok(Self {
58            dir,
59            write_lock: tokio::sync::Mutex::new(()),
60        })
61    }
62
63    /// Directory holding the sidecar files.
64    pub fn dir(&self) -> &Path {
65        &self.dir
66    }
67
68    // Filenames use the full UUID rather than `DownloadId`'s `Display`,
69    // which is a lossy aria2-style GID that cannot be mapped back to an ID.
70    fn record_path(&self, id: DownloadId) -> PathBuf {
71        self.dir.join(format!("{}.json", id.as_uuid()))
72    }
73
74    fn torrent_path(&self, id: DownloadId) -> PathBuf {
75        self.dir.join(format!("{}.torrent", id.as_uuid()))
76    }
77
78    #[cfg(feature = "recursive-http")]
79    fn recursive_path(&self, id: uuid::Uuid) -> PathBuf {
80        self.dir.join(format!("{RECURSIVE_PREFIX}{id}.json"))
81    }
82
83    async fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
84        match tokio::fs::read(path).await {
85            Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
86            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
87            Err(e) => Err(e.into()),
88        }
89    }
90
91    async fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
92        let bytes = serde_json::to_vec_pretty(value)?;
93        Self::write_atomic(path, &bytes).await
94    }
95
96    async fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
97        let mut tmp = path.as_os_str().to_owned();
98        tmp.push(".tmp");
99        let tmp = PathBuf::from(tmp);
100        tokio::fs::write(&tmp, bytes).await?;
101        tokio::fs::rename(&tmp, path).await?;
102        Ok(())
103    }
104
105    async fn remove_if_exists(path: &Path) -> Result<()> {
106        match tokio::fs::remove_file(path).await {
107            Ok(()) => Ok(()),
108            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
109            Err(e) => Err(e.into()),
110        }
111    }
112
113    async fn update_record<F>(&self, id: DownloadId, update: F) -> Result<()>
114    where
115        F: FnOnce(&mut DownloadRecord),
116    {
117        let _guard = self.write_lock.lock().await;
118        let path = self.record_path(id);
119        let mut record: DownloadRecord = Self::read_json(&path).await?.unwrap_or_default();
120        update(&mut record);
121        Self::write_json(&path, &record).await
122    }
123
124    /// Iterate the IDs of all download record files in the directory.
125    ///
126    /// IDs are recovered from the `<uuid>.json` filenames so that records
127    /// whose status hasn't been written yet are still visible.
128    async fn record_ids(&self) -> Result<Vec<DownloadId>> {
129        let mut ids = Vec::new();
130        let mut entries = tokio::fs::read_dir(&self.dir).await?;
131        while let Some(entry) = entries.next_entry().await? {
132            let path = entry.path();
133            if path.extension().and_then(|e| e.to_str()) != Some("json") {
134                continue;
135            }
136            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
137                continue;
138            };
139            if stem.starts_with(RECURSIVE_PREFIX) {
140                continue;
141            }
142            if let Ok(uuid) = uuid::Uuid::parse_str(stem) {
143                ids.push(DownloadId::from_uuid(uuid));
144            }
145        }
146        Ok(ids)
147    }
148}
149
150#[async_trait]
151impl Storage for FileStorage {
152    async fn save_download(&self, status: &DownloadStatus) -> Result<()> {
153        let status = status.clone();
154        self.update_record(status.id, move |record| record.status = Some(status))
155            .await
156    }
157
158    async fn load_download(&self, id: DownloadId) -> Result<Option<DownloadStatus>> {
159        let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
160        Ok(record.and_then(|r| r.status))
161    }
162
163    async fn load_all(&self) -> Result<Vec<DownloadStatus>> {
164        let mut statuses = Vec::new();
165        for id in self.record_ids().await? {
166            match Self::read_json::<DownloadRecord>(&self.record_path(id)).await {
167                Ok(Some(record)) => {
168                    if let Some(status) = record.status {
169                        statuses.push(status);
170                    }
171                }
172                Ok(None) => {}
173                Err(e) => {
174                    tracing::warn!("Skipping unreadable download record for {}: {}", id, e);
175                }
176            }
177        }
178        Ok(statuses)
179    }
180
181    async fn delete_download(&self, id: DownloadId) -> Result<()> {
182        let _guard = self.write_lock.lock().await;
183        Self::remove_if_exists(&self.record_path(id)).await?;
184        Self::remove_if_exists(&self.torrent_path(id)).await?;
185        Ok(())
186    }
187
188    async fn save_segments(&self, id: DownloadId, segments: &[Segment]) -> Result<()> {
189        let segments = segments.to_vec();
190        self.update_record(id, move |record| record.segments = segments)
191            .await
192    }
193
194    async fn load_segments(&self, id: DownloadId) -> Result<Vec<Segment>> {
195        let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
196        Ok(record.map(|r| r.segments).unwrap_or_default())
197    }
198
199    async fn delete_segments(&self, id: DownloadId) -> Result<()> {
200        let _guard = self.write_lock.lock().await;
201        let path = self.record_path(id);
202        if let Some(mut record) = Self::read_json::<DownloadRecord>(&path).await? {
203            record.segments.clear();
204            Self::write_json(&path, &record).await?;
205        }
206        Ok(())
207    }
208
209    async fn save_torrent_data(&self, id: DownloadId, data: &[u8]) -> Result<()> {
210        let _guard = self.write_lock.lock().await;
211        Self::write_atomic(&self.torrent_path(id), data).await
212    }
213
214    async fn load_torrent_data(&self, id: DownloadId) -> Result<Option<Vec<u8>>> {
215        match tokio::fs::read(self.torrent_path(id)).await {
216            Ok(bytes) => Ok(Some(bytes)),
217            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
218            Err(e) => Err(e.into()),
219        }
220    }
221
222    async fn save_runtime_metadata(&self, id: DownloadId, runtime_json: &str) -> Result<()> {
223        let runtime_json = runtime_json.to_string();
224        self.update_record(id, move |record| {
225            record.runtime_metadata = Some(runtime_json)
226        })
227        .await
228    }
229
230    async fn load_runtime_metadata(&self, id: DownloadId) -> Result<Option<String>> {
231        let record: Option<DownloadRecord> = Self::read_json(&self.record_path(id)).await?;
232        Ok(record.and_then(|r| r.runtime_metadata))
233    }
234
235    async fn load_all_runtime_metadata(&self) -> Result<HashMap<DownloadId, String>> {
236        let mut metadata = HashMap::new();
237        for id in self.record_ids().await? {
238            if let Some(json) = self.load_runtime_metadata(id).await? {
239                metadata.insert(id, json);
240            }
241        }
242        Ok(metadata)
243    }
244
245    #[cfg(feature = "recursive-http")]
246    async fn save_recursive_job(&self, job: &TrackedRecursiveJob) -> Result<()> {
247        let _guard = self.write_lock.lock().await;
248        Self::write_json(&self.recursive_path(job.id), job).await
249    }
250
251    #[cfg(feature = "recursive-http")]
252    async fn load_recursive_jobs(&self) -> Result<Vec<TrackedRecursiveJob>> {
253        let mut jobs: Vec<TrackedRecursiveJob> = Vec::new();
254        let mut entries = tokio::fs::read_dir(&self.dir).await?;
255        while let Some(entry) = entries.next_entry().await? {
256            let path = entry.path();
257            if path.extension().and_then(|e| e.to_str()) != Some("json") {
258                continue;
259            }
260            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
261                continue;
262            };
263            if !stem.starts_with(RECURSIVE_PREFIX) {
264                continue;
265            }
266            match Self::read_json::<TrackedRecursiveJob>(&path).await {
267                Ok(Some(job)) => jobs.push(job),
268                Ok(None) => {}
269                Err(e) => {
270                    tracing::warn!("Skipping unreadable recursive job record {:?}: {}", path, e);
271                }
272            }
273        }
274        jobs.sort_by_key(|job| std::cmp::Reverse(job.created_at));
275        Ok(jobs)
276    }
277
278    #[cfg(feature = "recursive-http")]
279    async fn delete_recursive_job(&self, id: uuid::Uuid) -> Result<()> {
280        let _guard = self.write_lock.lock().await;
281        Self::remove_if_exists(&self.recursive_path(id)).await
282    }
283
284    async fn health_check(&self) -> Result<()> {
285        // Recreate the directory if it disappeared and prove it is writable.
286        tokio::fs::create_dir_all(&self.dir).await?;
287        let probe = self.dir.join(".health-check.tmp");
288        tokio::fs::write(&probe, b"ok").await?;
289        Self::remove_if_exists(&probe).await
290    }
291
292    async fn compact(&self) -> Result<()> {
293        // Clean up temp files left behind by interrupted writes.
294        let mut entries = tokio::fs::read_dir(&self.dir).await?;
295        while let Some(entry) = entries.next_entry().await? {
296            let path = entry.path();
297            if path.extension().and_then(|e| e.to_str()) == Some("tmp") {
298                Self::remove_if_exists(&path).await?;
299            }
300        }
301        Ok(())
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use crate::storage::SegmentState;
309    use crate::types::{DownloadKind, DownloadMetadata, DownloadProgress, DownloadState};
310    use chrono::Utc;
311
312    fn create_test_status() -> DownloadStatus {
313        DownloadStatus {
314            id: DownloadId::new(),
315            kind: DownloadKind::Http,
316            state: DownloadState::Paused,
317            priority: crate::priority_queue::DownloadPriority::Normal,
318            progress: DownloadProgress::default(),
319            metadata: DownloadMetadata {
320                name: "test.zip".to_string(),
321                url: Some("https://example.com/test.zip".to_string()),
322                magnet_uri: None,
323                info_hash: None,
324                save_dir: PathBuf::from("/tmp"),
325                filename: Some("test.zip".to_string()),
326                user_agent: None,
327                referer: None,
328                headers: vec![],
329                cookies: Vec::new(),
330                checksum: None,
331                mirrors: Vec::new(),
332                etag: None,
333                last_modified: None,
334            },
335            torrent_info: None,
336            peers: None,
337            created_at: Utc::now(),
338            completed_at: None,
339        }
340    }
341
342    #[tokio::test]
343    async fn test_download_round_trip() {
344        let dir = tempfile::tempdir().unwrap();
345        let storage = FileStorage::new(dir.path()).await.unwrap();
346        let status = create_test_status();
347        let id = status.id;
348
349        storage.save_download(&status).await.unwrap();
350
351        let loaded = storage.load_download(id).await.unwrap().unwrap();
352        assert_eq!(loaded.id, id);
353        assert_eq!(loaded.state, DownloadState::Paused);
354
355        let all = storage.load_all().await.unwrap();
356        assert_eq!(all.len(), 1);
357
358        storage.delete_download(id).await.unwrap();
359        assert!(storage.load_download(id).await.unwrap().is_none());
360        assert!(storage.load_all().await.unwrap().is_empty());
361    }
362
363    #[tokio::test]
364    async fn test_segments_round_trip() {
365        let dir = tempfile::tempdir().unwrap();
366        let storage = FileStorage::new(dir.path()).await.unwrap();
367        let id = DownloadId::new();
368
369        let mut segments = vec![
370            Segment::new(0, 0, 999),
371            Segment::new(1, 1000, 1999),
372            Segment::new(2, 2000, 2999),
373        ];
374        segments[0].downloaded = 1000;
375        segments[0].state = SegmentState::Completed;
376        segments[1].downloaded = 512;
377        segments[1].state = SegmentState::Failed {
378            error: "boom".to_string(),
379            retries: 2,
380        };
381
382        storage.save_segments(id, &segments).await.unwrap();
383
384        let loaded = storage.load_segments(id).await.unwrap();
385        assert_eq!(loaded.len(), 3);
386        assert_eq!(loaded[0].downloaded, 1000);
387        assert_eq!(loaded[0].state, SegmentState::Completed);
388        assert_eq!(
389            loaded[1].state,
390            SegmentState::Failed {
391                error: "boom".to_string(),
392                retries: 2
393            }
394        );
395        assert_eq!(loaded[2].state, SegmentState::Pending);
396
397        storage.delete_segments(id).await.unwrap();
398        assert!(storage.load_segments(id).await.unwrap().is_empty());
399    }
400
401    #[tokio::test]
402    async fn test_torrent_data_round_trip() {
403        let dir = tempfile::tempdir().unwrap();
404        let storage = FileStorage::new(dir.path()).await.unwrap();
405        let id = DownloadId::new();
406
407        assert!(storage.load_torrent_data(id).await.unwrap().is_none());
408
409        let data = b"d8:announce3:url4:infod4:name4:teste".to_vec();
410        storage.save_torrent_data(id, &data).await.unwrap();
411        assert_eq!(storage.load_torrent_data(id).await.unwrap(), Some(data));
412
413        storage.delete_download(id).await.unwrap();
414        assert!(storage.load_torrent_data(id).await.unwrap().is_none());
415    }
416
417    #[tokio::test]
418    async fn test_runtime_metadata_round_trip() {
419        let dir = tempfile::tempdir().unwrap();
420        let storage = FileStorage::new(dir.path()).await.unwrap();
421        let status = create_test_status();
422        let id = status.id;
423
424        storage.save_download(&status).await.unwrap();
425        storage
426            .save_runtime_metadata(id, r#"{"recursive_child":{"fail_fast":true}}"#)
427            .await
428            .unwrap();
429
430        assert_eq!(
431            storage.load_runtime_metadata(id).await.unwrap().as_deref(),
432            Some(r#"{"recursive_child":{"fail_fast":true}}"#)
433        );
434        assert!(storage
435            .load_all_runtime_metadata()
436            .await
437            .unwrap()
438            .contains_key(&id));
439
440        // Saving metadata must not clobber the status saved earlier.
441        assert!(storage.load_download(id).await.unwrap().is_some());
442
443        storage.delete_download(id).await.unwrap();
444        assert!(storage.load_runtime_metadata(id).await.unwrap().is_none());
445    }
446
447    #[cfg(feature = "recursive-http")]
448    #[tokio::test]
449    async fn test_recursive_job_round_trip() {
450        let dir = tempfile::tempdir().unwrap();
451        let storage = FileStorage::new(dir.path()).await.unwrap();
452        let job = TrackedRecursiveJob {
453            id: uuid::Uuid::new_v4(),
454            root_url: "https://example.com/pub/".to_string(),
455            child_ids: vec![DownloadId::new(), DownloadId::new()],
456            created_at: Utc::now(),
457        };
458
459        storage.save_recursive_job(&job).await.unwrap();
460        assert_eq!(
461            storage.load_recursive_jobs().await.unwrap(),
462            vec![job.clone()]
463        );
464
465        // Recursive records must not show up as downloads.
466        assert!(storage.load_all().await.unwrap().is_empty());
467
468        storage.delete_recursive_job(job.id).await.unwrap();
469        assert!(storage.load_recursive_jobs().await.unwrap().is_empty());
470    }
471
472    #[tokio::test]
473    async fn test_persists_across_instances() {
474        let dir = tempfile::tempdir().unwrap();
475        let status = create_test_status();
476        let id = status.id;
477
478        {
479            let storage = FileStorage::new(dir.path()).await.unwrap();
480            storage.save_download(&status).await.unwrap();
481            storage
482                .save_segments(id, &[Segment::new(0, 0, 999)])
483                .await
484                .unwrap();
485        }
486
487        let storage = FileStorage::new(dir.path()).await.unwrap();
488        assert!(storage.load_download(id).await.unwrap().is_some());
489        assert_eq!(storage.load_segments(id).await.unwrap().len(), 1);
490    }
491
492    #[tokio::test]
493    async fn test_health_check_and_compact() {
494        let dir = tempfile::tempdir().unwrap();
495        let storage = FileStorage::new(dir.path()).await.unwrap();
496        storage.health_check().await.unwrap();
497
498        // Simulate an interrupted write and verify compact cleans it up.
499        let leftover = dir.path().join("garbage.json.tmp");
500        tokio::fs::write(&leftover, b"partial").await.unwrap();
501        storage.compact().await.unwrap();
502        assert!(!leftover.exists());
503    }
504}