Skip to main content

dial9_viewer/
storage.rs

1use serde::{Deserialize, Serialize};
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5
6/// Metadata about an object in storage.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ObjectInfo {
9    pub key: String,
10    pub size: i64,
11    pub last_modified: Option<String>,
12}
13
14/// Abstraction over trace storage (S3, local FS, etc.)
15pub 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    /// List immediate child prefixes under `prefix` using delimiter-based listing.
23    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
53/// S3-backed storage using the AWS SDK.
54pub 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    /// Create from an existing S3 client (useful for testing with s3s).
67    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
193/// Local filesystem storage backend. Serves trace files from a directory.
194///
195/// The `bucket` parameter is ignored — all operations are relative to `root`.
196/// Keys are relative paths from `root`.
197pub struct LocalBackend {
198    root: PathBuf,
199}
200
201impl LocalBackend {
202    pub fn new(root: impl Into<PathBuf>) -> Self {
203        let root = root.into();
204        // Canonicalize root so that symlink resolution in child paths
205        // (e.g. macOS /tmp → /private/tmp) matches the root prefix.
206        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                    // Resolve symlinks and verify the target stays within root.
263                    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                        // NOTE: This uses "/" unconditionally, matching S3 key semantics.
270                        // On Windows, this would need to use the platform separator or
271                        // normalize paths to forward slashes throughout.
272                        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
316/// Maximum directory depth to recurse into when listing local files.
317const MAX_COLLECT_DEPTH: u32 = 10;
318
319/// Maximum number of files to return from a local directory listing.
320const MAX_COLLECT_FILES: usize = 50;
321
322/// Maximum number of directory entries to visit (files + dirs) across the
323/// entire recursive walk. This bounds the number of syscalls (`canonicalize`,
324/// `metadata`) so a huge directory tree cannot hang the listing.
325const MAX_ENTRIES_VISITED: usize = 500;
326
327/// Directory names to skip during recursive file collection.
328fn 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        // Resolve symlinks and verify the target stays within root.
362        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        // Create more files than MAX_ENTRIES_VISITED to prove we stop early.
404        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        // visited must be capped — we should NOT have iterated all n files.
412        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}