1use std::fs::{File, OpenOptions, TryLockError};
29use std::io;
30use std::path::{Path, PathBuf};
31
32use camino::Utf8Path;
33
34use crate::applog;
35use crate::diagnostic::{Diagnostic, Reason};
36use crate::digest::Digest;
37use crate::error::RkError;
38
39pub const LOCKS_DIR: &str = "locks";
41
42#[derive(Debug)]
50pub struct TargetLock {
51 _file: File,
54 path: PathBuf,
55 identity: crate::held::Identity,
58}
59
60impl TargetLock {
61 #[must_use]
63 pub const fn identity(&self) -> crate::held::Identity {
64 self.identity
65 }
66
67 #[must_use]
69 pub fn path(&self) -> &Path {
70 &self.path
71 }
72}
73
74pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
87 let Some(root) = applog::state_root() else {
88 return Err(rootless());
89 };
90 acquire_in(&root.join(LOCKS_DIR), target)
91}
92
93fn rootless() -> RkError {
95 RkError::refusal(
96 Diagnostic::new(
97 Reason::PrerequisiteUnmet,
98 "no state root resolves, so this run cannot take the target, and nothing was written",
99 )
100 .expected("one landing against a target at a time, held by a lock under the state root")
101 .action("set XDG_STATE_HOME, or HOME, and run it again")
102 .target_state("unchanged"),
103 )
104}
105
106pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
122 let resolved = std::fs::canonicalize(target)
123 .and_then(|path| std::fs::metadata(&path).map(|metadata| (path, metadata)))
124 .map_err(|error| {
125 RkError::missing(
126 Diagnostic::new(
127 Reason::TargetNotFound,
128 format!("target {target} does not resolve to a directory to lock: {error}"),
129 )
130 .expected("an existing directory to land into")
131 .target_state("unchanged"),
132 )
133 })?;
134 let (resolved, metadata) = resolved;
135 if !metadata.is_dir() {
136 return Err(RkError::missing(
137 Diagnostic::new(
138 Reason::TargetNotFound,
139 format!("target {target} is not a directory, and nothing was written"),
140 )
141 .expected("an existing directory to land into")
142 .target_state("unchanged"),
143 ));
144 }
145 let identity = crate::held::Identity::of(&metadata);
146 let canonical = resolved.display().to_string();
147 std::fs::create_dir_all(dir)?;
148 restrict_lock_dir(dir)?;
149 let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
150 let file = open_lock_file(&path)?;
154 match file.try_lock() {
155 Ok(()) => {
156 let _ = file.set_len(0);
161 Ok(TargetLock {
162 _file: file,
163 path,
164 identity,
165 })
166 }
167 Err(TryLockError::WouldBlock) => Err(busy(&canonical)),
168 Err(TryLockError::Error(error)) => Err(RkError::Io(error)),
169 }
170}
171
172fn restrict_lock_dir(dir: &Path) -> io::Result<()> {
174 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
175
176 let metadata = std::fs::symlink_metadata(dir)?;
177 if metadata.file_type().is_symlink() || !metadata.is_dir() {
178 return Err(invalid_lock_entry(dir, "lock namespace is not a directory"));
179 }
180 let directory = OpenOptions::new()
181 .read(true)
182 .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
183 .open(dir)?;
184 directory.set_permissions(std::fs::Permissions::from_mode(0o700))?;
185 Ok(())
186}
187
188fn open_lock_file(path: &Path) -> io::Result<File> {
192 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
193
194 match std::fs::symlink_metadata(path) {
195 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
196 return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
197 }
198 Ok(_) => {}
199 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
200 Err(error) => return Err(error),
201 }
202 let file = OpenOptions::new()
203 .read(true)
204 .write(true)
205 .create(true)
206 .truncate(false)
207 .mode(0o600)
208 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
209 .open(path)?;
210 if !file.metadata()?.is_file() {
211 return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
212 }
213 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
214 Ok(file)
215}
216
217fn invalid_lock_entry(path: &Path, detail: &str) -> io::Error {
218 io::Error::new(
219 io::ErrorKind::InvalidData,
220 format!("{detail}: {}", path.display()),
221 )
222}
223
224fn busy(target: &str) -> RkError {
226 RkError::refusal(
227 Diagnostic::new(
228 Reason::TargetBusy,
229 format!("another run holds {target}, and nothing was written"),
230 )
231 .expected("one landing against a target at a time")
232 .action("wait for that run to finish, then run it again")
233 .target_state("unchanged"),
234 )
235}
236
237#[cfg(test)]
238mod tests {
239 use std::os::unix::fs::PermissionsExt as _;
240
241 use super::{TargetLock, acquire_in, rootless};
242 use crate::diagnostic::Reason;
243 use crate::digest::Digest;
244
245 fn acquire_freed(locks: &std::path::Path, target: &camino::Utf8Path) -> TargetLock {
250 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
251 loop {
252 match acquire_in(locks, target) {
253 Ok(lock) => return lock,
254 Err(error)
255 if error.reason() == Reason::TargetBusy
256 && std::time::Instant::now() < deadline =>
257 {
258 std::thread::sleep(std::time::Duration::from_millis(10));
259 }
260 Err(error) => panic!("the target is never freed: {error:?}"),
261 }
262 }
263 }
264
265 fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
266 camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
267 }
268
269 fn lock_path(locks: &tempfile::TempDir, target: &camino::Utf8Path) -> std::path::PathBuf {
270 let canonical = std::fs::canonicalize(target).expect("the target canonicalizes");
271 locks.path().join(format!(
272 "{}.lock",
273 Digest::of(canonical.display().to_string().as_bytes())
274 ))
275 }
276
277 #[test]
280 fn one_run_holds_a_target_at_a_time() {
281 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
282 let target = tempfile::tempdir().expect("a scratch target exists");
283 let path = utf8(&target);
284 let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
285 let second = acquire_in(locks.path(), &path);
286 let refused = second.expect_err("the second refuses");
287 assert_eq!(refused.reason(), Reason::TargetBusy);
288 assert_eq!(
289 refused.diagnostic().target_state.as_deref(),
290 Some("unchanged")
291 );
292 drop(first);
293 acquire_freed(locks.path(), &path);
294 }
295
296 #[test]
298 fn two_targets_are_two_locks() {
299 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
300 let a = tempfile::tempdir().expect("a scratch target exists");
301 let b = tempfile::tempdir().expect("a second scratch target exists");
302 let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
303 acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
304 }
305
306 #[test]
311 fn a_lock_file_without_a_live_holder_is_taken_over() {
312 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
313 let target = tempfile::tempdir().expect("a scratch target exists");
314 let path = utf8(&target);
315 let held = acquire_in(locks.path(), &path)
316 .expect("the first run takes the target")
317 .path()
318 .to_path_buf();
319 drop(acquire_freed(locks.path(), &path));
320 std::fs::write(&held, "4242\n/some/target\n").expect("the corpse's line writes");
321 assert!(held.exists(), "a killed run leaves its lock file");
322
323 let taken = acquire_freed(locks.path(), &path);
324 assert_eq!(taken.path(), held, "it is the same lock file");
325 assert_eq!(
326 std::fs::read_to_string(&held).expect("the lock file reads"),
327 "",
328 "the entry carries no holder description"
329 );
330 }
331
332 #[test]
338 fn a_non_regular_lock_entry_is_refused_and_the_lock_stays_owner_only() {
339 use std::os::unix::fs::symlink;
340
341 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
344 let target = tempfile::tempdir().expect("a scratch target exists");
345 let path = utf8(&target);
346 let victim = locks.path().join("victim");
347 std::fs::write(&victim, "untouched\n").expect("the victim exists");
348 symlink(&victim, lock_path(&locks, &path)).expect("the crafted lock link exists");
349 acquire_in(locks.path(), &path).expect_err("a lock link is refused");
350 assert_eq!(
351 std::fs::read_to_string(&victim).expect("the victim reads"),
352 "untouched\n"
353 );
354
355 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
357 let entry = lock_path(&locks, &path);
358 std::fs::create_dir(&entry).expect("a non-regular entry exists");
359 acquire_in(locks.path(), &path).expect_err("a non-regular lock is refused");
360 assert!(entry.is_dir(), "the refused entry stays unchanged");
361
362 let parent = tempfile::tempdir().expect("a scratch parent exists");
364 let destination = tempfile::tempdir().expect("a scratch destination exists");
365 let linked = parent.path().join("locks");
366 symlink(destination.path(), &linked).expect("the crafted namespace link exists");
367 acquire_in(&linked, &path).expect_err("a linked namespace is refused");
368 assert_eq!(
369 std::fs::read_dir(destination.path())
370 .expect("the destination reads")
371 .count(),
372 0
373 );
374
375 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
377 let taken = acquire_in(locks.path(), &path).expect("the target is taken");
378 let dir_mode = std::fs::metadata(locks.path())
379 .expect("the namespace has metadata")
380 .permissions()
381 .mode()
382 & 0o777;
383 let file_mode = std::fs::metadata(taken.path())
384 .expect("the lock has metadata")
385 .permissions()
386 .mode()
387 & 0o777;
388 assert_eq!(dir_mode, 0o700);
389 assert_eq!(file_mode, 0o600);
390 }
391
392 #[test]
395 fn a_host_with_no_state_root_refuses() {
396 let error = rootless();
397 assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
398 let diagnostic = error.diagnostic();
399 assert!(
400 diagnostic
401 .action
402 .unwrap_or_default()
403 .contains("XDG_STATE_HOME"),
404 "{:?}",
405 diagnostic.message
406 );
407 assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
408 }
409}