use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentMeta, AttachmentRef};
use super::{
AttachmentStore, AttachmentStoreError, AttachmentStorePersistence, StoredAttachment,
StoredBlobRef, content_id,
};
static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct FileAttachmentStore {
root: PathBuf,
}
impl FileAttachmentStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
fn content_root(&self) -> PathBuf {
self.root.join("sha256")
}
fn path_for_id(&self, id: &AttachmentId) -> PathBuf {
let id = id.as_str();
let prefix = id.get(..2).unwrap_or(id);
self.content_root().join(prefix).join(id)
}
}
fn write_atomic(final_path: &Path, bytes: &[u8]) -> Result<(), AttachmentStoreError> {
let counter = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut staging_name = final_path
.file_name()
.map(|name| name.to_os_string())
.unwrap_or_default();
staging_name.push(format!(".staging.{}.{counter}.tmp", std::process::id()));
let tmp_path = final_path
.parent()
.map(|parent| parent.join(&staging_name))
.unwrap_or_else(|| PathBuf::from(&staging_name));
let io_err = |path: &Path, source: std::io::Error| AttachmentStoreError::Io {
path: path.to_path_buf(),
source,
};
let write_result = (|| {
let mut file = fs::File::create(&tmp_path).map_err(|source| io_err(&tmp_path, source))?;
std::io::Write::write_all(&mut file, bytes).map_err(|source| io_err(&tmp_path, source))?;
file.sync_all()
.map_err(|source| io_err(&tmp_path, source))?;
fs::rename(&tmp_path, final_path).map_err(|source| io_err(final_path, source))?;
if let Some(parent) = final_path.parent() {
fsync_dir(parent);
}
Ok(())
})();
if write_result.is_err() {
let _ = fs::remove_file(&tmp_path);
}
write_result
}
fn fsync_dir(dir: &Path) {
if let Ok(handle) = fs::File::open(dir) {
let _ = handle.sync_all();
}
}
#[async_trait::async_trait]
impl AttachmentStore for FileAttachmentStore {
fn persistence(&self) -> AttachmentStorePersistence {
AttachmentStorePersistence::Durable
}
async fn put(
&self,
bytes: Vec<u8>,
meta: AttachmentCreateMeta,
) -> Result<AttachmentRef, AttachmentStoreError> {
let meta = AttachmentMeta::new(
content_id(&bytes),
meta.media_type,
bytes.len() as u64,
meta.width,
meta.height,
meta.label,
);
let path = self.path_for_id(&meta.id);
put_at_path(path, bytes, meta)
}
async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
get_at_path(self.path_for_id(id), id)
}
async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
delete_at_path(self.path_for_id(id))
}
async fn head(&self, id: &AttachmentId) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
head_at_path(self.path_for_id(id), id)
}
async fn list(&self) -> Result<Vec<StoredBlobRef>, AttachmentStoreError> {
let content_root = self.content_root();
let mut blobs = Vec::new();
let prefix_dirs = match fs::read_dir(&content_root) {
Ok(entries) => entries,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(blobs),
Err(source) => {
return Err(AttachmentStoreError::Io {
path: content_root,
source,
});
}
};
for prefix_dir in prefix_dirs {
let prefix_dir = prefix_dir.map_err(|source| AttachmentStoreError::Io {
path: content_root.clone(),
source,
})?;
if !prefix_dir
.file_type()
.map(|ty| ty.is_dir())
.unwrap_or(false)
{
continue;
}
let dir_path = prefix_dir.path();
for entry in fs::read_dir(&dir_path).map_err(|source| AttachmentStoreError::Io {
path: dir_path.clone(),
source,
})? {
let entry = entry.map_err(|source| AttachmentStoreError::Io {
path: dir_path.clone(),
source,
})?;
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if name.contains(".staging.") {
continue;
}
let last_modified_epoch_ms = entry
.metadata()
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|dur| dur.as_millis() as u64);
blobs.push(StoredBlobRef {
id: AttachmentId::new(name.to_string()),
last_modified_epoch_ms,
});
}
}
Ok(blobs)
}
}
fn put_at_path(
path: PathBuf,
bytes: Vec<u8>,
meta: AttachmentMeta,
) -> Result<AttachmentRef, AttachmentStoreError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| AttachmentStoreError::Io {
path: parent.to_path_buf(),
source,
})?;
}
if path.exists() {
refresh_mtime(&path, &bytes)?;
} else {
write_atomic(&path, &bytes)?;
}
Ok(meta.as_ref())
}
fn refresh_mtime(path: &Path, bytes: &[u8]) -> Result<(), AttachmentStoreError> {
match fs::OpenOptions::new().write(true).open(path) {
Ok(file) => {
if file.set_modified(std::time::SystemTime::now()).is_ok() {
return Ok(());
}
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
}
Err(source) => {
return Err(AttachmentStoreError::Io {
path: path.to_path_buf(),
source,
});
}
}
write_atomic(path, bytes)
}
fn head_at_path(
path: PathBuf,
id: &AttachmentId,
) -> Result<Option<StoredBlobRef>, AttachmentStoreError> {
match fs::metadata(&path) {
Ok(metadata) => {
let last_modified_epoch_ms = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|dur| dur.as_millis() as u64);
Ok(Some(StoredBlobRef {
id: id.clone(),
last_modified_epoch_ms,
}))
}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(AttachmentStoreError::Io { path, source }),
}
}
fn get_at_path(path: PathBuf, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
let bytes = fs::read(&path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
AttachmentStoreError::NotFound(id.clone())
} else {
AttachmentStoreError::Io {
path: path.clone(),
source,
}
}
})?;
Ok(StoredAttachment { bytes })
}
fn delete_at_path(path: PathBuf) -> Result<(), AttachmentStoreError> {
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(AttachmentStoreError::Io { path, source }),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ImageMediaType, MediaType};
use std::collections::BTreeSet;
use std::sync::Mutex;
fn meta() -> AttachmentCreateMeta {
AttachmentCreateMeta::new(
MediaType::Image(ImageMediaType::Png),
Some(1),
Some(1),
Some("pixel".to_string()),
)
}
#[tokio::test]
async fn file_store_round_trips_bytes_and_metadata() {
let temp = tempfile::tempdir().expect("tempdir");
let store = FileAttachmentStore::new(temp.path());
let reference = store.put(vec![1, 2, 3], meta()).await.expect("put");
let stored = store.get(&reference.id).await.expect("get");
assert_eq!(stored.bytes, vec![1, 2, 3]);
assert_eq!(reference.byte_len, 3);
}
#[tokio::test]
async fn file_store_writes_atomically_without_temp_litter() {
let temp = tempfile::tempdir().expect("tempdir");
let store = FileAttachmentStore::new(temp.path());
let reference = store.put(vec![9, 8, 7, 6], meta()).await.expect("put");
let final_path = store.path_for_id(&reference.id);
assert!(final_path.exists(), "content file must be in place");
let mut staging_files = Vec::new();
let dir = final_path.parent().expect("content dir");
for entry in fs::read_dir(dir).expect("read content dir") {
let path = entry.expect("dir entry").path();
if path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.contains(".staging."))
.unwrap_or(false)
{
staging_files.push(path);
}
}
assert!(
staging_files.is_empty(),
"atomic write must not leave staging files behind: {staging_files:?}"
);
let stored = store.get(&reference.id).await.expect("get");
assert_eq!(stored.bytes, vec![9, 8, 7, 6]);
}
#[tokio::test]
async fn file_store_ignores_stale_staging_file() {
let temp = tempfile::tempdir().expect("tempdir");
let store = FileAttachmentStore::new(temp.path());
let content_id = content_id(&[1, 1, 1]);
let id = AttachmentId::new(content_id.to_string());
let final_path = store.path_for_id(&id);
let parent = final_path.parent().expect("parent");
fs::create_dir_all(parent).expect("mkdir");
let mut stale = final_path.file_name().expect("name").to_os_string();
stale.push(".staging.999.0.tmp");
fs::write(parent.join(&stale), b"stale partial write").expect("seed stale staging");
let reference = store
.put(vec![1, 1, 1], meta())
.await
.expect("put over stale staging");
let stored = store.get(&reference.id).await.expect("get");
assert_eq!(stored.bytes, vec![1, 1, 1]);
let listed: Vec<AttachmentId> = store
.list()
.await
.expect("list")
.into_iter()
.map(|blob| blob.id)
.collect();
assert_eq!(listed, vec![reference.id]);
}
#[tokio::test]
async fn file_store_put_refreshes_mtime_on_dedup_hit() {
let temp = tempfile::tempdir().expect("tempdir");
let store = FileAttachmentStore::new(temp.path());
let reference = store.put(vec![2, 4, 6], meta()).await.expect("put");
let path = store.path_for_id(&reference.id);
let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("open blob")
.set_modified(old)
.expect("set old mtime");
let aged = store
.head(&reference.id)
.await
.expect("head")
.expect("blob present");
store.put(vec![2, 4, 6], meta()).await.expect("dedup put");
let refreshed = store
.head(&reference.id)
.await
.expect("head")
.expect("blob present");
assert!(
refreshed.last_modified_epoch_ms > aged.last_modified_epoch_ms,
"dedup-hit put must refresh mtime: {:?} !> {:?}",
refreshed.last_modified_epoch_ms,
aged.last_modified_epoch_ms
);
}
#[tokio::test]
async fn file_store_is_flat_content_addressed_across_writers() {
let temp = tempfile::tempdir().expect("tempdir");
let store = FileAttachmentStore::new(temp.path());
let first = store.put(vec![7, 7, 7], meta()).await.expect("put first");
let second = store.put(vec![7, 7, 7], meta()).await.expect("put second");
assert_eq!(first.id, second.id);
assert_eq!(store.path_for_id(&first.id), store.path_for_id(&second.id));
let listed: BTreeSet<AttachmentId> = store
.list()
.await
.expect("list")
.into_iter()
.map(|blob| blob.id)
.collect();
assert_eq!(listed.len(), 1);
assert!(listed.contains(&first.id));
}
#[tokio::test]
async fn file_attachment_store_satisfies_conformance() {
use std::sync::Arc;
use crate::testing::conformance::ReopenableAttachmentStore;
let dirs: Arc<Mutex<Vec<tempfile::TempDir>>> = Arc::new(Mutex::new(Vec::new()));
crate::testing::conformance::attachment_store_reopenable(
|| {
let dir = tempfile::tempdir().expect("tempdir");
let open =
Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
let reopen =
Arc::new(FileAttachmentStore::new(dir.path())) as Arc<dyn AttachmentStore>;
dirs.lock().expect("dirs lock").push(dir);
ReopenableAttachmentStore { open, reopen }
},
AttachmentStorePersistence::Durable,
)
.await;
}
}