Skip to main content

inference_gateway_adk/server/
artifact_storage.rs

1//! Pluggable storage backend for the artifacts subsystem.
2//!
3//! [`ArtifactStorage`] is the trait an [`ArtifactService`] holds as
4//! `Arc<dyn ArtifactStorage>` to persist file/data artifacts produced by
5//! task handlers and surface them via HTTP URLs rather than inline base64
6//! bytes embedded in JSON-RPC responses.
7//!
8//! The bundled default is [`FilesystemArtifactStorage`], which lays
9//! artifacts out under `<base_path>/<artifact_id>/<filename>` with
10//! path-traversal sanitization. Production deployments can implement
11//! [`ArtifactStorage`] themselves to wire in MinIO, GCS, or any other
12//! object store.
13//!
14//! [`ArtifactService`]: super::artifact_service::ArtifactService
15//! [`FilesystemArtifactStorage`]: FilesystemArtifactStorage
16
17use anyhow::{Context, Result, anyhow};
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use std::path::{Component, Path, PathBuf};
21use std::time::{Duration, SystemTime};
22use tokio::fs;
23use tokio::io::AsyncWriteExt;
24use tracing::{debug, warn};
25
26/// Metadata describing a stored artifact entry.
27#[derive(Debug, Clone)]
28pub struct StoredArtifactInfo {
29    pub artifact_id: String,
30    pub filename: String,
31    pub size: u64,
32    pub modified: Option<DateTime<Utc>>,
33}
34
35/// Pluggable backend that persists artifact bytes and resolves them back
36/// to a URL the artifacts HTTP server can hand to clients.
37///
38/// All methods are `async` so backends that need network/disk I/O fit
39/// naturally. The trait is object-safe so callers can hold an
40/// `Arc<dyn ArtifactStorage>`.
41#[async_trait]
42pub trait ArtifactStorage: Send + Sync + std::fmt::Debug {
43    /// Persist `data` under `artifact_id`/`filename` and return the URL
44    /// at which it can be retrieved.
45    async fn store(&self, artifact_id: &str, filename: &str, data: Vec<u8>) -> Result<String>;
46
47    /// Retrieve the raw bytes stored at `artifact_id`/`filename`.
48    async fn retrieve(&self, artifact_id: &str, filename: &str) -> Result<Vec<u8>>;
49
50    /// Whether a blob is stored at `artifact_id`/`filename`.
51    async fn exists(&self, artifact_id: &str, filename: &str) -> Result<bool>;
52
53    /// Delete the blob at `artifact_id`/`filename`. Returns `Ok(())` if
54    /// it didn't exist - idempotent.
55    async fn delete(&self, artifact_id: &str, filename: &str) -> Result<()>;
56
57    /// Stable URL the [`ArtifactsServer`] would serve `artifact_id`/`filename` at.
58    ///
59    /// [`ArtifactsServer`]: super::artifacts_server::ArtifactsServer
60    fn url(&self, artifact_id: &str, filename: &str) -> String;
61
62    /// Delete every blob whose modified time is older than `max_age`.
63    /// Returns the number of blobs removed.
64    async fn cleanup_expired(&self, max_age: Duration) -> Result<usize>;
65
66    /// Trim the store down so at most `max_count` blobs remain, deleting
67    /// the oldest first. Returns the number removed.
68    async fn cleanup_oldest(&self, max_count: usize) -> Result<usize>;
69
70    /// Enumerate every stored artifact. Used by retention and tests.
71    async fn list(&self) -> Result<Vec<StoredArtifactInfo>>;
72}
73
74/// Filesystem-backed [`ArtifactStorage`].
75///
76/// Lays each artifact out under `<base_path>/<artifact_id>/<filename>`.
77/// The generated `base_url` follows the same pattern - configure
78/// `base_url` to match wherever the [`ArtifactsServer`] is reachable so
79/// clients can resolve the URL.
80///
81/// [`ArtifactsServer`]: super::artifacts_server::ArtifactsServer
82#[derive(Debug, Clone)]
83pub struct FilesystemArtifactStorage {
84    base_path: PathBuf,
85    base_url: String,
86}
87
88impl FilesystemArtifactStorage {
89    /// Create a new filesystem-backed store. `base_url` is the URL prefix
90    /// (without trailing slash) under which the [`ArtifactsServer`] is
91    /// reachable. The on-disk root at `base_path` is created lazily on
92    /// the first [`store`](ArtifactStorage::store) call.
93    ///
94    /// [`ArtifactsServer`]: super::artifacts_server::ArtifactsServer
95    pub fn new(base_path: impl Into<PathBuf>, base_url: impl Into<String>) -> Self {
96        Self {
97            base_path: base_path.into(),
98            base_url: base_url.into().trim_end_matches('/').to_string(),
99        }
100    }
101
102    /// Resolve a sanitized path under `base_path` for `artifact_id`/`filename`.
103    /// Rejects empty / traversal / absolute components so callers can't
104    /// escape the configured root via `..` or leading slashes.
105    fn resolve_path(&self, artifact_id: &str, filename: &str) -> Result<PathBuf> {
106        let id = sanitize_segment(artifact_id, "artifact_id")?;
107        let name = sanitize_segment(filename, "filename")?;
108        Ok(self.base_path.join(id).join(name))
109    }
110}
111
112/// Reject anything that would let a caller escape the configured base
113/// path. This covers `..`, absolute paths, embedded path separators,
114/// and the empty / whitespace cases.
115pub(crate) fn sanitize_segment(value: &str, label: &str) -> Result<String> {
116    if value.trim().is_empty() {
117        return Err(anyhow!("{label} must not be empty"));
118    }
119    if value.contains('\0') {
120        return Err(anyhow!("{label} contains a NUL byte"));
121    }
122    if value.contains('/') || value.contains('\\') {
123        return Err(anyhow!(
124            "{label} `{value}` must not contain path separators"
125        ));
126    }
127    let path = Path::new(value);
128    for component in path.components() {
129        match component {
130            Component::Normal(_) => {}
131            _ => {
132                return Err(anyhow!(
133                    "{label} `{value}` must be a simple filename without traversal"
134                ));
135            }
136        }
137    }
138    Ok(value.to_string())
139}
140
141#[async_trait]
142impl ArtifactStorage for FilesystemArtifactStorage {
143    async fn store(&self, artifact_id: &str, filename: &str, data: Vec<u8>) -> Result<String> {
144        let target = self.resolve_path(artifact_id, filename)?;
145        if let Some(parent) = target.parent() {
146            fs::create_dir_all(parent).await.with_context(|| {
147                format!("failed to create artifact directory `{}`", parent.display())
148            })?;
149        }
150        let mut file = fs::File::create(&target)
151            .await
152            .with_context(|| format!("failed to create artifact file `{}`", target.display()))?;
153        file.write_all(&data)
154            .await
155            .with_context(|| format!("failed to write artifact bytes to `{}`", target.display()))?;
156        file.flush().await.ok();
157        debug!(
158            artifact_id,
159            filename,
160            bytes = data.len(),
161            path = %target.display(),
162            "stored artifact on filesystem",
163        );
164        Ok(self.url(artifact_id, filename))
165    }
166
167    async fn retrieve(&self, artifact_id: &str, filename: &str) -> Result<Vec<u8>> {
168        let target = self.resolve_path(artifact_id, filename)?;
169        fs::read(&target)
170            .await
171            .with_context(|| format!("failed to read artifact file `{}`", target.display()))
172    }
173
174    async fn exists(&self, artifact_id: &str, filename: &str) -> Result<bool> {
175        let target = self.resolve_path(artifact_id, filename)?;
176        match fs::metadata(&target).await {
177            Ok(_) => Ok(true),
178            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
179            Err(e) => Err(anyhow!(
180                "failed to stat artifact `{}`: {e}",
181                target.display()
182            )),
183        }
184    }
185
186    async fn delete(&self, artifact_id: &str, filename: &str) -> Result<()> {
187        let target = self.resolve_path(artifact_id, filename)?;
188        match fs::remove_file(&target).await {
189            Ok(()) => {}
190            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
191            Err(e) => {
192                return Err(anyhow!(
193                    "failed to delete artifact `{}`: {e}",
194                    target.display()
195                ));
196            }
197        }
198        // Best-effort: tidy up the artifact_id directory if it's now empty.
199        if let Some(parent) = target.parent()
200            && let Ok(mut read_dir) = fs::read_dir(parent).await
201            && read_dir.next_entry().await.ok().flatten().is_none()
202        {
203            let _ = fs::remove_dir(parent).await;
204        }
205        Ok(())
206    }
207
208    fn url(&self, artifact_id: &str, filename: &str) -> String {
209        format!(
210            "{}/artifacts/{}/{}",
211            self.base_url,
212            urlencode_segment(artifact_id),
213            urlencode_segment(filename),
214        )
215    }
216
217    async fn cleanup_expired(&self, max_age: Duration) -> Result<usize> {
218        let cutoff = SystemTime::now().checked_sub(max_age);
219        let Some(cutoff) = cutoff else {
220            return Ok(0);
221        };
222        let entries = self.list().await?;
223        let mut removed = 0usize;
224        for entry in entries {
225            let modified_system = match entry.modified {
226                Some(dt) => SystemTime::from(dt),
227                None => continue,
228            };
229            if modified_system < cutoff {
230                if let Err(e) = self.delete(&entry.artifact_id, &entry.filename).await {
231                    warn!(
232                        artifact_id = %entry.artifact_id,
233                        filename = %entry.filename,
234                        "cleanup_expired: delete failed: {e}",
235                    );
236                    continue;
237                }
238                removed += 1;
239            }
240        }
241        Ok(removed)
242    }
243
244    async fn cleanup_oldest(&self, max_count: usize) -> Result<usize> {
245        let mut entries = self.list().await?;
246        if entries.len() <= max_count {
247            return Ok(0);
248        }
249        entries.sort_by_key(|e| e.modified.unwrap_or_else(Utc::now));
250        let drop_count = entries.len() - max_count;
251        let mut removed = 0usize;
252        for entry in entries.into_iter().take(drop_count) {
253            if let Err(e) = self.delete(&entry.artifact_id, &entry.filename).await {
254                warn!(
255                    artifact_id = %entry.artifact_id,
256                    filename = %entry.filename,
257                    "cleanup_oldest: delete failed: {e}",
258                );
259                continue;
260            }
261            removed += 1;
262        }
263        Ok(removed)
264    }
265
266    async fn list(&self) -> Result<Vec<StoredArtifactInfo>> {
267        let mut out = Vec::new();
268        let mut top = match fs::read_dir(&self.base_path).await {
269            Ok(r) => r,
270            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
271            Err(e) => {
272                return Err(anyhow!(
273                    "failed to read artifacts root `{}`: {e}",
274                    self.base_path.display()
275                ));
276            }
277        };
278        while let Some(entry) = top.next_entry().await? {
279            let metadata = match entry.metadata().await {
280                Ok(m) => m,
281                Err(_) => continue,
282            };
283            if !metadata.is_dir() {
284                continue;
285            }
286            let artifact_id = entry.file_name().to_string_lossy().to_string();
287            let mut inner = match fs::read_dir(entry.path()).await {
288                Ok(r) => r,
289                Err(_) => continue,
290            };
291            while let Some(file) = inner.next_entry().await? {
292                let file_meta = match file.metadata().await {
293                    Ok(m) => m,
294                    Err(_) => continue,
295                };
296                if !file_meta.is_file() {
297                    continue;
298                }
299                let filename = file.file_name().to_string_lossy().to_string();
300                let modified = file_meta.modified().ok().map(DateTime::<Utc>::from);
301                out.push(StoredArtifactInfo {
302                    artifact_id: artifact_id.clone(),
303                    filename,
304                    size: file_meta.len(),
305                    modified,
306                });
307            }
308        }
309        Ok(out)
310    }
311}
312
313/// Minimal percent-encoder for path segments. We only need to escape
314/// characters that would break URL parsing or path semantics (space,
315/// `#`, `?`, control chars). Everything else is preserved so generated
316/// URLs remain readable.
317pub(crate) fn urlencode_segment(input: &str) -> String {
318    let mut out = String::with_capacity(input.len());
319    for byte in input.as_bytes() {
320        let b = *byte;
321        let is_safe = matches!(
322            b,
323            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
324        );
325        if is_safe {
326            out.push(b as char);
327        } else {
328            out.push_str(&format!("%{b:02X}"));
329        }
330    }
331    out
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn tempdir(name: &str) -> PathBuf {
339        let mut p = std::env::temp_dir();
340        p.push(format!(
341            "rust-adk-artifacts-{}-{}-{}",
342            std::process::id(),
343            name,
344            uuid::Uuid::new_v4()
345        ));
346        p
347    }
348
349    #[derive(Debug)]
350    struct SanitizeCase {
351        name: &'static str,
352        input: &'static str,
353        expect_err: bool,
354    }
355
356    #[test]
357    fn sanitize_segment_rejects_traversal_and_separators() {
358        let cases = vec![
359            SanitizeCase {
360                name: "plain",
361                input: "report.pdf",
362                expect_err: false,
363            },
364            SanitizeCase {
365                name: "dotdot",
366                input: "..",
367                expect_err: true,
368            },
369            SanitizeCase {
370                name: "leading_slash",
371                input: "/etc/passwd",
372                expect_err: true,
373            },
374            SanitizeCase {
375                name: "embedded_slash",
376                input: "a/b",
377                expect_err: true,
378            },
379            SanitizeCase {
380                name: "backslash",
381                input: "a\\b",
382                expect_err: true,
383            },
384            SanitizeCase {
385                name: "empty",
386                input: "",
387                expect_err: true,
388            },
389            SanitizeCase {
390                name: "whitespace_only",
391                input: "   ",
392                expect_err: true,
393            },
394            SanitizeCase {
395                name: "nul_byte",
396                input: "a\0b",
397                expect_err: true,
398            },
399        ];
400        for case in cases {
401            let result = sanitize_segment(case.input, "filename");
402            if case.expect_err {
403                assert!(
404                    result.is_err(),
405                    "case `{}` should have errored, got Ok",
406                    case.name,
407                );
408            } else {
409                assert!(
410                    result.is_ok(),
411                    "case `{}` should have succeeded, got {:?}",
412                    case.name,
413                    result.err(),
414                );
415            }
416        }
417    }
418
419    #[tokio::test]
420    async fn filesystem_store_retrieve_exists_delete_roundtrip() {
421        let root = tempdir("roundtrip");
422        let store = FilesystemArtifactStorage::new(&root, "http://localhost:8081");
423        let id = "artifact-1";
424        let name = "hello.txt";
425        let url = store
426            .store(id, name, b"hello world".to_vec())
427            .await
428            .expect("store");
429        assert_eq!(url, "http://localhost:8081/artifacts/artifact-1/hello.txt");
430        assert!(store.exists(id, name).await.expect("exists"));
431
432        let bytes = store.retrieve(id, name).await.expect("retrieve");
433        assert_eq!(bytes, b"hello world");
434
435        store.delete(id, name).await.expect("delete");
436        assert!(!store.exists(id, name).await.expect("exists after delete"));
437        // deleting again is idempotent
438        store.delete(id, name).await.expect("idempotent delete");
439
440        let _ = std::fs::remove_dir_all(&root);
441    }
442
443    #[tokio::test]
444    async fn filesystem_rejects_traversal_in_store() {
445        let root = tempdir("traversal");
446        let store = FilesystemArtifactStorage::new(&root, "http://localhost:8081");
447        let err = store
448            .store("..", "passwd", b"oops".to_vec())
449            .await
450            .expect_err("traversal must be rejected");
451        assert!(err.to_string().contains("artifact_id"));
452        let err = store
453            .store("ok", "../etc/passwd", b"oops".to_vec())
454            .await
455            .expect_err("traversal must be rejected");
456        assert!(err.to_string().contains("filename"));
457        let _ = std::fs::remove_dir_all(&root);
458    }
459
460    #[tokio::test]
461    async fn filesystem_cleanup_oldest_trims_to_cap() {
462        let root = tempdir("cleanup-oldest");
463        let store = FilesystemArtifactStorage::new(&root, "http://localhost:8081");
464        for i in 0..5 {
465            store
466                .store(&format!("a{i}"), "f.bin", vec![i as u8])
467                .await
468                .expect("store");
469            tokio::time::sleep(Duration::from_millis(15)).await;
470        }
471        let removed = store.cleanup_oldest(2).await.expect("cleanup_oldest");
472        assert_eq!(removed, 3, "should remove 3 of 5 to leave 2");
473        let remaining = store.list().await.expect("list");
474        assert_eq!(remaining.len(), 2);
475        let _ = std::fs::remove_dir_all(&root);
476    }
477
478    #[tokio::test]
479    async fn filesystem_cleanup_expired_drops_old_entries() {
480        let root = tempdir("cleanup-expired");
481        let store = FilesystemArtifactStorage::new(&root, "http://localhost:8081");
482        store
483            .store("old", "f.bin", b"old".to_vec())
484            .await
485            .expect("store");
486        tokio::time::sleep(Duration::from_millis(60)).await;
487        let removed = store
488            .cleanup_expired(Duration::from_millis(20))
489            .await
490            .expect("cleanup_expired");
491        assert_eq!(removed, 1);
492        assert!(!store.exists("old", "f.bin").await.expect("exists"));
493        let _ = std::fs::remove_dir_all(&root);
494    }
495
496    #[test]
497    fn url_encodes_special_characters() {
498        let store = FilesystemArtifactStorage::new(std::env::temp_dir(), "http://localhost:8081/");
499        let url = store.url("id 1", "report v1.pdf");
500        assert_eq!(
501            url,
502            "http://localhost:8081/artifacts/id%201/report%20v1.pdf"
503        );
504    }
505}