crypsol_storage 0.2.0

Multi-cloud storage library for Rust with image processing, validation, and thumbnail generation. Supports AWS S3, GCS, Azure Blob, Cloudflare R2, MinIO, and local filesystem.
Documentation
use std::path::{Path, PathBuf};

use bytes::Bytes;
use tokio::fs;

use crate::error::Error;
use crate::traits::StorageBackend;
use crate::types::{PutOptions, RawDownloadResult};

/// Local filesystem storage backend, primarily for development and testing.
///
/// **Note:** `content_type` and [`PutOptions`] are not persisted on disk;
/// downloaded content types are guessed from the file extension.
pub struct LocalBackend {
    base_dir: PathBuf,
    public_base_url: String,
}

impl LocalBackend {
    /// Creates a new local backend.
    ///
    /// * `base_dir`  – root directory where objects are stored on disk.
    /// * `public_base_url` – base URL returned by [`public_url`](StorageBackend::public_url)
    ///   (e.g. `"http://localhost:8080/files"`).
    pub fn new(base_dir: impl Into<PathBuf>, public_base_url: &str) -> Self {
        Self {
            base_dir: base_dir.into(),
            public_base_url: public_base_url.trim_end_matches('/').to_string(),
        }
    }

    /// Returns the base directory.
    pub fn base_dir(&self) -> &Path {
        &self.base_dir
    }

    /// Resolves `key` under `base_dir`, rejecting traversal components
    /// (`..`, `.`, absolute prefixes) to prevent path-escape attacks.
    fn safe_path(&self, key: &str) -> Result<PathBuf, Error> {
        use std::path::Component;

        if key.is_empty() {
            return Err(Error::Backend("Key must not be empty".into()));
        }

        for component in Path::new(key).components() {
            match component {
                Component::Normal(_) => {}
                _ => {
                    return Err(Error::Backend(format!(
                        "Invalid key '{key}': must consist of normal path components only"
                    )));
                }
            }
        }

        Ok(self.base_dir.join(key))
    }
}

fn guess_content_type(path: &Path) -> &str {
    match path.extension().and_then(|e| e.to_str()) {
        Some("jpg" | "jpeg") => "image/jpeg",
        Some("png") => "image/png",
        Some("gif") => "image/gif",
        Some("webp") => "image/webp",
        Some("pdf") => "application/pdf",
        Some("json") => "application/json",
        Some("html") => "text/html",
        Some("css") => "text/css",
        Some("js") => "application/javascript",
        Some("txt") => "text/plain",
        Some("svg") => "image/svg+xml",
        Some("xml") => "application/xml",
        _ => "application/octet-stream",
    }
}

impl StorageBackend for LocalBackend {
    async fn put_object(
        &self,
        key: &str,
        data: Bytes,
        _content_type: &str,
        _options: &PutOptions,
    ) -> Result<(), Error> {
        let path = self.safe_path(key)?;
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).await?;
        }
        fs::write(&path, &data).await?;
        Ok(())
    }

    async fn get_object(&self, key: &str) -> Result<RawDownloadResult, Error> {
        let path = self.safe_path(key)?;
        let data = fs::read(&path).await?;
        let content_type = guess_content_type(&path).to_string();
        let len = data.len() as i64;

        Ok(RawDownloadResult {
            data: Bytes::from(data),
            content_type,
            content_length: Some(len),
        })
    }

    async fn delete_object(&self, key: &str) -> Result<(), Error> {
        let path = self.safe_path(key)?;
        if path.exists() {
            fs::remove_file(&path).await?;
        }
        Ok(())
    }

    async fn exists(&self, key: &str) -> Result<bool, Error> {
        let path = self.safe_path(key)?;
        Ok(path.exists())
    }

    async fn presigned_get_url(&self, _key: &str, _expires_in_secs: u64) -> Result<String, Error> {
        Err(Error::PresignNotSupported)
    }

    async fn presigned_put_url(
        &self,
        _key: &str,
        _content_type: &str,
        _expires_in_secs: u64,
    ) -> Result<String, Error> {
        Err(Error::PresignNotSupported)
    }

    fn public_url(&self, key: &str) -> String {
        format!("{}/{key}", self.public_base_url)
    }

    async fn test_connection(&self) -> Result<(), Error> {
        if !self.base_dir.exists() {
            return Err(Error::Backend(format!(
                "Local storage directory does not exist: {}",
                self.base_dir.display()
            )));
        }
        if !self.base_dir.is_dir() {
            return Err(Error::Backend(format!(
                "Local storage path is not a directory: {}",
                self.base_dir.display()
            )));
        }
        Ok(())
    }
}