Skip to main content

aria2_core/filesystem/
file_lock.rs

1//! File locking to prevent concurrent aria2 instances from writing to same file.
2//!
3//! This module provides platform-appropriate file locking mechanisms:
4//!
5//! - **Unix**: Uses `flock(LOCK_EX | LOCK_NB)` for advisory exclusive locking.
6//! - **Windows**: Uses a lock marker file (`.lock` extension) as a simple
7//!   exclusive access mechanism.
8//! - **Other**: No-op fallback (no locking).
9//!
10//! # Example
11//!
12//! ```rust,no_run
13//! use aria2_core::filesystem::file_lock::{FileLock, DownloadPathLock};
14//! use std::path::Path;
15//!
16//! // Acquire exclusive lock on a specific file
17//! let lock = FileLock::acquire(Path::new("/data/file.zip")).unwrap();
18///
19/// // ... perform download while holding lock ...
20///
21/// // Lock is released automatically when `lock` goes out of scope
22/// ```
23use std::path::{Path, PathBuf};
24
25// ---------------------------------------------------------------------------
26// FileLock -- platform-adaptive exclusive file lock
27// ---------------------------------------------------------------------------
28
29/// Exclusive file lock to prevent concurrent aria2 instances from writing to
30/// the same file simultaneously.
31///
32/// Uses platform-appropriate locking mechanism:
33/// - On **Unix**: `flock(LOCK_EX | LOCK_NB)` via libc (non-blocking).
34/// - On **Windows**: Creates a `.lock` marker file with exclusive semantics.
35/// - On **other platforms**: No-op placeholder.
36///
37/// The lock is automatically released when this struct is dropped.
38pub struct FileLock {
39    path: PathBuf,
40    #[cfg(unix)]
41    file: Option<std::fs::File>,
42    #[cfg(windows)]
43    _handle: Option<()>,
44    #[cfg(not(any(unix, windows)))]
45    _handle: Option<()>,
46}
47
48impl FileLock {
49    /// Acquire an exclusive lock on the given file path.
50    ///
51    /// # Unix Behavior
52    ///
53    /// Opens/creates the file and calls `flock(fd, LOCK_EX | LOCK_NB)`.
54    /// If another process already holds the lock, returns an error immediately
55    /// (non-blocking). The lock is **advisory** -- cooperative processes must
56    /// all use `flock` for it to be effective. The lock is released when the
57    /// returned `FileLock` is dropped (or when [`release`] is called).
58    ///
59    /// # Windows Behavior
60    ///
61    /// Creates a sibling `.lock` file in the same directory. This is a simple
62    /// marker-based approach; a production implementation would use
63    /// `LockFileEx` or a named mutex for true exclusivity.
64    ///
65    /// # Errors
66    ///
67    /// Returns `Err` if:
68    /// - The file cannot be created/opened (permission denied, disk full, etc.)
69    /// - Another process already holds the lock (Unix only)
70    pub fn acquire(path: &Path) -> Result<Self, String> {
71        #[cfg(unix)]
72        {
73            Self::acquire_unix(path)
74        }
75        #[cfg(windows)]
76        {
77            Self::acquire_windows(path)
78        }
79        #[cfg(not(any(unix, windows)))]
80        {
81            Self::acquire_fallback(path)
82        }
83    }
84
85    /// Unix implementation: flock(LOCK_EX | LOCK_NB).
86    #[cfg(unix)]
87    fn acquire_unix(path: &Path) -> Result<Self, String> {
88        use std::os::unix::io::AsRawFd;
89
90        // Create or open the target file
91        let f = std::fs::File::create(path)
92            .map_err(|e| format!("Failed to create '{}': {}", path.display(), e))?;
93
94        let fd = f.as_raw_fd();
95
96        // Attempt non-blocking exclusive lock (flock LOCK_EX | LOCK_NB)
97        // Returns 0 on success, -1 on error (EAGAIN/EWOULDBLOCK if locked)
98        let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
99
100        if ret != 0 {
101            let err = std::io::Error::last_os_error();
102            return Err(format!(
103                "Cannot acquire lock on '{}': {} (already in use by another process?)",
104                path.display(),
105                err
106            ));
107        }
108
109        tracing::debug!(path = %path.display(), "File lock acquired (Unix flock)");
110
111        Ok(Self {
112            path: path.to_path_buf(),
113            file: Some(f),
114        })
115    }
116
117    /// Windows implementation: .lock marker file with exclusive create.
118    #[cfg(windows)]
119    fn acquire_windows(path: &Path) -> Result<Self, String> {
120        use std::io::Write;
121        // Use a clearly separate lock file name to avoid ambiguity with
122        // with_extension() behavior on dotted filenames like ".aria2.lock"
123        let lock_path = if let Some(parent) = path.parent() {
124            if let Some(stem) = path.file_stem() {
125                parent.join(format!("{}.lock", stem.to_string_lossy()))
126            } else {
127                path.with_extension(".lock")
128            }
129        } else {
130            path.with_extension(".lock")
131        };
132
133        // Try to create the lock file exclusively (fails if exists)
134        match std::fs::OpenOptions::new()
135            .write(true)
136            .create_new(true)
137            .open(&lock_path)
138        {
139            Ok(mut f) => {
140                // Write our PID as lock owner identifier
141                let pid = std::process::id();
142                let _ = write!(f, "{}", pid);
143                // Keep file handle open to maintain lock (dropped = released)
144                drop(f);
145                Ok(Self {
146                    path: path.to_path_buf(),
147                    _handle: Some(()),
148                })
149            }
150            Err(e) => {
151                if e.kind() == std::io::ErrorKind::AlreadyExists {
152                    Err(format!(
153                        "Cannot acquire lock on '{}': lock file '{}' already exists",
154                        path.display(),
155                        lock_path.display()
156                    ))
157                } else {
158                    Err(format!(
159                        "Failed to create lock file '{}': {}",
160                        lock_path.display(),
161                        e
162                    ))
163                }
164            }
165        }
166    }
167
168    /// Fallback for unsupported platforms: no-op.
169    #[cfg(not(any(unix, windows)))]
170    fn acquire_fallback(path: &Path) -> Result<Self, String> {
171        tracing::warn!(
172            path = %path.display(),
173            "File locking not supported on this platform; proceeding without lock"
174        );
175
176        Ok(Self {
177            path: path.to_path_buf(),
178            _handle: None,
179        })
180    }
181
182    /// Explicitly release the lock.
183    ///
184    /// After calling this method, the lock is released and the `FileLock`
185    /// should no longer be used. Dropping the `FileLock` also releases the
186    /// lock, so explicit release is optional.
187    pub fn release(self) {
188        #[cfg(unix)]
189        {
190            use std::os::fd::AsRawFd;
191            if let Some(f) = &self.file {
192                let fd = f.as_raw_fd();
193                let _ = unsafe { libc::flock(fd, libc::LOCK_UN) };
194                tracing::debug!(
195                    path = %self.path.display(),
196                    "File lock released (Unix flock)"
197                );
198            }
199        }
200        #[cfg(windows)]
201        {
202            let lock_path = self.lock_file_path();
203            let _ = std::fs::remove_file(&lock_path);
204            tracing::debug!(
205                path = %self.path.display(),
206                lock_file = %lock_path.display(),
207                "Lock file removed (Windows)"
208            );
209        }
210        #[cfg(not(any(unix, windows)))]
211        {
212            let _ = self; // suppress unused warning
213        }
214    }
215
216    /// Check whether we currently hold the lock.
217    ///
218    /// Always returns `true` if [`acquire`] succeeded (the lock is held until
219    /// dropped or explicitly released). Returns `false` only if the lock was
220    /// never acquired.
221    pub fn is_held(&self) -> bool {
222        #[cfg(unix)]
223        {
224            self.file.is_some()
225        }
226        #[cfg(windows)]
227        {
228            self._handle.is_some()
229        }
230        #[cfg(not(any(unix, windows)))]
231        {
232            false
233        }
234    }
235
236    /// Return the path that this lock guards.
237    pub fn path(&self) -> &Path {
238        &self.path
239    }
240
241    /// Return the actual platform-specific lock file path used for locking.
242    ///
243    /// - **Unix**: Same as `path()` (the file itself is locked via `flock`).
244    /// - **Windows**: The `.lock` marker file (e.g., `foo.lock` for target `foo`).
245    pub fn lock_file_path(&self) -> PathBuf {
246        #[cfg(unix)]
247        {
248            self.path.clone()
249        }
250        #[cfg(windows)]
251        {
252            // Match the same logic as acquire_windows()
253            if let Some(parent) = self.path.parent() {
254                if let Some(stem) = self.path.file_stem() {
255                    parent.join(format!("{}.lock", stem.to_string_lossy()))
256                } else {
257                    self.path.with_extension(".lock")
258                }
259            } else {
260                self.path.with_extension(".lock")
261            }
262        }
263        #[cfg(not(any(unix, windows)))]
264        {
265            self.path.clone()
266        }
267    }
268}
269
270impl Drop for FileLock {
271    fn drop(&mut self) {
272        // Release lock on drop (same logic as release but consuming self is not possible in Drop)
273        #[cfg(unix)]
274        {
275            use std::os::fd::AsRawFd;
276            if let Some(ref f) = self.file {
277                let fd = f.as_raw_fd();
278                let _ = unsafe { libc::flock(fd, libc::LOCK_UN) };
279                tracing::debug!(
280                    path = %self.path.display(),
281                    "File lock auto-released on drop (Unix)"
282                );
283            }
284        }
285        #[cfg(windows)]
286        {
287            let lock_path = self.lock_file_path();
288            let _ = std::fs::remove_file(&lock_path);
289            tracing::debug!(
290                path = %self.path.display(),
291                lock_file = %lock_path.display(),
292                "Lock file auto-removed on drop (Windows)"
293            );
294        }
295    }
296}
297
298impl std::fmt::Debug for FileLock {
299    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300        f.debug_struct("FileLock")
301            .field("path", &self.path)
302            .field("is_held", &self.is_held())
303            .finish()
304    }
305}
306
307// ---------------------------------------------------------------------------
308// DownloadPathLock -- manages locks for multi-file downloads
309// ---------------------------------------------------------------------------
310
311/// Manages file locks for all files in a download directory.
312///
313/// Creates a `.aria2.lock` file in the output directory to indicate that an
314/// aria2 download is active. This prevents multiple aria2 instances from
315/// writing to the same output location simultaneously.
316///
317/// The lock is held for the lifetime of the `DownloadPathLock` struct and
318/// is automatically released when it is dropped.
319pub struct DownloadPathLock {
320    base_lock: Option<FileLock>,
321    dir: PathBuf,
322}
323
324impl DownloadPathLock {
325    /// Acquire a download lock for the given output directory.
326    ///
327    /// Creates (or locks) a `.aria2.lock` file inside `output_dir`. If another
328    /// aria2 instance already holds a lock on this directory, returns an error.
329    ///
330    /// # Arguments
331    ///
332    /// * `output_dir` - The directory where downloaded files will be written.
333    ///
334    /// # Errors
335    ///
336    /// Returns `Err` if:
337    /// - The directory does not exist and cannot be created.
338    /// - Another process holds the lock on `.aria2.lock` in this directory.
339    pub fn acquire_for_download(output_dir: &Path) -> Result<Self, String> {
340        // Ensure output directory exists
341        if !output_dir.exists() {
342            std::fs::create_dir_all(output_dir).map_err(|e| {
343                format!(
344                    "Failed to create output directory '{}': {}",
345                    output_dir.display(),
346                    e
347                )
348            })?;
349        }
350
351        let lock_path = output_dir.join(".aria2.lock");
352        let base = FileLock::acquire(&lock_path)?;
353
354        tracing::info!(
355            dir = %output_dir.display(),
356            lock = %lock_path.display(),
357            "Download path lock acquired"
358        );
359
360        Ok(Self {
361            base_lock: Some(base),
362            dir: output_dir.to_path_buf(),
363        })
364    }
365
366    /// Release the download lock early (also happens on drop).
367    pub fn release(self) {
368        if let Some(lock) = self.base_lock {
369            lock.release();
370            tracing::info!(dir = %self.dir.display(), "Download path lock released");
371        }
372    }
373
374    /// Return the output directory this lock protects.
375    pub fn dir(&self) -> &Path {
376        &self.dir
377    }
378
379    /// Return the underlying FileLock if it exists.
380    pub fn file_lock(&self) -> Option<&FileLock> {
381        self.base_lock.as_ref()
382    }
383
384    /// Check whether the lock is currently held.
385    pub fn is_held(&self) -> bool {
386        self.base_lock.as_ref().is_some_and(|l| l.is_held())
387    }
388}
389
390// ---------------------------------------------------------------------------
391// Tests
392// ---------------------------------------------------------------------------
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// Generate a unique suffix for test file names to avoid collisions.
399    fn unique_suffix() -> String {
400        format!(
401            "_t{}_p{}",
402            std::time::SystemTime::now()
403                .duration_since(std::time::UNIX_EPOCH)
404                .map_or_else(|_| 0, |d| d.as_nanos()),
405            std::process::id()
406        )
407    }
408
409    #[test]
410    fn test_file_lock_acquire_release() {
411        let tmp_dir = std::env::temp_dir();
412        let lock_path = tmp_dir.join(format!("aria2_test_lock{}.tmp", unique_suffix()));
413
414        // Acquire lock should succeed
415        let lock = FileLock::acquire(&lock_path).expect("First acquire should succeed");
416        assert!(lock.is_held());
417        assert_eq!(lock.path(), lock_path);
418
419        // Explicit release
420        lock.release();
421
422        // After release, acquiring again should succeed (lock is freed)
423        let lock2 = FileLock::acquire(&lock_path).expect("Re-acquire after release should succeed");
424        assert!(lock2.is_held());
425
426        // Cleanup
427        let _ = std::fs::remove_file(&lock_path);
428        #[cfg(windows)]
429        let _ = std::fs::remove_file(lock_path.with_extension(".lock"));
430    }
431
432    #[test]
433    #[cfg(unix)]
434    fn test_file_lock_double_acquire_fails() {
435        let tmp_dir = std::env::temp_dir();
436        let lock_path = tmp_dir.join(format!("aria2_test_double_lock{}.tmp", unique_suffix()));
437
438        // First acquire succeeds
439        let lock1 = FileLock::acquire(&lock_path).expect("First acquire should succeed");
440
441        // Second acquire on same path should FAIL (non-blocking flock returns EAGAIN)
442        let result = FileLock::acquire(&lock_path);
443        assert!(
444            result.is_err(),
445            "Second acquire on same path should fail, got: {:?}",
446            result
447        );
448
449        // Drop first lock
450        drop(lock1);
451
452        // Now acquire should succeed again
453        let lock3 = FileLock::acquire(&lock_path).expect("Acquire after drop should succeed");
454        assert!(lock3.is_held());
455
456        // Cleanup
457        let _ = std::fs::remove_file(&lock_path);
458    }
459
460    #[test]
461    fn test_download_path_lock_creates_marker() {
462        let tmp_dir = std::env::temp_dir();
463        let test_dir = tmp_dir.join(format!("aria2_test_dl_lock{}", unique_suffix()));
464
465        // Ensure clean state
466        let _ = std::fs::remove_dir_all(&test_dir);
467
468        // Acquire download path lock
469        let dl_lock =
470            DownloadPathLock::acquire_for_download(&test_dir).expect("Acquire should succeed");
471
472        // Verify lock marker file exists while lock is held (use platform-aware path)
473        let lock_file = dl_lock.base_lock.as_ref().unwrap().lock_file_path();
474        assert!(
475            lock_file.exists(),
476            "lock marker file should exist while lock is held"
477        );
478        assert!(dl_lock.is_held());
479        assert_eq!(dl_lock.dir(), test_dir);
480
481        // Verify directory was created
482        assert!(test_dir.exists());
483
484        // Release lock
485        dl_lock.release();
486
487        // After release, the lock file should be cleaned up (platform dependent)
488        #[cfg(windows)]
489        assert!(
490            !lock_file.exists(),
491            ".lock file should be removed after release on Windows"
492        );
493
494        // Cleanup
495        let _ = std::fs::remove_dir_all(&test_dir);
496    }
497
498    #[test]
499    fn test_download_path_lock_reacquire_after_drop() {
500        let tmp_dir = std::env::temp_dir();
501        let test_dir = tmp_dir.join(format!("aria2_test_reacquire{}", unique_suffix()));
502
503        // Clean up
504        let _ = std::fs::remove_dir_all(&test_dir);
505
506        {
507            // Scope 1: acquire and hold
508            let _lock1 =
509                DownloadPathLock::acquire_for_download(&test_dir).expect("First acquire OK");
510            let lock_path = _lock1.file_lock().unwrap().lock_file_path();
511            assert!(lock_path.exists());
512        }
513        // _lock1 dropped here -> lock released
514
515        {
516            // Scope 2: re-acquire should succeed
517            let _lock2 = DownloadPathLock::acquire_for_download(&test_dir)
518                .expect("Re-acquire after drop OK");
519            assert!(_lock2.is_held());
520        }
521
522        // Cleanup
523        let _ = std::fs::remove_dir_all(&test_dir);
524    }
525}