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();
17
18pub const EVENT_FORMAT_VERSION: u32 = 2;
20
21const FORMAT_VERSION_FILE: &str = "FORMAT_VERSION";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum StorageEngine {
27 Sqlite,
30}
31
32impl StorageEngine {
33 pub const fn format_version(self) -> u32 {
35 match self {
36 StorageEngine::Sqlite => 3,
37 }
38 }
39
40 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
74pub 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
95pub fn store_file_path_for(data_dir: &Path, engine: StorageEngine) -> PathBuf {
97 data_dir.join("store").join(engine.store_file_name())
98}
99
100fn 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
107fn 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
133pub 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
183pub(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
191pub(crate) fn check_or_stamp(data_dir: &Path) -> Result<StorageEngine, PortError> {
197 check_or_stamp_as(data_dir, None)
198}
199
200pub(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
238fn 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}