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();
19
20pub const EVENT_FORMAT_VERSION: u32 = 3;
23
24const FORMAT_VERSION_FILE: &str = "FORMAT_VERSION";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum StorageEngine {
30 Sqlite,
33}
34
35impl StorageEngine {
36 pub const fn format_version(self) -> u32 {
38 match self {
39 StorageEngine::Sqlite => 4,
40 }
41 }
42
43 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
77pub 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
98pub fn store_file_path_for(data_dir: &Path, engine: StorageEngine) -> PathBuf {
100 data_dir.join("store").join(engine.store_file_name())
101}
102
103fn 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
110fn 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
136pub 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
186pub(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
194pub(crate) fn check_or_stamp(data_dir: &Path) -> Result<StorageEngine, PortError> {
200 check_or_stamp_as(data_dir, None)
201}
202
203pub(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
241fn 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}