Skip to main content

pylon_storage/
files.rs

1//! Pluggable file storage abstraction.
2//!
3//! Provides a trait for file storage operations that can be implemented
4//! for local disk, S3, R2, GCS, or any other storage backend.
5
6use serde::Serialize;
7
8// ---------------------------------------------------------------------------
9// File storage trait
10// ---------------------------------------------------------------------------
11
12/// Pluggable file storage backend.
13pub trait FileStorage: Send + Sync {
14    /// Store file content, returning a file ID and public URL.
15    fn store(
16        &self,
17        name: &str,
18        content: &[u8],
19        content_type: &str,
20    ) -> Result<StoredFile, FileStorageError>;
21
22    /// Retrieve file content by ID.
23    fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError>;
24
25    /// Delete a file by ID.
26    fn delete(&self, id: &str) -> Result<bool, FileStorageError>;
27
28    /// Generate a presigned upload URL (for direct client uploads).
29    /// Not all backends support this — returns None if unsupported.
30    fn presigned_upload_url(
31        &self,
32        _name: &str,
33        _content_type: &str,
34        _expires_secs: u64,
35    ) -> Result<Option<String>, FileStorageError> {
36        Ok(None)
37    }
38}
39
40#[derive(Debug, Clone, Serialize)]
41pub struct StoredFile {
42    pub id: String,
43    pub url: String,
44    pub size: usize,
45}
46
47#[derive(Debug, Clone)]
48pub struct FileStorageError {
49    pub code: String,
50    pub message: String,
51}
52
53impl std::fmt::Display for FileStorageError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        write!(f, "[{}] {}", self.code, self.message)
56    }
57}
58
59impl std::error::Error for FileStorageError {}
60
61// ---------------------------------------------------------------------------
62// Local filesystem implementation
63// ---------------------------------------------------------------------------
64
65/// File storage backed by a local directory.
66pub struct LocalFileStorage {
67    dir: std::path::PathBuf,
68    url_prefix: String,
69}
70
71impl LocalFileStorage {
72    pub fn new(dir: &str, url_prefix: &str) -> Self {
73        let path = std::path::PathBuf::from(dir);
74        let _ = std::fs::create_dir_all(&path);
75        Self {
76            dir: path,
77            url_prefix: url_prefix.to_string(),
78        }
79    }
80}
81
82impl FileStorage for LocalFileStorage {
83    fn store(
84        &self,
85        name: &str,
86        content: &[u8],
87        _content_type: &str,
88    ) -> Result<StoredFile, FileStorageError> {
89        let id = format!(
90            "file_{}_{}",
91            std::time::SystemTime::now()
92                .duration_since(std::time::UNIX_EPOCH)
93                .unwrap_or_default()
94                .as_nanos(),
95            name.replace(['/', '\\', '.'], "_")
96        );
97        let path = self.dir.join(&id);
98        std::fs::write(&path, content).map_err(|e| FileStorageError {
99            code: "WRITE_FAILED".into(),
100            message: format!("Failed to write file: {e}"),
101        })?;
102
103        Ok(StoredFile {
104            url: format!("{}/{}", self.url_prefix, id),
105            size: content.len(),
106            id,
107        })
108    }
109
110    fn get(&self, id: &str) -> Result<Vec<u8>, FileStorageError> {
111        if id.contains("..") || id.contains('/') || id.contains('\\') {
112            return Err(FileStorageError {
113                code: "INVALID_ID".into(),
114                message: "Invalid file ID".into(),
115            });
116        }
117        let path = self.dir.join(id);
118        std::fs::read(&path).map_err(|_| FileStorageError {
119            code: "NOT_FOUND".into(),
120            message: "File not found".into(),
121        })
122    }
123
124    fn delete(&self, id: &str) -> Result<bool, FileStorageError> {
125        if id.contains("..") || id.contains('/') || id.contains('\\') {
126            return Err(FileStorageError {
127                code: "INVALID_ID".into(),
128                message: "Invalid file ID".into(),
129            });
130        }
131        let path = self.dir.join(id);
132        match std::fs::remove_file(&path) {
133            Ok(()) => Ok(true),
134            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
135            Err(e) => Err(FileStorageError {
136                code: "DELETE_FAILED".into(),
137                message: format!("Failed to delete file: {e}"),
138            }),
139        }
140    }
141}
142
143// ---------------------------------------------------------------------------
144// S3-compatible storage (stub — needs an HTTP client at runtime)
145// ---------------------------------------------------------------------------
146
147/// Configuration for S3-compatible storage (S3, R2, GCS, MinIO).
148#[derive(Debug, Clone)]
149pub struct S3Config {
150    pub bucket: String,
151    pub region: String,
152    pub endpoint: Option<String>,
153    pub access_key: String,
154    pub secret_key: String,
155    pub public_url_prefix: Option<String>,
156}
157
158impl S3Config {
159    /// Create from environment variables.
160    ///
161    /// Reads: PYLON_S3_BUCKET, PYLON_S3_REGION, PYLON_S3_ENDPOINT,
162    /// PYLON_S3_ACCESS_KEY, PYLON_S3_SECRET_KEY, PYLON_S3_PUBLIC_URL
163    pub fn from_env() -> Option<Self> {
164        Some(Self {
165            bucket: std::env::var("PYLON_S3_BUCKET").ok()?,
166            region: std::env::var("PYLON_S3_REGION").unwrap_or_else(|_| "us-east-1".into()),
167            endpoint: std::env::var("PYLON_S3_ENDPOINT").ok(),
168            access_key: std::env::var("PYLON_S3_ACCESS_KEY").ok()?,
169            secret_key: std::env::var("PYLON_S3_SECRET_KEY").ok()?,
170            public_url_prefix: std::env::var("PYLON_S3_PUBLIC_URL").ok(),
171        })
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn local_store_and_get() {
181        let dir = std::env::temp_dir().join(format!("pylon_files_{}", std::process::id()));
182        let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
183
184        let stored = storage
185            .store("test.txt", b"hello world", "text/plain")
186            .unwrap();
187        assert_eq!(stored.size, 11);
188        assert!(stored.url.starts_with("/api/files/"));
189
190        let content = storage.get(&stored.id).unwrap();
191        assert_eq!(content, b"hello world");
192
193        let deleted = storage.delete(&stored.id).unwrap();
194        assert!(deleted);
195
196        let not_found = storage.get(&stored.id);
197        assert!(not_found.is_err());
198
199        let _ = std::fs::remove_dir_all(&dir);
200    }
201
202    #[test]
203    fn local_rejects_traversal() {
204        let dir = std::env::temp_dir().join(format!("pylon_files2_{}", std::process::id()));
205        let storage = LocalFileStorage::new(dir.to_str().unwrap(), "/api/files");
206
207        assert!(storage.get("../etc/passwd").is_err());
208        assert!(storage.delete("../etc/passwd").is_err());
209
210        let _ = std::fs::remove_dir_all(&dir);
211    }
212}