Skip to main content

adminx_attachments/
blobstore.rs

1// adminx-storage/src/blobstore.rs
2//
3// Where the bytes live. `BlobStore` is the swap point: the MVP ships a local
4// filesystem store, and an S3 / object-store backend can be added later behind a
5// feature without touching the attachment logic or adminx-core.
6
7use adminx_core::storage::StorageError;
8use async_trait::async_trait;
9use std::path::{Component, Path, PathBuf};
10
11/// A content-addressed byte store. Keys are opaque strings the caller records in
12/// the metadata table; the store only has to round-trip bytes under a key.
13#[async_trait]
14pub trait BlobStore: Send + Sync {
15    async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError>;
16    async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError>;
17    /// Remove a key. Absence is success — deleting twice must not error.
18    async fn delete(&self, key: &str) -> Result<(), StorageError>;
19}
20
21/// Stores blobs as files under a root directory.
22pub struct LocalFsStore {
23    root: PathBuf,
24}
25
26impl LocalFsStore {
27    /// Create a store rooted at `root` (created on first write if missing).
28    pub fn new(root: impl Into<PathBuf>) -> Self {
29        Self { root: root.into() }
30    }
31
32    /// Resolve a key to a path inside `root`, refusing anything that would climb
33    /// out. Keys we mint are safe by construction, but this is the boundary
34    /// where a key becomes a filesystem path, so it validates rather than trusts.
35    fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
36        let rel = Path::new(key);
37        // Reject absolute paths and any `..` / root components: the key must
38        // stay within the store root.
39        for comp in rel.components() {
40            match comp {
41                Component::Normal(_) => {}
42                _ => {
43                    return Err(StorageError::Backend(format!(
44                        "unsafe blob key: {key:?}"
45                    )))
46                }
47            }
48        }
49        Ok(self.root.join(rel))
50    }
51}
52
53#[async_trait]
54impl BlobStore for LocalFsStore {
55    async fn put(&self, key: &str, bytes: &[u8]) -> Result<(), StorageError> {
56        let path = self.path_for(key)?;
57        if let Some(dir) = path.parent() {
58            tokio::fs::create_dir_all(dir)
59                .await
60                .map_err(|e| StorageError::Backend(format!("create blob dir: {e}")))?;
61        }
62        tokio::fs::write(&path, bytes)
63            .await
64            .map_err(|e| StorageError::Backend(format!("write blob: {e}")))
65    }
66
67    async fn get(&self, key: &str) -> Result<Vec<u8>, StorageError> {
68        let path = self.path_for(key)?;
69        tokio::fs::read(&path).await.map_err(|e| {
70            if e.kind() == std::io::ErrorKind::NotFound {
71                StorageError::NotFound
72            } else {
73                StorageError::Backend(format!("read blob: {e}"))
74            }
75        })
76    }
77
78    async fn delete(&self, key: &str) -> Result<(), StorageError> {
79        let path = self.path_for(key)?;
80        match tokio::fs::remove_file(&path).await {
81            Ok(()) => Ok(()),
82            // Already gone is success — delete is idempotent.
83            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
84            Err(e) => Err(StorageError::Backend(format!("delete blob: {e}"))),
85        }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn keys_that_escape_the_root_are_refused() {
95        let store = LocalFsStore::new("/var/lib/adminx-blobs");
96        assert!(store.path_for("../../etc/passwd").is_err());
97        assert!(store.path_for("/etc/passwd").is_err());
98        assert!(store.path_for("a/../../b").is_err());
99    }
100
101    #[test]
102    fn a_normal_nested_key_resolves_under_root() {
103        let store = LocalFsStore::new("/var/lib/adminx-blobs");
104        let p = store.path_for("posts/1/avatar/abc123").unwrap();
105        assert!(p.starts_with("/var/lib/adminx-blobs"));
106        assert!(p.ends_with("posts/1/avatar/abc123"));
107    }
108}