1use serde::{Deserialize, Serialize};
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ObjectInfo {
9 pub key: String,
10 pub size: i64,
11 pub last_modified: Option<String>,
12}
13
14pub trait StorageBackend: Send + Sync {
16 fn list_objects(
17 &self,
18 bucket: &str,
19 prefix: &str,
20 ) -> Pin<Box<dyn Future<Output = Result<Vec<ObjectInfo>, StorageError>> + Send + '_>>;
21
22 fn list_prefixes(
24 &self,
25 bucket: &str,
26 prefix: &str,
27 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, StorageError>> + Send + '_>>;
28
29 fn get_object(
30 &self,
31 bucket: &str,
32 key: &str,
33 ) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, StorageError>> + Send + '_>>;
34}
35
36#[derive(Debug)]
37pub enum StorageError {
38 NotFound(String),
39 Other(String),
40}
41
42impl std::fmt::Display for StorageError {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 StorageError::NotFound(msg) => write!(f, "not found: {msg}"),
46 StorageError::Other(msg) => write!(f, "{msg}"),
47 }
48 }
49}
50
51impl std::error::Error for StorageError {}
52
53pub struct S3Backend {
55 client: aws_sdk_s3::Client,
56}
57
58impl S3Backend {
59 pub async fn from_env() -> Self {
60 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
61 Self {
62 client: aws_sdk_s3::Client::new(&config),
63 }
64 }
65
66 pub fn from_client(client: aws_sdk_s3::Client) -> Self {
68 Self { client }
69 }
70}
71
72impl StorageBackend for S3Backend {
73 fn list_objects(
74 &self,
75 bucket: &str,
76 prefix: &str,
77 ) -> Pin<Box<dyn Future<Output = Result<Vec<ObjectInfo>, StorageError>> + Send + '_>> {
78 let bucket = bucket.to_string();
79 let prefix = prefix.to_string();
80 Box::pin(async move {
81 const MAX_RESULTS: usize = 1000;
82 let mut objects = Vec::new();
83 let mut continuation: Option<String> = None;
84
85 loop {
86 let mut req = self
87 .client
88 .list_objects_v2()
89 .bucket(&bucket)
90 .prefix(&prefix);
91 if let Some(token) = continuation.take() {
92 req = req.continuation_token(token);
93 }
94
95 let resp = req.send().await.map_err(|e| {
96 use aws_sdk_s3::error::DisplayErrorContext;
97 StorageError::Other(format!("{}", DisplayErrorContext(&e)))
98 })?;
99
100 for obj in resp.contents() {
101 if let Some(key) = obj.key() {
102 objects.push(ObjectInfo {
103 key: key.to_string(),
104 size: obj.size().unwrap_or(0),
105 last_modified: obj.last_modified().map(|t| t.to_string()),
106 });
107 }
108 }
109
110 if objects.len() >= MAX_RESULTS {
111 objects.truncate(MAX_RESULTS);
112 break;
113 }
114
115 if resp.is_truncated() == Some(true) {
116 continuation = resp.next_continuation_token().map(|s| s.to_string());
117 } else {
118 break;
119 }
120 }
121
122 Ok(objects)
123 })
124 }
125
126 fn list_prefixes(
127 &self,
128 bucket: &str,
129 prefix: &str,
130 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, StorageError>> + Send + '_>> {
131 let bucket = bucket.to_string();
132 let prefix = prefix.to_string();
133 Box::pin(async move {
134 let resp = self
135 .client
136 .list_objects_v2()
137 .bucket(&bucket)
138 .prefix(&prefix)
139 .delimiter("/")
140 .send()
141 .await
142 .map_err(|e| {
143 use aws_sdk_s3::error::DisplayErrorContext;
144 StorageError::Other(format!("{}", DisplayErrorContext(&e)))
145 })?;
146
147 Ok(resp
148 .common_prefixes()
149 .iter()
150 .filter_map(|cp| cp.prefix().map(|s| s.to_string()))
151 .collect())
152 })
153 }
154
155 fn get_object(
156 &self,
157 bucket: &str,
158 key: &str,
159 ) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, StorageError>> + Send + '_>> {
160 let bucket = bucket.to_string();
161 let key = key.to_string();
162 Box::pin(async move {
163 let resp = self
164 .client
165 .get_object()
166 .bucket(&bucket)
167 .key(&key)
168 .send()
169 .await
170 .map_err(|e| {
171 use aws_sdk_s3::error::DisplayErrorContext;
172 use aws_sdk_s3::operation::get_object::GetObjectError;
173
174 match e.into_service_error() {
175 GetObjectError::NoSuchKey(_) => {
176 StorageError::NotFound(format!("{bucket}/{key}"))
177 }
178 other => StorageError::Other(format!("{}", DisplayErrorContext(&other))),
179 }
180 })?;
181
182 let bytes = resp
183 .body
184 .collect()
185 .await
186 .map_err(|e| StorageError::Other(e.to_string()))?;
187
188 Ok(bytes.to_vec())
189 })
190 }
191}
192
193pub struct LocalBackend {
198 root: PathBuf,
199}
200
201impl LocalBackend {
202 pub fn new(root: impl Into<PathBuf>) -> Self {
203 let root = root.into();
204 let root = root.canonicalize().unwrap_or(root);
207 Self { root }
208 }
209}
210
211impl StorageBackend for LocalBackend {
212 fn list_objects(
213 &self,
214 _bucket: &str,
215 prefix: &str,
216 ) -> Pin<Box<dyn Future<Output = Result<Vec<ObjectInfo>, StorageError>> + Send + '_>> {
217 let prefix = prefix.to_string();
218 Box::pin(async move {
219 let root = self.root.clone();
220 let prefix2 = prefix.clone();
221 tokio::task::spawn_blocking(move || {
222 let mut objects = Vec::new();
223 collect_files(&root, &root, &prefix2, &mut objects, 0, &mut 0)?;
224 objects.sort_by(|a, b| a.key.cmp(&b.key));
225 Ok(objects)
226 })
227 .await
228 .map_err(|e| StorageError::Other(e.to_string()))?
229 })
230 }
231
232 fn list_prefixes(
233 &self,
234 _bucket: &str,
235 prefix: &str,
236 ) -> Pin<Box<dyn Future<Output = Result<Vec<String>, StorageError>> + Send + '_>> {
237 let prefix = prefix.to_string();
238 Box::pin(async move {
239 let root = self.root.clone();
240 let prefix2 = prefix.clone();
241 tokio::task::spawn_blocking(move || {
242 let dir = root.join(&prefix2);
243 let dir = match dir.canonicalize() {
244 Ok(d) if d.starts_with(&root) => d,
245 Ok(_) => {
246 return Err(StorageError::NotFound(
247 "path escapes root directory".to_string(),
248 ));
249 }
250 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
251 Err(e) => return Err(StorageError::Other(e.to_string())),
252 };
253 let entries = match std::fs::read_dir(&dir) {
254 Ok(e) => e,
255 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
256 Err(e) => return Err(StorageError::Other(e.to_string())),
257 };
258 let mut prefixes = Vec::new();
259 for entry in entries {
260 let entry = entry.map_err(|e| StorageError::Other(e.to_string()))?;
261 let path = entry.path();
262 let canonical = match path.canonicalize() {
264 Ok(c) if c.starts_with(&root) => c,
265 _ => continue,
266 };
267 if canonical.is_dir() {
268 let name = entry.file_name().to_string_lossy().into_owned();
269 prefixes.push(format!("{prefix2}{name}/"));
273 }
274 }
275 prefixes.sort();
276 Ok(prefixes)
277 })
278 .await
279 .map_err(|e| StorageError::Other(e.to_string()))?
280 })
281 }
282
283 fn get_object(
284 &self,
285 _bucket: &str,
286 key: &str,
287 ) -> Pin<Box<dyn Future<Output = Result<Vec<u8>, StorageError>> + Send + '_>> {
288 let path = self.root.join(key);
289 let root = self.root.clone();
290 Box::pin(async move {
291 tokio::task::spawn_blocking(move || {
292 let canonical = path.canonicalize().map_err(|e| match e.kind() {
293 std::io::ErrorKind::NotFound => {
294 StorageError::NotFound(path.display().to_string())
295 }
296 _ => StorageError::Other(e.to_string()),
297 })?;
298 if !canonical.starts_with(&root) {
299 return Err(StorageError::NotFound(
300 "path escapes root directory".to_string(),
301 ));
302 }
303 std::fs::read(&canonical).map_err(|e| match e.kind() {
304 std::io::ErrorKind::NotFound => {
305 StorageError::NotFound(path.display().to_string())
306 }
307 _ => StorageError::Other(e.to_string()),
308 })
309 })
310 .await
311 .map_err(|e| StorageError::Other(e.to_string()))?
312 })
313 }
314}
315
316const MAX_COLLECT_DEPTH: u32 = 10;
318
319const MAX_COLLECT_FILES: usize = 50;
321
322const MAX_ENTRIES_VISITED: usize = 500;
326
327fn is_skipped_dir(name: &str) -> bool {
329 name.starts_with('.') || matches!(name, "target" | "node_modules")
330}
331
332fn collect_files(
333 root: &Path,
334 dir: &Path,
335 prefix: &str,
336 out: &mut Vec<ObjectInfo>,
337 depth: u32,
338 visited: &mut usize,
339) -> Result<(), StorageError> {
340 if depth > MAX_COLLECT_DEPTH
341 || out.len() >= MAX_COLLECT_FILES
342 || *visited >= MAX_ENTRIES_VISITED
343 {
344 return Ok(());
345 }
346 let entries = match std::fs::read_dir(dir) {
347 Ok(e) => e,
348 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
349 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
350 return Err(StorageError::Other("permission denied".into()));
351 }
352 Err(e) => return Err(StorageError::Other(e.to_string())),
353 };
354 for entry in entries {
355 *visited += 1;
356 if out.len() >= MAX_COLLECT_FILES || *visited >= MAX_ENTRIES_VISITED {
357 break;
358 }
359 let entry = entry.map_err(|e| StorageError::Other(e.to_string()))?;
360 let path = entry.path();
361 let canonical = match path.canonicalize() {
363 Ok(c) if c.starts_with(root) => c,
364 _ => continue,
365 };
366 if canonical.is_dir() {
367 let name = entry.file_name();
368 let name = name.to_string_lossy();
369 if !is_skipped_dir(&name) {
370 collect_files(root, &canonical, prefix, out, depth + 1, visited)?;
371 }
372 } else if canonical.is_file() {
373 let key = path
374 .strip_prefix(root)
375 .unwrap_or(&path)
376 .to_string_lossy()
377 .into_owned();
378 if key.starts_with(prefix) {
379 let meta = std::fs::metadata(&canonical)
380 .map_err(|e| StorageError::Other(e.to_string()))?;
381 out.push(ObjectInfo {
382 key,
383 size: meta.len() as i64,
384 last_modified: meta.modified().ok().and_then(|t| {
385 t.duration_since(std::time::UNIX_EPOCH)
386 .ok()
387 .map(|d| d.as_secs().to_string())
388 }),
389 });
390 }
391 }
392 }
393 Ok(())
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn collect_files_caps_entries_visited() {
402 let dir = tempfile::tempdir().unwrap();
403 let n = MAX_ENTRIES_VISITED + 500;
405 for i in 0..n {
406 std::fs::write(dir.path().join(format!("file_{i:05}.bin")), b"x").unwrap();
407 }
408 let mut out = Vec::new();
409 let mut visited = 0;
410 collect_files(dir.path(), dir.path(), "", &mut out, 0, &mut visited).unwrap();
411 assert!(
413 visited <= MAX_ENTRIES_VISITED,
414 "visited {visited} entries, expected at most {MAX_ENTRIES_VISITED}"
415 );
416 assert!(
417 out.len() <= MAX_COLLECT_FILES,
418 "collected {} files, expected at most {MAX_COLLECT_FILES}",
419 out.len()
420 );
421 }
422}