Skip to main content

khive_runtime/daemon/
store_guard.rs

1//! Daemon-lifetime store claims: path-sidecar locks and the database-file identity each one binds.
2
3#[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/// One daemon-lifetime path-sidecar claim and its bound database-file identity.
17#[cfg(unix)]
18#[derive(Debug)]
19pub struct DaemonStoreGuard {
20    /// Pins the directory in which the sidecar was claimed. Both the sidecar
21    /// and the database are opened relative to this same descriptor.
22    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        // An inherited or duplicated descriptor can outlive this guard.
33        // Closing only this descriptor would keep its flock alive.
34        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/// Placeholder for platforms where serving daemons and store claims are unavailable.
45#[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/// Open a single directory entry without re-resolving any parent pathname.
72#[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    // SAFETY: the directory fd and NUL-terminated entry name stay live through
86    // openat. FromRawFd takes ownership of the returned fd exactly once.
87    let fd = unsafe {
88        libc::openat(
89            parent.as_raw_fd(),
90            name.as_ptr(),
91            flags | libc::O_NOFOLLOW | libc::O_CLOEXEC,
92            // Variadic arguments require C default promotion; mode_t is u16 on macOS.
93            mode as libc::c_uint,
94        )
95    };
96    if fd < 0 {
97        return Err(std::io::Error::last_os_error());
98    }
99    // SAFETY: openat returned a new, valid fd owned by this process.
100    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
101}
102
103/// Walk an already-canonical absolute parent path one component at a time.
104/// A newly planted ancestor symlink cannot redirect the claim before the
105/// sidecar is opened; each later component is relative to the held directory.
106#[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/// Refuse a stable retarget of the canonical parent pathname. This check is
136/// useful before binding and after SQLite open, but is not an atomic proof of
137/// the pathname SQLite itself traversed in between.
138#[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/// Inspect the claimed directory entry, including a missing database, without
155/// following a final-component symlink or reopening the directory by name.
156#[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    // SAFETY: stat points to writable storage and name is NUL-terminated.
166    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    // SAFETY: fstatat succeeded and initialized the complete stat structure.
185    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/// Describe a failed bind. A writable bind refused on a file with no write bits
215/// is a snapshot declared writable; name the declaration to change.
216#[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/// Open each database relative to the directory that holds its sidecar claim.
240/// A missing writable database is created here, after the claim; a missing
241/// read-only database fails without creating it. The descriptor pins the
242/// identity to compare against the later SQLite pathname check.
243#[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        // The creation mode keeps new stores private without changing existing permissions.
265        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/// Fail boot if a canonical path no longer names its descriptor-bound file.
293/// Daemon backend constructors also pass each held descriptor's identity to
294/// the pool, which checks the SQLite-opened file before identity initialization
295/// and WAL setup. This final path check precedes schema preparation and serving.
296#[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/// Non-Unix hosts cannot run the daemon and have no store claims to verify.
321#[cfg(not(unix))]
322pub fn assert_daemon_store_identities(_guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
323    Ok(())
324}
325
326/// The persistent claim sidecar for one canonical database pathname.
327#[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/// The longest pid record a claim writes into its sidecar.
339#[cfg(unix)]
340const STORE_LOCK_RECORD_MAX: u64 = 64;
341
342/// PID text is diagnostic only. A malformed or oversized lock marker cannot
343/// make a contender allocate or read without a fixed bound.
344#[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/// A sidecar named like `.<name>.khived.lock` is a lock record for `<name>`,
354/// so a database may not carry that file name.
355#[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/// An existing sidecar is reused only when it is a lock record: no longer than
363/// the pid record a claim writes and not a SQLite database. Anything else is
364/// another store's data and is refused before it can be truncated.
365#[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/// Claim every database in `database_paths` as a writable store; see
397/// [`claim_stores`].
398#[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/// Hold one exclusive daemon-lifetime lock for each resolved SQLite file.
406/// Unlike the HOME-bound boot/recovery lock, these locks follow the storage
407/// topology. Callers acquire them before SQLite opens any store and retain
408/// them until daemon shutdown. Never unlink a lock file: unlinking a held
409/// inode would let a second boot lock a new one.
410///
411/// `database_paths` must contain canonical absolute paths. This function
412/// sorts and deduplicates them so overlapping multi-backend topologies acquire
413/// in one order and aliases of one path use one lock. It refuses a claim set
414/// whose derived lock sidecar is a configured database, or in which a database
415/// is named like a sidecar, before opening any sidecar for writing. An
416/// existing sidecar that holds anything but a short pid record is refused,
417/// never truncated.
418///
419/// `read_only_paths` names the databases this daemon opens read-only. A
420/// read-only claim never creates its parent directory: when one is missing the
421/// whole claim set is refused before any directory or sidecar is created.
422#[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                // The winning process writes its pid immediately after locking.
549                // A contender can race that write, so an empty/unreadable pid
550                // is an unknown holder, never permission to proceed.
551                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}