1use 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#[derive(Debug, Clone)]
38pub struct LocalStorage {
39 root: PathBuf,
40 base_url: Url,
43 signing_secret: Vec<u8>,
45}
46
47impl LocalStorage {
48 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 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 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 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#[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 assert!(storage.resolve("reports/report..v2.pdf").is_ok());
273 }
274}