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