use super::{Header, Layout, hold::Hold, resolve_header, sync_dir};
use crate::{BlobVersion, BufferPool, Error};
use commonware_formatting::{from_hex, hex};
use std::{
fs,
io::{Seek as _, SeekFrom, Write as _},
ops::RangeInclusive,
path::PathBuf,
sync::Arc,
};
use tokio::sync::Mutex;
mod blob;
#[derive(Clone)]
pub struct Config {
pub storage_directory: PathBuf,
pub blob_layouts: RangeInclusive<Layout>,
}
impl Config {
pub const fn new(storage_directory: PathBuf, blob_layouts: RangeInclusive<Layout>) -> Self {
Self {
storage_directory,
blob_layouts,
}
}
}
#[derive(Clone)]
pub struct Storage {
lock: Arc<Mutex<()>>,
cfg: Config,
pool: BufferPool,
hold: Arc<Hold>,
}
impl Storage {
pub fn new(cfg: Config, pool: BufferPool) -> Self {
let hold = Hold::acquire(&cfg.storage_directory).unwrap_or_else(|e| {
panic!(
"failed to acquire storage directory hold ({}): {e}",
cfg.storage_directory.display()
)
});
Self {
lock: Arc::new(Mutex::new(())),
cfg,
pool,
hold,
}
}
async fn dispatch<T: Send + 'static>(
&self,
f: impl FnOnce() -> Result<T, Error> + Send + 'static,
) -> Result<T, Error> {
let guard = self.lock.clone().lock_owned().await;
let hold = self.hold.clone();
let task = tokio::task::spawn_blocking(move || {
let _hold = hold;
let _guard = guard;
f()
});
match task.await {
Ok(result) => result,
Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()),
Err(_) => Err(Error::Closed),
}
}
}
impl crate::Storage for Storage {
type Blob = blob::Blob;
async fn open_versioned(
&self,
partition: &str,
name: &[u8],
versions: RangeInclusive<BlobVersion>,
) -> Result<(Self::Blob, u64, BlobVersion), Error> {
super::validate_partition_name(partition)?;
let path = self.cfg.storage_directory.join(partition).join(hex(name));
let storage_directory = self.cfg.storage_directory.clone();
let partition = partition.to_string();
let name = name.to_vec();
let blob_layouts = self.cfg.blob_layouts.clone();
let pool = self.pool.clone();
let hold = self.hold.clone();
self.dispatch(move || {
let parent = match path.parent() {
Some(parent) => parent,
None => return Err(Error::PartitionCreationFailed(partition)),
};
fs::create_dir_all(parent)
.map_err(|_| Error::PartitionCreationFailed(partition.clone()))?;
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|e| Error::BlobOpenFailed(partition.clone(), hex(&name), e.into()))?;
let raw_len = file.metadata().map_err(|_| Error::ReadFailed)?.len();
let existing = resolve_header(
&mut file,
raw_len,
&blob_layouts,
&versions,
&partition,
&name,
)?;
let (logical_size, blob_version, data_offset) = match existing {
Some(resolved) => resolved,
None => {
sync_dir(parent)?;
sync_dir(&storage_directory)?;
let (region, blob_version) = Header::create(&blob_layouts, &versions);
let data_offset = region.len() as u64;
file.set_len(0).map_err(|e| {
Error::BlobResizeFailed(partition.clone(), hex(&name), e.into())
})?;
file.seek(SeekFrom::Start(0))
.map_err(|_| Error::WriteFailed)?;
file.write_all(®ion).map_err(|_| Error::WriteFailed)?;
file.sync_all().map_err(|e| {
Error::BlobSyncFailed(partition.clone(), hex(&name), e.into())
})?;
(0, blob_version, data_offset)
}
};
let blob = Self::Blob::new(partition, &name, file, pool, data_offset, hold);
Ok((blob, logical_size, blob_version))
})
.await
}
async fn remove(&self, partition: &str, name: Option<&[u8]>) -> Result<(), Error> {
super::validate_partition_name(partition)?;
let path = self.cfg.storage_directory.join(partition);
let storage_directory = self.cfg.storage_directory.clone();
let partition = partition.to_string();
let name = name.map(<[u8]>::to_vec);
self.dispatch(move || {
if let Some(name) = name {
let blob_path = path.join(hex(&name));
fs::remove_file(blob_path)
.map_err(|_| Error::BlobMissing(partition, hex(&name)))?;
sync_dir(&path)?;
} else {
fs::remove_dir_all(&path).map_err(|_| Error::PartitionMissing(partition))?;
sync_dir(&storage_directory)?;
}
Ok(())
})
.await
}
async fn scan(&self, partition: &str) -> Result<Vec<Vec<u8>>, Error> {
super::validate_partition_name(partition)?;
let path = self.cfg.storage_directory.join(partition);
let partition = partition.to_string();
self.dispatch(move || {
let entries =
fs::read_dir(path).map_err(|_| Error::PartitionMissing(partition.clone()))?;
let mut blobs = Vec::new();
for entry in entries {
let entry = entry.map_err(|_| Error::ReadFailed)?;
let file_type = entry.file_type().map_err(|_| Error::ReadFailed)?;
if !file_type.is_file() {
return Err(Error::PartitionCorrupt(partition));
}
if let Some(name) = entry.file_name().to_str() {
let decoded =
from_hex(name).ok_or_else(|| Error::PartitionCorrupt(partition.clone()))?;
if hex(&decoded) != name {
return Err(Error::PartitionCorrupt(partition));
}
blobs.push(decoded);
}
}
Ok(blobs)
})
.await
}
}
#[cfg(test)]
#[allow(deprecated)]
mod tests {
use super::{Header, *};
use crate::{
Blob, BufferPoolConfig, ReadOptions, Storage as _, WriteOptions,
storage::{Layout, tests::run_storage_tests},
telemetry::metrics::Registry,
};
use commonware_utils::sys_rng;
use futures::FutureExt as _;
use rand::RngExt as _;
use std::{env, sync::mpsc::RecvTimeoutError};
fn test_pool() -> BufferPool {
let mut registry = Registry::default();
BufferPool::new(BufferPoolConfig::for_storage(), &mut registry)
}
fn random_suffix() -> u64 {
let mut rng = sys_rng();
rng.random()
}
#[tokio::test]
async fn test_hold_waits_for_straggling_remove() {
let storage_directory =
env::temp_dir().join(format!("storage_tokio_hold_remove_{}", random_suffix()));
let config = Config::new(storage_directory.clone(), Layout::ALL);
let storage = Storage::new(config.clone(), test_pool());
let partition_path = storage_directory.join("partition");
std::fs::create_dir_all(&partition_path).unwrap();
for i in 0..5_000u64 {
std::fs::write(partition_path.join(hex(&i.to_be_bytes())), b"x").unwrap();
}
{
let mut remove = Box::pin(storage.remove("partition", None));
assert!(
(&mut remove).now_or_never().is_none(),
"removal completed before it could straggle"
);
}
drop(storage);
let storage = Storage::new(config, test_pool());
let result = storage.scan("partition").await;
assert!(
matches!(result, Err(Error::PartitionMissing(_))),
"{result:?}"
);
drop(storage);
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_hold_retained_by_open_blob() {
let storage_directory =
env::temp_dir().join(format!("storage_tokio_hold_blob_{}", random_suffix()));
let config = Config::new(storage_directory.clone(), Layout::ALL);
let storage = Storage::new(config.clone(), test_pool());
let (blob, _) = storage.open("partition", b"blob").await.unwrap();
drop(storage);
let config_second = config.clone();
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let second = Storage::new(config_second, test_pool());
tx.send(()).unwrap();
drop(second);
});
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
Err(RecvTimeoutError::Timeout) => {}
other => panic!("second instance did not stay blocked on the hold: {other:?}"),
}
drop(blob);
rx.recv_timeout(std::time::Duration::from_secs(10))
.expect("second instance did not acquire the hold after the blob dropped");
handle.join().unwrap();
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[test]
fn test_hold_blocks_second_instance() {
let storage_directory =
env::temp_dir().join(format!("storage_tokio_hold_block_{}", random_suffix()));
let config = Config::new(storage_directory.clone(), Layout::ALL);
let first = Storage::new(config.clone(), test_pool());
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let second = Storage::new(config, test_pool());
tx.send(()).unwrap();
drop(second);
});
match rx.recv_timeout(std::time::Duration::from_millis(200)) {
Err(RecvTimeoutError::Timeout) => {}
other => panic!("second instance did not stay blocked on the hold: {other:?}"),
}
drop(first);
rx.recv_timeout(std::time::Duration::from_secs(10))
.expect("second instance did not acquire the hold after the first released it");
handle.join().unwrap();
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_storage() {
let mut rng = sys_rng();
let storage_directory =
env::temp_dir().join(format!("storage_tokio_{}", rng.random::<u64>()));
let config = Config::new(storage_directory, Layout::ALL);
let storage = Storage::new(config, test_pool());
run_storage_tests(storage).await;
}
#[tokio::test]
async fn test_start_sync_dropped_receiver() {
let mut rng = sys_rng();
let storage_directory =
env::temp_dir().join(format!("storage_tokio_start_sync_{}", rng.random::<u64>()));
let config = Config::new(storage_directory, Layout::ALL);
let storage = Storage::new(config, test_pool());
let (blob, _) = storage.open("partition", b"test_blob").await.unwrap();
blob.write_at(0, b"hello world", WriteOptions::default())
.await
.unwrap();
drop(blob.start_sync().await);
blob.start_sync().await.await.unwrap();
drop(blob);
let (blob, len) = storage.open("partition", b"test_blob").await.unwrap();
assert_eq!(len, 11);
let read = blob
.read_at(0, 11, ReadOptions::default())
.await
.unwrap()
.coalesce();
assert_eq!(read.as_ref(), b"hello world");
}
#[tokio::test]
async fn test_blob_header_handling() {
let mut rng = sys_rng();
let storage_directory =
env::temp_dir().join(format!("storage_tokio_header_{}", rng.random::<u64>()));
let config = Config::new(storage_directory.clone(), Layout::ALL);
let storage = Storage::new(config, test_pool());
let (blob, size) = storage.open("partition", b"test").await.unwrap();
assert_eq!(size, 0, "new blob should have logical size 0");
let data_offset = Layout::V1.data_offset();
let file_path = storage_directory.join("partition").join(hex(b"test"));
let metadata = std::fs::metadata(&file_path).unwrap();
assert_eq!(
metadata.len(),
data_offset,
"raw file should have a full header page"
);
let data = b"hello world";
blob.write_at(0, data, WriteOptions::default())
.await
.unwrap();
blob.sync().await.unwrap();
let metadata = std::fs::metadata(&file_path).unwrap();
assert_eq!(metadata.len(), data_offset + data.len() as u64);
let raw_content = std::fs::read(&file_path).unwrap();
assert_eq!(&raw_content[..Header::MAGIC_LENGTH], &Layout::V1.magic());
assert_eq!(
&raw_content[4..6],
&Layout::V1.layout_version().to_be_bytes()
);
assert_eq!(&raw_content[data_offset as usize..], data);
let read_buf = blob
.read_at(0, data.len(), ReadOptions::default())
.await
.unwrap();
assert_eq!(read_buf.coalesce(), data);
blob.resize(5).await.unwrap();
blob.sync().await.unwrap();
let metadata = std::fs::metadata(&file_path).unwrap();
assert_eq!(
metadata.len(),
data_offset + 5,
"resize(5) should leave 5 raw bytes past the header page"
);
blob.resize(0).await.unwrap();
blob.sync().await.unwrap();
let metadata = std::fs::metadata(&file_path).unwrap();
assert_eq!(
metadata.len(),
data_offset,
"resize(0) should leave only the header page"
);
blob.write_at(0, b"test data", WriteOptions::default())
.await
.unwrap();
blob.sync().await.unwrap();
drop(blob);
let (blob2, size2) = storage.open("partition", b"test").await.unwrap();
assert_eq!(size2, 9, "reopened blob should have logical size 9");
let read_buf = blob2.read_at(0, 9, ReadOptions::default()).await.unwrap();
assert_eq!(read_buf.coalesce(), b"test data");
drop(blob2);
let corrupted_path = storage_directory.join("partition").join(hex(b"corrupted"));
std::fs::write(&corrupted_path, vec![0u8; 4]).unwrap();
let (blob3, size3) = storage.open("partition", b"corrupted").await.unwrap();
assert_eq!(size3, 0, "corrupted blob should return logical size 0");
let metadata = std::fs::metadata(&corrupted_path).unwrap();
assert_eq!(
metadata.len(),
Layout::V1.data_offset(),
"corrupted blob should be reset to header-only"
);
drop(blob3);
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_v1_paged_alignment() {
let storage_directory =
env::temp_dir().join(format!("storage_tokio_aligned_{}", random_suffix()));
let config = Config::new(storage_directory.clone(), Layout::ALL);
let storage = Storage::new(config, test_pool());
const PHYSICAL_PAGE_SIZE: u64 = 4096;
let logical = crate::buffer::paged::page_size(PHYSICAL_PAGE_SIZE as u32);
let cache = crate::buffer::paged::CacheRef::new(
test_pool(),
logical,
std::num::NonZeroUsize::new(16).unwrap(),
);
let (blob, size) = storage.open("partition", b"aligned").await.unwrap();
let mut writer = crate::buffer::paged::Writer::new(blob, size, 1024, cache)
.await
.unwrap();
let item: Vec<u8> = (0..1000u32).flat_map(|i| i.to_be_bytes()).collect();
for _ in 0..12 {
writer.append(&item).await.unwrap();
}
let logical_size = writer.size();
writer.sync().await.unwrap();
let file_path = storage_directory.join("partition").join(hex(b"aligned"));
let raw = std::fs::read(&file_path).unwrap();
let pages = (logical_size as usize).div_ceil(logical.get() as usize);
assert_eq!(raw.len() as u64 % PHYSICAL_PAGE_SIZE, 0);
assert_eq!(
raw.len() as u64,
Layout::V1.data_offset() + pages as u64 * PHYSICAL_PAGE_SIZE
);
for page in 0..pages {
let start = Layout::V1.data_offset() as usize + page * PHYSICAL_PAGE_SIZE as usize;
let physical = &raw[start..start + PHYSICAL_PAGE_SIZE as usize];
assert!(
crate::buffer::paged::validate_page_for_tests(physical),
"page {page} failed CRC validation at aligned boundary"
);
}
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_blob_torn_creation_recovers() {
let storage_directory =
env::temp_dir().join(format!("test_torn_creation_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let (blob, _) = storage.open("partition", b"torn").await.unwrap();
blob.sync().await.unwrap();
drop(blob);
let path = storage_directory.join("partition").join(hex(b"torn"));
let region = std::fs::read(&path).unwrap();
let mut torn_content = vec![0u8; region.len()];
torn_content[..10].copy_from_slice(®ion[..10]);
let states = [region[..10].to_vec(), torn_content];
for state in states {
std::fs::write(&path, &state).unwrap();
let (blob, size) = storage.open("partition", b"torn").await.unwrap();
assert_eq!(size, 0);
blob.write_at(0, b"data".to_vec(), WriteOptions::default())
.await
.unwrap();
blob.sync().await.unwrap();
drop(blob);
let (blob, size) = storage.open("partition", b"torn").await.unwrap();
assert_eq!(size, 4);
let read = blob.read_at(0, 4, ReadOptions::default()).await.unwrap();
assert_eq!(read.coalesce(), b"data");
drop(blob);
}
let mut corrupt = vec![0u8; region.len()];
corrupt[..10].copy_from_slice(®ion[..10]);
corrupt[100] = 0xFF;
std::fs::write(&path, &corrupt).unwrap();
let result = storage.open("partition", b"torn").await;
assert!(matches!(result, Err(crate::Error::BlobCorrupt(_, _, _))));
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_open_dropped_mid_creation() {
use futures::FutureExt;
use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
};
struct DropAfter<F: Future + Unpin> {
inner: Option<F>,
remaining: usize,
}
impl<F: Future + Unpin> Future for DropAfter<F> {
type Output = Option<F::Output>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
self.inner = None;
return Poll::Ready(None);
}
self.remaining -= 1;
match self.inner.as_mut().unwrap().poll_unpin(cx) {
Poll::Ready(output) => Poll::Ready(Some(output)),
Poll::Pending => Poll::Pending,
}
}
}
let storage_directory =
env::temp_dir().join(format!("test_dropped_open_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
for depth in 0..64 {
let name = format!("blob{depth}");
let name = name.as_bytes();
let dropped = DropAfter {
inner: Some(Box::pin(storage.open("partition", name))),
remaining: depth,
}
.await;
let completed = dropped.is_some();
drop(dropped);
let (blob, size) = storage.open("partition", name).await.unwrap();
assert_eq!(size, 0);
blob.write_at(0, b"data".to_vec(), WriteOptions::default())
.await
.unwrap();
blob.sync().await.unwrap();
drop(blob);
let (blob, size) = storage.open("partition", name).await.unwrap();
assert_eq!(size, 4);
let read = blob.read_at(0, 4, ReadOptions::default()).await.unwrap();
assert_eq!(read.coalesce(), b"data");
drop(blob);
if completed {
let _ = std::fs::remove_dir_all(&storage_directory);
return;
}
}
panic!("open never completed within the poll budget");
}
#[tokio::test]
async fn test_blob_v1_rejects_nonzero_header_padding() {
let storage_directory =
env::temp_dir().join(format!("test_v1_header_padding_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let partition_dir = storage_directory.join("partition");
std::fs::create_dir_all(&partition_dir).unwrap();
let path = partition_dir.join(hex(b"dirty_padding"));
let mut raw = crate::storage::header::tests::v1_blob_bytes(0, b"payload");
raw[Header::PARSE_LEN] = 0xFF;
std::fs::write(&path, raw).unwrap();
let result = storage.open("partition", b"dirty_padding").await;
assert!(
matches!(result, Err(crate::Error::BlobCorrupt(_, _, reason)) if reason.contains("header padding"))
);
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_blob_v0_legacy_read() {
let storage_directory =
env::temp_dir().join(format!("test_v0_legacy_read_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let payload = b"hello world";
let partition_dir = storage_directory.join("partition");
std::fs::create_dir_all(&partition_dir).unwrap();
let file_path = partition_dir.join(hex(b"v0"));
std::fs::write(&file_path, crate::storage::tests::v0_blob_bytes(0, payload)).unwrap();
let (blob, size) = storage.open("partition", b"v0").await.unwrap();
assert_eq!(size, payload.len() as u64);
assert_eq!(
blob.read_at(0, payload.len(), ReadOptions::default())
.await
.unwrap()
.coalesce(),
payload
);
blob.write_at(size, b"!".to_vec(), WriteOptions::default())
.await
.unwrap();
blob.sync().await.unwrap();
drop(blob);
let raw_content = std::fs::read(&file_path).unwrap();
assert_eq!(raw_content.len(), Header::PRELUDE_SIZE + payload.len() + 1);
assert_eq!(&raw_content[..Header::MAGIC_LENGTH], &Layout::V0.magic());
assert_eq!(&raw_content[Header::PRELUDE_SIZE..], b"hello world!");
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_blob_magic_mismatch() {
let storage_directory =
env::temp_dir().join(format!("test_magic_mismatch_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let partition_path = storage_directory.join("partition");
std::fs::create_dir_all(&partition_path).unwrap();
let bad_magic_path = partition_path.join(hex(b"bad_magic"));
std::fs::write(&bad_magic_path, b"XXXXXXXX").unwrap();
let result = storage.open("partition", b"bad_magic").await;
assert!(
matches!(result, Err(crate::Error::BlobCorrupt(_, _, reason)) if reason.contains("invalid magic"))
);
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_blob_partial_header_reset() {
let storage_directory =
env::temp_dir().join(format!("test_partial_header_reset_{}", random_suffix()));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let partition_path = storage_directory.join("partition");
std::fs::create_dir_all(&partition_path).unwrap();
for prefix_len in 0..Header::PRELUDE_SIZE {
let name = format!("short_{prefix_len}");
let path = partition_path.join(hex(name.as_bytes()));
std::fs::write(&path, vec![0u8; prefix_len]).unwrap();
let (blob, size) = storage
.open("partition", name.as_bytes())
.await
.expect("interrupted create should recover, not fail");
assert_eq!(size, 0, "recovered blob should be empty");
drop(blob);
let raw = std::fs::read(&path).unwrap();
assert_eq!(
raw.len(),
Layout::V1.data_offset() as usize,
"recovered blob should be header-only"
);
assert_eq!(&raw[..Header::MAGIC_LENGTH], &Layout::V1.magic());
storage
.open("partition", name.as_bytes())
.await
.expect("reopen after recovery should succeed");
}
let _ = std::fs::remove_dir_all(&storage_directory);
}
#[tokio::test]
async fn test_scan_rejects_non_canonical_hex_file_names() {
for bad_name in ["0x626c6f62", "0X626C6F62", " 626c6f62", "626C6F62"] {
let storage_directory = env::temp_dir().join(format!(
"test_scan_non_canonical_{}_{}",
bad_name.replace([' ', '0', 'x', 'X'], "_"),
random_suffix()
));
let storage = Storage::new(
Config::new(storage_directory.clone(), Layout::ALL),
test_pool(),
);
let partition_path = storage_directory.join("partition");
std::fs::create_dir_all(&partition_path).unwrap();
std::fs::write(partition_path.join(bad_name), []).unwrap();
let err = match storage.scan("partition").await {
Ok(_) => panic!("scan should have failed for filename {bad_name:?}"),
Err(err) => err,
};
assert_eq!(
err.to_string(),
"partition corrupt: partition",
"filename {bad_name:?} should be rejected as corrupt",
);
let _ = std::fs::remove_dir_all(&storage_directory);
}
}
}