diff --git a/crates/floresta-chain/src/pruned_utreexo/chainstore.rs b/crates/floresta-chain/src/pruned_utreexo/chainstore.rs index fc0a42998..b1cea392a 100644 --- a/crates/floresta-chain/src/pruned_utreexo/chainstore.rs +++ b/crates/floresta-chain/src/pruned_utreexo/chainstore.rs @@ -7,6 +7,21 @@ //! It also defines two important types for our storage format: //! - [DiskBlockHeader]: A block header linked to its validation-state metadata //! - [BestChain]: Tracks the current best chain, last valid block, and fork tips +//! +//! # Error types +//! +//! This module also centralises the error types used by chainstore implementations: +//! - [ChainstoreError]: The public-facing error enum for [ChainStore] operations +//! - [InternalError]: Internal, non-recoverable error variants wrapped inside +//! [ChainstoreError::Internal] + +extern crate std; + +use core::error; +use core::fmt; +use core::fmt::Display; +use core::fmt::Formatter; +use std::io; use bitcoin::BlockHash; use bitcoin::block::Header as BlockHeader; @@ -18,6 +33,215 @@ use crate::BlockchainError; use crate::DatabaseError; use crate::prelude::*; +/// the maximum theoretical size of the Utreexo accumulator +/// +/// In the worst case that all leaves are filled: +/// * The accumulator can have up to 64 roots +/// * Each root is 32 bytes +/// * The number of leaves is expressed as a [`u64`] +/// +/// 64 (MAX_ROOTS) * 32 bytes (ROOT_SIZE) + 8 bytes (LEAF_COUNT) = 2056 bytes +/// +/// Re-exported here so callers do not need to import from `flat_chain_store` +pub const MAX_ACCUMULATOR_SIZE: usize = 2056; + +#[derive(Debug)] +/// errors that can happen whilst interacting with a [`ChainStore`] implementation +/// +/// variants that carry domain meaning ([`HeaderNotFound`], [`OversizedAccumulator`], +/// [`CorruptedDatabase`], [`InvalidValidationIndex`]) allow callers to react +/// programmatically. Everything else is wrapped inside [`Internal`] +/// +/// [`HeaderNotFound`]: ChainstoreError::HeaderNotFound +/// [`OversizedAccumulator`]: ChainstoreError::OversizedAccumulator +/// [`CorruptedDatabase`]: ChainstoreError::CorruptedDatabase +/// [`InvalidValidationIndex`]: ChainstoreError::InvalidValidationIndex +/// [`Internal`]: ChainstoreError::Internal +pub enum ChainstoreError { + /// the requested block header was not found in the store + HeaderNotFound, + + /// the accumulator data exceeds the maximum allowed size of + /// [`MAX_ACCUMULATOR_SIZE`] bytes + OversizedAccumulator, + + /// the database integrity check failed; data is corrupted + /// + /// can be remedied by a full reindex + CorruptedDatabase, + + /// the validation index references a block without a known height + /// + /// usually indicates the block is orphaned or on an invalid chain + /// can be remedied by a full reindex + InvalidValidationIndex, + + /// an internal, non-recoverable error + /// + /// wraps implementation details such as I/O failures, lock poisoning, + /// capacity exhaustion, schema mismatches, and other non-actionable + /// conditions + Internal(Box), +} + +impl Display for ChainstoreError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderNotFound => { + write!(f, "the requested block header was not found") + } + Self::OversizedAccumulator => write!( + f, + "accumulator exceeds the maximum size of {} bytes", + MAX_ACCUMULATOR_SIZE + ), + Self::CorruptedDatabase => write!( + f, + "database integrity check failed; can be remedied by a full reindex" + ), + Self::InvalidValidationIndex => { + write!( + f, + "validation index has no known height; can be remedied by a full reindex" + ) + } + Self::Internal(e) => write!(f, "internal error: {e}"), + } + } +} + +impl error::Error for ChainstoreError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Internal(e) => Some(e.as_ref()), + Self::HeaderNotFound + | Self::OversizedAccumulator + | Self::CorruptedDatabase + | Self::InvalidValidationIndex => None, + } + } +} + +/// allows [`ChainstoreError`] to be used as the associated `Error` type of [`ChainStore`] +impl DatabaseError for ChainstoreError {} + +/// converts a standard I/O error into a [`ChainstoreError::Internal`] +impl From for ChainstoreError { + fn from(e: io::Error) -> Self { + ChainstoreError::Internal(Box::new(InternalError::Io(e))) + } +} + +#[derive(Debug)] +/// internal error variants that are always wrapped inside [`ChainstoreError::Internal`] +/// +/// these are implementation details of a specific [`ChainStore`] backend and are +/// not intended to be matched on by generic callers. Use [`core::error::Error::source`] +/// (or `Box::downcast_ref`) if you need to inspect the underlying cause +pub enum InternalError { + /// a standard I/O error occurred + Io(io::Error), + + /// the open-addressing block index has no remaining free buckets + FullIndex, + + /// an index value exceeded the 31-bit tagged-index limit + OversizedIndex(u32), + + /// an index exceeded the capacity of the backing file + CapacityExceeded { index: usize, max_size: usize }, + + /// the stored schema version is newer than this build supports + UnsupportedSchema(u32), + + /// a mutex protecting the LRU cache was poisoned + PoisonedLock, + + /// the magic number in the metadata file does not match the expected value + BadMagic(u32), + + /// the pointer derived from the metadata memory-map was null + InvalidMetadataPointer, + + /// the metadata records more alternative chain tips than the fixed-size array allows + TooManyAlternativeTips(usize), +} + +impl Display for InternalError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::FullIndex => write!(f, "block index is full"), + Self::OversizedIndex(idx) => write!(f, "index value {idx} exceeds 31-bit limit"), + Self::CapacityExceeded { index, max_size } => { + write!(f, "index {index} exceeds file capacity {max_size}") + } + Self::UnsupportedSchema(version) => write!(f, "unsupported schema version: {version}"), + Self::PoisonedLock => write!(f, "cache lock poisoned"), + Self::BadMagic(magic) => write!(f, "bad database magic: {magic:#010x}"), + Self::InvalidMetadataPointer => write!(f, "metadata pointer is null"), + Self::TooManyAlternativeTips(len) => { + write!(f, "too many alternative tips: {len} (max 64)") + } + } + } +} + +impl error::Error for InternalError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Io(e) => Some(e), + Self::FullIndex + | Self::OversizedIndex(_) + | Self::CapacityExceeded { .. } + | Self::UnsupportedSchema(_) + | Self::PoisonedLock + | Self::BadMagic(_) + | Self::InvalidMetadataPointer + | Self::TooManyAlternativeTips(_) => None, + } + } +} + +/// convenience constructors that wrap an [`InternalError`] variant inside +/// [`ChainstoreError::Internal`]. These are `pub(crate)` so that +/// `flat_chain_store` (and any future backends) can build errors without +/// importing [`ChainstoreError`] explicitly at every call site +#[cfg(feature = "flat-chainstore")] +impl InternalError { + pub(crate) fn oversized_index(index: u32) -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::OversizedIndex(index))) + } + + pub(crate) fn capacity_exceeded(index: usize, max_size: usize) -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::CapacityExceeded { index, max_size })) + } + + pub(crate) fn unsupported_schema(version: u32) -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::UnsupportedSchema(version))) + } + + pub(crate) fn bad_magic(magic: u32) -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::BadMagic(magic))) + } + + pub(crate) fn invalid_metadata_pointer() -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::InvalidMetadataPointer)) + } + + pub(crate) fn too_many_alternative_tips(len: usize) -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::TooManyAlternativeTips(len))) + } + + pub(crate) fn full_index() -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::FullIndex)) + } + + pub(crate) fn poisoned_lock() -> ChainstoreError { + ChainstoreError::Internal(Box::new(Self::PoisonedLock)) + } +} + /// A trait defining methods for interacting with our chain database. These methods will be used by /// the [ChainState](super::chain_state::ChainState) to save and retrieve data about the blockchain, /// likely on disk. diff --git a/crates/floresta-chain/src/pruned_utreexo/flat_chain_store.rs b/crates/floresta-chain/src/pruned_utreexo/flat_chain_store.rs index b0647c5a8..d763c09d2 100644 --- a/crates/floresta-chain/src/pruned_utreexo/flat_chain_store.rs +++ b/crates/floresta-chain/src/pruned_utreexo/flat_chain_store.rs @@ -69,12 +69,22 @@ //! an infinite loop. If we are about to reach the map's capacity, we should re-hash with a new //! capacity. +#![deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented, + clippy::indexing_slicing, + clippy::map_err_ignore, + clippy::wildcard_enum_match_arm, + clippy::result_unit_err +)] +#![warn(clippy::result_large_err)] + extern crate std; -use core::error; -use core::fmt; -use core::fmt::Display; -use core::fmt::Formatter; use core::mem::size_of; use core::num::NonZeroUsize; use std::fs::DirBuilder; @@ -82,7 +92,6 @@ use std::fs::File; use std::fs::OpenOptions; #[cfg(unix)] use std::fs::Permissions; -use std::io; use std::io::Seek; use std::io::SeekFrom; #[cfg(unix)] @@ -95,7 +104,6 @@ use std::sync::PoisonError; use bitcoin::BlockHash; use bitcoin::hashes::Hash; -use floresta_common::impl_error_from; use floresta_common::prelude::*; use index_impl::Index; use lru::LruCache; @@ -106,8 +114,12 @@ use twox_hash::XxHash3_64; use crate::BestChain; use crate::ChainStore; -use crate::DatabaseError; use crate::DiskBlockHeader; +use crate::pruned_utreexo::chainstore::ChainstoreError; +use crate::pruned_utreexo::chainstore::InternalError; +// MAX_ACCUMULATOR_SIZE is defined in chainstore and re-exported from the crate root, +// so we pull it in through the chainstore path to keep the single source of truth. +use crate::pruned_utreexo::chainstore::MAX_ACCUMULATOR_SIZE; /// The magic number we use to make sure we're reading the right file /// @@ -122,78 +134,12 @@ const FLAT_CHAINSTORE_VERSION: u32 = 1; /// again. This is the type of our cache type CacheType = LruCache; -/// The maximum theoretical size of the Utreexo accumulator. -/// -/// In the worst case that all leaves are filled: -/// * The accumulator can have up to 64 roots -/// * Each root is 32 bytes -/// * The number of leaves is expressed as a [`u64`] -/// -/// 64 (MAX_ROOTS) * 32 bytes (ROOT_SIZE) + 8 bytes (LEAF_COUNT) = 2056 bytes -pub const MAX_ACCUMULATOR_SIZE: usize = 2056; - -#[derive(Clone)] -/// Configuration for our flat chain store. See each field for more information -pub struct FlatChainStoreConfig { - /// The index map size, in buckets - /// - /// This index holds our map from block hashes to block heights. We use an open-addressing hash - /// map to map block hashes to block heights. Ideally, size should be way bigger than the - /// number of blocks we expect to have in our chain, therefore reducing the load factor to a - /// negligible value. The default value is having space for 10 million blocks. - /// - /// We compute the actual capacity by rounding the requested size up to the next power of two, - /// so we can use `hash & (capacity - 1)` instead of `hash % capacity`. - pub block_index_size: Option, - - /// The size of the headers file map, in headers - /// - /// This is the size of the flat file that holds all of our block headers. We keep all headers - /// in a simple flat file, one after the other. That file then gets mapped into RAM, so we can - /// use pointer arithmetic to find specific block, since pos(h) = h * size_of(DiskBlockHeader) - /// The default value is having space for 10 million blocks. - /// - /// We compute the actual capacity by rounding the requested size up to the next power of two. - pub headers_file_size: Option, - - /// The size of the cache, in blocks - /// - /// We keep a LRU cache of the last n blocks we've touched. This is to avoid going into the - /// map every time we need to find a block. The default value is 1000 blocks. - pub cache_size: Option, - - /// The permission for all the files we create - /// - /// This is the permission we give to all the files we create. The default value is 0o660 - pub file_permission: Option, - - /// The size of the fork headers file map, in headers - /// - /// This store keeps headers that are not in our main chain, but may be needed sometime. The - /// default value is having space for 10,000 blocks. - /// - /// We compute the actual capacity by rounding the requested size up to the next power of two. - pub fork_file_size: Option, - - /// The path where we store our files - /// - /// We'll create a few files (namely, the index map, headers file, forks file, and metadata file). - /// We need a directory where we can read and write, it needs at least 880 MiB of free space. - /// And have a file system that supports mmap and sparse files (all the default *unix FS do). - pub path: PathBuf, -} - -impl FlatChainStoreConfig { - /// Creates a new configuration with the default values - pub fn new(path: impl AsRef) -> Self { - FlatChainStoreConfig { - file_permission: Some(0o666), - fork_file_size: Some(10_000), - headers_file_size: Some(10_000_000), - block_index_size: Some(10_000_000), - cache_size: Some(10_000), - path: path.as_ref().into(), - } +// NOTE: `PoisonError>` cannot be boxed as `Box` +// due to the borrow lifetime on the guard, the diagnostic information is preserved as the +// `PoisonedLock` enum variant instead +impl From>> for ChainstoreError { + fn from(_: PoisonError>) -> Self { + InternalError::poisoned_lock() } } @@ -233,7 +179,8 @@ enum IndexBucket { /// A simple index implementation with safe API mod index_impl { - use super::FlatChainstoreError; + use crate::pruned_utreexo::chainstore::ChainstoreError; + use crate::pruned_utreexo::chainstore::InternalError; #[repr(transparent)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -253,20 +200,20 @@ mod index_impl { const INDEX_MASK: u32 = 0x7FFF_FFFF; /// Create a new mainchain entry (MSB is zero) - pub fn new(index: u32) -> Result { + pub fn new(index: u32) -> Result { if index >= Self::FORK_BIT { // Index value is out of bounds for our 31-bit indexes - return Err(FlatChainstoreError::OversizedIndex); + return Err(InternalError::oversized_index(index)); } Ok(Index(index)) } /// Create a new fork entry (MSB is set) - pub fn new_fork(index: u32) -> Result { + pub fn new_fork(index: u32) -> Result { if index >= Self::FORK_BIT { // Index value is out of bounds for our 31-bit indexes - return Err(FlatChainstoreError::OversizedIndex); + return Err(InternalError::oversized_index(index)); } Ok(Index(index | Self::FORK_BIT)) @@ -354,96 +301,6 @@ struct Metadata { checksum: DbCheckSum, } -#[derive(Debug)] -/// Errors that can happen whilst interacting with the [`FlatChainStore`]. -pub enum FlatChainstoreError { - /// An I/O error. - /// - /// See the inner error for more information. - Io(io::Error), - - /// The requested block header was not found in the [`FlatChainStore`]. - HeaderNotFound, - - /// Failed to add a block header to the [`FlatChainStore`] due to a full index. - FullIndex, - - /// Attempted to create an index larger than 31 bits. - OversizedIndex, - - /// Attempted to open a [`FlatChainStore`] database using an unsupported schema. - UnsupportedSchema(u32), - - /// The cache lock is poisoned. - PoisonedLock, - - /// Invalid value for the database magic. - /// - /// Usually indicates that the database is corrupted. - BadMagic(u32), - - /// The accumulator is larger than [`MAX_ACCUMULATOR_SIZE`]. - OversizedAccumulator, - - /// The [`FlatChainStore`] has a bad metadata file. - InvalidMetadataPointer, - - /// The [`FlatChainStore`] is corrupted. - CorruptedDatabase, - - /// No height present on the validation index. - /// - /// Usually indicates a fork or invalid chain. - InvalidValidationIndex, -} - -impl Display for FlatChainstoreError { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self { - Self::Io(e) => write!(f, "FlatChainStore I/O Error: {e:?}"), - Self::HeaderNotFound => write!( - f, - "The requested block header was not found in the FlatChainStore" - ), - Self::FullIndex => write!( - f, - "Failed to add a block to the FlatChainStore due to a full index" - ), - Self::OversizedIndex => write!(f, "Attempted to create an index larger than 31 bits"), - Self::UnsupportedSchema(schema_version) => write!( - f, - "Attempted to open the FlatChainStore database using an unsupported schema with version={}", - schema_version - ), - Self::PoisonedLock => write!(f, "The FlatChainStore's cache lock is poisoned"), - Self::BadMagic(magic) => write!(f, "The FlatChainStore has bad magic={}", magic), - Self::OversizedAccumulator => write!( - f, - "The FlatChainStore's accumulator is larger than the maximum value of {}", - MAX_ACCUMULATOR_SIZE - ), - Self::InvalidMetadataPointer => write!(f, "The FlatChainStore has invalid metadata"), - Self::CorruptedDatabase => write!(f, "The FlatChainStore is corrupted"), - Self::InvalidValidationIndex => { - write!(f, "The FlatChainStore has an invalid validation index") - } - } - } -} - -impl error::Error for FlatChainstoreError {} - -/// Need this to use [FlatChainstoreError] as a [DatabaseError] in [ChainStore] -impl DatabaseError for FlatChainstoreError {} - -impl_error_from!(FlatChainstoreError, std::io::Error, Io); - -impl From>> for FlatChainstoreError { - fn from(_: PoisonError>) -> Self { - FlatChainstoreError::PoisonedLock - } -} - /// A hash map implementation that maps block hashes to u32 indexes. Indexes are stored scattered /// across the memory-mapped file and accessed via `hash_map_find_pos`. We keep track of how many /// buckets are occupied in the metadata file (so we can re-hash the map when needed). @@ -472,7 +329,7 @@ impl BlockIndex { /// /// If we have enough changes that we don't want to lose, we should flush the index map to disk. /// This makes sure the indexes are persisted, and we can recover them in case of a crash. - fn flush(&self) -> Result<(), FlatChainstoreError> { + fn flush(&self) -> Result<(), ChainstoreError> { self.index_map.flush()?; Ok(()) @@ -487,13 +344,13 @@ impl BlockIndex { &self, hash: BlockHash, index: Index, - get_header_by_index: impl Fn(Index) -> Result, - ) -> Result { + get_header_by_index: impl Fn(Index) -> Result, + ) -> Result { let pos = unsafe { self.hash_map_find_pos(hash, get_header_by_index) }?; match pos { IndexBucket::Empty { ptr } => { - unsafe { ptr.write(index) } + unsafe { ptr.write(index) }; Ok(true) } @@ -501,7 +358,7 @@ impl BlockIndex { // If this is the case, we should update the fork block to make it into the main chain, // and mark the old main chain block as a fork. IndexBucket::Occupied { ptr, .. } => { - unsafe { ptr.write(index) } + unsafe { ptr.write(index) }; Ok(false) } } @@ -511,8 +368,8 @@ impl BlockIndex { unsafe fn get_index_for_hash( &self, hash: BlockHash, - get_header_by_index: impl Fn(Index) -> Result, - ) -> Result, FlatChainstoreError> { + get_header_by_index: impl Fn(Index) -> Result, + ) -> Result, ChainstoreError> { match unsafe { self.hash_map_find_pos(hash, get_header_by_index) }? { IndexBucket::Empty { .. } => Ok(None), IndexBucket::Occupied { ptr, header } => Ok(Some((unsafe { *ptr }, header))), @@ -529,8 +386,8 @@ impl BlockIndex { unsafe fn hash_map_find_pos( &self, block_hash: BlockHash, - get_header_by_index: impl Fn(Index) -> Result, - ) -> Result { + get_header_by_index: impl Fn(Index) -> Result, + ) -> Result { let mut hash = Self::index_hash_fn(block_hash) as usize; // Retrieve the base pointer to the start of the memory-mapped index @@ -547,17 +404,24 @@ impl BlockIndex { // If this is the first time we've accessed this pointer, this candidate index is 0 let candidate_index = unsafe { *entry_ptr }; - // If the header at `candidate_index` matches `block_hash`, this is the target bucket - let file_header = get_header_by_index(candidate_index)?; - if file_header.hash == block_hash { - return Ok(IndexBucket::Occupied { - ptr: entry_ptr, - header: file_header.header, - }); + // if the header at `candidate_index` matches `block_hash`, this is the target bucket + let file_header = match get_header_by_index(candidate_index) { + Ok(file_header) => Some(file_header), + Err(ChainstoreError::HeaderNotFound) if candidate_index.is_empty() => None, + Err(e) => return Err(e), + }; + if let Some(file_header) = file_header { + if file_header.hash == block_hash { + return Ok(IndexBucket::Occupied { + ptr: entry_ptr, + header: file_header.header, + }); + } } // If we find an empty index, this bucket is where the entry would be added - // Note: The genesis block doesn't reach this point, as its header hash is matched + // for candidate index 0, we only get here when the slot does not match the + // searched hash (including the genesis mismatch case) if candidate_index.is_empty() { return Ok(IndexBucket::Empty { ptr: entry_ptr }); } @@ -567,7 +431,7 @@ impl BlockIndex { } // If we reach here, it means the index is full. We should re-hash the map - Err(FlatChainstoreError::FullIndex) + Err(InternalError::full_index()) } /// The (short) hash function we use to compute where in the map a given index should be @@ -625,11 +489,75 @@ pub struct FlatChainStore { cache: Mutex>, } +#[derive(Clone)] +/// Configuration for our flat chain store, see each field for more information +pub struct FlatChainStoreConfig { + /// The index map size, in buckets + /// + /// This index holds our map from block hashes to block heights. We use an open-addressing hash + /// map to map block hashes to block heights. Ideally, size should be way bigger than the + /// number of blocks we expect to have in our chain, therefore reducing the load factor to a + /// negligible value. The default value is having space for 10 million blocks + /// + /// We compute the actual capacity by rounding the requested size up to the next power of two, + /// so we can use `hash & (capacity - 1)` instead of `hash % capacity` + pub block_index_size: Option, + + /// The size of the headers file map, in headers + /// + /// This is the size of the flat file that holds all of our block headers. We keep all headers + /// in a simple flat file, one after the other. that file then gets mapped into RAM, so we can + /// use pointer arithmetic to find specific block, since pos(h) = h * size_of(DiskBlockHeader) + /// The default value is having space for 10 million blocks + /// + /// We compute the actual capacity by rounding the requested size up to the next power of two + pub headers_file_size: Option, + + /// The size of the cache, in blocks + /// + /// We keep a LRU cache of the last n blocks we've touched. this is to avoid going into the + /// map every time we need to find a block. the default value is 1000 blocks + pub cache_size: Option, + + /// The permission for all the files we create + /// + /// This is the permission we give to all the files we create. The default value is 0o660 + pub file_permission: Option, + + /// The size of the fork headers file map, in headers + /// + /// This store keeps headers that are not in our main chain, but may be needed sometime, the + /// default value is having space for 10,000 blocks + /// + /// We compute the actual capacity by rounding the requested size up to the next power of two + pub fork_file_size: Option, + + /// The path where we store our files + /// + /// We'll create a few files (namely, the index map, headers file, forks file, and metadata file) + /// We need a directory where we can read and write, it needs at least 880 MiB of free space + /// And have a file system that supports mmap and sparse files (all the default *unix FS do) + pub path: PathBuf, +} + +impl FlatChainStoreConfig { + pub fn new(path: impl AsRef) -> Self { + FlatChainStoreConfig { + file_permission: Some(0o666), + fork_file_size: Some(10_000), + path: path.as_ref().into(), + headers_file_size: Some(10_000_000), + block_index_size: Some(10_000_000), + cache_size: Some(10_000), + } + } +} + impl FlatChainStore { /// Creates a new storage, given a configuration /// /// If any of the I/O operations fail, this function should return an error - fn create_chain_store(config: FlatChainStoreConfig) -> Result { + fn create_chain_store(config: FlatChainStoreConfig) -> Result { let file_mode = config.file_permission.unwrap_or(0o600); let datadir: &Path = config.path.as_ref(); @@ -693,6 +621,11 @@ impl FlatChainStore { fork_headers_checksum: FileChecksum(0), }; + // this is INVARIANT: 1000 is a non-zero compile-time constant + #[allow( + clippy::expect_used, + reason = "compile-time constant is always non-zero" + )] let cache_size = config.cache_size.and_then(NonZeroUsize::new).unwrap_or( NonZeroUsize::new(1000).expect("Infallible: Hard-coded default is always non-zero"), ); @@ -715,8 +648,8 @@ impl FlatChainStore { } /// Opens a new storage. If it already exists, just load. If not, create a new one - pub fn new(config: FlatChainStoreConfig) -> Result { - let datadir = &config.path; + pub fn new(config: FlatChainStoreConfig) -> Result { + let datadir: &Path = config.path.as_ref(); let metadata_path = datadir.join("metadata.bin"); let file_mode = config.file_permission.unwrap_or(0o600); @@ -740,16 +673,16 @@ impl FlatChainStore { let metadata = unsafe { metadata .as_ref() - .ok_or(FlatChainstoreError::InvalidMetadataPointer)? + .ok_or_else(InternalError::invalid_metadata_pointer)? }; // check the magic number and version if metadata.version > FLAT_CHAINSTORE_VERSION { - return Err(FlatChainstoreError::UnsupportedSchema(metadata.version)); + return Err(InternalError::unsupported_schema(metadata.version)); } if metadata.magic != FLAT_CHAINSTORE_MAGIC { - return Err(FlatChainstoreError::BadMagic(metadata.magic)); + return Err(InternalError::bad_magic(metadata.magic)); } let index_path = datadir.join("blocks_index.bin"); @@ -764,6 +697,11 @@ impl FlatChainStore { let index_map = unsafe { Self::init_file(&index_path, index_file_size, file_mode)? }; let headers = unsafe { Self::init_file(&headers_file_path, headers_file_size, file_mode)? }; let fork_headers = unsafe { Self::init_file(&fork_file_path, fork_file_size, file_mode)? }; + // this is INVARIANT: 1000 is a non-zero compile-time constant + #[allow( + clippy::expect_used, + reason = "compile-time constant is always non-zero" + )] let cache_size = config.cache_size.and_then(NonZeroUsize::new).unwrap_or( NonZeroUsize::new(1000).expect("Infallible: Hard-coded default is always non-zero"), ); @@ -793,16 +731,17 @@ impl FlatChainStore { &mut self, hash: BlockHash, index: Index, - ) -> Result<(), FlatChainstoreError> { + ) -> Result<(), ChainstoreError> { let metadata = unsafe { self.get_metadata() }?; let next_occupancy = metadata.block_index_occupancy + 1; if next_occupancy >= metadata.index_capacity { - return Err(FlatChainstoreError::FullIndex); + return Err(InternalError::full_index()); } let is_new = unsafe { self.block_index - .set_index_for_hash(hash, index, |index| self.get_disk_header(index).copied()) - }?; + .set_index_for_hash(hash, index, |index| self.get_disk_header(index).copied())? + }; + // Only increment the index occupancy if this is a new entry, i.e., a new block. Otherwise, // if this is a reorg, the occupancy is kept the same as we just overwrite indexes. if is_new { @@ -821,12 +760,12 @@ impl FlatChainStore { /// enough for random errors in a file. /// /// [xxHash]: https://github.com/Cyan4973/xxHash - fn check_integrity(&self) -> Result<(), FlatChainstoreError> { + fn check_integrity(&self) -> Result<(), ChainstoreError> { let computed_checksum = self.compute_checksum(); let metadata = unsafe { self.get_metadata()? }; if metadata.checksum != computed_checksum { - return Err(FlatChainstoreError::CorruptedDatabase); + return Err(ChainstoreError::CorruptedDatabase); } Ok(()) @@ -876,7 +815,7 @@ impl FlatChainStore { path: impl AsRef, size: usize, _mode: u32, - ) -> Result { + ) -> Result { let file = OpenOptions::new() // Set read and write access .read(true) @@ -895,17 +834,12 @@ impl FlatChainStore { file.set_len(size as u64)?; // Return the `MmapMut` instance that represents the file - let mmap = unsafe { MmapOptions::default().len(size).map_mut(&file) }?; - - Ok(mmap) + Ok(unsafe { MmapOptions::default().len(size).map_mut(&file) }?) } /// Returns a reference to the respective disk header from the file. Errors if nothing is found. - unsafe fn get_disk_header( - &self, - index: Index, - ) -> Result<&HashedDiskHeader, FlatChainstoreError> { - let metadata = unsafe { self.get_metadata()? }; + unsafe fn get_disk_header(&self, index: Index) -> Result<&HashedDiskHeader, ChainstoreError> { + let metadata = unsafe { self.get_metadata() }?; let (max_size, base_ptr) = match index.is_main_chain() { true => (metadata.headers_file_size, self.headers.as_ptr()), false => (metadata.fork_file_size, self.fork_headers.as_ptr()), @@ -913,7 +847,7 @@ impl FlatChainStore { let index = index.index() as usize; if index >= max_size { - return Err(FlatChainstoreError::FullIndex); + return Err(InternalError::capacity_exceeded(index, max_size)); } // SAFETY: we've checked index < max_size @@ -922,7 +856,7 @@ impl FlatChainStore { // Uninitialized memory means we haven't written anything here yet if header.hash == BlockHash::all_zeros() { - return Err(FlatChainstoreError::HeaderNotFound); + return Err(ChainstoreError::HeaderNotFound); } Ok(header) @@ -933,8 +867,8 @@ impl FlatChainStore { unsafe fn get_disk_header_mut( &mut self, index: Index, - ) -> Result<&mut HashedDiskHeader, FlatChainstoreError> { - let metadata = unsafe { self.get_metadata()? }; + ) -> Result<&mut HashedDiskHeader, ChainstoreError> { + let metadata = unsafe { self.get_metadata() }?; let (max_size, base_ptr) = match index.is_main_chain() { true => (metadata.headers_file_size, self.headers.as_ptr()), false => (metadata.fork_file_size, self.fork_headers.as_ptr()), @@ -942,7 +876,7 @@ impl FlatChainStore { let index = index.index() as usize; if index >= max_size { - return Err(FlatChainstoreError::FullIndex); + return Err(InternalError::capacity_exceeded(index, max_size)); } // SAFETY: we've checked index < max_size @@ -951,14 +885,18 @@ impl FlatChainStore { Ok(unsafe { &mut *ptr }) } - unsafe fn do_save_height(&mut self, best_block: &BestChain) -> Result<(), FlatChainstoreError> { + unsafe fn do_save_height(&mut self, best_block: &BestChain) -> Result<(), ChainstoreError> { let metadata = unsafe { self.get_metadata_mut() }?; metadata.best_block = best_block.best_block; metadata.depth = best_block.depth; metadata.validation_index = best_block.validation_index; - assert!(best_block.alternative_tips.len() <= 64); + if best_block.alternative_tips.len() > 64 { + return Err(InternalError::too_many_alternative_tips( + best_block.alternative_tips.len(), + )); + } unsafe { metadata @@ -973,8 +911,8 @@ impl FlatChainStore { Ok(()) } - unsafe fn get_best_chain(&self) -> Result { - let metadata = unsafe { self.get_metadata()? }; + unsafe fn get_best_chain(&self) -> Result { + let metadata = unsafe { self.get_metadata() }?; Ok(BestChain { best_block: metadata.best_block, @@ -994,22 +932,37 @@ impl FlatChainStore { unsafe fn get_header_by_hash( &self, hash: BlockHash, - ) -> Result, FlatChainstoreError> { + ) -> Result, ChainstoreError> { let result = unsafe { self.block_index - .get_index_for_hash(hash, |height| self.get_disk_header(height).copied()) - }? + .get_index_for_hash(hash, |height| self.get_disk_header(height).copied())? + } .map(|idx_and_header| idx_and_header.1); + Ok(result) } - unsafe fn get_metadata(&self) -> Result<&Metadata, FlatChainstoreError> { + unsafe fn get_metadata(&self) -> Result<&Metadata, ChainstoreError> { let ptr = self.metadata.as_ptr() as *const Metadata; + + // this is INVARIANT: `self.metadata` is a valid `MmapMut` created during construction; + // `MmapMut::as_ptr()` always returns a non-null pointer for a valid mapping + #[allow( + clippy::expect_used, + reason = "MmapMut::as_ptr() is non-null for valid mappings" + )] Ok(unsafe { ptr.as_ref() }.expect("Infallible: we already validated this pointer")) } - unsafe fn get_metadata_mut(&mut self) -> Result<&mut Metadata, FlatChainstoreError> { + unsafe fn get_metadata_mut(&mut self) -> Result<&mut Metadata, ChainstoreError> { let ptr = self.metadata.as_ptr() as *mut Metadata; + + // this is INVARIANT: `self.metadata` is a valid `MmapMut` created during construction; + // `MmapMut::as_ptr()` always returns a non-null pointer for a valid mapping + #[allow( + clippy::expect_used, + reason = "MmapMut::as_ptr() is non-null for valid mappings" + )] Ok(unsafe { ptr.as_mut() }.expect("Infallible: we already validated this pointer")) } @@ -1020,11 +973,12 @@ impl FlatChainStore { unsafe fn write_header_to_storage( &mut self, header: DiskBlockHeader, - ) -> Result<(), FlatChainstoreError> { + ) -> Result<(), ChainstoreError> { let height = header .try_height() - .expect("Infallible: this function is only called for best chain blocks"); + .map_err(|e| ChainstoreError::Internal(Box::new(e)))?; let index = Index::new(height)?; + let pos = unsafe { self.get_disk_header_mut(index) }?; *pos = HashedDiskHeader { header, @@ -1073,12 +1027,10 @@ impl FlatChainStore { /// We will write 3 in a new position, and it should work fine. However, we now have a stale 3 /// that points to the main chain position where it originally was. This will never be used /// again, but will occupy a position in the index. Increasing the load factor for no reason. - unsafe fn save_fork_block( - &mut self, - header: DiskBlockHeader, - ) -> Result<(), FlatChainstoreError> { + unsafe fn save_fork_block(&mut self, header: DiskBlockHeader) -> Result<(), ChainstoreError> { let fork_blocks = unsafe { self.get_metadata() }?.fork_count; let index = Index::new_fork(fork_blocks)?; + let pos = unsafe { self.get_disk_header_mut(index) }?; let block_hash = header.block_hash(); @@ -1097,7 +1049,7 @@ impl FlatChainStore { Ok(()) } - unsafe fn do_flush(&mut self) -> Result<(), FlatChainstoreError> { + unsafe fn do_flush(&mut self) -> Result<(), ChainstoreError> { self.headers.flush()?; self.block_index.flush()?; self.fork_headers.flush()?; @@ -1121,7 +1073,7 @@ impl FlatChainStore { } impl ChainStore for FlatChainStore { - type Error = FlatChainstoreError; + type Error = ChainstoreError; fn check_integrity(&self) -> Result<(), Self::Error> { self.check_integrity() @@ -1139,7 +1091,7 @@ impl ChainStore for FlatChainStore { .get_header(&metadata.validation_index)? .map(|h| { h.try_height() - .map_err(|_| FlatChainstoreError::InvalidValidationIndex) + .map_err(|_no_height| ChainstoreError::InvalidValidationIndex) }) .transpose()? .unwrap_or(0); @@ -1151,22 +1103,20 @@ impl ChainStore for FlatChainStore { // this is where the new acc starts, truncating the file to this position let pos = header.acc_pos as u64; - self.accumulator_file - .set_len(pos) - .map_err(FlatChainstoreError::Io)?; + self.accumulator_file.set_len(pos)?; } let pos = self.accumulator_file.seek(SeekFrom::End(0))?; let size = roots.len(); if size > MAX_ACCUMULATOR_SIZE { - return Err(FlatChainstoreError::OversizedAccumulator); + return Err(ChainstoreError::OversizedAccumulator); } let header = unsafe { self.get_disk_header_mut(index)? }; // Only write to this header if we actually have it in our store if header.hash == BlockHash::all_zeros() { - return Err(FlatChainstoreError::HeaderNotFound); + return Err(ChainstoreError::HeaderNotFound); } header.acc_pos = pos as u32; @@ -1221,7 +1171,7 @@ impl ChainStore for FlatChainStore { unsafe { match self.get_disk_header(index) { Ok(header) => Ok(Some(header.header)), - Err(FlatChainstoreError::HeaderNotFound) => Ok(None), + Err(ChainstoreError::HeaderNotFound) => Ok(None), Err(e) => Err(e), } } @@ -1260,7 +1210,7 @@ impl ChainStore for FlatChainStore { unsafe { match self.get_disk_header(index) { Ok(header) => Ok(Some(header.hash)), - Err(FlatChainstoreError::HeaderNotFound) => Ok(None), + Err(ChainstoreError::HeaderNotFound) => Ok(None), Err(e) => Err(e), } } @@ -1328,7 +1278,7 @@ pub mod migrate_v0_to_v1 { pub fn maybe_migrate( metadata_path: impl AsRef, mode: u32, - ) -> Result { + ) -> Result { let metadata_path = metadata_path.as_ref(); match fs::metadata(metadata_path) { @@ -1342,7 +1292,7 @@ pub mod migrate_v0_to_v1 { } // 1) back up - let metadata_backup_path = Path::new(metadata_path).with_extension("bin.old"); + let metadata_backup_path = metadata_path.with_extension("bin.old"); fs::rename(metadata_path, &metadata_backup_path)?; // 2) read old struct @@ -1362,7 +1312,7 @@ pub mod migrate_v0_to_v1 { } /// Initialize a read-only mmap - pub(super) fn init_mmap(file_path: &Path, size: usize) -> Result { + pub(super) fn init_mmap(file_path: &Path, size: usize) -> Result { let file = OpenOptions::new().read(true).open(file_path)?; let mmap = unsafe { MmapOptions::new().len(size).map(&file)? }; Ok(mmap) @@ -1370,6 +1320,7 @@ pub mod migrate_v0_to_v1 { } #[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use core::mem::size_of; use std::fs; @@ -1386,12 +1337,13 @@ mod tests { use tempfile::TempDir; use twox_hash::XxHash3_64; + use super::ChainstoreError; use super::FLAT_CHAINSTORE_MAGIC; use super::FLAT_CHAINSTORE_VERSION; use super::FlatChainStore; use super::FlatChainStoreConfig; - use super::FlatChainstoreError; use super::Index; + use super::MAX_ACCUMULATOR_SIZE; use crate::AssumeValidArg; use crate::BestChain; use crate::ChainState; @@ -1401,6 +1353,7 @@ mod tests { use crate::migrate_v0_to_v1::init_mmap; use crate::migrate_v0_to_v1::maybe_migrate; use crate::pruned_utreexo::UpdatableChainstate; + use crate::pruned_utreexo::chainstore::InternalError; use crate::pruned_utreexo::flat_chain_store::FileChecksum; use crate::pruned_utreexo::flat_chain_store::Metadata; @@ -1433,7 +1386,7 @@ mod tests { ); } - fn get_test_chainstore(id: Option) -> Result { + fn get_test_chainstore(id: Option) -> Result { let test_id = id.unwrap_or_else(rand::random::); let config = FlatChainStoreConfig { @@ -1482,13 +1435,17 @@ mod tests { let store_id = tweak_version_and_magic(version, FLAT_CHAINSTORE_MAGIC); match get_test_chainstore(Some(store_id)) { - Err(FlatChainstoreError::UnsupportedSchema(v)) if v == version => {} - Err(e) => panic!( - "Should have failed with `FlatChainstoreError::DbTooNew({version})`, instead we got {e:?}" - ), - Ok(_) => panic!( - "Should have failed with `FlatChainstoreError::DbTooNew({version})`, instead we got `Ok`" - ), + Err(ChainstoreError::Internal(e)) => { + let internal = e.downcast_ref::(); + assert!( + matches!(internal, Some(InternalError::UnsupportedSchema(v)) if *v == version), + "Expected UnsupportedSchema" + ); + } + Err(e) => { + panic!("Should have failed with `UnsupportedSchema`, instead we got {e:?}") + } + Ok(_) => panic!("Should have failed with `UnsupportedSchema`, instead we got `Ok`"), } } @@ -1500,13 +1457,15 @@ mod tests { let store_id = tweak_version_and_magic(FLAT_CHAINSTORE_VERSION, magic); match get_test_chainstore(Some(store_id)) { - Err(FlatChainstoreError::BadMagic(m)) if m == magic => {} - Err(e) => panic!( - "Should have failed with `FlatChainstoreError::InvalidMagic({magic})`, instead we got {e:?}" - ), - Ok(_) => panic!( - "Should have failed with `FlatChainstoreError::InvalidMagic({magic})`, instead we got `Ok`" - ), + Err(ChainstoreError::Internal(e)) => { + let internal = e.downcast_ref::(); + assert!( + matches!(internal, Some(InternalError::BadMagic(m)) if *m == magic), + "Expected BadMagic" + ); + } + Err(e) => panic!("Should have failed with `BadMagic`, instead we got {e:?}"), + Ok(_) => panic!("Should have failed with `BadMagic`, instead we got `Ok`"), } } } @@ -1647,46 +1606,68 @@ mod tests { // Test that the inner header-fetching function returns the proper error for mainnet indices unsafe { match store.get_disk_header(Index::new(151).unwrap()) { - Err(FlatChainstoreError::HeaderNotFound) => (), + Err(ChainstoreError::HeaderNotFound) => (), Err(e) => panic!("Unexpected err: {e:?}"), Ok(val) => panic!("Should not have found a header at height 151: {val:?}"), } // Last available position match store.get_disk_header(Index::new(32_767).unwrap()) { - Err(FlatChainstoreError::HeaderNotFound) => (), + Err(ChainstoreError::HeaderNotFound) => (), Err(e) => panic!("Unexpected err: {e:?}"), Ok(val) => panic!("Should not have found a header at height 32767: {val:?}"), } - // Exceeds header file capacity - match store.get_disk_header(Index::new(32_768).unwrap()) { - Err(FlatChainstoreError::FullIndex) => (), - Err(e) => panic!("Unexpected err: {e:?}"), - Ok(val) => { - panic!("Should not have found a header exceeding file capacity: {val:?}") - } + // exceeds header file capacity, must be CapacityExceeded, not a silent wildcard + let err = store + .get_disk_header(Index::new(32_768).unwrap()) + .unwrap_err(); + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::CapacityExceeded { + index: 32768, + max_size: 32768 + }) + ), + "Expected CapacityExceeded for header index exceeding file capacity" + ); + } else { + panic!( + "Expected ChainstoreError::Internal for header index exceeding file capacity" + ); } } // Test that the inner header-fetching function returns the proper error for fork indices unsafe { match store.get_disk_header(Index::new_fork(0).unwrap()) { - Err(FlatChainstoreError::HeaderNotFound) => (), + Err(ChainstoreError::HeaderNotFound) => (), Err(e) => panic!("Unexpected err: {e:?}"), Ok(val) => panic!("Should not have found any fork header: {val:?}"), } // Last available position match store.get_disk_header(Index::new_fork(16_383).unwrap()) { - Err(FlatChainstoreError::HeaderNotFound) => (), + Err(ChainstoreError::HeaderNotFound) => (), Err(e) => panic!("Unexpected err: {e:?}"), Ok(val) => panic!("Should not have found any fork header: {val:?}"), } - // Exceeds fork file capacity - match store.get_disk_header(Index::new_fork(16_384).unwrap()) { - Err(FlatChainstoreError::FullIndex) => (), - Err(e) => panic!("Unexpected err: {e:?}"), - Ok(val) => { - panic!("Should not have found a header exceeding file capacity: {val:?}") - } + // exceeds fork file capacity, must be CapacityExceeded, not a silent wildcard + let err = store + .get_disk_header(Index::new_fork(16_384).unwrap()) + .unwrap_err(); + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::CapacityExceeded { + index: 16384, + max_size: 16384 + }) + ), + "Expected CapacityExceeded for fork index exceeding file capacity" + ); + } else { + panic!("Expected ChainstoreError::Internal for fork index exceeding file capacity"); } } } @@ -1846,9 +1827,192 @@ mod tests { let result = store.save_roots_for_block(acc.clone(), 10); match result { - Err(FlatChainstoreError::HeaderNotFound) => (), + Err(ChainstoreError::HeaderNotFound) => (), Err(e) => panic!("Unexpected err: {e:?}"), Ok(_) => panic!("Should not have been able to save roots for a block we don't have"), } } + + #[test] + fn propagates_header_not_found() { + let store = get_test_chainstore(None).unwrap(); + let missing = BlockHash::from_byte_array([0xAB; 32]); + let err = store.get_header(&missing).unwrap(); + // get_header returns Ok(None) for missing headers + assert!(err.is_none()); + + // get_header_by_height returns Ok(None) for unwritten heights + let err = store.get_header_by_height(9999).unwrap(); + assert!(err.is_none()); + + // get_disk_header returns HeaderNotFound for unwritten-but-in-range indices + let result = unsafe { store.get_disk_header(Index::new(1).unwrap()) }; + let err = result.unwrap_err(); + assert!(matches!(err, ChainstoreError::HeaderNotFound)); + } + + #[test] + fn propagates_oversized_accumulator() { + let mut store = get_test_chainstore(None).unwrap(); + + // try to save the genesis block so we have a header at height 0 + let genesis = genesis_block(Network::Regtest); + store + .save_header(&DiskBlockHeader::FullyValid(genesis.header, 0)) + .unwrap(); + store.update_block_index(0, genesis.block_hash()).unwrap(); + store + .save_height(&BestChain { + best_block: genesis.block_hash(), + depth: 0, + validation_index: genesis.block_hash(), + alternative_tips: vec![], + }) + .unwrap(); + + // try to save an accumulator that exceeds the maximum size + let oversized = vec![0u8; MAX_ACCUMULATOR_SIZE + 1]; + let err = store.save_roots_for_block(oversized, 0).unwrap_err(); + assert!(matches!(err, ChainstoreError::OversizedAccumulator)); + } + + #[test] + fn propagates_corrupted_database() { + let test_id = rand::random::(); + let mut store = get_test_chainstore(Some(test_id)).unwrap(); + store.flush().unwrap(); + + // corrupt the database by tampering with the checksum + let metadata = unsafe { store.get_metadata_mut().unwrap() }; + metadata.checksum.headers_checksum = FileChecksum(0xDEAD_BEEF); + + let err = store.check_integrity().unwrap_err(); + assert!(matches!(err, ChainstoreError::CorruptedDatabase)); + } + + #[test] + fn propagates_internal_from_io() { + use core::error::Error; + + let err = ChainstoreError::from(std::io::Error::new( + std::io::ErrorKind::NotFound, + "test io error", + )); + assert!(matches!(err, ChainstoreError::Internal(_))); + assert!(err.source().is_some()); + } + + #[test] + fn propagates_internal_from_bad_magic() { + let test_id = rand::random::(); + let mut store = get_test_chainstore(Some(test_id)).unwrap(); + + let metadata = unsafe { store.get_metadata_mut().unwrap() }; + metadata.magic = 0xBAD0_FACE; + store.flush().unwrap(); + + let err = match get_test_chainstore(Some(test_id)) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::BadMagic(0xBAD0_FACE)) + ), + "Expected specific BadMagic internal error" + ); + } else { + panic!("Expected ChainstoreError::Internal error"); + } + } + + #[test] + fn propagates_internal_from_unsupported_schema() { + let test_id = rand::random::(); + let mut store = get_test_chainstore(Some(test_id)).unwrap(); + + let metadata = unsafe { store.get_metadata_mut().unwrap() }; + metadata.version = FLAT_CHAINSTORE_VERSION + 99; + store.flush().unwrap(); + + let err = match get_test_chainstore(Some(test_id)) { + Err(e) => e, + Ok(_) => panic!("expected error"), + }; + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::UnsupportedSchema(_)) + ), + "Expected specific UnsupportedSchema internal error" + ); + } else { + panic!("Expected ChainstoreError::Internal error"); + } + } + + #[test] + fn propagates_internal_from_oversized_index() { + // index values >= 0x8000_0000 are out of bounds for 31-bit indexes + let err = Index::new(0x8000_0000).unwrap_err(); + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::OversizedIndex(0x8000_0000)) + ), + "Expected specific OversizedIndex internal error" + ); + } else { + panic!("Expected ChainstoreError::Internal error"); + } + } + + #[test] + fn propagates_internal_from_capacity_exceeded() { + let store = get_test_chainstore(None).unwrap(); + + // try to access an index beyond the header file capacity (32_768) + let result = unsafe { store.get_disk_header(Index::new(32_768).unwrap()) }; + let err = result.unwrap_err(); + if let ChainstoreError::Internal(e) = err { + assert!( + matches!( + e.downcast_ref::(), + Some(InternalError::CapacityExceeded { + index: 32768, + max_size: 32768 + }) + ), + "Expected specific CapacityExceeded internal error" + ); + } else { + panic!("Expected ChainstoreError::Internal error"); + } + } + + #[test] + fn propagates_invalid_validation_index() { + let mut store = get_test_chainstore(None).unwrap(); + let genesis = genesis_block(Network::Regtest); + + // save an orphan header, then point validation_index to it, orphan headers + // don't have a stored height, so save_roots_for_block must surface + // InvalidValidationIndex instead of HeaderNotFound + let mut orphan_header = genesis.header; + orphan_header.nonce = orphan_header.nonce.wrapping_add(1); + let orphan_hash = orphan_header.block_hash(); + store + .save_header(&DiskBlockHeader::Orphan(orphan_header)) + .unwrap(); + + let metadata = unsafe { store.get_metadata_mut().unwrap() }; + metadata.validation_index = orphan_hash; + + let err = store.save_roots_for_block(vec![0u8], 0).unwrap_err(); + assert!(matches!(err, ChainstoreError::InvalidValidationIndex)); + } }