//! Blob storage behind the three file drives (spec ยง7.4). The local //! filesystem implementation serves dev and the air-gapped target; an //! S3-compatible implementation slots in behind the same trait for cloud. mod s3; pub use s3::S3BlobStore; use std::path::{Component, Path, PathBuf}; #[derive(Debug, thiserror::Error)] pub enum BlobError { #[error("blob not found")] NotFound, #[error("invalid blob key: {0}")] InvalidKey(String), #[error("storage io: {0}")] Io(String), } #[async_trait::async_trait] pub trait BlobStore: Send + Sync { async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), BlobError>; async fn get(&self, key: &str) -> Result, BlobError>; async fn delete(&self, key: &str) -> Result<(), BlobError>; } /// Filesystem-backed store rooted at a data directory. pub struct LocalBlobStore { root: PathBuf, } impl LocalBlobStore { pub fn new(root: PathBuf) -> LocalBlobStore { LocalBlobStore { root } } /// Resolves a key strictly below the root: rejects absolute paths and /// any `..`/`.` components so keys can never escape the data dir. fn resolve(&self, key: &str) -> Result { let path = Path::new(key); if path.is_absolute() || key.is_empty() { return Err(BlobError::InvalidKey(key.to_owned())); } for component in path.components() { match component { Component::Normal(_) => {} _ => return Err(BlobError::InvalidKey(key.to_owned())), } } Ok(self.root.join(path)) } } #[async_trait::async_trait] impl BlobStore for LocalBlobStore { async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), BlobError> { let path = self.resolve(key)?; if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| BlobError::Io(e.to_string()))?; } tokio::fs::write(&path, bytes) .await .map_err(|e| BlobError::Io(e.to_string())) } async fn get(&self, key: &str) -> Result, BlobError> { let path = self.resolve(key)?; match tokio::fs::read(&path).await { Ok(bytes) => Ok(bytes), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(BlobError::NotFound), Err(e) => Err(BlobError::Io(e.to_string())), } } async fn delete(&self, key: &str) -> Result<(), BlobError> { let path = self.resolve(key)?; match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(BlobError::NotFound), Err(e) => Err(BlobError::Io(e.to_string())), } } }