Skip to main content

kmp_adapter_embedded/adapter/
format_version.rs

1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use kmp_domain::PortError;
6
7/// The layout this binary creates for a fresh data directory: shareable
8/// SQLite ([historical ADR-018](https://github.com/underpass-ai/kmp/blob/v0.5.0/archive/docs/adr/ADR-018-multi-process-embedded-store.md)).
9///
10/// `FORMAT_VERSION` names the storage layout and logical identity contract.
11/// Format 3 requires dimensional identities with about, key and value.
12/// Format 3 changes dimensional identity; there is no legacy migration.
13/// Bumping it is what makes a binary that predates
14/// a layout refuse the directory instead of opening an empty store beside
15/// it, so a new engine is a new number ([historical ADR-018](https://github.com/underpass-ai/kmp/blob/v0.5.0/archive/docs/adr/ADR-018-multi-process-embedded-store.md)).
16pub const SUPPORTED_FORMAT_VERSION: u32 = StorageEngine::Sqlite.format_version();
17
18/// The logical shape of the event log carried by a portable bundle.
19pub const EVENT_FORMAT_VERSION: u32 = 2;
20
21const FORMAT_VERSION_FILE: &str = "FORMAT_VERSION";
22
23/// The engine behind a data directory's `store/`. Chosen once, when the
24/// directory is created; recorded as its `FORMAT_VERSION`; never guessed.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StorageEngine {
27    /// WAL-mode SQLite: several processes may open the same store. This is
28    /// the only compiled storage engine.
29    Sqlite,
30}
31
32impl StorageEngine {
33    /// The `FORMAT_VERSION` this engine stamps.
34    pub const fn format_version(self) -> u32 {
35        match self {
36            StorageEngine::Sqlite => 3,
37        }
38    }
39
40    /// The highest layout number any build of this crate knows about,
41    /// compiled in or not. Above this the binary is simply too old.
42    pub(crate) const NEWEST_KNOWN_FORMAT_VERSION: u32 = StorageEngine::Sqlite.format_version();
43
44    pub(crate) const fn from_format_version(version: u32) -> Option<Self> {
45        match version {
46            3 => Some(StorageEngine::Sqlite),
47            _ => None,
48        }
49    }
50
51    pub const fn name(self) -> &'static str {
52        match self {
53            StorageEngine::Sqlite => "sqlite",
54        }
55    }
56
57    const fn store_file_name(self) -> &'static str {
58        match self {
59            StorageEngine::Sqlite => "kernel.sqlite3",
60        }
61    }
62}
63
64impl std::fmt::Display for StorageEngine {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(self.name())
67    }
68}
69
70pub fn format_version_path(data_dir: &Path) -> PathBuf {
71    data_dir.join(FORMAT_VERSION_FILE)
72}
73
74/// The version stamped in `data_dir`, without applying the open gate.
75///
76/// Kept as a compatibility API for callers that need to report an unsupported
77/// layout without opening it.
78pub fn read_stamped_version(data_dir: &Path) -> Result<u32, PortError> {
79    let version_path = format_version_path(data_dir);
80    let raw = fs::read_to_string(&version_path).map_err(|error| {
81        PortError::Unavailable(format!(
82            "could not read FORMAT_VERSION at `{}`: {error}",
83            version_path.display()
84        ))
85    })?;
86    raw.trim().parse().map_err(|_| {
87        PortError::InvalidState(format!(
88            "FORMAT_VERSION at `{}` is corrupt (`{}`)",
89            version_path.display(),
90            raw.trim()
91        ))
92    })
93}
94
95/// Where `engine` keeps its store inside `data_dir`.
96pub fn store_file_path_for(data_dir: &Path, engine: StorageEngine) -> PathBuf {
97    data_dir.join("store").join(engine.store_file_name())
98}
99
100/// Whether any engine's store file is present — the "half-initialized
101/// layout" signal used to refuse a directory with a store but no stamp.
102fn any_store_file_exists(data_dir: &Path) -> bool {
103    fs::read_dir(data_dir.join("store"))
104        .is_ok_and(|entries| entries.flatten().any(|entry| entry.path().is_file()))
105}
106
107/// Files in `store/` that are not part of the only supported SQLite layout.
108///
109/// The names of retired engines are deliberately irrelevant here. Unknown
110/// bytes are preserved and rejected as unsupported storage artifacts.
111fn unsupported_store_files(data_dir: &Path) -> Vec<PathBuf> {
112    let sqlite = store_file_path_for(data_dir, StorageEngine::Sqlite);
113    let wal = sqlite.with_file_name("kernel.sqlite3-wal");
114    let shm = sqlite.with_file_name("kernel.sqlite3-shm");
115    let rollback_journal = sqlite.with_file_name("kernel.sqlite3-journal");
116    let mut paths = fs::read_dir(data_dir.join("store"))
117        .into_iter()
118        .flatten()
119        .flatten()
120        .map(|entry| entry.path())
121        .filter(|path| {
122            path.is_file()
123                && path != &sqlite
124                && path != &wal
125                && path != &shm
126                && path != &rollback_journal
127        })
128        .collect::<Vec<_>>();
129    paths.sort();
130    paths
131}
132
133/// Applies the existing-layout gate without creating or opening anything.
134///
135/// Diagnostics use this exact gate so they cannot call a store healthy when
136/// the next real kernel operation will refuse it. `None` means genuinely
137/// fresh: no stamp and no engine file. A stamp without a store file is also
138/// valid — startup may have stopped between stamping and first engine open.
139pub fn validate_store_layout(data_dir: &Path) -> Result<Option<StorageEngine>, PortError> {
140    let version_path = format_version_path(data_dir);
141    match fs::read_to_string(&version_path) {
142        Ok(raw) => {
143            let version: u32 = raw.trim().parse().map_err(|_| {
144                PortError::InvalidState(format!(
145                    "embedded store at `{}` has a corrupt FORMAT_VERSION (`{}`); refusing to open",
146                    data_dir.display(),
147                    raw.trim()
148                ))
149            })?;
150            let stamped = resolve_stamped(data_dir, version)?;
151            let unsupported = unsupported_store_files(data_dir);
152            if !unsupported.is_empty() {
153                return Err(PortError::InvalidState(format!(
154                    "embedded store at `{}` says format version {} ({stamped}), but `store/` contains unsupported storage artifacts: {}; refusing to open memory under an unknown layout",
155                    data_dir.display(),
156                    stamped.format_version(),
157                    unsupported
158                        .iter()
159                        .map(|path| path.display().to_string())
160                        .collect::<Vec<_>>()
161                        .join(", ")
162                )));
163            }
164            Ok(Some(stamped))
165        }
166        Err(error) if error.kind() == ErrorKind::NotFound => {
167            if any_store_file_exists(data_dir) {
168                return Err(PortError::InvalidState(format!(
169                    "embedded store at `{}` has a store file but no FORMAT_VERSION; the data \
170                     directory layout is corrupt, refusing to open",
171                    data_dir.display()
172                )));
173            }
174            Ok(None)
175        }
176        Err(error) => Err(PortError::Unavailable(format!(
177            "embedded store could not read FORMAT_VERSION at `{}`: {error}",
178            version_path.display()
179        ))),
180    }
181}
182
183/// The supported store file named by a valid stamp, when it already exists.
184pub(crate) fn existing_store_file(data_dir: &Path) -> Option<(StorageEngine, PathBuf)> {
185    let version = read_stamped_version(data_dir).ok()?;
186    let engine = StorageEngine::from_format_version(version)?;
187    let path = store_file_path_for(data_dir, engine);
188    path.exists().then_some((engine, path))
189}
190
191/// Fail-fast format check per ADR-012: stamp fresh directories with the
192/// default layout, reject version mismatches and half-initialized layouts
193/// explicitly — never open a store that could silently read as empty memory.
194///
195/// Returns the engine the directory is (now) stamped for.
196pub(crate) fn check_or_stamp(data_dir: &Path) -> Result<StorageEngine, PortError> {
197    check_or_stamp_as(data_dir, None)
198}
199
200/// [`check_or_stamp`] with a say in the outcome: a fresh directory is
201/// stamped for `wanted`, and an existing one must already be `wanted` — a
202/// store is never reinterpreted as another engine's.
203pub(crate) fn check_or_stamp_as(
204    data_dir: &Path,
205    wanted: Option<StorageEngine>,
206) -> Result<StorageEngine, PortError> {
207    match validate_store_layout(data_dir)? {
208        Some(stamped) => {
209            if let Some(wanted) = wanted
210                && wanted != stamped
211            {
212                return Err(PortError::InvalidState(format!(
213                    "embedded store at `{}` is a {stamped} store (format version {}), not {wanted}; \
214                     a store is never reopened under another layout; unset the engine selector \
215                     to open the stamped SQLite layout",
216                    data_dir.display(),
217                    stamped.format_version()
218                )));
219            }
220            Ok(stamped)
221        }
222        None => {
223            let engine = wanted.unwrap_or(StorageEngine::Sqlite);
224            let version_path = format_version_path(data_dir);
225            fs::write(&version_path, format!("{}\n", engine.format_version())).map_err(
226                |error| {
227                    PortError::Unavailable(format!(
228                        "embedded store could not stamp FORMAT_VERSION at `{}`: {error}",
229                        version_path.display()
230                    ))
231                },
232            )?;
233            Ok(engine)
234        }
235    }
236}
237
238/// Maps a stamped number to an engine this build can open, or says exactly
239/// why not: retired, unsupported, or too new for the binary.
240fn resolve_stamped(data_dir: &Path, version: u32) -> Result<StorageEngine, PortError> {
241    if version > StorageEngine::NEWEST_KNOWN_FORMAT_VERSION {
242        return Err(PortError::InvalidState(format!(
243            "embedded store at `{}` uses format version {version}, newer than this \
244             binary supports ({}); upgrade the binary",
245            data_dir.display(),
246            StorageEngine::NEWEST_KNOWN_FORMAT_VERSION
247        )));
248    }
249    if version < SUPPORTED_FORMAT_VERSION {
250        return Err(PortError::InvalidState(format!(
251            "embedded store at `{}` uses unsupported format version {version}; current KMP \
252             opens format {SUPPORTED_FORMAT_VERSION} only and left the directory untouched. \
253             Preserve that directory and use a compatible binary to inspect it. \
254             This redesign requires a fresh store; old refs and bundles are not migrated",
255            data_dir.display(),
256        )));
257    }
258    StorageEngine::from_format_version(version).ok_or_else(|| {
259        PortError::InvalidState(format!(
260            "embedded store at `{}` uses unsupported format version {version}; this binary \
261             only opens format {SUPPORTED_FORMAT_VERSION} and left the store untouched",
262            data_dir.display()
263        ))
264    })
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn fresh_directory_is_stamped_with_supported_version() {
273        let dir = tempfile::tempdir().expect("tempdir");
274
275        let engine = check_or_stamp(dir.path()).expect("fresh directory should stamp");
276
277        assert_eq!(engine, StorageEngine::Sqlite);
278        let stamped = fs::read_to_string(format_version_path(dir.path())).expect("read stamp");
279        assert_eq!(stamped.trim(), SUPPORTED_FORMAT_VERSION.to_string());
280        check_or_stamp(dir.path()).expect("stamped directory should reopen");
281    }
282
283    #[test]
284    fn newer_format_version_fails_fast() {
285        let dir = tempfile::tempdir().expect("tempdir");
286        fs::write(format_version_path(dir.path()), "999\n").expect("write");
287
288        let error = check_or_stamp(dir.path()).expect_err("newer version must fail");
289        assert!(error.to_string().contains("upgrade the binary"));
290    }
291
292    #[test]
293    fn unknown_older_format_version_is_rejected_untouched() {
294        let dir = tempfile::tempdir().expect("tempdir");
295        fs::write(format_version_path(dir.path()), "0\n").expect("write");
296
297        let error = check_or_stamp(dir.path()).expect_err("older version must fail");
298        let message = error.to_string();
299        assert!(
300            message.contains("unsupported format version 0"),
301            "{message}"
302        );
303        assert!(
304            message.contains("left the directory untouched"),
305            "{message}"
306        );
307    }
308
309    #[test]
310    fn corrupt_version_content_fails_fast() {
311        let dir = tempfile::tempdir().expect("tempdir");
312        fs::write(format_version_path(dir.path()), "not-a-number\n").expect("write");
313
314        let error = check_or_stamp(dir.path()).expect_err("corrupt version must fail");
315        assert!(error.to_string().contains("corrupt FORMAT_VERSION"));
316    }
317
318    #[test]
319    fn store_without_version_stamp_is_a_corrupt_layout() {
320        let dir = tempfile::tempdir().expect("tempdir");
321        let store = dir.path().join("store/unknown-store.bin");
322        fs::create_dir_all(store.parent().expect("parent")).expect("mkdir");
323        fs::write(&store, b"stub").expect("write store stub");
324
325        let error = check_or_stamp(dir.path()).expect_err("missing stamp must fail");
326        assert!(error.to_string().contains("corrupt"));
327    }
328
329    #[test]
330    fn diagnostics_can_apply_the_open_gate_without_stamping_a_fresh_directory() {
331        let fresh = tempfile::tempdir().expect("tempdir");
332        assert_eq!(
333            validate_store_layout(fresh.path()).expect("fresh layout is valid"),
334            None
335        );
336        assert!(!format_version_path(fresh.path()).exists());
337
338        let invalid = tempfile::tempdir().expect("tempdir");
339        let store = store_file_path_for(invalid.path(), StorageEngine::Sqlite);
340        fs::create_dir_all(store.parent().expect("parent")).expect("mkdir");
341        fs::write(&store, b"memory remains here").expect("store marker");
342        for stamp in [Some("4\n"), Some("banana\n"), None] {
343            match stamp {
344                Some(stamp) => fs::write(format_version_path(invalid.path()), stamp)
345                    .expect("write invalid stamp"),
346                None => fs::remove_file(format_version_path(invalid.path())).expect("remove stamp"),
347            }
348            let error = validate_store_layout(invalid.path())
349                .expect_err("the same gate as real open must refuse this layout");
350            let message = error.to_string();
351            assert!(
352                message.contains("upgrade the binary")
353                    || message.contains("corrupt FORMAT_VERSION")
354                    || message.contains("store file but no FORMAT_VERSION"),
355                "{message}"
356            );
357            assert!(
358                store.exists(),
359                "the read-only probe preserves the memory file"
360            );
361        }
362    }
363
364    #[test]
365    fn a_stamp_cannot_hide_an_unsupported_storage_artifact() {
366        let dir = tempfile::tempdir().expect("tempdir");
367        fs::write(format_version_path(dir.path()), "3\n").expect("sqlite stamp");
368        let unsupported = dir.path().join("store/retired-layout.bin");
369        fs::create_dir_all(unsupported.parent().expect("parent")).expect("mkdir");
370        fs::write(unsupported, b"legacy memory").expect("legacy marker");
371
372        let error = check_or_stamp(dir.path()).expect_err("mismatched engine must fail");
373        assert!(
374            error.to_string().contains("unsupported storage artifacts"),
375            "{error}"
376        );
377    }
378
379    #[test]
380    fn a_transient_sqlite_rollback_journal_is_part_of_the_supported_layout() {
381        let dir = tempfile::tempdir().expect("tempdir");
382        fs::write(format_version_path(dir.path()), "3\n").expect("sqlite stamp");
383        let journal = dir.path().join("store/kernel.sqlite3-journal");
384        fs::create_dir_all(journal.parent().expect("parent")).expect("mkdir");
385        fs::write(&journal, b"startup in progress").expect("journal marker");
386
387        assert_eq!(
388            validate_store_layout(dir.path()).expect("SQLite journal is recognized"),
389            Some(StorageEngine::Sqlite)
390        );
391        assert!(journal.exists(), "validation is read-only");
392    }
393
394    #[test]
395    fn a_format_one_store_is_rejected_without_being_opened() {
396        let dir = tempfile::tempdir().expect("tempdir");
397        fs::write(format_version_path(dir.path()), "1\n").expect("legacy stamp");
398        let store = dir.path().join("store/retired-layout.bin");
399        fs::create_dir_all(store.parent().expect("parent")).expect("store dir");
400        fs::write(&store, b"legacy bytes").expect("legacy bytes");
401
402        let error = check_or_stamp(dir.path()).expect_err("format 1 must not open");
403        let message = error.to_string();
404        assert!(
405            message.contains("unsupported format version 1"),
406            "{message}"
407        );
408        assert!(message.contains("fresh store"), "{message}");
409        assert_eq!(fs::read(&store).expect("source remains"), b"legacy bytes");
410    }
411
412    #[test]
413    fn sqlite_layout_is_always_available() {
414        let dir = tempfile::tempdir().expect("tempdir");
415        fs::write(format_version_path(dir.path()), "3\n").expect("write");
416
417        assert_eq!(
418            check_or_stamp(dir.path()).expect("sqlite is always compiled in"),
419            StorageEngine::Sqlite
420        );
421    }
422}