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}