From 16fa559427fe6179a41f99774976840c4ee496f5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Thu, 29 Oct 2026 20:37:48 -0700 Subject: [PATCH] Mark the store with inbuxa's own store version The store now carries an inbuxa store version (1), kept in the fork's subspace beside the 0.16 schema version, which stays as it is. A store without the mark, from an older release or from Stalwart, gets it on start. A store marked newer than the running release is refused before anything writes to it, with a message pointing at the newer release or the pre-upgrade backup. Layout 1 is what 2026.x and 27.1 both write today, so the same check goes to release/2026: a 2026.x release then refuses a store a later 27.x has changed instead of opening it. (cherry picked from commit f7ec11c107d75e7ceea66967e3b4a00d059e9727) --- crates/common/src/lib.rs | 10 +++++ crates/migration/src/lib.rs | 55 ++++++++++++++++++++++++--- tests/src/lib.rs | 2 + tests/src/store_version.rs | 76 +++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 tests/src/store_version.rs diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ee34a61a..4e6984a8 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -106,6 +106,16 @@ Schema history: pub const DATABASE_SCHEMA_VERSION: u32 = 6; +// inbuxa: the layout of the store as our own line keeps it, beside the +// schema version above (which stays at 0.16's, so a Stalwart 0.16 store +// still opens). Kept in the fork's subspace under `Vs`; a store without it +// was last opened by a release from before the mark, or by Stalwart, and +// gets it on start. A release refuses a store marked newer than its own. +// +// 1 - the 0.16 layout plus the fork's own subspace (2026.10.x, 27.1) +pub const INBUXA_STORE_VERSION: u32 = 1; +pub const INBUXA_STORE_VERSION_KEY: &[u8] = b"Vs"; + pub const LONG_1D_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24); pub const LONG_1Y_SLUMBER: Duration = Duration::from_secs(60 * 60 * 24 * 365); diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 6cc289d4..7acc3d0c 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -9,9 +9,9 @@ #![warn(clippy::large_futures)] use crate::v016::migrate_v0_16; -use common::{DATABASE_SCHEMA_VERSION, Server}; +use common::{DATABASE_SCHEMA_VERSION, INBUXA_STORE_VERSION, INBUXA_STORE_VERSION_KEY, Server}; use store::{ - IterateParams, SUBSPACE_PROPERTY, SUBSPACE_QUEUE_MESSAGE, SUBSPACE_REPORT_IN, + IterateParams, SUBSPACE_INBUXA, SUBSPACE_PROPERTY, SUBSPACE_QUEUE_MESSAGE, SUBSPACE_REPORT_IN, SUBSPACE_REPORT_OUT, SerializeInfallible, write::{AnyClass, AnyKey, BatchBuilder, ValueClass}, }; @@ -21,6 +21,15 @@ pub mod destroy; pub mod v016; pub async fn try_migrate(server: &Server) -> trc::Result<()> { + // inbuxa: a store a newer release has changed is refused before anything + // below writes to it. + let store_version = store_version(server).await?; + if let Some(version) = store_version.filter(|version| *version > INBUXA_STORE_VERSION) { + abort(&format!( + "The data store was upgraded by a newer release (store version {version}, this release reads up to {INBUXA_STORE_VERSION}). Run that release again, or restore the backup taken before the upgrade, see https://docs.inbuxa.org/install/migrating/" + )); + } + // inbuxa: before the version check, which returns early on a current // store, and before migrate_v0_16, which reads the renamed key. rename_spam_blobs(server).await?; @@ -36,7 +45,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { { Some(DATABASE_SCHEMA_VERSION) => { if !std::env::var("DANGER_FORCE_MIGRATE").is_ok_and(|v| v == "1") { - return Ok(()); + return mark_store(server, store_version).await; } } Some(0..=4) => { @@ -63,7 +72,7 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { _ => { if is_new_install(server).await.caused_by(trc::location!())? { write_schema_version(server).await?; - return Ok(()); + return mark_store(server, store_version).await; } else { abort(concat!( "You must first upgrade to version 0.15, please read ", @@ -74,7 +83,43 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { } migrate_v0_16(server).await?; - write_schema_version(server).await + write_schema_version(server).await?; + mark_store(server, store_version).await +} + +async fn store_version(server: &Server) -> trc::Result> { + server + .store() + .get_value::(AnyKey { + subspace: SUBSPACE_INBUXA, + key: INBUXA_STORE_VERSION_KEY.to_vec(), + }) + .await + .caused_by(trc::location!()) +} + +// inbuxa: written once, when a store has no mark yet +async fn mark_store(server: &Server, store_version: Option) -> trc::Result<()> { + if store_version.is_some() { + return Ok(()); + } + + let mut batch = BatchBuilder::new(); + batch.set( + ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: INBUXA_STORE_VERSION_KEY.to_vec(), + }), + INBUXA_STORE_VERSION.serialize(), + ); + + server + .store() + .write(batch.build_all()) + .await + .caused_by(trc::location!())?; + + Ok(()) } async fn write_schema_version(server: &Server) -> trc::Result<()> { diff --git a/tests/src/lib.rs b/tests/src/lib.rs index 4f1abc01..6aae1e6d 100644 --- a/tests/src/lib.rs +++ b/tests/src/lib.rs @@ -38,6 +38,8 @@ pub mod smtp; #[cfg(test)] pub mod store; #[cfg(test)] +pub mod store_version; // inbuxa: INBUXA_STORE_VERSION +#[cfg(test)] pub mod system; #[cfg(test)] pub mod telemetry; diff --git a/tests/src/store_version.rs b/tests/src/store_version.rs new file mode 100644 index 00000000..5874b5be --- /dev/null +++ b/tests/src/store_version.rs @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! inbuxa: the store version mark. A store without it gets it on start, a +//! marked store is left alone, and a store marked by a newer release is +//! refused before anything is written to it. + +use crate::utils::server::TestServerBuilder; +use common::{INBUXA_STORE_VERSION, INBUXA_STORE_VERSION_KEY, Server}; +use futures::FutureExt; +use std::panic::AssertUnwindSafe; +use store::{ + SUBSPACE_INBUXA, SerializeInfallible, + write::{AnyClass, AnyKey, BatchBuilder, ValueClass}, +}; + +async fn read_mark(server: &Server) -> Option { + server + .store() + .get_value::(AnyKey { + subspace: SUBSPACE_INBUXA, + key: INBUXA_STORE_VERSION_KEY.to_vec(), + }) + .await + .unwrap() +} + +async fn write_mark(server: &Server, version: Option) { + let class = ValueClass::Any(AnyClass { + subspace: SUBSPACE_INBUXA, + key: INBUXA_STORE_VERSION_KEY.to_vec(), + }); + let mut batch = BatchBuilder::new(); + match version { + Some(version) => batch.set(class, version.serialize()), + None => batch.clear(class), + }; + server.store().write(batch.build_all()).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn store_version() { + let test = TestServerBuilder::new("store_version") + .await + .disable_services() + .build() + .await; + let server = &test.server; + + println!("Running store version tests..."); + + // A store from before the mark (or from Stalwart) gets it on start. + write_mark(server, None).await; + migration::try_migrate(server).await.unwrap(); + assert_eq!(read_mark(server).await, Some(INBUXA_STORE_VERSION)); + + // Starting again leaves it as it is. + migration::try_migrate(server).await.unwrap(); + assert_eq!(read_mark(server).await, Some(INBUXA_STORE_VERSION)); + + // A store marked by a newer release is refused, and the mark stays. + let newer = INBUXA_STORE_VERSION + 1; + write_mark(server, Some(newer)).await; + let result = AssertUnwindSafe(migration::try_migrate(server)) + .catch_unwind() + .await; + assert!(result.is_err(), "a store marked newer must be refused"); + assert_eq!(read_mark(server).await, Some(newer)); + + // Put back, so the store is usable again. + write_mark(server, Some(INBUXA_STORE_VERSION)).await; + migration::try_migrate(server).await.unwrap(); +} -- 2.54.0