diff --git a/Cargo.lock b/Cargo.lock index b7f8b8fea..380338859 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,7 @@ dependencies = [ "rayon", "serde", "tapes", + "tempfile", "thiserror 2.0.18", "tower", "tracing", diff --git a/storage/blockchain/Cargo.toml b/storage/blockchain/Cargo.toml index 23edb45b3..6a9ffc2d4 100644 --- a/storage/blockchain/Cargo.toml +++ b/storage/blockchain/Cargo.toml @@ -35,6 +35,7 @@ thiserror = { workspace = true, features = ["std"] } cuprate-constants = { workspace = true } proptest = { workspace = true } +tempfile = { workspace = true } [lints] workspace = true diff --git a/storage/blockchain/src/database.rs b/storage/blockchain/src/database.rs index 28f553028..9dce5c135 100644 --- a/storage/blockchain/src/database.rs +++ b/storage/blockchain/src/database.rs @@ -8,6 +8,7 @@ use cuprate_helper::cast::{u64_to_usize, usize_to_u64}; use crate::{ config::Config, + constants::DATABASE_VERSION, types::{Amount, BlockInfo, RctOutput, TxInfo}, BlockchainError, }; @@ -142,6 +143,11 @@ impl BlockchainDatabase { config: &Config, fjall: fjall::Database, ) -> Result { + // Verify (or initialize) the on-disk format version BEFORE opening the tapes, so an + // incompatible database is refused without creating any tape files. + let metadata = fjall.keyspace("metadata", KeyspaceCreateOptions::default)?; + Self::check_or_init_format_version(&metadata)?; + let block_heights = fjall.keyspace("block_heights", KeyspaceCreateOptions::default)?; let key_images = fjall.keyspace("key_images", KeyspaceCreateOptions::default)?; let pre_rct_outputs = fjall.keyspace("pre_rct_outputs", KeyspaceCreateOptions::default)?; @@ -252,6 +258,32 @@ impl BlockchainDatabase { }) } + /// Reads `b"format_version"` from `metadata`. If absent, stamps `DATABASE_VERSION`. + /// If present and equal, ok. Otherwise returns `DbFormatVersionMismatch` (never panics). + fn check_or_init_format_version(metadata: &fjall::Keyspace) -> Result<(), BlockchainError> { + match metadata.get(b"format_version")? { + None => { + metadata.insert(b"format_version", DATABASE_VERSION.to_le_bytes())?; + Ok(()) + } + Some(bytes) => { + let mut found_bytes = [0_u8; 8]; + let len = bytes.len().min(found_bytes.len()); + found_bytes[..len].copy_from_slice(&bytes[..len]); + let found = u64::from_le_bytes(found_bytes); + + if found == DATABASE_VERSION && bytes.len() == found_bytes.len() { + Ok(()) + } else { + Err(BlockchainError::DbFormatVersionMismatch { + expected: DATABASE_VERSION, + found, + }) + } + } + } + } + /// Checks if the fjall and tapes database are in sync and rebuilds the fjall database if it /// is not. pub fn make_consistent(&self) -> Result<(), BlockchainError> { @@ -353,3 +385,106 @@ impl Drop for BlockchainDatabase { let _ = self.linear_tapes.append().commit(Persistence::SyncAll); } } + +#[cfg(test)] +mod tests { + use fjall::KeyspaceCreateOptions; + + use super::BlockchainDatabase; + use crate::{config::Config, constants::DATABASE_VERSION, error::BlockchainError}; + + #[test] + fn fresh_db_open_stamps_format_version_in_metadata() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + blob_dir: dir.path().into(), + index_dir: dir.path().into(), + ..Default::default() + }; + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + + let db = BlockchainDatabase::open_with_fjall_database(&config, fjall).unwrap(); + + let metadata = db + .fjall + .keyspace("metadata", KeyspaceCreateOptions::default) + .unwrap(); + assert_eq!( + metadata.get(b"format_version").unwrap().as_deref(), + Some(DATABASE_VERSION.to_le_bytes().as_slice()) + ); + } + + #[test] + fn reopening_db_with_matching_stored_version_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + blob_dir: dir.path().into(), + index_dir: dir.path().into(), + ..Default::default() + }; + + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let db = BlockchainDatabase::open_with_fjall_database(&config, fjall).unwrap(); + drop(db); + + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let reopened = BlockchainDatabase::open_with_fjall_database(&config, fjall); + + assert!(reopened.is_ok()); + } + + #[test] + fn opening_db_with_mismatched_stored_version_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + blob_dir: dir.path().into(), + index_dir: dir.path().into(), + ..Default::default() + }; + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let metadata = fjall + .keyspace("metadata", KeyspaceCreateOptions::default) + .unwrap(); + metadata + .insert(b"format_version", 999_u64.to_le_bytes()) + .unwrap(); + + let result = BlockchainDatabase::open_with_fjall_database(&config, fjall); + + assert!(matches!( + result, + Err(BlockchainError::DbFormatVersionMismatch { + expected, + found + }) if expected == DATABASE_VERSION && found == 999 + )); + } + + #[test] + fn opening_db_with_malformed_stored_version_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + blob_dir: dir.path().into(), + index_dir: dir.path().into(), + ..Default::default() + }; + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let metadata = fjall + .keyspace("metadata", KeyspaceCreateOptions::default) + .unwrap(); + metadata.insert(b"format_version", b"bad").unwrap(); + + let result = BlockchainDatabase::open_with_fjall_database(&config, fjall); + + assert!(result.is_err()); + } + + #[test] + fn db_version_property_matches_database_version_constant() { + assert_eq!( + crate::ops::property::db_version().unwrap(), + DATABASE_VERSION + ); + } +} diff --git a/storage/blockchain/src/error.rs b/storage/blockchain/src/error.rs index b3d745ea8..5c1e88edb 100644 --- a/storage/blockchain/src/error.rs +++ b/storage/blockchain/src/error.rs @@ -7,6 +7,8 @@ pub enum BlockchainError { IO(#[from] std::io::Error), #[error(transparent)] Fjall(#[from] fjall::Error), + #[error("database format version mismatch: this binary supports format version {expected} but the on-disk database is version {found}; refusing to start (a future release may provide a migration)")] + DbFormatVersionMismatch { expected: u64, found: u64 }, #[error("not found")] NotFound, } diff --git a/storage/txpool/src/error.rs b/storage/txpool/src/error.rs index ec030f18e..1a77834b7 100644 --- a/storage/txpool/src/error.rs +++ b/storage/txpool/src/error.rs @@ -3,6 +3,8 @@ pub enum TxPoolError { #[error("{0}")] Fjall(#[from] fjall::Error), + #[error("database format version mismatch: this binary supports format version {expected} but the on-disk database is version {found}; refusing to start (a future release may provide a migration)")] + DbFormatVersionMismatch { expected: u64, found: u64 }, #[error("Required key not found")] NotFound, } diff --git a/storage/txpool/src/txpool.rs b/storage/txpool/src/txpool.rs index b01550542..526dfb066 100644 --- a/storage/txpool/src/txpool.rs +++ b/storage/txpool/src/txpool.rs @@ -2,6 +2,14 @@ use std::{collections::HashMap, sync::Mutex}; use fjall::{KeyspaceCreateOptions, KvSeparationOptions}; +use crate::error::TxPoolError; + +/// Current on-disk format version of the txpool database. +/// +/// Incremented by 1 whenever the txpool database structure/schema changes. +/// Persisted under `b"format_version"` in the `metadata` keyspace and checked on open. +const DATABASE_FORMAT_VERSION: u64 = 0; + /// The txpool database. pub struct TxpoolDatabase { pub(crate) fjall_database: fjall::Database, @@ -10,7 +18,6 @@ pub struct TxpoolDatabase { pub(crate) tx_infos: fjall::Keyspace, pub(crate) spent_key_images: fjall::Keyspace, pub(crate) known_blob_hashes: fjall::Keyspace, - #[expect(dead_code)] // TODO: version? pub(crate) metadata: fjall::Keyspace, pub(crate) in_progress_key_images: Mutex>, @@ -18,7 +25,7 @@ pub struct TxpoolDatabase { impl TxpoolDatabase { /// Open a txpool database with the given fjall backing database. - pub fn open_with_database(fjall_database: fjall::Database) -> fjall::Result { + pub fn open_with_database(fjall_database: fjall::Database) -> Result { let s = Self { tx_blobs: fjall_database.keyspace("tx_blobs", || { KeyspaceCreateOptions::default().with_kv_separation(Some( @@ -35,6 +42,107 @@ impl TxpoolDatabase { in_progress_key_images: Mutex::new(HashMap::new()), }; + Self::check_or_init_format_version(&s.metadata)?; + Ok(s) } + + /// Reads `b"format_version"` from `metadata`. If absent, stamps `DATABASE_FORMAT_VERSION`. + /// If present and equal, ok. Otherwise returns `DbFormatVersionMismatch` (never panics). + fn check_or_init_format_version(metadata: &fjall::Keyspace) -> Result<(), TxPoolError> { + match metadata.get(b"format_version")? { + None => { + metadata.insert(b"format_version", DATABASE_FORMAT_VERSION.to_le_bytes())?; + Ok(()) + } + Some(bytes) => { + let mut version_bytes = [0_u8; 8]; + let copy_len = bytes.len().min(version_bytes.len()); + version_bytes[..copy_len].copy_from_slice(&bytes[..copy_len]); + + let found = u64::from_le_bytes(version_bytes); + + if found == DATABASE_FORMAT_VERSION && bytes.len() == version_bytes.len() { + Ok(()) + } else { + Err(TxPoolError::DbFormatVersionMismatch { + expected: DATABASE_FORMAT_VERSION, + found, + }) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{TxpoolDatabase, DATABASE_FORMAT_VERSION}; + use crate::error::TxPoolError; + + #[test] + fn open_with_database_stamps_format_version_on_fresh_db() { + let dir = tempfile::tempdir().unwrap(); + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let db = TxpoolDatabase::open_with_database(fjall).unwrap(); + + let stored = db.metadata.get(b"format_version").unwrap().unwrap(); + + assert_eq!(stored.as_ref(), DATABASE_FORMAT_VERSION.to_le_bytes()); + } + + #[test] + fn open_with_database_allows_reopen_with_matching_format_version() { + let dir = tempfile::tempdir().unwrap(); + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let db = TxpoolDatabase::open_with_database(fjall).unwrap(); + + drop(db); + + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let reopened = TxpoolDatabase::open_with_database(fjall); + + assert!(reopened.is_ok()); + } + + #[test] + fn open_with_database_rejects_mismatched_format_version() { + let dir = tempfile::tempdir().unwrap(); + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let metadata = fjall + .keyspace("metadata", fjall::KeyspaceCreateOptions::default) + .unwrap(); + metadata + .insert(b"format_version", 999_u64.to_le_bytes()) + .unwrap(); + + match TxpoolDatabase::open_with_database(fjall) { + Err(TxPoolError::DbFormatVersionMismatch { expected, found }) => { + assert_eq!(expected, DATABASE_FORMAT_VERSION); + assert_eq!(found, 999); + } + Err(other) => panic!("unexpected error: {other:?}"), + Ok(_) => panic!("expected format version mismatch error"), + } + } + + #[test] + fn open_with_database_rejects_malformed_format_version_value() { + let dir = tempfile::tempdir().unwrap(); + let fjall = fjall::Database::builder(dir.path()).open().unwrap(); + let metadata = fjall + .keyspace("metadata", fjall::KeyspaceCreateOptions::default) + .unwrap(); + metadata.insert(b"format_version", [1_u8, 2, 3]).unwrap(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + TxpoolDatabase::open_with_database(fjall) + })); + + assert!( + result.is_ok(), + "open_with_database panicked on malformed version" + ); + assert!(result.unwrap().is_err()); + } }