1#[cfg(unix)]
4use std::ffi::{CString, OsStr};
5#[cfg(unix)]
6use std::io::{Read as _, Write as _};
7#[cfg(unix)]
8use std::os::unix::ffi::OsStrExt;
9#[cfg(unix)]
10use std::os::unix::fs::{FileExt as _, MetadataExt};
11#[cfg(unix)]
12use std::os::unix::io::{AsRawFd, FromRawFd};
13#[cfg(unix)]
14use std::path::PathBuf;
15
16#[cfg(unix)]
18#[derive(Debug)]
19pub struct DaemonStoreGuard {
20 pub(super) parent_dir: std::fs::File,
23 pub(super) _sidecar: std::fs::File,
24 pub(super) database: PathBuf,
25 claimed_identity: Option<(u64, u64)>,
26 pub(super) _bound_database: Option<std::fs::File>,
27}
28
29#[cfg(unix)]
30impl Drop for DaemonStoreGuard {
31 fn drop(&mut self) {
32 if let Err(error) = self._sidecar.unlock() {
35 tracing::warn!(
36 database = %self.database.display(),
37 %error,
38 "cannot release daemon store lock"
39 );
40 }
41 }
42}
43
44#[cfg(not(unix))]
46#[derive(Debug)]
47pub struct DaemonStoreGuard;
48
49#[cfg(unix)]
50pub(super) fn regular_store_identity(
51 database: &std::path::Path,
52) -> anyhow::Result<Option<(u64, u64)>> {
53 let metadata = match std::fs::symlink_metadata(database) {
54 Ok(metadata) => metadata,
55 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
56 Err(error) => {
57 anyhow::bail!(
58 "cannot inspect claimed database {}: {error}",
59 database.display()
60 )
61 }
62 };
63 anyhow::ensure!(
64 metadata.is_file(),
65 "claimed database {} is not a regular file",
66 database.display()
67 );
68 Ok(Some((metadata.dev(), metadata.ino())))
69}
70
71#[cfg(unix)]
73fn open_claimed_entry(
74 parent: &std::fs::File,
75 name: &OsStr,
76 flags: libc::c_int,
77 mode: libc::mode_t,
78) -> std::io::Result<std::fs::File> {
79 let name = CString::new(name.as_bytes()).map_err(|_| {
80 std::io::Error::new(
81 std::io::ErrorKind::InvalidInput,
82 "store entry contains U+0000",
83 )
84 })?;
85 let fd = unsafe {
88 libc::openat(
89 parent.as_raw_fd(),
90 name.as_ptr(),
91 flags | libc::O_NOFOLLOW | libc::O_CLOEXEC,
92 mode as libc::c_uint,
94 )
95 };
96 if fd < 0 {
97 return Err(std::io::Error::last_os_error());
98 }
99 Ok(unsafe { std::fs::File::from_raw_fd(fd) })
101}
102
103#[cfg(unix)]
107fn open_claimed_directory(path: &std::path::Path) -> std::io::Result<std::fs::File> {
108 use std::path::Component;
109
110 if !path.is_absolute() {
111 return Err(std::io::Error::new(
112 std::io::ErrorKind::InvalidInput,
113 "claimed store directory must be absolute",
114 ));
115 }
116 let mut directory = std::fs::File::open("/")?;
117 for component in path.components() {
118 match component {
119 Component::RootDir => {}
120 Component::Normal(name) => {
121 directory =
122 open_claimed_entry(&directory, name, libc::O_RDONLY | libc::O_DIRECTORY, 0)?;
123 }
124 _ => {
125 return Err(std::io::Error::new(
126 std::io::ErrorKind::InvalidInput,
127 "claimed store directory must be canonical",
128 ));
129 }
130 }
131 }
132 Ok(directory)
133}
134
135#[cfg(unix)]
139pub(super) fn ensure_claimed_parent_identity(
140 directory: &std::fs::File,
141 database: &std::path::Path,
142) -> anyhow::Result<()> {
143 let parent = database.parent().expect("claimed path has a parent");
144 let held = directory.metadata()?;
145 let observed = std::fs::symlink_metadata(parent)?;
146 anyhow::ensure!(
147 observed.is_dir() && (held.dev(), held.ino()) == (observed.dev(), observed.ino()),
148 "claimed database {} parent directory changed since claim",
149 database.display()
150 );
151 Ok(())
152}
153
154#[cfg(unix)]
157fn regular_store_identity_at(
158 parent: &std::fs::File,
159 name: &OsStr,
160 database: &std::path::Path,
161) -> anyhow::Result<Option<(u64, u64)>> {
162 let name = CString::new(name.as_bytes())
163 .map_err(|_| anyhow::anyhow!("database name {} contains U+0000", database.display()))?;
164 let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
165 let rc = unsafe {
167 libc::fstatat(
168 parent.as_raw_fd(),
169 name.as_ptr(),
170 stat.as_mut_ptr(),
171 libc::AT_SYMLINK_NOFOLLOW,
172 )
173 };
174 if rc != 0 {
175 let error = std::io::Error::last_os_error();
176 if error.kind() == std::io::ErrorKind::NotFound {
177 return Ok(None);
178 }
179 anyhow::bail!(
180 "cannot inspect claimed database {}: {error}",
181 database.display()
182 );
183 }
184 let stat = unsafe { stat.assume_init() };
186 anyhow::ensure!(
187 stat.st_mode & libc::S_IFMT == libc::S_IFREG,
188 "claimed database {} is not a regular file",
189 database.display()
190 );
191 #[cfg(target_os = "linux")]
192 let device = stat.st_dev;
193 #[cfg(not(target_os = "linux"))]
194 let device = stat.st_dev as u64;
195 Ok(Some((device, stat.st_ino)))
196}
197
198#[cfg(all(test, unix))]
199thread_local! {
200 static STORE_BIND_RACE_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
201 const { std::cell::RefCell::new(None) };
202}
203
204#[cfg(all(test, unix))]
205pub(super) fn set_store_bind_race_hook(hook: impl FnOnce() + 'static) {
206 STORE_BIND_RACE_HOOK.with(|cell| *cell.borrow_mut() = Some(Box::new(hook)));
207}
208
209#[cfg(all(test, unix))]
210fn take_store_bind_race_hook() -> Option<Box<dyn FnOnce()>> {
211 STORE_BIND_RACE_HOOK.with(|cell| cell.borrow_mut().take())
212}
213
214#[cfg(unix)]
217fn bind_open_error(
218 error: &std::io::Error,
219 database: &std::path::Path,
220 read_only: bool,
221) -> anyhow::Error {
222 if !read_only
223 && error.kind() == std::io::ErrorKind::PermissionDenied
224 && std::fs::metadata(database).is_ok_and(|metadata| metadata.permissions().readonly())
225 {
226 return anyhow::anyhow!(
227 "claimed database {} has no filesystem write bits; declare `read_only = true` so \
228 backend topology and daemon config identity describe the snapshot-inspection mode \
229 explicitly",
230 database.display()
231 );
232 }
233 anyhow::anyhow!(
234 "cannot open claimed database {}: {error}",
235 database.display()
236 )
237}
238
239#[cfg(unix)]
244pub fn bind_daemon_store_files(
245 guards: &mut [DaemonStoreGuard],
246 read_only_paths: &[PathBuf],
247) -> anyhow::Result<()> {
248 for guard in guards {
249 let read_only = read_only_paths.contains(&guard.database);
250 ensure_claimed_parent_identity(&guard.parent_dir, &guard.database)?;
251 let filename = guard
252 .database
253 .file_name()
254 .expect("claimed path has a file name");
255 #[cfg(test)]
256 if let Some(hook) = take_store_bind_race_hook() {
257 hook();
258 }
259 let flags = if read_only {
260 libc::O_RDONLY
261 } else {
262 libc::O_RDWR | libc::O_CREAT
263 };
264 let file = open_claimed_entry(&guard.parent_dir, filename, flags, 0o600)
266 .map_err(|error| bind_open_error(&error, &guard.database, read_only))?;
267 let metadata = file.metadata()?;
268 anyhow::ensure!(
269 metadata.is_file(),
270 "claimed database {} is not a regular file",
271 guard.database.display()
272 );
273 let opened_identity = (metadata.dev(), metadata.ino());
274 if let Some(claimed_identity) = guard.claimed_identity {
275 anyhow::ensure!(
276 claimed_identity == opened_identity,
277 "claimed database {} changed inode between claim and open",
278 guard.database.display()
279 );
280 }
281 guard.claimed_identity = Some(opened_identity);
282 guard._bound_database = Some(file);
283 anyhow::ensure!(
284 regular_store_identity(&guard.database)? == Some(opened_identity),
285 "claimed database {} changed inode while binding its open file",
286 guard.database.display()
287 );
288 }
289 Ok(())
290}
291
292#[cfg(unix)]
297pub fn assert_daemon_store_identities(guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
298 for guard in guards {
299 ensure_claimed_parent_identity(&guard.parent_dir, &guard.database)?;
300 let bound = guard._bound_database.as_ref().ok_or_else(|| {
301 anyhow::anyhow!(
302 "claimed database {} was not bound",
303 guard.database.display()
304 )
305 })?;
306 let metadata = bound.metadata()?;
307 let bound_identity = (metadata.dev(), metadata.ino());
308 let observed = regular_store_identity(&guard.database)?;
309 anyhow::ensure!(
310 observed == Some(bound_identity) && observed == guard.claimed_identity,
311 "claimed database {} changed inode after open (claimed {:?}, now {:?}); refusing daemon boot",
312 guard.database.display(),
313 guard.claimed_identity,
314 observed
315 );
316 }
317 Ok(())
318}
319
320#[cfg(not(unix))]
322pub fn assert_daemon_store_identities(_guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
323 Ok(())
324}
325
326#[cfg(unix)]
328pub(super) fn daemon_store_lock_path(database: &std::path::Path) -> anyhow::Result<PathBuf> {
329 let filename = database
330 .file_name()
331 .ok_or_else(|| anyhow::anyhow!("database path {} has no file name", database.display()))?;
332 let mut lock_name = std::ffi::OsString::from(".");
333 lock_name.push(filename);
334 lock_name.push(".khived.lock");
335 Ok(database.with_file_name(lock_name))
336}
337
338#[cfg(unix)]
340const STORE_LOCK_RECORD_MAX: u64 = 64;
341
342#[cfg(unix)]
345pub(super) fn store_lock_holder_pid(file: &mut std::fs::File) -> Option<u32> {
346 let mut holder_text = String::new();
347 file.take(STORE_LOCK_RECORD_MAX)
348 .read_to_string(&mut holder_text)
349 .ok()?;
350 holder_text.trim().parse::<u32>().ok()
351}
352
353#[cfg(unix)]
356fn has_lock_sidecar_name(filename: &OsStr) -> bool {
357 const SUFFIX: &[u8] = b".khived.lock";
358 let name = filename.as_bytes();
359 name.len() > SUFFIX.len() && name.starts_with(b".") && name.ends_with(SUFFIX)
360}
361
362#[cfg(unix)]
366fn ensure_sidecar_holds_only_a_lock_record(
367 file: &std::fs::File,
368 len: u64,
369 lock_path: &std::path::Path,
370 database: &std::path::Path,
371) -> anyhow::Result<()> {
372 const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0";
373 let mut sqlite_header = false;
374 if len >= 16 {
375 let mut head = [0u8; 16];
376 file.read_exact_at(&mut head, 0).map_err(|error| {
377 anyhow::anyhow!(
378 "cannot read daemon store lock {}: {error}",
379 lock_path.display()
380 )
381 })?;
382 sqlite_header = head == *SQLITE_HEADER;
383 }
384 anyhow::ensure!(
385 len <= STORE_LOCK_RECORD_MAX && !sqlite_header,
386 "refusing daemon store claim: existing file at lock sidecar {} for database {} is not a \
387 daemon lock record (longer than {} bytes or a SQLite database); the file was left \
388 untouched",
389 lock_path.display(),
390 database.display(),
391 STORE_LOCK_RECORD_MAX
392 );
393 Ok(())
394}
395
396#[cfg(unix)]
399pub fn acquire_daemon_store_guards(
400 database_paths: impl IntoIterator<Item = PathBuf>,
401) -> anyhow::Result<Vec<DaemonStoreGuard>> {
402 claim_stores(&database_paths.into_iter().collect::<Vec<_>>(), &[])
403}
404
405#[cfg(unix)]
423pub fn claim_stores(
424 database_paths: &[PathBuf],
425 read_only_paths: &[PathBuf],
426) -> anyhow::Result<Vec<DaemonStoreGuard>> {
427 let mut database_paths = database_paths.to_vec();
428 database_paths.sort();
429 database_paths.dedup();
430 let lock_paths: Vec<PathBuf> = database_paths
431 .iter()
432 .map(|database| daemon_store_lock_path(database))
433 .collect::<anyhow::Result<_>>()?;
434 for (database, lock_path) in database_paths.iter().zip(&lock_paths) {
435 if database_paths.binary_search(lock_path).is_ok() {
436 anyhow::bail!(
437 "refusing daemon store claim: lock sidecar {} for database {} is itself a \
438 configured database; no store lock was opened",
439 lock_path.display(),
440 database.display()
441 );
442 }
443 }
444 for database in &database_paths {
445 if database.file_name().is_some_and(has_lock_sidecar_name) {
446 anyhow::bail!(
447 "refusing daemon store claim: database {} is named like a store lock sidecar \
448 (`.<name>.khived.lock`); no store lock was opened",
449 database.display()
450 );
451 }
452 if read_only_paths.contains(database) {
453 if let Some(parent) = database.parent() {
454 if let Err(error) = std::fs::metadata(parent) {
455 if error.kind() == std::io::ErrorKind::NotFound {
456 anyhow::bail!(
457 "refusing daemon store claim: parent directory {} of read-only \
458 database {} does not exist; nothing was created",
459 parent.display(),
460 database.display()
461 );
462 }
463 }
464 }
465 }
466 }
467 let mut configured_identities = Vec::new();
468 for database in &database_paths {
469 if let Some(identity) = regular_store_identity(database)? {
470 configured_identities.push((database.clone(), identity));
471 }
472 }
473 let mut guards = Vec::with_capacity(database_paths.len());
474
475 for (database, lock_path) in database_paths.into_iter().zip(lock_paths) {
476 let filename = database
477 .file_name()
478 .expect("store lock preflight required a database file name");
479 let parent = lock_path
480 .parent()
481 .ok_or_else(|| anyhow::anyhow!("store lock {} has no parent", lock_path.display()))?;
482 if !read_only_paths.contains(&database) {
483 std::fs::create_dir_all(parent).map_err(|error| {
484 anyhow::anyhow!(
485 "cannot create store lock directory {}: {error}",
486 parent.display()
487 )
488 })?;
489 }
490 let parent_dir = open_claimed_directory(parent).map_err(|error| {
491 anyhow::anyhow!(
492 "cannot open daemon store directory {} for {}: {error}",
493 parent.display(),
494 database.display()
495 )
496 })?;
497 ensure_claimed_parent_identity(&parent_dir, &database)?;
498 let mut file = open_claimed_entry(
499 &parent_dir,
500 lock_path.file_name().expect("sidecar path has a file name"),
501 libc::O_RDWR | libc::O_CREAT,
502 0o600,
503 )
504 .map_err(|error| {
505 anyhow::anyhow!(
506 "cannot open daemon store lock {} for {}: {error}",
507 lock_path.display(),
508 database.display()
509 )
510 })?;
511 let sidecar_metadata = file.metadata()?;
512 if !sidecar_metadata.is_file() {
513 anyhow::bail!(
514 "daemon store lock {} is not a regular file",
515 lock_path.display()
516 );
517 }
518 let sidecar_identity = (sidecar_metadata.dev(), sidecar_metadata.ino());
519 if let Some((matching_database, _)) = configured_identities
520 .iter()
521 .find(|(_, identity)| *identity == sidecar_identity)
522 {
523 anyhow::bail!(
524 "refusing daemon store claim: opened lock sidecar {} for database {} is the same \
525 file as configured database {}; no sidecar lock was acquired or truncated",
526 lock_path.display(),
527 database.display(),
528 matching_database.display()
529 );
530 }
531 anyhow::ensure!(
532 sidecar_metadata.nlink() <= 1,
533 "refusing daemon store claim: opened lock sidecar {} for database {} has {} hard \
534 links; no sidecar lock was acquired or truncated",
535 lock_path.display(),
536 database.display(),
537 sidecar_metadata.nlink()
538 );
539 ensure_sidecar_holds_only_a_lock_record(
540 &file,
541 sidecar_metadata.len(),
542 &lock_path,
543 &database,
544 )?;
545 match file.try_lock() {
546 Ok(()) => {}
547 Err(std::fs::TryLockError::WouldBlock) => {
548 let holder = store_lock_holder_pid(&mut file)
552 .map(|pid| format!("pid {pid}"))
553 .unwrap_or_else(|| "an unknown pid".to_string());
554 anyhow::bail!(
555 "refusing to start: khived is already running as {holder} for database {}; \
556 daemon store lock {} is held",
557 database.display(),
558 lock_path.display()
559 );
560 }
561 Err(std::fs::TryLockError::Error(error)) => {
562 anyhow::bail!(
563 "cannot acquire daemon store lock {} for {}: {error}",
564 lock_path.display(),
565 database.display()
566 );
567 }
568 }
569 file.set_len(0)?;
570 file.write_all(std::process::id().to_string().as_bytes())?;
571 file.sync_data()?;
572 let claimed_identity = regular_store_identity_at(&parent_dir, filename, &database)?;
573 guards.push(DaemonStoreGuard {
574 parent_dir,
575 _sidecar: file,
576 database,
577 claimed_identity,
578 _bound_database: None,
579 });
580 }
581 Ok(guards)
582}