kmp_adapter_embedded/adapter/
format_version.rs1use std::fs;
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4
5use kmp_domain::PortError;
6
7pub const SUPPORTED_FORMAT_VERSION: u32 = StorageEngine::Sqlite.format_version();
15
16pub const EVENT_FORMAT_VERSION: u32 = 1;
18
19const FORMAT_VERSION_FILE: &str = "FORMAT_VERSION";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum StorageEngine {
25 Sqlite,
28}
29
30impl StorageEngine {
31 pub const fn format_version(self) -> u32 {
33 match self {
34 StorageEngine::Sqlite => 2,
35 }
36 }
37
38 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
72pub 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
93pub fn store_file_path_for(data_dir: &Path, engine: StorageEngine) -> PathBuf {
95 data_dir.join("store").join(engine.store_file_name())
96}
97
98fn 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
105fn 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
131pub 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
181pub(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
189pub(crate) fn check_or_stamp(data_dir: &Path) -> Result<StorageEngine, PortError> {
195 check_or_stamp_as(data_dir, None)
196}
197
198pub(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
236fn 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}