adminx_attachments/
blobstore.rs1use adminx_core::storage::StorageError;
8use async_trait::async_trait;
9use std::path::{Component, Path, PathBuf};
10
11#[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 async fn delete(&self, key: &str) -> Result<(), StorageError>;
19}
20
21pub struct LocalFsStore {
23 root: PathBuf,
24}
25
26impl LocalFsStore {
27 pub fn new(root: impl Into<PathBuf>) -> Self {
29 Self { root: root.into() }
30 }
31
32 fn path_for(&self, key: &str) -> Result<PathBuf, StorageError> {
36 let rel = Path::new(key);
37 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 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}