1#[cfg(unix)]
4use std::ffi::{CString, OsStr};
5#[cfg(unix)]
6use std::io::{Read as _, Write as _};
7#[cfg(unix)]
8use std::os::fd::AsFd;
9#[cfg(unix)]
10use std::os::unix::ffi::OsStrExt;
11#[cfg(unix)]
12use std::os::unix::fs::{FileExt as _, MetadataExt};
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 check_store_entry_name(name: &OsStr) -> std::io::Result<()> {
74 CString::new(name.as_bytes()).map_err(|_| {
75 std::io::Error::new(
76 std::io::ErrorKind::InvalidInput,
77 "store entry contains U+0000",
78 )
79 })?;
80 Ok(())
81}
82
83#[cfg(unix)]
85fn open_claimed_store_file(
86 parent: &std::fs::File,
87 name: &OsStr,
88 read_only: bool,
89) -> std::io::Result<std::fs::File> {
90 check_store_entry_name(name)?;
91 if read_only {
92 khive_fs::fd_relative::open_at(parent, name, false)
95 } else {
96 khive_fs::fd_relative::open_file_at(
97 parent.as_fd(),
98 name,
99 khive_fs::fd_relative::OpenFileOptions {
100 read_write: true,
101 create: khive_fs::fd_relative::Create::IfMissing,
102 nonblock: false,
103 mode: 0o600,
104 },
105 )
106 }
107}
108
109#[cfg(unix)]
113fn open_claimed_directory(path: &std::path::Path) -> std::io::Result<std::fs::File> {
114 use std::path::Component;
115
116 if !path.is_absolute() {
117 return Err(std::io::Error::new(
118 std::io::ErrorKind::InvalidInput,
119 "claimed store directory must be absolute",
120 ));
121 }
122 let mut directory = std::fs::File::open("/")?;
123 for component in path.components() {
124 match component {
125 Component::RootDir => {}
126 Component::Normal(name) => {
127 check_store_entry_name(name)?;
128 directory = khive_fs::fd_relative::open_dir_at(&directory, name)?;
129 }
130 _ => {
131 return Err(std::io::Error::new(
132 std::io::ErrorKind::InvalidInput,
133 "claimed store directory must be canonical",
134 ));
135 }
136 }
137 }
138 Ok(directory)
139}
140
141#[cfg(unix)]
145pub(super) fn ensure_claimed_parent_identity(
146 directory: &std::fs::File,
147 database: &std::path::Path,
148) -> anyhow::Result<()> {
149 let parent = database.parent().expect("claimed path has a parent");
150 let held = khive_fs::fd_relative::FileIdentity::of(directory)?;
151 let observed = std::fs::symlink_metadata(parent)?;
152 anyhow::ensure!(
153 observed.is_dir() && (held.dev, held.ino) == (observed.dev(), observed.ino()),
154 "claimed database {} parent directory changed since claim",
155 database.display()
156 );
157 Ok(())
158}
159
160#[cfg(unix)]
163fn regular_store_identity_at(
164 parent: &std::fs::File,
165 name: &OsStr,
166 database: &std::path::Path,
167) -> anyhow::Result<Option<(u64, u64)>> {
168 CString::new(name.as_bytes())
169 .map_err(|_| anyhow::anyhow!("database name {} contains U+0000", database.display()))?;
170 let stat = match khive_fs::fd_relative::stat_at(parent, name) {
171 Ok(stat) => stat,
172 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
173 Err(error) => anyhow::bail!(
174 "cannot inspect claimed database {}: {error}",
175 database.display()
176 ),
177 };
178 anyhow::ensure!(
179 stat.st_mode & libc::S_IFMT == libc::S_IFREG,
180 "claimed database {} is not a regular file",
181 database.display()
182 );
183 #[cfg(target_os = "linux")]
184 let device = stat.st_dev;
185 #[cfg(not(target_os = "linux"))]
186 let device = stat.st_dev as u64;
187 Ok(Some((device, stat.st_ino)))
188}
189
190#[cfg(all(test, unix))]
191thread_local! {
192 static STORE_BIND_RACE_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
193 const { std::cell::RefCell::new(None) };
194}
195
196#[cfg(all(test, unix))]
197pub(super) fn set_store_bind_race_hook(hook: impl FnOnce() + 'static) {
198 STORE_BIND_RACE_HOOK.with(|cell| *cell.borrow_mut() = Some(Box::new(hook)));
199}
200
201#[cfg(all(test, unix))]
202fn take_store_bind_race_hook() -> Option<Box<dyn FnOnce()>> {
203 STORE_BIND_RACE_HOOK.with(|cell| cell.borrow_mut().take())
204}
205
206#[cfg(unix)]
209fn bind_open_error(
210 error: &std::io::Error,
211 database: &std::path::Path,
212 read_only: bool,
213) -> anyhow::Error {
214 if !read_only
215 && error.kind() == std::io::ErrorKind::PermissionDenied
216 && std::fs::metadata(database).is_ok_and(|metadata| metadata.permissions().readonly())
217 {
218 return anyhow::anyhow!(
219 "claimed database {} has no filesystem write bits; declare `read_only = true` so \
220 backend topology and daemon config identity describe the snapshot-inspection mode \
221 explicitly",
222 database.display()
223 );
224 }
225 anyhow::anyhow!(
226 "cannot open claimed database {}: {error}",
227 database.display()
228 )
229}
230
231#[cfg(unix)]
236pub fn bind_daemon_store_files(
237 guards: &mut [DaemonStoreGuard],
238 read_only_paths: &[PathBuf],
239) -> anyhow::Result<()> {
240 for guard in guards {
241 let read_only = read_only_paths.contains(&guard.database);
242 ensure_claimed_parent_identity(&guard.parent_dir, &guard.database)?;
243 let filename = guard
244 .database
245 .file_name()
246 .expect("claimed path has a file name");
247 #[cfg(test)]
248 if let Some(hook) = take_store_bind_race_hook() {
249 hook();
250 }
251 let file = open_claimed_store_file(&guard.parent_dir, filename, read_only)
253 .map_err(|error| bind_open_error(&error, &guard.database, read_only))?;
254 let metadata = file.metadata()?;
255 anyhow::ensure!(
256 metadata.is_file(),
257 "claimed database {} is not a regular file",
258 guard.database.display()
259 );
260 let opened_identity = (metadata.dev(), metadata.ino());
261 if let Some(claimed_identity) = guard.claimed_identity {
262 anyhow::ensure!(
263 claimed_identity == opened_identity,
264 "claimed database {} changed inode between claim and open",
265 guard.database.display()
266 );
267 }
268 guard.claimed_identity = Some(opened_identity);
269 guard._bound_database = Some(file);
270 anyhow::ensure!(
271 regular_store_identity(&guard.database)? == Some(opened_identity),
272 "claimed database {} changed inode while binding its open file",
273 guard.database.display()
274 );
275 }
276 Ok(())
277}
278
279#[cfg(unix)]
284pub fn assert_daemon_store_identities(guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
285 for guard in guards {
286 ensure_claimed_parent_identity(&guard.parent_dir, &guard.database)?;
287 let bound = guard._bound_database.as_ref().ok_or_else(|| {
288 anyhow::anyhow!(
289 "claimed database {} was not bound",
290 guard.database.display()
291 )
292 })?;
293 let identity = khive_fs::fd_relative::FileIdentity::of(bound)?;
294 let bound_identity = (identity.dev, identity.ino);
295 let observed = regular_store_identity(&guard.database)?;
296 anyhow::ensure!(
297 observed == Some(bound_identity) && observed == guard.claimed_identity,
298 "claimed database {} changed inode after open (claimed {:?}, now {:?}); refusing daemon boot",
299 guard.database.display(),
300 guard.claimed_identity,
301 observed
302 );
303 }
304 Ok(())
305}
306
307#[cfg(not(unix))]
309pub fn assert_daemon_store_identities(_guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
310 Ok(())
311}
312
313#[cfg(unix)]
315pub(super) fn daemon_store_lock_path(database: &std::path::Path) -> anyhow::Result<PathBuf> {
316 let filename = database
317 .file_name()
318 .ok_or_else(|| anyhow::anyhow!("database path {} has no file name", database.display()))?;
319 let mut lock_name = std::ffi::OsString::from(".");
320 lock_name.push(filename);
321 lock_name.push(".khived.lock");
322 Ok(database.with_file_name(lock_name))
323}
324
325#[cfg(unix)]
327const STORE_LOCK_RECORD_MAX: u64 = 64;
328
329#[cfg(unix)]
332pub(super) fn store_lock_holder_pid(file: &mut std::fs::File) -> Option<u32> {
333 let mut holder_text = String::new();
334 file.take(STORE_LOCK_RECORD_MAX)
335 .read_to_string(&mut holder_text)
336 .ok()?;
337 holder_text.trim().parse::<u32>().ok()
338}
339
340#[cfg(unix)]
343fn has_lock_sidecar_name(filename: &OsStr) -> bool {
344 const SUFFIX: &[u8] = b".khived.lock";
345 let name = filename.as_bytes();
346 name.len() > SUFFIX.len() && name.starts_with(b".") && name.ends_with(SUFFIX)
347}
348
349#[cfg(unix)]
353fn ensure_sidecar_holds_only_a_lock_record(
354 file: &std::fs::File,
355 len: u64,
356 lock_path: &std::path::Path,
357 database: &std::path::Path,
358) -> anyhow::Result<()> {
359 const SQLITE_HEADER: &[u8; 16] = b"SQLite format 3\0";
360 let mut sqlite_header = false;
361 if len >= 16 {
362 let mut head = [0u8; 16];
363 file.read_exact_at(&mut head, 0).map_err(|error| {
364 anyhow::anyhow!(
365 "cannot read daemon store lock {}: {error}",
366 lock_path.display()
367 )
368 })?;
369 sqlite_header = head == *SQLITE_HEADER;
370 }
371 anyhow::ensure!(
372 len <= STORE_LOCK_RECORD_MAX && !sqlite_header,
373 "refusing daemon store claim: existing file at lock sidecar {} for database {} is not a \
374 daemon lock record (longer than {} bytes or a SQLite database); the file was left \
375 untouched",
376 lock_path.display(),
377 database.display(),
378 STORE_LOCK_RECORD_MAX
379 );
380 Ok(())
381}
382
383#[cfg(unix)]
386pub fn acquire_daemon_store_guards(
387 database_paths: impl IntoIterator<Item = PathBuf>,
388) -> anyhow::Result<Vec<DaemonStoreGuard>> {
389 claim_stores(&database_paths.into_iter().collect::<Vec<_>>(), &[])
390}
391
392#[cfg(unix)]
410pub fn claim_stores(
411 database_paths: &[PathBuf],
412 read_only_paths: &[PathBuf],
413) -> anyhow::Result<Vec<DaemonStoreGuard>> {
414 let mut database_paths = database_paths.to_vec();
415 database_paths.sort();
416 database_paths.dedup();
417 let lock_paths: Vec<PathBuf> = database_paths
418 .iter()
419 .map(|database| daemon_store_lock_path(database))
420 .collect::<anyhow::Result<_>>()?;
421 for (database, lock_path) in database_paths.iter().zip(&lock_paths) {
422 if database_paths.binary_search(lock_path).is_ok() {
423 anyhow::bail!(
424 "refusing daemon store claim: lock sidecar {} for database {} is itself a \
425 configured database; no store lock was opened",
426 lock_path.display(),
427 database.display()
428 );
429 }
430 }
431 for database in &database_paths {
432 if database.file_name().is_some_and(has_lock_sidecar_name) {
433 anyhow::bail!(
434 "refusing daemon store claim: database {} is named like a store lock sidecar \
435 (`.<name>.khived.lock`); no store lock was opened",
436 database.display()
437 );
438 }
439 if read_only_paths.contains(database) {
440 if let Some(parent) = database.parent() {
441 if let Err(error) = std::fs::metadata(parent) {
442 if error.kind() == std::io::ErrorKind::NotFound {
443 anyhow::bail!(
444 "refusing daemon store claim: parent directory {} of read-only \
445 database {} does not exist; nothing was created",
446 parent.display(),
447 database.display()
448 );
449 }
450 }
451 }
452 }
453 }
454 let mut configured_identities = Vec::new();
455 for database in &database_paths {
456 if let Some(identity) = regular_store_identity(database)? {
457 configured_identities.push((database.clone(), identity));
458 }
459 }
460 let mut guards = Vec::with_capacity(database_paths.len());
461
462 for (database, lock_path) in database_paths.into_iter().zip(lock_paths) {
463 let filename = database
464 .file_name()
465 .expect("store lock preflight required a database file name");
466 let parent = lock_path
467 .parent()
468 .ok_or_else(|| anyhow::anyhow!("store lock {} has no parent", lock_path.display()))?;
469 if !read_only_paths.contains(&database) {
470 std::fs::create_dir_all(parent).map_err(|error| {
471 anyhow::anyhow!(
472 "cannot create store lock directory {}: {error}",
473 parent.display()
474 )
475 })?;
476 }
477 let parent_dir = open_claimed_directory(parent).map_err(|error| {
478 anyhow::anyhow!(
479 "cannot open daemon store directory {} for {}: {error}",
480 parent.display(),
481 database.display()
482 )
483 })?;
484 ensure_claimed_parent_identity(&parent_dir, &database)?;
485 let mut file = open_claimed_store_file(
486 &parent_dir,
487 lock_path.file_name().expect("sidecar path has a file name"),
488 false,
489 )
490 .map_err(|error| {
491 anyhow::anyhow!(
492 "cannot open daemon store lock {} for {}: {error}",
493 lock_path.display(),
494 database.display()
495 )
496 })?;
497 let sidecar_metadata = file.metadata()?;
498 if !sidecar_metadata.is_file() {
499 anyhow::bail!(
500 "daemon store lock {} is not a regular file",
501 lock_path.display()
502 );
503 }
504 let sidecar_identity = (sidecar_metadata.dev(), sidecar_metadata.ino());
505 if let Some((matching_database, _)) = configured_identities
506 .iter()
507 .find(|(_, identity)| *identity == sidecar_identity)
508 {
509 anyhow::bail!(
510 "refusing daemon store claim: opened lock sidecar {} for database {} is the same \
511 file as configured database {}; no sidecar lock was acquired or truncated",
512 lock_path.display(),
513 database.display(),
514 matching_database.display()
515 );
516 }
517 anyhow::ensure!(
518 sidecar_metadata.nlink() <= 1,
519 "refusing daemon store claim: opened lock sidecar {} for database {} has {} hard \
520 links; no sidecar lock was acquired or truncated",
521 lock_path.display(),
522 database.display(),
523 sidecar_metadata.nlink()
524 );
525 ensure_sidecar_holds_only_a_lock_record(
526 &file,
527 sidecar_metadata.len(),
528 &lock_path,
529 &database,
530 )?;
531 match file.try_lock() {
532 Ok(()) => {}
533 Err(std::fs::TryLockError::WouldBlock) => {
534 let holder = store_lock_holder_pid(&mut file)
538 .map(|pid| format!("pid {pid}"))
539 .unwrap_or_else(|| "an unknown pid".to_string());
540 anyhow::bail!(
541 "refusing to start: khived is already running as {holder} for database {}; \
542 daemon store lock {} is held",
543 database.display(),
544 lock_path.display()
545 );
546 }
547 Err(std::fs::TryLockError::Error(error)) => {
548 anyhow::bail!(
549 "cannot acquire daemon store lock {} for {}: {error}",
550 lock_path.display(),
551 database.display()
552 );
553 }
554 }
555 file.set_len(0)?;
556 file.write_all(std::process::id().to_string().as_bytes())?;
557 file.sync_data()?;
558 let claimed_identity = regular_store_identity_at(&parent_dir, filename, &database)?;
559 guards.push(DaemonStoreGuard {
560 parent_dir,
561 _sidecar: file,
562 database,
563 claimed_identity,
564 _bound_database: None,
565 });
566 }
567 Ok(guards)
568}