greentic-designer 0.6.0

Greentic Designer — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! Local filesystem image store.

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"))
    }

    /// Validate that an ID matches the expected `img_` + 16 hex chars format.
    /// Prevents path traversal via crafted image IDs.
    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");
    }
}