Skip to main content

khive_runtime/
code_map_runtime.rs

1//! Dedicated code-map runtime construction.
2
3use std::collections::{HashMap, HashSet};
4use std::fs::{self, File};
5use std::io::Read;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::OnceLock;
9
10use khive_db::StorageBackend;
11use uuid::Uuid;
12
13use crate::{CodeMapConfig, KhiveRuntime, RuntimeConfig, RuntimeError, RuntimeResult};
14
15/// Physical identity recorded from an opened code-map file: device and inode
16/// on Unix, volume serial and file id on Windows.
17#[cfg(any(unix, windows))]
18pub type FileIdentity = khive_db::file_identity::DatabaseFileIdentity;
19
20/// Physical identity recorded from an opened code-map file. This platform
21/// reports none, so every registration is refused.
22#[cfg(not(any(unix, windows)))]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct FileIdentity;
25
26fn opened_file_identity(file: &File) -> Result<FileIdentity, String> {
27    #[cfg(any(unix, windows))]
28    {
29        khive_db::file_identity::database_file_identity_from_file(file)
30            .map_err(|_| "opened file identity could not be read".to_string())
31    }
32    #[cfg(not(any(unix, windows)))]
33    {
34        let _ = file;
35        Err("device and inode identity is unsupported on this platform".to_string())
36    }
37}
38
39fn opened_file_link_count(file: &File) -> Result<u64, String> {
40    #[cfg(any(unix, windows))]
41    {
42        khive_db::file_identity::file_link_count(file)
43            .map_err(|_| "opened file link count could not be read".to_string())
44    }
45    #[cfg(not(any(unix, windows)))]
46    {
47        let _ = file;
48        Err("link count is unsupported on this platform".to_string())
49    }
50}
51
52fn path_identity(path: &Path) -> std::io::Result<FileIdentity> {
53    #[cfg(any(unix, windows))]
54    {
55        khive_db::file_identity::database_file_identity(path)
56    }
57    #[cfg(not(any(unix, windows)))]
58    {
59        let _ = path;
60        Err(std::io::Error::new(
61            std::io::ErrorKind::Unsupported,
62            "device and inode identity is unsupported on this platform",
63        ))
64    }
65}
66
67/// A registration lifecycle value unique within one process run.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub struct RegistrationGeneration {
70    boot_id: Uuid,
71    counter: u64,
72}
73
74impl RegistrationGeneration {
75    /// Return the identifier generated for the current process run.
76    pub fn boot_id(self) -> Uuid {
77        self.boot_id
78    }
79
80    /// Return the monotonic counter assigned to this registration.
81    pub fn counter(self) -> u64 {
82        self.counter
83    }
84}
85
86impl std::fmt::Display for RegistrationGeneration {
87    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        write!(formatter, "{}:{}", self.boot_id, self.counter)
89    }
90}
91
92/// A validated map declaration held by the runtime registry.
93#[derive(Debug, Clone)]
94pub struct RegisteredCodeMap {
95    name: String,
96    path: PathBuf,
97    root: PathBuf,
98    source_root: PathBuf,
99    identity: FileIdentity,
100    generation: RegistrationGeneration,
101}
102
103impl RegisteredCodeMap {
104    /// Return the configured map name.
105    pub fn name(&self) -> &str {
106        &self.name
107    }
108
109    /// Return the canonical database path.
110    pub fn path(&self) -> &Path {
111        &self.path
112    }
113
114    /// Return the declared source tree path.
115    pub fn source_root(&self) -> &Path {
116        &self.source_root
117    }
118
119    /// Return the identity captured from the opened database file.
120    pub fn identity(&self) -> FileIdentity {
121        self.identity
122    }
123
124    /// Return the generation assigned when this entry was registered.
125    pub fn generation(&self) -> RegistrationGeneration {
126        self.generation
127    }
128}
129
130/// Validated code maps loaded from daemon configuration.
131#[derive(Debug, Default)]
132pub struct CodeMapRegistry {
133    entries: HashMap<String, RegisteredCodeMap>,
134    protected_databases: Vec<PathBuf>,
135}
136
137impl CodeMapRegistry {
138    /// Load valid entries and log each rejected entry without aborting the load.
139    ///
140    /// `protected_databases` lists the shared production database paths whose
141    /// main files and SQLite companions must remain outside the map surface.
142    pub fn load(
143        code_map_root: Option<&str>,
144        entries: &[CodeMapConfig],
145        protected_databases: &[PathBuf],
146    ) -> Self {
147        let root = canonical_root(code_map_root);
148        if entries.is_empty() {
149            if code_map_root.is_some() {
150                if let Err(check) = root {
151                    tracing::warn!(check = %check, "code_map_root is invalid");
152                }
153            }
154            return Self::default();
155        }
156
157        let protected = collect_protected_identities(protected_databases);
158        let mut name_counts = HashMap::<&str, usize>::new();
159        for entry in entries {
160            if let Some(name) = entry.name.as_deref() {
161                *name_counts.entry(name).or_default() += 1;
162            }
163        }
164        let duplicate_names: HashSet<&str> = name_counts
165            .into_iter()
166            .filter_map(|(name, count)| (count > 1).then_some(name))
167            .collect();
168        let mut registered = HashMap::new();
169
170        for entry in entries {
171            let result = match &root {
172                Err(reason) => Err(reason.clone()),
173                Ok(_)
174                    if entry
175                        .name
176                        .as_deref()
177                        .is_some_and(|name| name.trim().is_empty()) =>
178                {
179                    Err("name must not be empty".to_string())
180                }
181                Ok(_)
182                    if entry
183                        .name
184                        .as_deref()
185                        .is_some_and(|name| duplicate_names.contains(name)) =>
186                {
187                    Err("name is duplicated".to_string())
188                }
189                Ok(root) => match &protected {
190                    Err(reason) => Err(reason.clone()),
191                    Ok(protected) => validate_code_map(entry, root, protected),
192                },
193            };
194
195            match result {
196                Ok(map) => {
197                    registered.insert(map.name.clone(), map);
198                }
199                Err(check) => {
200                    tracing::warn!(
201                        map = %entry.name.as_deref().unwrap_or("<missing>"),
202                        check = %check,
203                        "code map entry rejected during configuration load"
204                    );
205                }
206            }
207        }
208
209        Self {
210            entries: registered,
211            protected_databases: protected_databases.to_vec(),
212        }
213    }
214
215    /// Resolve a registered map name without exposing rejected-entry details.
216    pub fn resolve(&self, name: &str) -> Result<&RegisteredCodeMap, RuntimeError> {
217        self.entries
218            .get(name)
219            .ok_or_else(|| RuntimeError::InvalidInput("unknown code map".to_string()))
220    }
221
222    /// Return the number of registered maps.
223    pub fn len(&self) -> usize {
224        self.entries.len()
225    }
226
227    /// Return whether no maps were registered.
228    pub fn is_empty(&self) -> bool {
229        self.entries.is_empty()
230    }
231
232    /// Run a short read transaction against a currently registered rollback-mode map.
233    /// The connection is closed before this method returns.
234    pub fn with_read<T>(
235        &self,
236        name: &str,
237        read: impl FnOnce(&rusqlite::Connection) -> Result<T, RuntimeError>,
238    ) -> Result<T, RuntimeError> {
239        let map = self.resolve(name)?;
240        let header_records_wal = map.revalidate().map_err(|reason| {
241            RuntimeError::InvalidInput(format!(
242                "code map {name:?} is no longer available: {reason}"
243            ))
244        })?;
245        if header_records_wal || map.has_wal_companion() {
246            return Err(RuntimeError::InvalidInput(format!(
247                "code map {name:?} is in WAL mode and maps are read in rollback mode only; \
248                 run code.ingest on it to settle it, then read again"
249            )));
250        }
251        let connection = StorageBackend::open_code_map_read_only(
252            &map.path,
253            map.identity,
254            &self.protected_databases,
255        )
256        .map_err(|_| {
257            RuntimeError::Internal(format!("code map {name:?} could not be opened safely"))
258        })?;
259        connection
260            .execute_batch("BEGIN DEFERRED")
261            .map_err(|_| RuntimeError::Internal("code map read could not begin".to_string()))?;
262
263        let result = read(&connection);
264        let result = match result {
265            Ok(value) => connection
266                .execute_batch("COMMIT")
267                .map(|()| value)
268                .map_err(|_| RuntimeError::Internal("code map read could not finish".to_string())),
269            Err(error) => {
270                let _rollback = connection.execute_batch("ROLLBACK");
271                Err(error)
272            }
273        };
274        drop(connection);
275        result
276    }
277
278    pub(crate) fn load_for_runtime(
279        config: &RuntimeConfig,
280        runtime_db_path: Option<PathBuf>,
281    ) -> Self {
282        let mut production_databases = config.code_map_protected_database_paths.clone();
283        if let Some(path) = crate::config::resolve_db_anchor(None) {
284            production_databases.push(path);
285        }
286        if let Some(path) = &config.db_path {
287            production_databases.push(path.clone());
288        }
289        if config.db_path.is_none() {
290            if let Ok(path) = std::env::var("KHIVE_DB") {
291                if !path.is_empty() {
292                    production_databases.push(PathBuf::from(path));
293                }
294            }
295        }
296        if let Some(path) = runtime_db_path {
297            production_databases.push(path);
298        }
299        production_databases.sort();
300        production_databases.dedup();
301
302        let mut protected_databases = Vec::with_capacity(production_databases.len() * 2);
303        for path in production_databases {
304            protected_databases.push(crate::events_split::events_db_path_beside(&path));
305            protected_databases.push(path);
306        }
307
308        Self::load(
309            config.code_map_root.as_deref(),
310            &config.code_maps,
311            &protected_databases,
312        )
313    }
314}
315
316fn canonical_root(root: Option<&str>) -> Result<PathBuf, String> {
317    let root = root.ok_or_else(|| "code_map_root is missing".to_string())?;
318    if has_uri_syntax(root) {
319        return Err("code_map_root uses URI syntax".to_string());
320    }
321    let path = Path::new(root);
322    if !path.is_absolute() {
323        return Err("code_map_root must be absolute".to_string());
324    }
325    let canonical = fs::canonicalize(path)
326        .map_err(|_| "code_map_root does not exist or cannot be canonicalized".to_string())?;
327    if !canonical.is_dir() {
328        return Err("code_map_root is not a directory".to_string());
329    }
330    Ok(canonical)
331}
332
333fn validate_code_map(
334    entry: &CodeMapConfig,
335    root: &Path,
336    protected: &HashSet<FileIdentity>,
337) -> Result<RegisteredCodeMap, String> {
338    if let Some(field) = entry.unknown_fields.keys().next() {
339        return Err(format!("unknown field {field}"));
340    }
341    let name = entry
342        .name
343        .as_deref()
344        .ok_or_else(|| "missing name".to_string())?;
345    let path = entry
346        .path
347        .as_deref()
348        .ok_or_else(|| "missing path".to_string())?;
349    let source_root = entry
350        .source_root
351        .as_deref()
352        .ok_or_else(|| "missing source_root".to_string())?;
353
354    let source_root_path = Path::new(source_root);
355    if has_uri_syntax(source_root) {
356        return Err("source_root uses URI syntax".to_string());
357    }
358    if !source_root_path.is_absolute() {
359        return Err("source_root must be absolute".to_string());
360    }
361
362    if has_uri_syntax(path) {
363        return Err("path uses URI syntax".to_string());
364    }
365    let configured_path = Path::new(path);
366    if !configured_path.is_absolute() {
367        return Err("path must be absolute".to_string());
368    }
369    let metadata = fs::symlink_metadata(configured_path)
370        .map_err(|_| "path does not exist or cannot be inspected".to_string())?;
371    if metadata.file_type().is_symlink() {
372        return Err("path is a symlink".to_string());
373    }
374    if !metadata.file_type().is_file() {
375        return Err("path is not a regular file".to_string());
376    }
377    let canonical_path = fs::canonicalize(configured_path)
378        .map_err(|_| "path cannot be canonicalized".to_string())?;
379    if !canonical_path.starts_with(root) {
380        return Err("canonical path is outside code_map_root".to_string());
381    }
382
383    let file = open_identity_file(&canonical_path)
384        .map_err(|_| "path could not be opened without following a symlink".to_string())?;
385    let file_metadata = file
386        .metadata()
387        .map_err(|_| "opened path metadata could not be read".to_string())?;
388    if !file_metadata.file_type().is_file() {
389        return Err("opened path is not a regular file".to_string());
390    }
391    let identity = opened_file_identity(&file)?;
392    if protected.contains(&identity) {
393        return Err("file identity matches a production database or companion".to_string());
394    }
395    // Canonicalization does not resolve hard links: a second link inside the root
396    // can name a file outside it (ADR-139 Amendment 2).
397    if opened_file_link_count(&file)? != 1 {
398        return Err("map file does not have exactly one link".to_string());
399    }
400    drop(file);
401
402    let generation = next_registration_generation()
403        .ok_or_else(|| "registration generation counter is exhausted".to_string())?;
404    Ok(RegisteredCodeMap {
405        name: name.to_string(),
406        path: canonical_path,
407        root: root.to_path_buf(),
408        source_root: source_root_path.to_path_buf(),
409        identity,
410        generation,
411    })
412}
413
414impl RegisteredCodeMap {
415    /// Revalidate the registered file at open time and report whether its
416    /// SQLite header records WAL mode. The header is read from the same
417    /// no-follow descriptor whose identity was just checked.
418    fn revalidate(&self) -> Result<bool, String> {
419        let root = fs::canonicalize(&self.root)
420            .map_err(|_| "registered map root cannot be canonicalized".to_string())?;
421        if root != self.root {
422            return Err("registered map root changed".to_string());
423        }
424        let metadata = fs::symlink_metadata(&self.path)
425            .map_err(|_| "registered map file is unavailable".to_string())?;
426        if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
427            return Err("registered map is not a regular file".to_string());
428        }
429        let canonical = fs::canonicalize(&self.path)
430            .map_err(|_| "registered map file cannot be canonicalized".to_string())?;
431        if canonical != self.path || !canonical.starts_with(&root) {
432            return Err("registered map path changed".to_string());
433        }
434        let file = open_identity_file(&canonical).map_err(|_| {
435            "registered map cannot be opened without following a symlink".to_string()
436        })?;
437        if !file
438            .metadata()
439            .map_err(|_| "registered map metadata cannot be read".to_string())?
440            .file_type()
441            .is_file()
442            || opened_file_identity(&file)? != self.identity
443        {
444            return Err("registered map identity changed".to_string());
445        }
446        if opened_file_link_count(&file)? != 1 {
447            return Err("registered map file does not have exactly one link".to_string());
448        }
449        // Header bytes 18 and 19 are the write and read format versions; 2 is WAL.
450        let mut header = [0u8; 20];
451        match (&file).read_exact(&mut header) {
452            Ok(()) => Ok(header[18] == 2 || header[19] == 2),
453            Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false),
454            Err(_) => Err("registered map header cannot be read".to_string()),
455        }
456    }
457
458    /// A `-wal` or `-shm` name beside the map, of any file type, marks it as in
459    /// WAL mode. An entry that cannot be inspected counts as absent here; the
460    /// VFS rollback admission still refuses any sidecar it finds.
461    fn has_wal_companion(&self) -> bool {
462        ["-wal", "-shm"].iter().any(|suffix| {
463            let mut companion = self.path.as_os_str().to_owned();
464            companion.push(suffix);
465            fs::symlink_metadata(PathBuf::from(companion)).is_ok()
466        })
467    }
468}
469
470fn collect_protected_identities(paths: &[PathBuf]) -> Result<HashSet<FileIdentity>, String> {
471    let mut identities = HashSet::new();
472    for path in paths {
473        match path_identity(path) {
474            Ok(identity) => {
475                identities.insert(identity);
476            }
477            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
478            Err(_) => {
479                return Err("production database identity could not be inspected".to_string());
480            }
481        }
482        for suffix in ["-journal", "-wal", "-shm"] {
483            let companion = append_suffix(path, suffix);
484            match path_identity(&companion) {
485                Ok(identity) => {
486                    identities.insert(identity);
487                }
488                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
489                Err(_) => {
490                    return Err(
491                        "production database companion identity could not be inspected".to_string(),
492                    );
493                }
494            }
495        }
496    }
497    Ok(identities)
498}
499
500fn append_suffix(path: &Path, suffix: &str) -> PathBuf {
501    let mut value = path.as_os_str().to_os_string();
502    value.push(suffix);
503    PathBuf::from(value)
504}
505
506fn has_uri_syntax(value: &str) -> bool {
507    let Some((scheme, _)) = value.split_once(':') else {
508        return false;
509    };
510    if scheme.is_empty()
511        || !scheme.as_bytes()[0].is_ascii_alphabetic()
512        || !scheme
513            .bytes()
514            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'.' | b'-'))
515    {
516        return false;
517    }
518    #[cfg(windows)]
519    if scheme.len() == 1 {
520        if value
521            .as_bytes()
522            .get(2)
523            .is_some_and(|byte| matches!(byte, b'/' | b'\\'))
524        {
525            return false;
526        }
527    }
528    true
529}
530
531fn open_identity_file(path: &Path) -> std::io::Result<File> {
532    #[cfg(unix)]
533    {
534        use std::os::unix::fs::OpenOptionsExt;
535        std::fs::OpenOptions::new()
536            .read(true)
537            .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
538            .open(path)
539    }
540    #[cfg(windows)]
541    {
542        use std::os::windows::fs::OpenOptionsExt;
543        const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
544        std::fs::OpenOptions::new()
545            .read(true)
546            .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
547            .open(path)
548    }
549    #[cfg(not(any(unix, windows)))]
550    {
551        File::open(path)
552    }
553}
554
555fn next_registration_generation() -> Option<RegistrationGeneration> {
556    static BOOT_ID: OnceLock<Uuid> = OnceLock::new();
557    static NEXT_COUNTER: AtomicU64 = AtomicU64::new(1);
558    let counter = NEXT_COUNTER
559        .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
560            current.checked_add(1)
561        })
562        .ok()?;
563    Some(RegistrationGeneration {
564        boot_id: *BOOT_ID.get_or_init(Uuid::new_v4),
565        counter,
566    })
567}
568
569impl KhiveRuntime {
570    /// Construct a dedicated code-map runtime. Every connection in its pool
571    /// selects the native handle-proving VFS before the first schema read or
572    /// migration. The caller prepares the omitted-db parent; an explicit
573    /// target receives no path transform or directory creation here, so its
574    /// parent directory must already exist. A code-map database runs in
575    /// rollback-journal mode, so a configured WAL ceiling does not apply to it.
576    pub fn new_code_map(
577        mut config: RuntimeConfig,
578        protected_main: Vec<std::path::PathBuf>,
579        protected_events: Vec<std::path::PathBuf>,
580    ) -> RuntimeResult<Self> {
581        if config.db_path.is_none() {
582            return Err(RuntimeError::InvalidInput(
583                "code-map runtime requires a guarded file-backed database".into(),
584            ));
585        }
586        let disk_guard = config
587            .resolve_disk_guard_policy(false)?
588            .expect("code-map has a file-backed disk policy");
589        let volume_lock_dir = config.volume_lock_dir.clone();
590        Self::new_with_file_backend(config, false, move |path| {
591            StorageBackend::sqlite_code_map_with_policies(
592                path,
593                &protected_main,
594                &protected_events,
595                disk_guard,
596                khive_db::require_volume_lock_dir(volume_lock_dir)?,
597            )
598        })
599    }
600}
601
602#[cfg(test)]
603#[path = "code_map_runtime_tests.rs"]
604mod tests;