Skip to main content

aria2_core/engine/
active_output_registry.rs

1//! Active output path registry — prevents silent filename collisions between concurrent downloads.
2//!
3//! When multiple `DownloadCommand` instances target the same output directory with the same
4//! inferred filename, the last writer would silently overwrite previous results. This module
5//! provides a process-wide registry that detects such collisions and automatically appends
6//! a `(N)` suffix (Windows/Mac style) to produce unique filenames.
7
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use tokio::sync::RwLock;
12use tracing::{debug, info, warn};
13
14/// Process-wide registry of output paths that are currently being written by active downloads.
15///
16/// All download command types (`DownloadCommand`, `MetalinkDownloadCommand`,
17/// `ConcurrentDownloadCommand`) consult this registry **before** opening their disk writer,
18/// so that concurrent downloads targeting the same filename receive distinct paths.
19pub struct ActiveOutputRegistry {
20    inner: Arc<RwLock<HashSet<PathBuf>>>,
21}
22
23impl Default for ActiveOutputRegistry {
24    fn default() -> Self {
25        Self::new()
26    }
27}
28
29impl ActiveOutputRegistry {
30    /// Create a new empty registry.
31    pub fn new() -> Self {
32        Self {
33            inner: Arc::new(RwLock::new(HashSet::new())),
34        }
35    }
36
37    /// Resolve the final output path for a download, registering it to prevent collisions.
38    ///
39    /// If `desired` is not currently claimed by another active download, it is registered
40    /// and returned as-is.  If a collision is detected, the method appends ` (1)`, ` (2)`,
41    /// etc. (before the file extension) until an unused name is found.
42    ///
43    /// # Returns
44    ///
45    /// The resolved `PathBuf` that the caller should use for all subsequent disk I/O.
46    /// The caller **must** call [`Self::release`] when the download finishes (success or failure).
47    pub async fn resolve(&self, desired: &Path) -> PathBuf {
48        let mut registry = self.inner.write().await;
49
50        if !registry.contains(desired) {
51            registry.insert(desired.to_path_buf());
52            debug!(
53                "Output path registered (no conflict): {}",
54                desired.display()
55            );
56            return desired.to_path_buf();
57        }
58
59        // Collision detected — generate unique name with numeric suffix.
60        let stem = desired
61            .file_stem()
62            .map(|s| s.to_string_lossy().into_owned())
63            .unwrap_or_default();
64
65        let ext = desired
66            .extension()
67            .map(|e| format!(".{}", e.to_string_lossy()))
68            .unwrap_or_default();
69
70        let parent = desired.parent().unwrap_or_else(|| Path::new("."));
71
72        let mut counter: u32 = 1;
73        loop {
74            let candidate = parent.join(format!("{} ({}){}", stem, counter, ext));
75
76            // Check both the in-progress registry AND the filesystem to avoid conflicts
77            // with previously completed downloads that happened to use the same suffix.
78            if !registry.contains(&candidate) && !candidate.exists() {
79                registry.insert(candidate.clone());
80                warn!(
81                    "Filename collision: '{}' is already in use. Resolved to '{}'",
82                    desired.display(),
83                    candidate.display()
84                );
85                return candidate;
86            }
87
88            counter += 1;
89
90            // Safety upper bound to prevent unbounded looping in pathological cases.
91            if counter > 10_000 {
92                let fallback = parent.join(format!("{}_collision_{}", stem, counter));
93                registry.insert(fallback.clone());
94                warn!(
95                    "Exhausted normal suffix range for '{}', using fallback '{}'",
96                    desired.display(),
97                    fallback.display()
98                );
99                return fallback;
100            }
101        }
102    }
103
104    /// Release a previously-resolved path from the registry.
105    ///
106    /// Must be called when a download completes (success or failure) so that the path
107    /// becomes available for future downloads if needed.
108    pub async fn release(&self, path: &Path) {
109        let mut registry = self.inner.write().await;
110        if registry.remove(path) {
111            debug!("Output path released: {}", path.display());
112        }
113    }
114
115    /// Return the number of paths currently registered (for diagnostics / testing).
116    pub async fn len(&self) -> usize {
117        self.inner.read().await.len()
118    }
119
120    /// Check whether the registry is empty.
121    pub async fn is_empty(&self) -> bool {
122        self.inner.read().await.is_empty()
123    }
124}
125
126// ---------------------------------------------------------------------------
127// Global singleton — every download command shares the same instance so that
128// cross-task collisions are caught regardless of how commands are spawned.
129// ---------------------------------------------------------------------------
130
131use std::sync::OnceLock;
132
133/// Global singleton registry instance. Initialized on first access.
134static GLOBAL_REGISTRY: OnceLock<ActiveOutputRegistry> = OnceLock::new();
135
136/// Obtain a reference to the global `ActiveOutputRegistry`.
137pub fn global_registry() -> &'static ActiveOutputRegistry {
138    GLOBAL_REGISTRY.get_or_init(|| {
139        info!("Global ActiveOutputRegistry initialized");
140        ActiveOutputRegistry::new()
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[tokio::test]
149    async fn test_no_collision_when_unique() {
150        let reg = ActiveOutputRegistry::new();
151        let p1 = reg.resolve(Path::new("/tmp/file.txt")).await;
152        assert_eq!(p1, PathBuf::from("/tmp/file.txt"));
153        assert_eq!(reg.len().await, 1);
154    }
155
156    #[tokio::test]
157    async fn test_collision_generates_suffix() {
158        let reg = ActiveOutputRegistry::new();
159
160        let p1 = reg.resolve(Path::new("/tmp/file.txt")).await;
161        let p2 = reg.resolve(Path::new("/tmp/file.txt")).await;
162
163        assert_eq!(p1, PathBuf::from("/tmp/file.txt"));
164        assert_eq!(p2, PathBuf::from("/tmp/file (1).txt"));
165        assert_eq!(reg.len().await, 2);
166    }
167
168    #[tokio::test]
169    async fn test_triple_collision() {
170        let reg = ActiveOutputRegistry::new();
171
172        let _p1 = reg.resolve(Path::new("/tmp/data.bin")).await;
173        let _p2 = reg.resolve(Path::new("/tmp/data.bin")).await;
174        let p3 = reg.resolve(Path::new("/tmp/data.bin")).await;
175
176        assert_eq!(p3, PathBuf::from("/tmp/data (2).bin"));
177    }
178
179    #[tokio::test]
180    async fn test_release_allows_reuse() {
181        let reg = ActiveOutputRegistry::new();
182
183        let p1 = reg.resolve(Path::new("/tmp/reusable.txt")).await;
184        reg.release(&p1).await;
185        assert!(reg.is_empty().await);
186
187        // After release, the same path can be claimed again.
188        let p2 = reg.resolve(Path::new("/tmp/reusable.txt")).await;
189        assert_eq!(p2, PathBuf::from("/tmp/reusable.txt"));
190    }
191
192    #[tokio::test]
193    async fn test_no_extension_handling() {
194        let reg = ActiveOutputRegistry::new();
195
196        let p1 = reg.resolve(Path::new("/tmp/Makefile")).await;
197        let p2 = reg.resolve(Path::new("/tmp/Makefile")).await;
198
199        assert_eq!(p1, PathBuf::from("/tmp/Makefile"));
200        // Files without extension should still get suffixed correctly.
201        assert_eq!(p2, PathBuf::from("/tmp/Makefile (1)"));
202    }
203
204    #[tokio::test]
205    async fn test_global_singleton() {
206        let r1 = global_registry();
207        let r2 = global_registry();
208        // Both references must point to the same underlying registry.
209        assert!(Arc::ptr_eq(&r1.inner, &r2.inner));
210    }
211}