Skip to main content

backbone_bucket/storage/
local.rs

1//! Filesystem-backed [`ObjectStorage`] implementation.
2//!
3//! Intended for development, tests, and single-node deployments. Objects
4//! are stored as files under `root`; presigned URLs are module-signed
5//! HMAC-SHA256 tokens that the serving handler validates in-process.
6//!
7//! # Presigned URL format
8//!
9//! ```text
10//! {base_url}/{key}?expires={unix_ts}&sig={hex}
11//! ```
12//!
13//! `sig = hex(hmac_sha256(secret, format!("{method}\n{key}\n{expires}")))`
14//!
15//! where `method` is `"GET"` or `"PUT"`. This is NOT compatible with S3
16//! clients — it only works when the serving handler in this module fronts
17//! the storage, which is exactly what mode-B does in dev.
18
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use async_trait::async_trait;
23use bytes::Bytes;
24use chrono::{DateTime, Utc};
25use hmac::{Hmac, Mac};
26use sha2::Sha256;
27use tokio::fs;
28use tokio::io::AsyncWriteExt;
29use url::Url;
30
31use crate::error::{BucketError, BucketResult};
32use crate::storage::{ObjectMeta, ObjectStorage};
33
34type HmacSha256 = Hmac<Sha256>;
35
36/// Filesystem-backed object storage.
37#[derive(Debug, Clone)]
38pub struct LocalStorage {
39    root: PathBuf,
40    /// Base URL used to build presigned URLs. Typically the URL at which
41    /// the serving handler is mounted (e.g. `http://localhost:8080/cdn`).
42    base_url: Url,
43    /// Shared secret used to sign module-issued URLs.
44    signing_secret: Vec<u8>,
45}
46
47impl LocalStorage {
48    /// Build a new local-filesystem backend.
49    pub fn new(
50        root: impl Into<PathBuf>,
51        base_url: Url,
52        signing_secret: impl Into<Vec<u8>>,
53    ) -> Self {
54        Self {
55            root: root.into(),
56            base_url,
57            signing_secret: signing_secret.into(),
58        }
59    }
60
61    /// Resolve `key` to a filesystem path, rejecting traversal attempts.
62    fn resolve(&self, key: &str) -> BucketResult<PathBuf> {
63        if key.starts_with('/') || key.split('/').any(|seg| seg == "..") {
64            return Err(BucketError::Other(format!("invalid key: {key}")));
65        }
66        Ok(self.root.join(key))
67    }
68
69    /// Verify a token produced by [`sign`](Self::sign_token).
70    ///
71    /// Returns `Ok(())` if `sig` matches and `expires` is in the future.
72    pub fn verify_token(
73        &self,
74        method: &str,
75        key: &str,
76        expires: i64,
77        sig: &str,
78    ) -> BucketResult<()> {
79        if expires < Utc::now().timestamp() {
80            return Err(BucketError::InvalidSignature);
81        }
82        let expected = self.sign_token(method, key, expires);
83        // constant-time compare
84        if expected.len() != sig.len()
85            || expected
86                .as_bytes()
87                .iter()
88                .zip(sig.as_bytes())
89                .fold(0u8, |acc, (a, b)| acc | (a ^ b))
90                != 0
91        {
92            return Err(BucketError::InvalidSignature);
93        }
94        Ok(())
95    }
96
97    fn sign_token(&self, method: &str, key: &str, expires: i64) -> String {
98        let mut mac = HmacSha256::new_from_slice(&self.signing_secret)
99            .expect("HMAC accepts any key length");
100        mac.update(method.as_bytes());
101        mac.update(b"\n");
102        mac.update(key.as_bytes());
103        mac.update(b"\n");
104        mac.update(expires.to_string().as_bytes());
105        hex::encode(mac.finalize().into_bytes())
106    }
107
108    fn build_signed_url(
109        &self,
110        method: &str,
111        key: &str,
112        ttl: Duration,
113    ) -> BucketResult<Url> {
114        let expires = (Utc::now() + chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::hours(1)))
115            .timestamp();
116        let sig = self.sign_token(method, key, expires);
117        let mut url = self
118            .base_url
119            .join(key)
120            .map_err(|e| BucketError::Url(e.to_string()))?;
121        url.query_pairs_mut()
122            .append_pair("expires", &expires.to_string())
123            .append_pair("sig", &sig);
124        Ok(url)
125    }
126}
127
128#[async_trait]
129impl ObjectStorage for LocalStorage {
130    async fn put(&self, key: &str, body: Bytes, _content_type: &str) -> BucketResult<()> {
131        let path = self.resolve(key)?;
132        if let Some(parent) = path.parent() {
133            fs::create_dir_all(parent).await?;
134        }
135        let mut file = fs::File::create(&path).await?;
136        file.write_all(&body).await?;
137        file.flush().await?;
138        Ok(())
139    }
140
141    async fn get(&self, key: &str) -> BucketResult<Bytes> {
142        let path = self.resolve(key)?;
143        match fs::read(&path).await {
144            Ok(bytes) => Ok(Bytes::from(bytes)),
145            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(BucketError::NotFound),
146            Err(e) => Err(e.into()),
147        }
148    }
149
150    async fn delete(&self, key: &str) -> BucketResult<()> {
151        let path = self.resolve(key)?;
152        match fs::remove_file(&path).await {
153            Ok(()) => Ok(()),
154            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
155            Err(e) => Err(e.into()),
156        }
157    }
158
159    async fn head(&self, key: &str) -> BucketResult<ObjectMeta> {
160        let path = self.resolve(key)?;
161        let meta = match fs::metadata(&path).await {
162            Ok(m) => m,
163            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(BucketError::NotFound),
164            Err(e) => return Err(e.into()),
165        };
166        let last_modified: Option<DateTime<Utc>> = meta
167            .modified()
168            .ok()
169            .map(|t| DateTime::<Utc>::from(t));
170        Ok(ObjectMeta {
171            key: key.to_string(),
172            size: meta.len(),
173            content_type: None,
174            etag: None,
175            last_modified,
176        })
177    }
178
179    async fn presigned_get(&self, key: &str, ttl: Duration) -> BucketResult<Url> {
180        self.build_signed_url("GET", key, ttl)
181    }
182
183    async fn presigned_put(
184        &self,
185        key: &str,
186        ttl: Duration,
187        _content_type: &str,
188    ) -> BucketResult<Url> {
189        self.build_signed_url("PUT", key, ttl)
190    }
191
192    fn public_url(&self, _key: &str) -> Option<Url> {
193        None
194    }
195}
196
197/// Path helper used by tests and the serving handler when LocalStorage is active.
198#[doc(hidden)]
199pub fn local_object_path(root: &Path, key: &str) -> PathBuf {
200    root.join(key)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn make() -> (tempfile::TempDir, LocalStorage) {
208        let dir = tempfile::tempdir().unwrap();
209        let base = Url::parse("http://localhost:8080/cdn/").unwrap();
210        let storage = LocalStorage::new(dir.path(), base, b"test-secret".to_vec());
211        (dir, storage)
212    }
213
214    #[tokio::test]
215    async fn roundtrip() {
216        let (_dir, storage) = make();
217        storage
218            .put("foo/bar.txt", Bytes::from_static(b"hi"), "text/plain")
219            .await
220            .unwrap();
221        let got = storage.get("foo/bar.txt").await.unwrap();
222        assert_eq!(&got[..], b"hi");
223
224        let meta = storage.head("foo/bar.txt").await.unwrap();
225        assert_eq!(meta.size, 2);
226
227        storage.delete("foo/bar.txt").await.unwrap();
228        assert!(matches!(
229            storage.get("foo/bar.txt").await,
230            Err(BucketError::NotFound)
231        ));
232    }
233
234    #[tokio::test]
235    async fn signed_url_roundtrip() {
236        let (_dir, storage) = make();
237        let url = storage
238            .presigned_get("a/b.txt", Duration::from_secs(60))
239            .await
240            .unwrap();
241        let query: std::collections::HashMap<_, _> =
242            url.query_pairs().into_owned().collect();
243        let expires: i64 = query["expires"].parse().unwrap();
244        storage
245            .verify_token("GET", "a/b.txt", expires, &query["sig"])
246            .unwrap();
247    }
248
249    #[test]
250    fn signed_url_rejects_expired() {
251        let (_dir, storage) = make();
252        let past = (Utc::now() - chrono::Duration::minutes(1)).timestamp();
253        let sig = storage.sign_token("GET", "a/b.txt", past);
254        assert!(matches!(
255            storage.verify_token("GET", "a/b.txt", past, &sig),
256            Err(BucketError::InvalidSignature)
257        ));
258    }
259
260    #[test]
261    fn rejects_path_traversal() {
262        let (_dir, storage) = make();
263        assert!(storage.resolve("../secrets").is_err());
264        assert!(storage.resolve("foo/../bar").is_err());
265        assert!(storage.resolve("/etc/passwd").is_err());
266    }
267
268    #[test]
269    fn allows_dotdot_inside_filename() {
270        let (_dir, storage) = make();
271        // `..` inside a segment is a legitimate filename, not traversal.
272        assert!(storage.resolve("reports/report..v2.pdf").is_ok());
273    }
274}