use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::fs;
use tokio::io::AsyncWriteExt;
use url::Url;
use crate::error::{BucketError, BucketResult};
use crate::storage::{ObjectMeta, ObjectStorage};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct LocalStorage {
root: PathBuf,
base_url: Url,
signing_secret: Vec<u8>,
}
impl LocalStorage {
pub fn new(
root: impl Into<PathBuf>,
base_url: Url,
signing_secret: impl Into<Vec<u8>>,
) -> Self {
Self {
root: root.into(),
base_url,
signing_secret: signing_secret.into(),
}
}
fn resolve(&self, key: &str) -> BucketResult<PathBuf> {
if key.starts_with('/') || key.split('/').any(|seg| seg == "..") {
return Err(BucketError::Other(format!("invalid key: {key}")));
}
Ok(self.root.join(key))
}
pub fn verify_token(
&self,
method: &str,
key: &str,
expires: i64,
sig: &str,
) -> BucketResult<()> {
if expires < Utc::now().timestamp() {
return Err(BucketError::InvalidSignature);
}
let expected = self.sign_token(method, key, expires);
if expected.len() != sig.len()
|| expected
.as_bytes()
.iter()
.zip(sig.as_bytes())
.fold(0u8, |acc, (a, b)| acc | (a ^ b))
!= 0
{
return Err(BucketError::InvalidSignature);
}
Ok(())
}
fn sign_token(&self, method: &str, key: &str, expires: i64) -> String {
let mut mac = HmacSha256::new_from_slice(&self.signing_secret)
.expect("HMAC accepts any key length");
mac.update(method.as_bytes());
mac.update(b"\n");
mac.update(key.as_bytes());
mac.update(b"\n");
mac.update(expires.to_string().as_bytes());
hex::encode(mac.finalize().into_bytes())
}
fn build_signed_url(
&self,
method: &str,
key: &str,
ttl: Duration,
) -> BucketResult<Url> {
let expires = (Utc::now() + chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::hours(1)))
.timestamp();
let sig = self.sign_token(method, key, expires);
let mut url = self
.base_url
.join(key)
.map_err(|e| BucketError::Url(e.to_string()))?;
url.query_pairs_mut()
.append_pair("expires", &expires.to_string())
.append_pair("sig", &sig);
Ok(url)
}
}
#[async_trait]
impl ObjectStorage for LocalStorage {
async fn put(&self, key: &str, body: Bytes, _content_type: &str) -> BucketResult<()> {
let path = self.resolve(key)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let mut file = fs::File::create(&path).await?;
file.write_all(&body).await?;
file.flush().await?;
Ok(())
}
async fn get(&self, key: &str) -> BucketResult<Bytes> {
let path = self.resolve(key)?;
match fs::read(&path).await {
Ok(bytes) => Ok(Bytes::from(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(BucketError::NotFound),
Err(e) => Err(e.into()),
}
}
async fn delete(&self, key: &str) -> BucketResult<()> {
let path = self.resolve(key)?;
match fs::remove_file(&path).await {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
async fn head(&self, key: &str) -> BucketResult<ObjectMeta> {
let path = self.resolve(key)?;
let meta = match fs::metadata(&path).await {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(BucketError::NotFound),
Err(e) => return Err(e.into()),
};
let last_modified: Option<DateTime<Utc>> = meta
.modified()
.ok()
.map(|t| DateTime::<Utc>::from(t));
Ok(ObjectMeta {
key: key.to_string(),
size: meta.len(),
content_type: None,
etag: None,
last_modified,
})
}
async fn presigned_get(&self, key: &str, ttl: Duration) -> BucketResult<Url> {
self.build_signed_url("GET", key, ttl)
}
async fn presigned_put(
&self,
key: &str,
ttl: Duration,
_content_type: &str,
) -> BucketResult<Url> {
self.build_signed_url("PUT", key, ttl)
}
fn public_url(&self, _key: &str) -> Option<Url> {
None
}
}
#[doc(hidden)]
pub fn local_object_path(root: &Path, key: &str) -> PathBuf {
root.join(key)
}
#[cfg(test)]
mod tests {
use super::*;
fn make() -> (tempfile::TempDir, LocalStorage) {
let dir = tempfile::tempdir().unwrap();
let base = Url::parse("http://localhost:8080/cdn/").unwrap();
let storage = LocalStorage::new(dir.path(), base, b"test-secret".to_vec());
(dir, storage)
}
#[tokio::test]
async fn roundtrip() {
let (_dir, storage) = make();
storage
.put("foo/bar.txt", Bytes::from_static(b"hi"), "text/plain")
.await
.unwrap();
let got = storage.get("foo/bar.txt").await.unwrap();
assert_eq!(&got[..], b"hi");
let meta = storage.head("foo/bar.txt").await.unwrap();
assert_eq!(meta.size, 2);
storage.delete("foo/bar.txt").await.unwrap();
assert!(matches!(
storage.get("foo/bar.txt").await,
Err(BucketError::NotFound)
));
}
#[tokio::test]
async fn signed_url_roundtrip() {
let (_dir, storage) = make();
let url = storage
.presigned_get("a/b.txt", Duration::from_secs(60))
.await
.unwrap();
let query: std::collections::HashMap<_, _> =
url.query_pairs().into_owned().collect();
let expires: i64 = query["expires"].parse().unwrap();
storage
.verify_token("GET", "a/b.txt", expires, &query["sig"])
.unwrap();
}
#[test]
fn signed_url_rejects_expired() {
let (_dir, storage) = make();
let past = (Utc::now() - chrono::Duration::minutes(1)).timestamp();
let sig = storage.sign_token("GET", "a/b.txt", past);
assert!(matches!(
storage.verify_token("GET", "a/b.txt", past, &sig),
Err(BucketError::InvalidSignature)
));
}
#[test]
fn rejects_path_traversal() {
let (_dir, storage) = make();
assert!(storage.resolve("../secrets").is_err());
assert!(storage.resolve("foo/../bar").is_err());
assert!(storage.resolve("/etc/passwd").is_err());
}
#[test]
fn allows_dotdot_inside_filename() {
let (_dir, storage) = make();
assert!(storage.resolve("reports/report..v2.pdf").is_ok());
}
}