use std::path::Path;
use std::sync::{Arc, Mutex};
use freenet_stdlib::prelude::*;
use redb::{
Database, DatabaseError, ReadTransaction, ReadableDatabase, ReadableTable, StorageError,
TableDefinition, TransactionError, WriteTransaction,
};
use crate::wasm_runtime::StateStorage;
const CONTRACT_PARAMS_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("contract_params");
const STATE_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("state");
const HOSTING_METADATA_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("hosting_metadata");
pub(crate) const CONTRACT_INDEX_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("contract_index");
pub(crate) const DELEGATE_INDEX_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("delegate_index");
pub(crate) const BROKEN_INVARIANTS_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("broken_invariants");
pub(crate) const SECRETS_INDEX_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("secrets_index");
pub(crate) const USER_SECRETS_INDEX_TABLE: TableDefinition<&[u8], &[u8]> =
TableDefinition::new("user_secrets_index");
#[derive(Debug, Clone, Copy)]
pub struct HostingMetadata {
pub last_access_ms: u64,
pub access_type: u8,
pub size_bytes: u64,
pub code_hash: [u8; 32],
pub local_client_access: bool,
}
impl HostingMetadata {
pub fn new(
last_access_ms: u64,
access_type: u8,
size_bytes: u64,
code_hash: [u8; 32],
local_client_access: bool,
) -> Self {
Self {
last_access_ms,
access_type,
size_bytes,
code_hash,
local_client_access,
}
}
pub fn to_bytes(&self) -> [u8; 50] {
let mut buf = [0u8; 50];
buf[0..8].copy_from_slice(&self.last_access_ms.to_le_bytes());
buf[8] = self.access_type;
buf[9..17].copy_from_slice(&self.size_bytes.to_le_bytes());
buf[17..49].copy_from_slice(&self.code_hash);
buf[49] = u8::from(self.local_client_access);
buf
}
pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
if bytes.len() < 49 {
return None;
}
let last_access_ms = u64::from_le_bytes(bytes[0..8].try_into().ok()?);
let access_type = bytes[8];
let size_bytes = u64::from_le_bytes(bytes[9..17].try_into().ok()?);
let code_hash: [u8; 32] = bytes[17..49].try_into().ok()?;
let local_client_access = bytes.get(49).copied().unwrap_or(0) != 0;
Some(Self {
last_access_ms,
access_type,
size_bytes,
code_hash,
local_client_access,
})
}
}
fn storage_error_is_poison(e: &StorageError) -> bool {
matches!(
e,
StorageError::PreviousIo
| StorageError::Io(_)
| StorageError::LockPoisoned(_)
| StorageError::DatabaseClosed
)
}
fn transaction_error_is_poison(e: &TransactionError) -> bool {
matches!(e, TransactionError::Storage(s) if storage_error_is_poison(s))
}
fn redb_error_is_poison(e: &redb::Error) -> bool {
matches!(
e,
redb::Error::PreviousIo | redb::Error::LockPoisoned(_) | redb::Error::DatabaseClosed
)
}
#[cfg(test)]
static POISON_RECOVERY_TRIGGERED: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[derive(Clone)]
pub struct ReDb {
db: Arc<Database>,
contract_blob_lock: Arc<Mutex<()>>,
}
impl ReDb {
pub fn contract_blob_lock(&self) -> Arc<Mutex<()>> {
self.contract_blob_lock.clone()
}
}
impl ReDb {
fn begin_write(&self) -> Result<WriteTransaction, TransactionError> {
self.db.begin_write().map_err(Self::route_txn_error)
}
fn begin_read(&self) -> Result<ReadTransaction, TransactionError> {
self.db.begin_read().map_err(Self::route_txn_error)
}
fn route_txn_error(e: TransactionError) -> TransactionError {
if transaction_error_is_poison(&e) {
#[cfg(test)]
POISON_RECOVERY_TRIGGERED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
crate::node::abort_process_on_redb_poison(&e.to_string());
}
e
}
fn route_redb_error(e: redb::Error) -> redb::Error {
if redb_error_is_poison(&e) {
#[cfg(test)]
POISON_RECOVERY_TRIGGERED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
crate::node::abort_process_on_redb_poison(&e.to_string());
}
e
}
fn read_guarded<T>(
&self,
f: impl FnOnce(&ReadTransaction) -> Result<T, redb::Error>,
) -> Result<T, redb::Error> {
let txn = self.begin_read()?;
f(&txn).map_err(Self::route_redb_error)
}
fn commit_guarded(txn: WriteTransaction) -> Result<(), redb::Error> {
match txn.commit() {
Ok(()) => Ok(()),
Err(e) => {
if let redb::CommitError::Storage(s) = &e {
if storage_error_is_poison(s) {
#[cfg(test)]
POISON_RECOVERY_TRIGGERED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
crate::node::abort_process_on_redb_poison(&e.to_string());
}
}
Err(e.into())
}
}
}
pub async fn new(data_dir: &Path) -> Result<Self, redb::Error> {
let db_path = data_dir.join("db");
tracing::info!(
db_path = ?db_path,
phase = "store_init",
"Loading contract store"
);
match Database::create(&db_path) {
Ok(db) => Self::initialize_database(db),
Err(e) if Self::is_version_mismatch(&e) => {
tracing::warn!(
db_path = ?db_path,
error = %e,
phase = "version_mismatch",
"Database format mismatch detected, automatically migrating"
);
Self::backup_and_remove_database(&db_path)?;
tracing::info!(
db_path = ?db_path,
phase = "create_new_db",
"Creating new database"
);
let db = Database::create(&db_path)?;
Self::initialize_database(db)
}
Err(e) => {
tracing::error!(
db_path = ?db_path,
error = %e,
phase = "store_init_failed",
"Failed to load contract store"
);
Err(e.into())
}
}
}
fn initialize_database(db: Database) -> Result<Self, redb::Error> {
let db = Self {
db: Arc::new(db),
contract_blob_lock: Arc::new(Mutex::new(())),
};
let txn = db.db.begin_write()?;
{
txn.open_table(STATE_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "STATE_TABLE",
phase = "table_init_failed",
"Failed to open STATE_TABLE"
);
e
})?;
txn.open_table(CONTRACT_PARAMS_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "CONTRACT_PARAMS_TABLE",
phase = "table_init_failed",
"Failed to open CONTRACT_PARAMS_TABLE"
);
e
})?;
txn.open_table(HOSTING_METADATA_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "HOSTING_METADATA_TABLE",
phase = "table_init_failed",
"Failed to open HOSTING_METADATA_TABLE"
);
e
})?;
txn.open_table(CONTRACT_INDEX_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "CONTRACT_INDEX_TABLE",
phase = "table_init_failed",
"Failed to open CONTRACT_INDEX_TABLE"
);
e
})?;
txn.open_table(DELEGATE_INDEX_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "DELEGATE_INDEX_TABLE",
phase = "table_init_failed",
"Failed to open DELEGATE_INDEX_TABLE"
);
e
})?;
txn.open_table(SECRETS_INDEX_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "SECRETS_INDEX_TABLE",
phase = "table_init_failed",
"Failed to open SECRETS_INDEX_TABLE"
);
e
})?;
txn.open_table(USER_SECRETS_INDEX_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "USER_SECRETS_INDEX_TABLE",
phase = "table_init_failed",
"Failed to open USER_SECRETS_INDEX_TABLE"
);
e
})?;
txn.open_table(BROKEN_INVARIANTS_TABLE).map_err(|e| {
tracing::error!(
error = %e,
table = "BROKEN_INVARIANTS_TABLE",
phase = "table_init_failed",
"Failed to open BROKEN_INVARIANTS_TABLE"
);
e
})?;
}
txn.commit()?;
Ok(db)
}
fn is_version_mismatch(error: &DatabaseError) -> bool {
matches!(error, DatabaseError::UpgradeRequired(..))
}
fn backup_and_remove_database(db_path: &Path) -> Result<(), redb::Error> {
use std::io::ErrorKind;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let backup_path = db_path.with_extension(format!("db.backup.{}", timestamp));
match std::fs::rename(db_path, &backup_path) {
Ok(_) => {
tracing::info!(
backup_path = ?backup_path,
phase = "backup_complete",
"Old database backed up - you can safely delete this backup after verifying the new database works correctly"
);
Ok(())
}
Err(e) if e.kind() == ErrorKind::NotFound => {
tracing::debug!(
db_path = ?db_path,
"No existing database to backup"
);
Ok(())
}
Err(e) => {
tracing::warn!(
db_path = ?db_path,
error = %e,
phase = "backup_failed",
"Failed to backup old database, attempting to remove it directly"
);
std::fs::remove_file(db_path).map_err(|remove_err| {
tracing::error!(
db_path = ?db_path,
error = %remove_err,
phase = "remove_failed",
"Failed to remove incompatible database"
);
redb::Error::Io(remove_err)
})?;
tracing::info!(
db_path = ?db_path,
phase = "db_removed",
"Removed incompatible database (backup failed)"
);
Ok(())
}
}
}
pub fn store_hosting_metadata(
&self,
key: &ContractKey,
metadata: HostingMetadata,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
tbl.insert(key.as_bytes(), metadata.to_bytes().as_slice())?;
}
Self::commit_guarded(txn)
}
pub fn get_hosting_metadata(
&self,
key: &ContractKey,
) -> Result<Option<HostingMetadata>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
Ok(match tbl.get(key.as_bytes())? {
Some(v) => HostingMetadata::from_bytes(v.value()),
None => None,
})
})
}
pub fn remove_hosting_metadata(&self, key: &ContractKey) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
tbl.remove(key.as_bytes())?;
}
Self::commit_guarded(txn)
}
pub fn load_all_hosting_metadata(
&self,
) -> Result<Vec<(Vec<u8>, HostingMetadata)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
if let Some(metadata) = HostingMetadata::from_bytes(value.value()) {
result.push((key.value().to_vec(), metadata));
}
}
Ok(result)
})
}
pub fn get_state_size(&self, key: &ContractKey) -> Result<Option<u64>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(STATE_TABLE)?;
Ok(tbl.get(key.as_bytes())?.map(|v| v.value().len() as u64))
})
}
pub fn store_state_sync(
&self,
key: &ContractKey,
state: WrappedState,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(STATE_TABLE)?;
tbl.insert(key.as_bytes(), state.as_ref())?;
}
Self::commit_guarded(txn)
}
pub fn update_state_sync(
&self,
key: &ContractKey,
state: WrappedState,
) -> Result<bool, redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(STATE_TABLE)?;
let exists = tbl.get(key.as_bytes())?.is_some();
if !exists {
return Ok(false);
}
tbl.insert(key.as_bytes(), state.as_ref())?;
}
Self::commit_guarded(txn)?;
Ok(true)
}
pub fn get_state_sync(&self, key: &ContractKey) -> Result<Option<WrappedState>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(STATE_TABLE)?;
Ok(tbl
.get(key.as_bytes())?
.map(|v| WrappedState::new(v.value().to_vec())))
})
}
pub fn iter_all_state_keys(&self) -> Result<Vec<Vec<u8>>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(STATE_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, _) = entry?;
result.push(key.value().to_vec());
}
Ok(result)
})
}
pub fn store_contract_index(
&self,
instance_id: &ContractInstanceId,
code_hash: &CodeHash,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(CONTRACT_INDEX_TABLE)?;
tbl.insert(instance_id.as_ref(), code_hash.as_ref())?;
}
Self::commit_guarded(txn)
}
pub fn get_contract_index(
&self,
instance_id: &ContractInstanceId,
) -> Result<Option<CodeHash>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(CONTRACT_INDEX_TABLE)?;
match tbl.get(instance_id.as_ref())? {
Some(v) => {
let bytes: [u8; 32] = v.value().try_into().map_err(|_| {
redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid CodeHash length",
))
})?;
Ok(Some(CodeHash::from(&bytes)))
}
None => Ok(None),
}
})
}
pub fn remove_contract_index(
&self,
instance_id: &ContractInstanceId,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(CONTRACT_INDEX_TABLE)?;
tbl.remove(instance_id.as_ref())?;
}
Self::commit_guarded(txn)
}
pub fn load_all_contract_index(
&self,
) -> Result<Vec<(ContractInstanceId, CodeHash)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(CONTRACT_INDEX_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
let key_bytes: [u8; 32] = key.value().try_into().map_err(|_| {
redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid ContractInstanceId length",
))
})?;
let value_bytes: [u8; 32] = value.value().try_into().map_err(|_| {
redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid CodeHash length",
))
})?;
result.push((
ContractInstanceId::new(key_bytes),
CodeHash::from(&value_bytes),
));
}
Ok(result)
})
}
pub fn store_delegate_index(
&self,
delegate_key: &DelegateKey,
code_hash: &CodeHash,
) -> Result<(), redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(DELEGATE_INDEX_TABLE)?;
tbl.insert(key_bytes.as_slice(), code_hash.as_ref())?;
}
Self::commit_guarded(txn)
}
pub fn get_delegate_index(
&self,
delegate_key: &DelegateKey,
) -> Result<Option<CodeHash>, redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
self.read_guarded(|txn| {
let tbl = txn.open_table(DELEGATE_INDEX_TABLE)?;
match tbl.get(key_bytes.as_slice())? {
Some(v) => {
let bytes: [u8; 32] = v.value().try_into().map_err(|_| {
redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid CodeHash length",
))
})?;
Ok(Some(CodeHash::from(&bytes)))
}
None => Ok(None),
}
})
}
pub fn remove_delegate_index(&self, delegate_key: &DelegateKey) -> Result<(), redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(DELEGATE_INDEX_TABLE)?;
tbl.remove(key_bytes.as_slice())?;
}
Self::commit_guarded(txn)
}
pub fn load_all_delegate_index(&self) -> Result<Vec<(DelegateKey, CodeHash)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(DELEGATE_INDEX_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
let key_bytes = key.value();
if key_bytes.len() != 64 {
continue; }
let delegate_key_bytes: [u8; 32] = key_bytes[..32].try_into().unwrap();
let code_hash_bytes: [u8; 32] = key_bytes[32..].try_into().unwrap();
let value_bytes: [u8; 32] = value.value().try_into().map_err(|_| {
redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid CodeHash length",
))
})?;
let delegate_key =
DelegateKey::new(delegate_key_bytes, CodeHash::from(&code_hash_bytes));
result.push((delegate_key, CodeHash::from(&value_bytes)));
}
Ok(result)
})
}
pub fn store_secrets_index(
&self,
delegate_key: &DelegateKey,
secret_keys: &[[u8; 32]],
) -> Result<(), redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
let mut value_bytes = Vec::with_capacity(secret_keys.len() * 32);
for sk in secret_keys {
value_bytes.extend_from_slice(sk);
}
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(SECRETS_INDEX_TABLE)?;
tbl.insert(key_bytes.as_slice(), value_bytes.as_slice())?;
}
Self::commit_guarded(txn)
}
pub fn get_secrets_index(
&self,
delegate_key: &DelegateKey,
) -> Result<Option<Vec<[u8; 32]>>, redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
self.read_guarded(|txn| {
let tbl = txn.open_table(SECRETS_INDEX_TABLE)?;
match tbl.get(key_bytes.as_slice())? {
Some(v) => {
let value = v.value();
if value.len() % 32 != 0 {
return Err(redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid secrets index value length",
)));
}
let mut result = Vec::with_capacity(value.len() / 32);
for chunk in value.chunks(32) {
let arr: [u8; 32] = chunk.try_into().unwrap();
result.push(arr);
}
Ok(Some(result))
}
None => Ok(None),
}
})
}
pub fn remove_secrets_index(&self, delegate_key: &DelegateKey) -> Result<(), redb::Error> {
let mut key_bytes = [0u8; 64];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..].copy_from_slice(delegate_key.code_hash().as_ref());
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(SECRETS_INDEX_TABLE)?;
tbl.remove(key_bytes.as_slice())?;
}
Self::commit_guarded(txn)
}
#[allow(clippy::type_complexity)]
pub fn load_all_secrets_index(&self) -> Result<Vec<(DelegateKey, Vec<[u8; 32]>)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(SECRETS_INDEX_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
let key_bytes = key.value();
if key_bytes.len() != 64 {
continue; }
let delegate_key_bytes: [u8; 32] = key_bytes[..32].try_into().unwrap();
let code_hash_bytes: [u8; 32] = key_bytes[32..].try_into().unwrap();
let value_bytes = value.value();
if value_bytes.len() % 32 != 0 {
continue; }
let mut secret_keys = Vec::with_capacity(value_bytes.len() / 32);
for chunk in value_bytes.chunks(32) {
let arr: [u8; 32] = chunk.try_into().unwrap();
secret_keys.push(arr);
}
let delegate_key =
DelegateKey::new(delegate_key_bytes, CodeHash::from(&code_hash_bytes));
result.push((delegate_key, secret_keys));
}
Ok(result)
})
}
fn user_index_key(delegate_key: &DelegateKey, user_id: &[u8; 32]) -> [u8; 96] {
let mut key_bytes = [0u8; 96];
key_bytes[..32].copy_from_slice(delegate_key.as_ref());
key_bytes[32..64].copy_from_slice(delegate_key.code_hash().as_ref());
key_bytes[64..].copy_from_slice(user_id);
key_bytes
}
pub fn store_user_secrets_index(
&self,
delegate_key: &DelegateKey,
user_id: &[u8; 32],
secret_keys: &[[u8; 32]],
) -> Result<(), redb::Error> {
let key_bytes = Self::user_index_key(delegate_key, user_id);
let mut value_bytes = Vec::with_capacity(secret_keys.len() * 32);
for sk in secret_keys {
value_bytes.extend_from_slice(sk);
}
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(USER_SECRETS_INDEX_TABLE)?;
tbl.insert(key_bytes.as_slice(), value_bytes.as_slice())?;
}
Self::commit_guarded(txn)
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn get_user_secrets_index(
&self,
delegate_key: &DelegateKey,
user_id: &[u8; 32],
) -> Result<Option<Vec<[u8; 32]>>, redb::Error> {
let key_bytes = Self::user_index_key(delegate_key, user_id);
self.read_guarded(|txn| {
let tbl = txn.open_table(USER_SECRETS_INDEX_TABLE)?;
match tbl.get(key_bytes.as_slice())? {
Some(v) => {
let value = v.value();
if value.len() % 32 != 0 {
return Err(redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid user secrets index value length",
)));
}
let mut result = Vec::with_capacity(value.len() / 32);
for chunk in value.chunks(32) {
let arr: [u8; 32] = chunk.try_into().unwrap();
result.push(arr);
}
Ok(Some(result))
}
None => Ok(None),
}
})
}
pub fn remove_user_secrets_index(
&self,
delegate_key: &DelegateKey,
user_id: &[u8; 32],
) -> Result<(), redb::Error> {
let key_bytes = Self::user_index_key(delegate_key, user_id);
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(USER_SECRETS_INDEX_TABLE)?;
tbl.remove(key_bytes.as_slice())?;
}
Self::commit_guarded(txn)
}
#[allow(clippy::type_complexity)]
pub fn load_all_user_secrets_index(
&self,
) -> Result<Vec<((DelegateKey, [u8; 32]), Vec<[u8; 32]>)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(USER_SECRETS_INDEX_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
let key_bytes = key.value();
if key_bytes.len() != 96 {
tracing::warn!(
len = key_bytes.len(),
"Skipping malformed user-secrets-index row (key length != 96)"
);
continue;
}
let delegate_key_bytes: [u8; 32] = key_bytes[..32].try_into().unwrap();
let code_hash_bytes: [u8; 32] = key_bytes[32..64].try_into().unwrap();
let user_id_bytes: [u8; 32] = key_bytes[64..].try_into().unwrap();
let value_bytes = value.value();
if value_bytes.len() % 32 != 0 {
tracing::warn!(
len = value_bytes.len(),
"Skipping malformed user-secrets-index row (value length not a multiple of 32)"
);
continue;
}
let mut secret_keys = Vec::with_capacity(value_bytes.len() / 32);
for chunk in value_bytes.chunks(32) {
let arr: [u8; 32] = chunk.try_into().unwrap();
secret_keys.push(arr);
}
let delegate_key =
DelegateKey::new(delegate_key_bytes, CodeHash::from(&code_hash_bytes));
result.push(((delegate_key, user_id_bytes), secret_keys));
}
Ok(result)
})
}
pub fn store_broken_invariant(
&self,
instance_id: &ContractInstanceId,
kind_byte: u8,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(BROKEN_INVARIANTS_TABLE)?;
tbl.insert(instance_id.as_ref(), &[kind_byte][..])?;
}
Self::commit_guarded(txn)
}
pub fn remove_broken_invariant(
&self,
instance_id: &ContractInstanceId,
) -> Result<(), redb::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(BROKEN_INVARIANTS_TABLE)?;
tbl.remove(instance_id.as_ref())?;
}
Self::commit_guarded(txn)
}
pub fn load_all_broken_invariants(&self) -> Result<Vec<(ContractInstanceId, u8)>, redb::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(BROKEN_INVARIANTS_TABLE)?;
let mut result = Vec::new();
for entry in tbl.iter()? {
let (key, value) = entry?;
let key_bytes: [u8; 32] = match key.value().try_into() {
Ok(b) => b,
Err(_) => {
tracing::warn!(
len = key.value().len(),
"Skipping malformed broken-invariants row (key length)"
);
continue;
}
};
let v = value.value();
if v.len() != 1 {
tracing::warn!(
len = v.len(),
"Skipping malformed broken-invariants row (value length)"
);
continue;
}
result.push((ContractInstanceId::new(key_bytes), v[0]));
}
Ok(result)
})
}
}
impl StateStorage for ReDb {
type Error = redb::Error;
async fn store(&self, key: ContractKey, state: WrappedState) -> Result<(), Self::Error> {
let state_size = state.size() as u64;
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(STATE_TABLE)?;
tbl.insert(key.as_bytes(), state.as_ref())?;
}
{
let mut tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let code_hash: [u8; 32] = **key.code_hash();
let existing_local = tbl
.get(key.as_bytes())
.ok()
.flatten()
.and_then(|v| HostingMetadata::from_bytes(v.value()))
.map(|m| m.local_client_access)
.unwrap_or(false);
let metadata = HostingMetadata::new(now_ms, 1, state_size, code_hash, existing_local);
tbl.insert(key.as_bytes(), metadata.to_bytes().as_slice())?;
}
Self::commit_guarded(txn)
}
async fn get(&self, key: &ContractKey) -> Result<Option<WrappedState>, Self::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(STATE_TABLE)?;
Ok(tbl
.get(key.as_bytes())?
.map(|v| WrappedState::new(v.value().to_vec())))
})
}
async fn store_params(
&self,
key: ContractKey,
params: Parameters<'static>,
) -> Result<(), Self::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(CONTRACT_PARAMS_TABLE)?;
tbl.insert(key.as_bytes(), params.as_ref())?;
}
Self::commit_guarded(txn)
}
async fn get_params<'a>(
&'a self,
key: &'a ContractKey,
) -> Result<Option<Parameters<'static>>, Self::Error> {
self.read_guarded(|txn| {
let tbl = txn.open_table(CONTRACT_PARAMS_TABLE)?;
Ok(tbl
.get(key.as_bytes())?
.map(|v| Parameters::from(v.value().to_vec())))
})
}
async fn remove(&self, key: &ContractKey) -> Result<(), Self::Error> {
let txn = self.begin_write()?;
{
let mut tbl = txn.open_table(STATE_TABLE)?;
tbl.remove(key.as_bytes())?;
}
{
let mut tbl = txn.open_table(CONTRACT_PARAMS_TABLE)?;
tbl.remove(key.as_bytes())?;
}
{
let mut tbl = txn.open_table(HOSTING_METADATA_TABLE)?;
tbl.remove(key.as_bytes())?;
}
Self::commit_guarded(txn)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
#[tokio::test]
async fn test_backup_nonexistent_database() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("nonexistent_db");
let result = ReDb::backup_and_remove_database(&db_path);
assert!(
result.is_ok(),
"Should handle nonexistent database gracefully"
);
}
#[tokio::test]
async fn test_backup_creates_timestamped_backup() {
let temp_dir = TempDir::new().unwrap();
let db_path = temp_dir.path().join("db");
let mut file = std::fs::File::create(&db_path).unwrap();
file.write_all(b"dummy database content").unwrap();
drop(file);
ReDb::backup_and_remove_database(&db_path).unwrap();
assert!(!db_path.exists(), "Original database should be removed");
let backups: Vec<_> = std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name().to_string_lossy().to_string();
name.starts_with("db.backup.") || name.starts_with("db.db.backup.")
})
.collect();
assert!(
!backups.is_empty(),
"Should create at least one backup. Found files: {:?}",
std::fs::read_dir(temp_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name())
.collect::<Vec<_>>()
);
let backup_path = backups[0].path();
let backup_content = std::fs::read_to_string(&backup_path).unwrap();
assert_eq!(
backup_content, "dummy database content",
"Backup should preserve original content"
);
}
#[tokio::test]
async fn test_migration_with_fresh_database() {
let temp_dir = TempDir::new().unwrap();
let result = ReDb::new(temp_dir.path()).await;
assert!(result.is_ok(), "Should successfully create fresh database");
let db_path = temp_dir.path().join("db");
assert!(db_path.exists(), "Database file should exist");
}
#[test]
fn test_hosting_metadata_roundtrip() {
let metadata = HostingMetadata::new(1234567890, 1, 4096, [0xAB; 32], true);
let bytes = metadata.to_bytes();
let restored = HostingMetadata::from_bytes(&bytes).unwrap();
assert_eq!(restored.last_access_ms, 1234567890);
assert_eq!(restored.access_type, 1);
assert_eq!(restored.size_bytes, 4096);
assert_eq!(restored.code_hash, [0xAB; 32]);
assert!(restored.local_client_access);
let metadata2 = HostingMetadata::new(9999, 0, 100, [0x01; 32], false);
let restored2 = HostingMetadata::from_bytes(&metadata2.to_bytes()).unwrap();
assert!(!restored2.local_client_access);
}
#[test]
fn test_hosting_metadata_legacy_49_byte_compat() {
let mut legacy = [0u8; 49];
legacy[0..8].copy_from_slice(&1000u64.to_le_bytes());
legacy[8] = 0; legacy[9..17].copy_from_slice(&512u64.to_le_bytes());
legacy[17..49].copy_from_slice(&[0xCC; 32]);
let restored = HostingMetadata::from_bytes(&legacy).unwrap();
assert_eq!(restored.last_access_ms, 1000);
assert_eq!(restored.access_type, 0);
assert_eq!(restored.size_bytes, 512);
assert_eq!(restored.code_hash, [0xCC; 32]);
assert!(
!restored.local_client_access,
"Legacy 49-byte entries must default to local_client_access=false"
);
}
#[test]
fn test_hosting_metadata_too_short() {
assert!(HostingMetadata::from_bytes(&[0u8; 48]).is_none());
assert!(HostingMetadata::from_bytes(&[]).is_none());
}
fn make_test_key() -> ContractKey {
let code = ContractCode::from(vec![1, 2, 3, 4]);
let params = Parameters::from(vec![5, 6, 7, 8]);
ContractKey::from_params_and_code(¶ms, &code)
}
#[tokio::test]
async fn test_remove_deletes_state_and_params() {
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let key = make_test_key();
let state = WrappedState::new(vec![1, 2, 3]);
let params = Parameters::from(vec![10, 20, 30]);
db.store(key, state.clone()).await.unwrap();
db.store_params(key, params.clone()).await.unwrap();
assert_eq!(db.get(&key).await.unwrap(), Some(state));
assert_eq!(db.get_params(&key).await.unwrap(), Some(params));
db.remove(&key).await.unwrap();
assert_eq!(db.get(&key).await.unwrap(), None);
assert_eq!(db.get_params(&key).await.unwrap(), None);
}
#[tokio::test]
async fn test_remove_never_stored_is_idempotent() {
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let key = make_test_key();
db.remove(&key)
.await
.expect("removing a never-stored contract should be Ok");
}
fn fake_instance_id(seed: u8) -> ContractInstanceId {
let mut bytes = [0u8; 32];
bytes[0] = seed;
ContractInstanceId::new(bytes)
}
#[tokio::test]
async fn broken_invariants_persistence_round_trip() {
let temp_dir = TempDir::new().unwrap();
let id_a = fake_instance_id(0xA1);
let id_b = fake_instance_id(0xB2);
{
let db = ReDb::new(temp_dir.path()).await.unwrap();
db.store_broken_invariant(&id_a, 0).expect("store id_a");
db.store_broken_invariant(&id_b, 0).expect("store id_b");
}
let db = ReDb::new(temp_dir.path()).await.unwrap();
let mut loaded = db.load_all_broken_invariants().expect("load");
loaded.sort_by_key(|(id, _)| id.as_bytes().to_vec());
let mut expected = vec![(id_a, 0u8), (id_b, 0u8)];
expected.sort_by_key(|(id, _)| id.as_bytes().to_vec());
assert_eq!(
loaded, expected,
"broken-invariants table must survive close-and-reopen exactly"
);
}
#[tokio::test]
async fn broken_invariants_remove_makes_load_empty() {
let temp_dir = TempDir::new().unwrap();
let id = fake_instance_id(0x42);
{
let db = ReDb::new(temp_dir.path()).await.unwrap();
db.store_broken_invariant(&id, 0).unwrap();
assert_eq!(db.load_all_broken_invariants().unwrap().len(), 1);
db.remove_broken_invariant(&id).unwrap();
assert!(db.load_all_broken_invariants().unwrap().is_empty());
}
let db = ReDb::new(temp_dir.path()).await.unwrap();
assert!(
db.load_all_broken_invariants().unwrap().is_empty(),
"removal must survive a close/reopen"
);
}
#[tokio::test]
async fn broken_invariants_store_is_upsert_not_append() {
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let id = fake_instance_id(0x77);
db.store_broken_invariant(&id, 0).unwrap();
db.store_broken_invariant(&id, 0).unwrap();
db.store_broken_invariant(&id, 0).unwrap();
let rows = db.load_all_broken_invariants().unwrap();
assert_eq!(rows.len(), 1, "repeated stores must collapse to one row");
assert_eq!(rows[0].0, id);
}
#[tokio::test]
async fn broken_invariants_load_skips_malformed_value() {
use redb::Database;
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let db_path = temp_dir.path().join("db");
drop(db);
let raw = Database::open(&db_path).unwrap();
{
let txn = raw.begin_write().unwrap();
{
let mut tbl = txn.open_table(BROKEN_INVARIANTS_TABLE).unwrap();
let id = fake_instance_id(0xCC);
let bogus: [u8; 4] = [1, 2, 3, 4];
tbl.insert(id.as_ref(), &bogus[..]).unwrap();
}
txn.commit().unwrap();
}
drop(raw);
let db = ReDb::new(temp_dir.path()).await.unwrap();
let rows = db.load_all_broken_invariants().unwrap();
assert!(
rows.is_empty(),
"malformed row must be silently skipped; got: {:?}",
rows
);
}
fn fake_delegate_key(key_seed: u8, code_seed: u8) -> DelegateKey {
DelegateKey::new([key_seed; 32], CodeHash::from(&[code_seed; 32]))
}
#[tokio::test]
async fn user_secrets_index_store_get_remove_load_round_trip() {
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let delegate = fake_delegate_key(0x11, 0x22);
let alice: [u8; 32] = [0xAA; 32];
let bob: [u8; 32] = [0xBB; 32];
let alice_secrets = vec![[1u8; 32], [2u8; 32]];
let bob_secrets = vec![[3u8; 32]];
db.store_user_secrets_index(&delegate, &alice, &alice_secrets)
.unwrap();
db.store_user_secrets_index(&delegate, &bob, &bob_secrets)
.unwrap();
assert_eq!(
db.get_user_secrets_index(&delegate, &alice).unwrap(),
Some(alice_secrets.clone()),
"alice's secret set must round-trip"
);
assert_eq!(
db.get_user_secrets_index(&delegate, &bob).unwrap(),
Some(bob_secrets.clone()),
"bob's secret set must round-trip independently"
);
let mut loaded = db.load_all_user_secrets_index().unwrap();
loaded.sort_by_key(|((_, user), _)| *user);
let mut expected = vec![
((delegate.clone(), alice), alice_secrets.clone()),
((delegate.clone(), bob), bob_secrets.clone()),
];
expected.sort_by_key(|((_, user), _)| *user);
assert_eq!(loaded, expected, "load_all must return both users' rows");
db.remove_user_secrets_index(&delegate, &alice).unwrap();
assert_eq!(
db.get_user_secrets_index(&delegate, &alice).unwrap(),
None,
"removed user must read back as None"
);
assert_eq!(
db.get_user_secrets_index(&delegate, &bob).unwrap(),
Some(bob_secrets.clone()),
"removing alice must not touch bob"
);
let remaining = db.load_all_user_secrets_index().unwrap();
assert_eq!(
remaining,
vec![((delegate, bob), bob_secrets)],
"only bob's row must remain after removing alice"
);
}
#[tokio::test]
async fn user_secrets_index_load_skips_malformed_rows() {
use redb::Database;
let temp_dir = TempDir::new().unwrap();
let db = ReDb::new(temp_dir.path()).await.unwrap();
let good_delegate = fake_delegate_key(0x01, 0x02);
let good_user: [u8; 32] = [0x03; 32];
let good_secrets = vec![[0x44u8; 32]];
db.store_user_secrets_index(&good_delegate, &good_user, &good_secrets)
.unwrap();
let db_path = temp_dir.path().join("db");
drop(db);
let raw = Database::open(&db_path).unwrap();
{
let txn = raw.begin_write().unwrap();
{
let mut tbl = txn.open_table(USER_SECRETS_INDEX_TABLE).unwrap();
let short_key = [0xEE_u8; 95];
let valid_value = [0x55_u8; 32];
tbl.insert(short_key.as_slice(), valid_value.as_slice())
.unwrap();
let valid_key = [0xCD_u8; 96];
let bogus_value = [0x66_u8; 33];
tbl.insert(valid_key.as_slice(), bogus_value.as_slice())
.unwrap();
}
txn.commit().unwrap();
}
drop(raw);
let db = ReDb::new(temp_dir.path()).await.unwrap();
let loaded = db.load_all_user_secrets_index().unwrap();
assert_eq!(
loaded,
vec![((good_delegate, good_user), good_secrets)],
"malformed key/value rows must be skipped, leaving only the good row"
);
}
#[derive(Debug, Clone)]
struct FailingBackend {
inner: Arc<redb::backends::InMemoryBackend>,
fail: Arc<std::sync::atomic::AtomicBool>,
}
impl FailingBackend {
fn new() -> Self {
Self {
inner: Arc::new(redb::backends::InMemoryBackend::new()),
fail: Arc::new(std::sync::atomic::AtomicBool::new(false)),
}
}
fn start_failing(&self) {
self.fail.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn check(&self) -> std::io::Result<()> {
if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
Err(std::io::Error::other(
"injected I/O failure (#4604 redb-poison test)",
))
} else {
Ok(())
}
}
}
impl redb::StorageBackend for FailingBackend {
fn len(&self) -> std::io::Result<u64> {
self.check()?;
self.inner.len()
}
fn read(&self, offset: u64, out: &mut [u8]) -> std::io::Result<()> {
self.check()?;
self.inner.read(offset, out)
}
fn set_len(&self, len: u64) -> std::io::Result<()> {
self.check()?;
self.inner.set_len(len)
}
fn sync_data(&self) -> std::io::Result<()> {
self.check()?;
self.inner.sync_data()
}
fn write(&self, offset: u64, data: &[u8]) -> std::io::Result<()> {
self.check()?;
self.inner.write(offset, data)
}
}
fn open_redb_with_backend<B: redb::StorageBackend>(backend: B) -> ReDb {
let db = Database::builder()
.create_with_backend(backend)
.expect("create_with_backend");
ReDb::initialize_database(db).expect("initialize_database")
}
#[test]
fn redb_poison_classifier_is_precise() {
let backend = FailingBackend::new();
let db = Database::builder()
.create_with_backend(backend.clone())
.unwrap();
{
let w = db.begin_write().unwrap();
w.open_table(STATE_TABLE).unwrap();
w.commit().unwrap();
}
{
let r = db.begin_read().unwrap();
let missing: TableDefinition<&[u8], &[u8]> = TableDefinition::new("nope");
if let redb::TableError::Storage(s) = r.open_table(missing).unwrap_err() {
assert!(
!storage_error_is_poison(&s),
"a benign table-open storage error must not classify as poison"
);
}
}
backend.start_failing();
let storage_err: StorageError = match db.begin_write() {
Err(TransactionError::Storage(s)) => s,
Err(other) => panic!("unexpected begin error: {other:?}"),
Ok(w) => {
{
let mut t = w.open_table(STATE_TABLE).unwrap();
let _insert = t.insert([1u8, 2, 3].as_slice(), [4u8, 5, 6].as_slice());
}
match w.commit() {
Ok(()) => {
panic!("commit unexpectedly succeeded while backend was failing")
}
Err(redb::CommitError::Storage(s)) => s,
Err(other) => panic!("unexpected commit error: {other:?}"),
}
}
};
assert!(
storage_error_is_poison(&storage_err),
"the underlying I/O error (StorageError::Io) must classify as poison"
);
let begin_err = match db.begin_write() {
Ok(_) => panic!("a poisoned database must reject begin_write"),
Err(e) => e,
};
assert!(
transaction_error_is_poison(&begin_err),
"PreviousIo from a poisoned database's begin_write must classify as poison"
);
let umbrella: redb::Error = begin_err.into();
assert!(
redb_error_is_poison(&umbrella),
"PreviousIo must classify as poison on the umbrella read path too"
);
let malformed_row = redb::Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid CodeHash length",
));
assert!(
!redb_error_is_poison(&malformed_row),
"a synthetic Io(InvalidData) malformed-row error must NOT be treated as poison"
);
}
#[test]
fn poisoned_redb_takes_recovery_path_benign_does_not() {
use std::sync::atomic::Ordering;
let backend = FailingBackend::new();
let db = open_redb_with_backend(backend.clone());
let key = make_test_key();
POISON_RECOVERY_TRIGGERED.store(0, Ordering::SeqCst);
assert!(db.get_state_sync(&key).unwrap().is_none());
db.store_state_sync(&key, WrappedState::new(vec![1, 2, 3]))
.unwrap();
assert_eq!(
POISON_RECOVERY_TRIGGERED.load(Ordering::SeqCst),
0,
"benign not-found / normal ops must NOT take the poison-recovery path"
);
backend.start_failing();
POISON_RECOVERY_TRIGGERED.store(0, Ordering::SeqCst);
assert!(
db.store_state_sync(&key, WrappedState::new(vec![4, 5, 6]))
.is_err(),
"the injected I/O failure must surface as an error"
);
assert!(
POISON_RECOVERY_TRIGGERED.load(Ordering::SeqCst) >= 1,
"the poisoning write (commit-time I/O error) must take the recovery path \
on the same op, not only on a later one"
);
POISON_RECOVERY_TRIGGERED.store(0, Ordering::SeqCst);
assert!(
db.store_state_sync(&key, WrappedState::new(vec![7, 8, 9]))
.is_err(),
"a poisoned write must return an error, not silently no-op"
);
assert!(
POISON_RECOVERY_TRIGGERED.load(Ordering::SeqCst) >= 1,
"a poisoned database write must take the recovery (exit-for-restart) path \
rather than failing forever"
);
let previous_io: redb::Error = match db.begin_write() {
Ok(_) => panic!("database should still be poisoned"),
Err(e) => e.into(),
};
assert!(
redb_error_is_poison(&previous_io),
"a poisoned handle's PreviousIo must classify as poison on the read path"
);
POISON_RECOVERY_TRIGGERED.store(0, Ordering::SeqCst);
let routed: Result<(), redb::Error> = db.read_guarded(|_txn| Err(previous_io));
assert!(routed.is_err());
assert!(
POISON_RECOVERY_TRIGGERED.load(Ordering::SeqCst) >= 1,
"read_guarded must route a poison surfacing inside the read body to recovery"
);
}
}