1use std::fs::{File, OpenOptions, TryLockError};
32use std::io::{self, Write};
33use std::path::{Path, PathBuf};
34
35use camino::Utf8Path;
36
37use crate::applog;
38use crate::diagnostic::{Diagnostic, Reason};
39use crate::digest::Digest;
40use crate::error::RkError;
41
42pub const LOCKS_DIR: &str = "locks";
44
45#[derive(Debug)]
54pub struct TargetLock {
55 _file: File,
58 path: PathBuf,
59}
60
61impl TargetLock {
62 #[must_use]
64 pub fn path(&self) -> &Path {
65 &self.path
66 }
67}
68
69pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
85 let Some(root) = applog::state_root() else {
86 return Err(rootless());
87 };
88 acquire_in(&root.join(LOCKS_DIR), target)
89}
90
91fn rootless() -> RkError {
93 RkError::refusal(
94 Diagnostic::new(
95 Reason::PrerequisiteUnmet,
96 "no state root resolves, so this run cannot take the target, and nothing was written",
97 )
98 .expected("one apply against a target at a time, held by a lock under the state root")
99 .action("set XDG_STATE_HOME, or HOME, and run it again")
100 .target_state("unchanged"),
101 )
102}
103
104pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
114 let canonical = std::fs::canonicalize(target)
115 .map_or_else(|_| target.to_string(), |path| path.display().to_string());
116 std::fs::create_dir_all(dir)?;
117 restrict_lock_dir(dir)?;
118 let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
119 let mut file = open_lock_file(&path)?;
123 match file.try_lock() {
124 Ok(()) => {
125 let _ = file.set_len(0);
130 let _ = writeln!(file, "{}", std::process::id());
131 let _ = writeln!(file, "{canonical}");
132 let _ = file.flush();
133 Ok(TargetLock { _file: file, path })
134 }
135 Err(TryLockError::WouldBlock) => Err(busy(&canonical, &path)),
136 Err(TryLockError::Error(error)) => Err(RkError::Io(error)),
137 }
138}
139
140fn restrict_lock_dir(dir: &Path) -> io::Result<()> {
142 let metadata = std::fs::symlink_metadata(dir)?;
143 if metadata.file_type().is_symlink() || !metadata.is_dir() {
144 return Err(invalid_lock_entry(dir, "lock namespace is not a directory"));
145 }
146 #[cfg(unix)]
147 {
148 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
149
150 let directory = OpenOptions::new()
151 .read(true)
152 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
153 .open(dir)?;
154 directory.set_permissions(std::fs::Permissions::from_mode(0o700))?;
155 }
156 Ok(())
157}
158
159fn open_lock_file(path: &Path) -> io::Result<File> {
161 match std::fs::symlink_metadata(path) {
162 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
163 return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
164 }
165 Ok(_) => {}
166 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
167 Err(error) => return Err(error),
168 }
169 let mut options = OpenOptions::new();
170 options.read(true).write(true).create(true).truncate(false);
171 configure_lock_open(&mut options);
172 let file = options.open(path)?;
173 if !file.metadata()?.is_file() {
174 return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
175 }
176 #[cfg(unix)]
177 {
178 use std::os::unix::fs::PermissionsExt as _;
179
180 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
181 }
182 Ok(file)
183}
184
185#[cfg(unix)]
186fn configure_lock_open(options: &mut OpenOptions) {
187 use std::os::unix::fs::OpenOptionsExt as _;
188
189 options
190 .mode(0o600)
191 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
192}
193
194#[cfg(windows)]
195fn configure_lock_open(options: &mut OpenOptions) {
196 use std::os::windows::fs::OpenOptionsExt as _;
197
198 const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
202 options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
203}
204
205#[cfg(not(any(unix, windows)))]
206fn configure_lock_open(_options: &mut OpenOptions) {}
207
208fn invalid_lock_entry(path: &Path, detail: &str) -> io::Error {
209 io::Error::new(
210 io::ErrorKind::InvalidData,
211 format!("{detail}: {}", path.display()),
212 )
213}
214
215fn busy(target: &str, path: &Path) -> RkError {
217 let holder = std::fs::read_to_string(path)
218 .ok()
219 .and_then(|text| text.lines().next().map(str::to_owned))
220 .filter(|line| !line.is_empty())
221 .map_or_else(
222 || "another run".to_owned(),
223 |pid| format!("the run at process {pid}"),
224 );
225 RkError::refusal(
226 Diagnostic::new(
227 Reason::TargetBusy,
228 format!("{holder} holds {target}, and nothing was written"),
229 )
230 .expected("one apply against a target at a time")
231 .action("wait for that run to finish, then run it again")
232 .target_state("unchanged"),
233 )
234}
235
236#[cfg(test)]
237mod tests {
238 use super::{acquire_in, busy, rootless};
239 use crate::diagnostic::Reason;
240 use crate::digest::Digest;
241
242 fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
243 camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
244 }
245
246 fn lock_path(locks: &tempfile::TempDir, target: &camino::Utf8Path) -> std::path::PathBuf {
247 let canonical = std::fs::canonicalize(target).expect("the target canonicalizes");
248 locks.path().join(format!(
249 "{}.lock",
250 Digest::of(canonical.display().to_string().as_bytes())
251 ))
252 }
253
254 #[test]
257 fn one_run_holds_a_target_at_a_time() {
258 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
259 let target = tempfile::tempdir().expect("a scratch target exists");
260 let path = utf8(&target);
261 let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
262 let second = acquire_in(locks.path(), &path);
263 assert_eq!(
264 second.expect_err("the second refuses").reason(),
265 Reason::TargetBusy
266 );
267 drop(first);
268 acquire_in(locks.path(), &path).expect("the target is free again");
269 }
270
271 #[test]
273 fn two_targets_are_two_locks() {
274 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
275 let a = tempfile::tempdir().expect("a scratch target exists");
276 let b = tempfile::tempdir().expect("a second scratch target exists");
277 let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
278 acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
279 }
280
281 #[test]
288 fn a_lock_file_without_a_live_holder_is_taken_over() {
289 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
290 let target = tempfile::tempdir().expect("a scratch target exists");
291 let path = utf8(&target);
292 let held = acquire_in(locks.path(), &path)
293 .expect("the first run takes the target")
294 .path()
295 .to_path_buf();
296
297 drop(acquire_in(locks.path(), &path));
300 std::fs::write(&held, "4242\n/some/target\n").expect("the corpse's line writes");
301 assert!(held.exists(), "a killed run leaves its lock file");
302
303 let taken = acquire_in(locks.path(), &path).expect("the next run takes the target");
304 assert_eq!(taken.path(), held, "it is the same lock file");
305 let body = std::fs::read_to_string(&held).expect("the lock file reads");
306 assert!(
307 body.starts_with(&format!("{}\n", std::process::id())),
308 "the taking run names itself, and no tail of the corpse survives: {body:?}"
309 );
310 }
311
312 #[cfg(unix)]
315 #[test]
316 fn a_symlink_lock_file_is_refused_without_touching_its_target() {
317 use std::os::unix::fs::symlink;
318
319 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
320 let target = tempfile::tempdir().expect("a scratch target exists");
321 let path = utf8(&target);
322 let victim = locks.path().join("victim");
323 std::fs::write(&victim, "untouched\n").expect("the victim exists");
324 symlink(&victim, lock_path(&locks, &path)).expect("the crafted lock link exists");
325
326 acquire_in(locks.path(), &path).expect_err("a lock link is refused");
327 assert_eq!(
328 std::fs::read_to_string(victim).expect("the victim reads"),
329 "untouched\n",
330 "acquisition must not truncate or write through the link"
331 );
332 }
333
334 #[test]
337 fn a_non_regular_lock_entry_is_refused() {
338 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
339 let target = tempfile::tempdir().expect("a scratch target exists");
340 let path = utf8(&target);
341 let entry = lock_path(&locks, &path);
342 std::fs::create_dir(&entry).expect("a non-regular entry exists");
343
344 acquire_in(locks.path(), &path).expect_err("a non-regular lock is refused");
345 assert!(entry.is_dir(), "the refused entry stays unchanged");
346 }
347
348 #[cfg(unix)]
351 #[test]
352 fn the_lock_namespace_and_file_are_owner_only() {
353 use std::os::unix::fs::PermissionsExt as _;
354
355 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
356 let target = tempfile::tempdir().expect("a scratch target exists");
357 let taken = acquire_in(locks.path(), &utf8(&target)).expect("the target is taken");
358
359 let dir_mode = std::fs::metadata(locks.path())
360 .expect("the namespace has metadata")
361 .permissions()
362 .mode()
363 & 0o777;
364 let file_mode = std::fs::metadata(taken.path())
365 .expect("the lock has metadata")
366 .permissions()
367 .mode()
368 & 0o777;
369 assert_eq!(dir_mode, 0o700);
370 assert_eq!(file_mode, 0o600);
371 }
372
373 #[cfg(unix)]
376 #[test]
377 fn a_symlink_lock_namespace_is_refused() {
378 use std::os::unix::fs::symlink;
379
380 let parent = tempfile::tempdir().expect("a scratch parent exists");
381 let destination = tempfile::tempdir().expect("a scratch destination exists");
382 let locks = parent.path().join("locks");
383 symlink(destination.path(), &locks).expect("the crafted namespace link exists");
384 let target = tempfile::tempdir().expect("a scratch target exists");
385
386 acquire_in(&locks, &utf8(&target)).expect_err("a linked namespace is refused");
387 assert_eq!(
388 std::fs::read_dir(destination.path())
389 .expect("the destination reads")
390 .count(),
391 0,
392 "no lock is written through the namespace link"
393 );
394 }
395
396 #[test]
399 fn a_host_with_no_state_root_refuses() {
400 let error = rootless();
401 assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
402 let diagnostic = error.diagnostic();
403 assert!(
404 diagnostic
405 .action
406 .unwrap_or_default()
407 .contains("XDG_STATE_HOME"),
408 "{:?}",
409 diagnostic.message
410 );
411 assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
412 }
413
414 #[test]
417 fn the_refusal_names_the_holder() {
418 let dir = tempfile::tempdir().expect("a scratch directory exists");
419 let path = dir.path().join("held.lock");
420 std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
421 let diagnostic = busy("/some/target", &path).diagnostic();
422 assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
423 assert!(
424 diagnostic.message.contains("/some/target"),
425 "{diagnostic:?}"
426 );
427 }
428}