Skip to main content

fraiseql_storage/backend/
local.rs

1//! Local filesystem storage backend.
2
3use std::{path::PathBuf, time::Duration};
4
5use fraiseql_error::{FileError, FraiseQLError, Result};
6
7use super::{
8    types::{ListResult, ObjectInfo},
9    validate_key,
10};
11
12/// Stores files on the local filesystem under a root directory.
13pub struct LocalBackend {
14    root: PathBuf,
15}
16
17impl LocalBackend {
18    /// Creates a new local storage backend rooted at `root`.
19    #[must_use]
20    pub fn new(root: &str) -> Self {
21        Self {
22            root: PathBuf::from(root),
23        }
24    }
25
26    fn key_path(&self, key: &str) -> Result<PathBuf> {
27        validate_key(key)?;
28        Ok(self.root.join(key))
29    }
30
31    /// Uploads data and returns the storage key.
32    ///
33    /// # Errors
34    ///
35    /// Returns `FraiseQLError::File(FileError::IoError)` if the upload fails.
36    pub async fn upload(&self, key: &str, data: &[u8], _content_type: &str) -> Result<String> {
37        let path = self.key_path(key)?;
38        if let Some(parent) = path.parent() {
39            tokio::fs::create_dir_all(parent).await.map_err(|e| {
40                FraiseQLError::File(FileError::IoError {
41                    message: format!("Failed to create directory: {e}"),
42                    source:  Some(Box::new(e)),
43                })
44            })?;
45        }
46        tokio::fs::write(&path, data).await.map_err(|e| {
47            FraiseQLError::File(FileError::IoError {
48                message: format!("Failed to write file: {e}"),
49                source:  Some(Box::new(e)),
50            })
51        })?;
52        Ok(key.to_string())
53    }
54
55    /// Downloads the contents of the given key.
56    ///
57    /// # Errors
58    ///
59    /// Returns `FraiseQLError::File(FileError::NotFound)` if the key does not exist,
60    /// or `FileError::IoError` on backend failures.
61    pub async fn download(&self, key: &str) -> Result<Vec<u8>> {
62        let path = self.key_path(key)?;
63        tokio::fs::read(&path).await.map_err(|e| {
64            if e.kind() == std::io::ErrorKind::NotFound {
65                FraiseQLError::File(FileError::NotFound {
66                    id: key.to_string(),
67                })
68            } else {
69                FraiseQLError::File(FileError::IoError {
70                    message: format!("Failed to read file: {e}"),
71                    source:  Some(Box::new(e)),
72                })
73            }
74        })
75    }
76
77    /// Deletes the object at the given key.
78    ///
79    /// # Errors
80    ///
81    /// Returns `FraiseQLError::File` on backend failures.
82    pub async fn delete(&self, key: &str) -> Result<()> {
83        let path = self.key_path(key)?;
84        tokio::fs::remove_file(&path).await.map_err(|e| {
85            if e.kind() == std::io::ErrorKind::NotFound {
86                FraiseQLError::File(FileError::NotFound {
87                    id: key.to_string(),
88                })
89            } else {
90                FraiseQLError::File(FileError::IoError {
91                    message: format!("Failed to delete file: {e}"),
92                    source:  Some(Box::new(e)),
93                })
94            }
95        })
96    }
97
98    /// Checks whether an object exists at the given key.
99    ///
100    /// # Errors
101    ///
102    /// Returns `FraiseQLError::File(FileError::IoError)` on backend communication errors.
103    pub async fn exists(&self, key: &str) -> Result<bool> {
104        let path = self.key_path(key)?;
105        match tokio::fs::metadata(&path).await {
106            Ok(_) => Ok(true),
107            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
108            Err(e) => Err(FraiseQLError::File(FileError::IoError {
109                message: format!("Failed to check file existence: {e}"),
110                source:  Some(Box::new(e)),
111            })),
112        }
113    }
114
115    /// Generates a presigned (time-limited) URL for direct access to an object.
116    ///
117    /// # Errors
118    ///
119    /// Returns `FraiseQLError::File(FileError::Unsupported)` because presigned URLs
120    /// are not supported by the local backend.
121    pub async fn presigned_url(&self, _key: &str, _expiry: Duration) -> Result<String> {
122        Err(FraiseQLError::File(FileError::Unsupported {
123            message: "Presigned URLs are not supported for local storage".to_string(),
124        }))
125    }
126
127    /// Lists objects in the bucket by prefix with pagination.
128    ///
129    /// # Errors
130    ///
131    /// Returns `FraiseQLError::File(FileError::IoError)` on I/O failures.
132    pub async fn list(
133        &self,
134        prefix: &str,
135        cursor: Option<&str>,
136        limit: usize,
137    ) -> Result<ListResult> {
138        // Walk the directory tree
139        let mut objects = Vec::new();
140        let prefix_path = self.root.join(prefix);
141
142        // If prefix directory doesn't exist, return empty list
143        if !prefix_path.exists() {
144            return Ok(ListResult {
145                objects:     Vec::new(),
146                next_cursor: None,
147            });
148        }
149
150        // Walk the directory and collect matching files
151        for entry in walkdir::WalkDir::new(&prefix_path)
152            .into_iter()
153            .filter_map(|e| e.ok())
154            .filter(|e| e.file_type().is_file())
155        {
156            let full_path = entry.path();
157            let relative_path = full_path
158                .strip_prefix(&self.root)
159                .map_err(|_| {
160                    FraiseQLError::File(FileError::IoError {
161                        message: "Failed to compute relative path".to_string(),
162                        source:  None,
163                    })
164                })?
165                .to_string_lossy()
166                .into_owned();
167
168            // Normalize path separators to forward slashes
169            let key = relative_path.replace('\\', "/");
170
171            // Get file metadata
172            let metadata = tokio::fs::metadata(full_path).await.map_err(|e| {
173                FraiseQLError::File(FileError::IoError {
174                    message: format!("Failed to read file metadata: {e}"),
175                    source:  Some(Box::new(e)),
176                })
177            })?;
178
179            let size = metadata.len();
180            let last_modified = metadata
181                .modified()
182                .ok()
183                .and_then(|t| {
184                    let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
185                    // Reason: u64→i64 cast for chrono timestamp; only wraps after year
186                    // 292277026596.
187                    #[allow(clippy::cast_possible_wrap)]
188                    let secs = duration.as_secs() as i64;
189                    chrono::DateTime::from_timestamp(secs, duration.subsec_nanos())
190                })
191                .map_or_else(|| chrono::Utc::now().to_rfc3339(), |dt| dt.to_rfc3339());
192
193            // Generate simple etag from size and mtime
194            let etag = format!("{:x}", fnv1a_hash(&format!("{size}-{last_modified}")));
195
196            objects.push((
197                key.clone(),
198                ObjectInfo {
199                    key,
200                    size,
201                    content_type: "application/octet-stream".to_string(), /* Default for local
202                                                                           * storage */
203                    etag,
204                    last_modified,
205                },
206            ));
207        }
208
209        // Sort by key
210        objects.sort_by(|a, b| a.0.cmp(&b.0));
211
212        // Apply cursor pagination
213        let start_idx = if let Some(c) = cursor {
214            objects.iter().position(|(k, _)| k == c).map_or(0, |i| i + 1)
215        } else {
216            0
217        };
218
219        let end_idx = (start_idx + limit).min(objects.len());
220        // `start_idx <= objects.len()` (sourced from `.position()` or `0`) and
221        // `end_idx <= objects.len()` by the `.min()` above, so this slice is
222        // always in-bounds; fall back to an empty page if the invariant ever
223        // breaks rather than panicking.
224        let page: Vec<ObjectInfo> = objects
225            .get(start_idx..end_idx)
226            .unwrap_or(&[])
227            .iter()
228            .map(|(_, info)| info.clone())
229            .collect();
230
231        let next_cursor = if end_idx < objects.len() {
232            page.last().map(|o| o.key.clone())
233        } else {
234            None
235        };
236
237        Ok(ListResult {
238            objects: page,
239            next_cursor,
240        })
241    }
242}
243
244/// Simple FNV-1a hash function
245fn fnv1a_hash(data: &str) -> u64 {
246    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
247    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
248
249    let mut hash = FNV_OFFSET_BASIS;
250    for byte in data.bytes() {
251        hash ^= u64::from(byte);
252        hash = hash.wrapping_mul(FNV_PRIME);
253    }
254    hash
255}