use std::path::{Path, PathBuf};
use std::time::SystemTime;
use anyhow::{Context, Result};
use super::{ImageMeta, ImageStore};
pub struct LocalImageStore {
images_dir: PathBuf,
}
impl LocalImageStore {
pub fn new(storage_dir: &Path) -> Result<Self> {
let images_dir = storage_dir.join("images");
std::fs::create_dir_all(&images_dir).with_context(|| {
format!(
"failed to create images directory: {}",
images_dir.display()
)
})?;
Ok(Self { images_dir })
}
fn image_path(&self, id: &str) -> PathBuf {
self.images_dir.join(format!("{id}.png"))
}
fn meta_path(&self, id: &str) -> PathBuf {
self.images_dir.join(format!("{id}.meta.json"))
}
fn validate_id(id: &str) -> bool {
id.len() == 20 && id.starts_with("img_") && id[4..].chars().all(|c| c.is_ascii_hexdigit())
}
fn generate_id() -> String {
use rand::Rng;
let mut rng = rand::rng();
let hex: String = (0..16)
.map(|_| format!("{:x}", rng.random_range(0..16u8)))
.collect();
format!("img_{hex}")
}
}
impl ImageStore for LocalImageStore {
async fn store(&self, filename: &str, data: &[u8]) -> Result<ImageMeta> {
let id = Self::generate_id();
let image_path = self.image_path(&id);
let meta_path = self.meta_path(&id);
let meta = ImageMeta {
id: id.clone(),
original_filename: filename.to_string(),
content_type: "image/png".to_string(),
size_bytes: data.len() as u64,
stored_at: SystemTime::now(),
};
tokio::fs::write(&image_path, data)
.await
.with_context(|| format!("failed to write image: {}", image_path.display()))?;
let meta_json = serde_json::to_string_pretty(&meta)?;
tokio::fs::write(&meta_path, meta_json)
.await
.with_context(|| format!("failed to write metadata: {}", meta_path.display()))?;
Ok(meta)
}
async fn load(&self, id: &str) -> Result<(ImageMeta, Vec<u8>)> {
anyhow::ensure!(Self::validate_id(id), "image not found: {id}");
let meta_path = self.meta_path(id);
let image_path = self.image_path(id);
let meta_json = tokio::fs::read_to_string(&meta_path)
.await
.with_context(|| format!("image not found: {id}"))?;
let meta: ImageMeta = serde_json::from_str(&meta_json)?;
let data = tokio::fs::read(&image_path)
.await
.with_context(|| format!("image file missing: {}", image_path.display()))?;
Ok((meta, data))
}
async fn delete(&self, id: &str) -> Result<()> {
anyhow::ensure!(Self::validate_id(id), "image not found: {id}");
let _ = tokio::fs::remove_file(self.image_path(id)).await;
let _ = tokio::fs::remove_file(self.meta_path(id)).await;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn store_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let store = LocalImageStore::new(dir.path()).unwrap();
let data = b"fake png data";
let meta = store.store("test.png", data).await.unwrap();
assert!(meta.id.starts_with("img_"));
assert_eq!(meta.original_filename, "test.png");
assert_eq!(meta.size_bytes, data.len() as u64);
let (loaded_meta, loaded_data) = store.load(&meta.id).await.unwrap();
assert_eq!(loaded_meta.id, meta.id);
assert_eq!(loaded_data, data);
}
#[tokio::test]
async fn load_nonexistent_fails() {
let dir = tempfile::tempdir().unwrap();
let store = LocalImageStore::new(dir.path()).unwrap();
assert!(store.load("img_nonexistent").await.is_err());
}
#[tokio::test]
async fn delete_removes_files() {
let dir = tempfile::tempdir().unwrap();
let store = LocalImageStore::new(dir.path()).unwrap();
let meta = store.store("del.png", b"data").await.unwrap();
store.delete(&meta.id).await.unwrap();
assert!(store.load(&meta.id).await.is_err());
}
#[test]
fn generate_id_format() {
let id = LocalImageStore::generate_id();
assert!(id.starts_with("img_"));
assert_eq!(id.len(), 4 + 16);
assert!(LocalImageStore::validate_id(&id));
}
#[test]
fn validate_id_rejects_traversal() {
assert!(!LocalImageStore::validate_id("../../etc/passwd"));
assert!(!LocalImageStore::validate_id("img_../../../etc"));
assert!(!LocalImageStore::validate_id(""));
assert!(!LocalImageStore::validate_id("img_short"));
assert!(LocalImageStore::validate_id("img_0123456789abcdef"));
}
#[tokio::test]
async fn load_rejects_invalid_id() {
let dir = tempfile::tempdir().unwrap();
let store = LocalImageStore::new(dir.path()).unwrap();
assert!(store.load("../../etc/passwd").await.is_err());
}
#[tokio::test]
async fn meta_sidecar_is_valid_json() {
let dir = tempfile::tempdir().unwrap();
let store = LocalImageStore::new(dir.path()).unwrap();
let meta = store.store("check.png", b"data").await.unwrap();
let meta_path = store.meta_path(&meta.id);
let json_str = tokio::fs::read_to_string(&meta_path).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed["original_filename"], "check.png");
}
}