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(); +}