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