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::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/// 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/// Preserve the store claim's existing diagnostic before shared component validation.
72#[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/// Open a database or sidecar relative to the held claim directory.
84#[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        // The shared read-only open is non-blocking; the caller still checks
93        // the opened file's regular type and descriptor identity before use.
94        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/// Walk an already-canonical absolute parent path one component at a time.
110/// A newly planted ancestor symlink cannot redirect the claim before the
111/// sidecar is opened; each later component is relative to the held directory.
112#[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/// Refuse a stable retarget of the canonical parent pathname. This check is
142/// useful before binding and after SQLite open, but is not an atomic proof of
143/// the pathname SQLite itself traversed in between.
144#[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/// Inspect the claimed directory entry, including a missing database, without
161/// following a final-component symlink or reopening the directory by name.
162#[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/// Describe a failed bind. A writable bind refused on a file with no write bits
207/// is a snapshot declared writable; name the declaration to change.
208#[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/// Open each database relative to the directory that holds its sidecar claim.
232/// A missing writable database is created here, after the claim; a missing
233/// read-only database fails without creating it. The descriptor pins the
234/// identity to compare against the later SQLite pathname check.
235#[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        // The creation mode keeps new stores private without changing existing permissions.
252        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/// Fail boot if a canonical path no longer names its descriptor-bound file.
280/// Daemon backend constructors also pass each held descriptor's identity to
281/// the pool, which checks the SQLite-opened file before identity initialization
282/// and WAL setup. This final path check precedes schema preparation and serving.
283#[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/// Non-Unix hosts cannot run the daemon and have no store claims to verify.
308#[cfg(not(unix))]
309pub fn assert_daemon_store_identities(_guards: &[DaemonStoreGuard]) -> anyhow::Result<()> {
310    Ok(())
311}
312
313/// The persistent claim sidecar for one canonical database pathname.
314#[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/// The longest pid record a claim writes into its sidecar.
326#[cfg(unix)]
327const STORE_LOCK_RECORD_MAX: u64 = 64;
328
329/// PID text is diagnostic only. A malformed or oversized lock marker cannot
330/// make a contender allocate or read without a fixed bound.
331#[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/// A sidecar named like `.<name>.khived.lock` is a lock record for `<name>`,
341/// so a database may not carry that file name.
342#[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/// An existing sidecar is reused only when it is a lock record: no longer than
350/// the pid record a claim writes and not a SQLite database. Anything else is
351/// another store's data and is refused before it can be truncated.
352#[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/// Claim every database in `database_paths` as a writable store; see
384/// [`claim_stores`].
385#[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/// Hold one exclusive daemon-lifetime lock for each resolved SQLite file.
393/// Unlike the HOME-bound boot/recovery lock, these locks follow the storage
394/// topology. Callers acquire them before SQLite opens any store and retain
395/// them until daemon shutdown. Never unlink a lock file: unlinking a held
396/// inode would let a second boot lock a new one.
397///
398/// `database_paths` must contain canonical absolute paths. This function
399/// sorts and deduplicates them so overlapping multi-backend topologies acquire
400/// in one order and aliases of one path use one lock. It refuses a claim set
401/// whose derived lock sidecar is a configured database, or in which a database
402/// is named like a sidecar, before opening any sidecar for writing. An
403/// existing sidecar that holds anything but a short pid record is refused,
404/// never truncated.
405///
406/// `read_only_paths` names the databases this daemon opens read-only. A
407/// read-only claim never creates its parent directory: when one is missing the
408/// whole claim set is refused before any directory or sidecar is created.
409#[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                // The winning process writes its pid immediately after locking.
535                // A contender can race that write, so an empty/unreadable pid
536                // is an unknown holder, never permission to proceed.
537                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}