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::{acquire_in, rootless};
242 use crate::diagnostic::Reason;
243 use crate::digest::Digest;
244
245 fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
246 camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
247 }
248
249 fn lock_path(locks: &tempfile::TempDir, target: &camino::Utf8Path) -> std::path::PathBuf {
250 let canonical = std::fs::canonicalize(target).expect("the target canonicalizes");
251 locks.path().join(format!(
252 "{}.lock",
253 Digest::of(canonical.display().to_string().as_bytes())
254 ))
255 }
256
257 #[test]
260 fn one_run_holds_a_target_at_a_time() {
261 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
262 let target = tempfile::tempdir().expect("a scratch target exists");
263 let path = utf8(&target);
264 let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
265 let second = acquire_in(locks.path(), &path);
266 let refused = second.expect_err("the second refuses");
267 assert_eq!(refused.reason(), Reason::TargetBusy);
268 assert_eq!(
269 refused.diagnostic().target_state.as_deref(),
270 Some("unchanged")
271 );
272 drop(first);
273 acquire_in(locks.path(), &path).expect("the target is free again");
274 }
275
276 #[test]
278 fn two_targets_are_two_locks() {
279 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
280 let a = tempfile::tempdir().expect("a scratch target exists");
281 let b = tempfile::tempdir().expect("a second scratch target exists");
282 let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
283 acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
284 }
285
286 #[test]
291 fn a_lock_file_without_a_live_holder_is_taken_over() {
292 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
293 let target = tempfile::tempdir().expect("a scratch target exists");
294 let path = utf8(&target);
295 let held = acquire_in(locks.path(), &path)
296 .expect("the first run takes the target")
297 .path()
298 .to_path_buf();
299 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 assert_eq!(
306 std::fs::read_to_string(&held).expect("the lock file reads"),
307 "",
308 "the entry carries no holder description"
309 );
310 }
311
312 #[test]
318 fn a_non_regular_lock_entry_is_refused_and_the_lock_stays_owner_only() {
319 use std::os::unix::fs::symlink;
320
321 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
324 let target = tempfile::tempdir().expect("a scratch target exists");
325 let path = utf8(&target);
326 let victim = locks.path().join("victim");
327 std::fs::write(&victim, "untouched\n").expect("the victim exists");
328 symlink(&victim, lock_path(&locks, &path)).expect("the crafted lock link exists");
329 acquire_in(locks.path(), &path).expect_err("a lock link is refused");
330 assert_eq!(
331 std::fs::read_to_string(&victim).expect("the victim reads"),
332 "untouched\n"
333 );
334
335 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
337 let entry = lock_path(&locks, &path);
338 std::fs::create_dir(&entry).expect("a non-regular entry exists");
339 acquire_in(locks.path(), &path).expect_err("a non-regular lock is refused");
340 assert!(entry.is_dir(), "the refused entry stays unchanged");
341
342 let parent = tempfile::tempdir().expect("a scratch parent exists");
344 let destination = tempfile::tempdir().expect("a scratch destination exists");
345 let linked = parent.path().join("locks");
346 symlink(destination.path(), &linked).expect("the crafted namespace link exists");
347 acquire_in(&linked, &path).expect_err("a linked namespace is refused");
348 assert_eq!(
349 std::fs::read_dir(destination.path())
350 .expect("the destination reads")
351 .count(),
352 0
353 );
354
355 let locks = tempfile::tempdir().expect("a scratch locks directory exists");
357 let taken = acquire_in(locks.path(), &path).expect("the target is taken");
358 let dir_mode = std::fs::metadata(locks.path())
359 .expect("the namespace has metadata")
360 .permissions()
361 .mode()
362 & 0o777;
363 let file_mode = std::fs::metadata(taken.path())
364 .expect("the lock has metadata")
365 .permissions()
366 .mode()
367 & 0o777;
368 assert_eq!(dir_mode, 0o700);
369 assert_eq!(file_mode, 0o600);
370 }
371
372 #[test]
375 fn a_host_with_no_state_root_refuses() {
376 let error = rootless();
377 assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
378 let diagnostic = error.diagnostic();
379 assert!(
380 diagnostic
381 .action
382 .unwrap_or_default()
383 .contains("XDG_STATE_HOME"),
384 "{:?}",
385 diagnostic.message
386 );
387 assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
388 }
389}