pg_embedded_setup_unpriv/cache/
lock.rs1use std::{
9 fs::{File, OpenOptions},
10 io,
11};
12
13use camino::Utf8Path;
14use fs4::FileExt;
15
16const LOCKS_SUBDIR: &str = ".locks";
21
22#[derive(Debug)]
26pub struct CacheLock {
27 _file: File,
28}
29
30impl CacheLock {
31 pub fn acquire_exclusive(cache_dir: &Utf8Path, version: &str) -> io::Result<Self> {
53 Self::acquire(cache_dir, version, LockType::Exclusive)
54 }
55
56 pub fn acquire_shared(cache_dir: &Utf8Path, version: &str) -> io::Result<Self> {
78 Self::acquire(cache_dir, version, LockType::Shared)
79 }
80
81 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 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#[derive(Debug, Clone, Copy)]
112enum LockType {
113 Exclusive,
115 Shared,
117}
118
119fn 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 use rstest::{fixture, rstest};
140 use tempfile::TempDir;
141
142 use super::*;
143
144 #[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 #[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 #[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 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 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 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}