Skip to main content

pg_embedded_setup_unpriv/cache/
lock.rs

1//! Cross-process file locking for cache coordination.
2//!
3//! Provides exclusive and shared locks to coordinate binary downloads across
4//! parallel test runners. Locking goes through `fs4`, which uses `flock(2)` on
5//! Unix and `LockFileEx` on Windows, so both platforms exclude rather than one
6//! excluding and the other merely holding a file open.
7
8use std::{
9    fs::{File, OpenOptions},
10    io,
11};
12
13use camino::Utf8Path;
14use fs4::FileExt;
15
16/// Subdirectory within the cache for lock files.
17///
18/// The lock lives beside the cache it guards, so two caches that happen to
19/// share a version string do not contend.
20const LOCKS_SUBDIR: &str = ".locks";
21
22/// Guard that holds a file lock until dropped.
23///
24/// The lock is automatically released when the guard goes out of scope.
25#[derive(Debug)]
26pub struct CacheLock {
27    _file: File,
28}
29
30impl CacheLock {
31    /// Acquires an exclusive lock for a specific version.
32    ///
33    /// Use exclusive locks when downloading or populating the cache to prevent
34    /// concurrent writes.
35    ///
36    /// # Errors
37    ///
38    /// Returns an error if the lock file cannot be created or the lock cannot
39    /// be acquired.
40    ///
41    /// # Examples
42    ///
43    /// ```no_run
44    /// use camino::Utf8Path;
45    /// use pg_embedded_setup_unpriv::cache::CacheLock;
46    ///
47    /// let cache_dir = Utf8Path::new("/tmp/pg-cache");
48    /// let _lock = CacheLock::acquire_exclusive(cache_dir, "17.4.0")?;
49    /// // Exclusive access to version 17.4.0 cache entry
50    /// # Ok::<(), std::io::Error>(())
51    /// ```
52    pub fn acquire_exclusive(cache_dir: &Utf8Path, version: &str) -> io::Result<Self> {
53        Self::acquire(cache_dir, version, LockType::Exclusive)
54    }
55
56    /// Acquires a shared lock for a specific version.
57    ///
58    /// Use shared locks when reading from the cache to allow concurrent reads
59    /// whilst blocking writes.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the lock file cannot be created or the lock cannot
64    /// be acquired.
65    ///
66    /// # Examples
67    ///
68    /// ```no_run
69    /// use camino::Utf8Path;
70    /// use pg_embedded_setup_unpriv::cache::CacheLock;
71    ///
72    /// let cache_dir = Utf8Path::new("/tmp/pg-cache");
73    /// let _lock = CacheLock::acquire_shared(cache_dir, "17.4.0")?;
74    /// // Shared access to version 17.4.0 cache entry
75    /// # Ok::<(), std::io::Error>(())
76    /// ```
77    pub fn acquire_shared(cache_dir: &Utf8Path, version: &str) -> io::Result<Self> {
78        Self::acquire(cache_dir, version, LockType::Shared)
79    }
80
81    /// Opens the version's lock file and takes the requested kernel lock.
82    ///
83    /// Blocks until the lock is available. The lock is released when the file
84    /// handle drops with the guard, so `unlock` is not called explicitly.
85    fn acquire(cache_dir: &Utf8Path, version: &str, lock_type: LockType) -> io::Result<Self> {
86        validate_version(version)?;
87        let locks_dir = cache_dir.join(LOCKS_SUBDIR);
88        std::fs::create_dir_all(&locks_dir)?;
89
90        let lock_path = locks_dir.join(format!("{version}.lock"));
91        let file = OpenOptions::new()
92            .read(true)
93            .write(true)
94            .create(true)
95            .truncate(false)
96            .open(&lock_path)?;
97
98        // `fs4` names the exclusive lock `lock`, so both calls are spelled
99        // out through the trait rather than relying on the shorter name to
100        // read as "exclusive" at the call site.
101        match lock_type {
102            LockType::Exclusive => FileExt::lock(&file)?,
103            LockType::Shared => FileExt::lock_shared(&file)?,
104        }
105
106        Ok(Self { _file: file })
107    }
108}
109
110/// Type of lock to acquire.
111#[derive(Debug, Clone, Copy)]
112enum LockType {
113    /// Exclusive lock for writes.
114    Exclusive,
115    /// Shared lock for reads.
116    Shared,
117}
118
119/// Validates that a version string is a single path component.
120///
121/// Rejects versions containing path separators or parent directory references
122/// that could escape the cache directory.
123fn validate_version(version: &str) -> io::Result<()> {
124    use std::path::Component;
125
126    let mut components = std::path::Path::new(version).components();
127    match (components.next(), components.next()) {
128        (Some(Component::Normal(_)), None) => Ok(()),
129        _ => Err(io::Error::new(
130            io::ErrorKind::InvalidInput,
131            "version must be a single path component",
132        )),
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    //! Tests for cache lock acquisition.
139    use rstest::{fixture, rstest};
140    use tempfile::TempDir;
141
142    use super::*;
143
144    /// Fixture providing a temporary cache directory as a UTF-8 path.
145    #[fixture]
146    fn cache_fixture() -> io::Result<(TempDir, camino::Utf8PathBuf)> {
147        let temp = tempfile::tempdir()?;
148        let cache_dir = camino::Utf8PathBuf::from_path_buf(temp.path().to_path_buf())
149            .map_err(|path| io::Error::other(format!("non-UTF-8 temp path: {}", path.display())))?;
150        Ok((temp, cache_dir))
151    }
152
153    /// The lock file lives beside the cache it guards, on every platform.
154    ///
155    /// Keeping it under the cache directory means two caches that happen to
156    /// share a version string do not contend.
157    #[rstest]
158    #[case::exclusive("17.4.0", true)]
159    #[case::shared("16.3.0", false)]
160    fn acquire_lock_creates_lock_file(
161        cache_fixture: io::Result<(TempDir, camino::Utf8PathBuf)>,
162        #[case] version: &str,
163        #[case] exclusive: bool,
164    ) {
165        let (temp, cache_dir) = cache_fixture.expect("cache fixture");
166        let _lock = if exclusive {
167            CacheLock::acquire_exclusive(&cache_dir, version).expect("acquire lock")
168        } else {
169            CacheLock::acquire_shared(&cache_dir, version).expect("acquire lock")
170        };
171
172        let lock_path = temp
173            .path()
174            .join(LOCKS_SUBDIR)
175            .join(format!("{version}.lock"));
176        assert!(lock_path.exists(), "lock file should be created");
177    }
178
179    /// An exclusive lock excludes a second caller until the first releases.
180    ///
181    /// This is the property the type's name promises, and it runs on every
182    /// platform rather than only Unix: before `fs4` the non-Unix arm held the
183    /// file open without taking a kernel lock, so two callers could enter the
184    /// critical section together, observe the same cache miss, and download
185    /// the same archive independently.
186    ///
187    /// The second acquisition is attempted from another thread, because an
188    /// exclusive lock blocks: the test asserts it does not complete while the
189    /// first guard lives, then that it completes once the guard drops.
190    #[rstest]
191    fn an_exclusive_lock_excludes_a_second_caller(
192        cache_fixture: io::Result<(TempDir, camino::Utf8PathBuf)>,
193    ) {
194        use std::{
195            sync::mpsc,
196            thread,
197            time::{Duration, Instant},
198        };
199
200        let (_temp, cache_dir) = cache_fixture.expect("cache fixture");
201        let held = CacheLock::acquire_exclusive(&cache_dir, "17.4.0").expect("first lock");
202
203        let (tx, rx) = mpsc::channel();
204        let contender_dir = cache_dir.clone();
205        let contender = thread::spawn(move || {
206            let lock = CacheLock::acquire_exclusive(&contender_dir, "17.4.0");
207            tx.send(Instant::now()).ok();
208            lock
209        });
210
211        // While the first guard lives the contender must not get through.
212        assert!(
213            rx.recv_timeout(Duration::from_millis(250)).is_err(),
214            "a second exclusive lock must not be granted while the first is held"
215        );
216
217        drop(held);
218        rx.recv_timeout(Duration::from_secs(10))
219            .expect("the contender must acquire once the first lock is released");
220        contender
221            .join()
222            .expect("contender thread")
223            .expect("second lock");
224    }
225
226    #[rstest]
227    fn multiple_shared_locks_can_coexist(
228        cache_fixture: io::Result<(TempDir, camino::Utf8PathBuf)>,
229    ) {
230        let (_temp, cache_dir) = cache_fixture.expect("cache fixture");
231
232        let lock1 = CacheLock::acquire_shared(&cache_dir, "17.4.0").expect("acquire lock 1");
233        let lock2 = CacheLock::acquire_shared(&cache_dir, "17.4.0").expect("acquire lock 2");
234
235        // Both locks should be held successfully
236        drop(lock1);
237        drop(lock2);
238    }
239
240    #[rstest]
241    fn different_versions_have_separate_locks(
242        cache_fixture: io::Result<(TempDir, camino::Utf8PathBuf)>,
243    ) {
244        let (_temp, cache_dir) = cache_fixture.expect("cache fixture");
245
246        let lock1 = CacheLock::acquire_exclusive(&cache_dir, "17.4.0").expect("acquire lock 1");
247        let lock2 = CacheLock::acquire_exclusive(&cache_dir, "16.3.0").expect("acquire lock 2");
248
249        // Different versions should not block each other
250        drop(lock1);
251        drop(lock2);
252    }
253
254    #[rstest]
255    #[case::parent_dir_exclusive("..")]
256    #[case::parent_dir_shared("..")]
257    #[case::path_separator_exclusive("foo/bar")]
258    #[case::path_separator_shared("foo/bar")]
259    #[case::parent_in_path_exclusive("../17.4.0")]
260    #[case::absolute_path_exclusive("/etc/passwd")]
261    fn acquire_rejects_invalid_version_strings(
262        cache_fixture: io::Result<(TempDir, camino::Utf8PathBuf)>,
263        #[case] invalid_version: &str,
264    ) {
265        let (_temp, cache_dir) = cache_fixture.expect("cache fixture");
266
267        let exclusive_err = CacheLock::acquire_exclusive(&cache_dir, invalid_version)
268            .expect_err("acquire_exclusive should reject invalid version");
269        assert_eq!(
270            exclusive_err.kind(),
271            io::ErrorKind::InvalidInput,
272            "error kind should be InvalidInput for: {invalid_version}"
273        );
274
275        let shared_err = CacheLock::acquire_shared(&cache_dir, invalid_version)
276            .expect_err("acquire_shared should reject invalid version");
277        assert_eq!(
278            shared_err.kind(),
279            io::ErrorKind::InvalidInput,
280            "error kind should be InvalidInput for: {invalid_version}"
281        );
282    }
283}