use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use cratefield_core::{Blob, BlobError, BlobObject};
pub struct DirBlob {
base: PathBuf,
}
impl DirBlob {
#[must_use]
pub fn new(base: impl Into<PathBuf>) -> Self {
Self { base: base.into() }
}
#[must_use]
pub fn arc(base: impl Into<PathBuf>) -> Arc<dyn Blob> {
Arc::new(Self::new(base))
}
fn object_path(&self, key: &str) -> Result<PathBuf, BlobError> {
if key.is_empty() {
return Err(BlobError::BadKey("a blob key cannot be empty".to_owned()));
}
if key.starts_with('/') {
return Err(BlobError::BadKey(format!("key `{key}` must be relative")));
}
if key
.split('/')
.any(|segment| segment == ".." || segment == ".")
{
return Err(BlobError::BadKey(format!(
"key `{key}` must not contain `.` or `..` segments"
)));
}
Ok(self.base.join(key))
}
fn content_type_path(path: &std::path::Path) -> PathBuf {
PathBuf::from(format!("{}.__ct", path.display()))
}
fn io_err(err: &std::io::Error) -> BlobError {
BlobError::Operation(err.to_string())
}
}
#[async_trait]
impl Blob for DirBlob {
async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), BlobError> {
let path = self.object_path(key)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|err| Self::io_err(&err))?;
}
tokio::fs::write(&path, bytes)
.await
.map_err(|err| Self::io_err(&err))?;
tokio::fs::write(Self::content_type_path(&path), content_type.as_bytes())
.await
.map_err(|err| Self::io_err(&err))?;
Ok(())
}
async fn get(&self, key: &str) -> Result<Option<BlobObject>, BlobError> {
let path = self.object_path(key)?;
let bytes = match tokio::fs::read(&path).await {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(Self::io_err(&err)),
};
let content_type = tokio::fs::read_to_string(Self::content_type_path(&path))
.await
.unwrap_or_else(|_| "application/octet-stream".to_owned());
Ok(Some(BlobObject {
bytes,
content_type,
}))
}
async fn delete(&self, key: &str) -> Result<(), BlobError> {
let path = self.object_path(key)?;
for target in [path.clone(), Self::content_type_path(&path)] {
match tokio::fs::remove_file(&target).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(Self::io_err(&err)),
}
}
Ok(())
}
async fn signed_url(&self, _key: &str, _ttl: Duration) -> Result<String, BlobError> {
Err(BlobError::Unsupported(
"a directory store has no presigned URLs; serve the bytes through the harness \
or use an S3-compatible adapter"
.to_owned(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dir() -> PathBuf {
std::env::temp_dir().join(format!("cf-blob-{}", uuid_like()))
}
fn uuid_like() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
format!(
"{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
)
}
#[tokio::test]
async fn round_trips_bytes_and_content_type() {
let base = temp_dir();
let blob = DirBlob::new(&base);
blob.put("cms/hero.png", b"\x89PNG", "image/png")
.await
.unwrap();
let got = blob.get("cms/hero.png").await.unwrap().expect("present");
assert_eq!(got.bytes, b"\x89PNG");
assert_eq!(got.content_type, "image/png");
assert!(blob.get("cms/missing.png").await.unwrap().is_none());
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn delete_is_idempotent_and_removes_both_files() {
let base = temp_dir();
let blob = DirBlob::new(&base);
blob.put("a/b.txt", b"hi", "text/plain").await.unwrap();
blob.delete("a/b.txt").await.unwrap();
assert!(blob.get("a/b.txt").await.unwrap().is_none());
blob.delete("a/b.txt").await.unwrap();
let _ = std::fs::remove_dir_all(&base);
}
#[tokio::test]
async fn an_escaping_key_is_refused() {
let blob = DirBlob::new(temp_dir());
for bad in ["", "/etc/passwd", "../x", "a/../../b", "."] {
assert!(matches!(
blob.get(bad).await.unwrap_err(),
BlobError::BadKey(_)
));
}
}
}