1use 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#[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#[async_trait]
42pub trait ArtifactStorage: Send + Sync + std::fmt::Debug {
43 async fn store(&self, artifact_id: &str, filename: &str, data: Vec<u8>) -> Result<String>;
46
47 async fn retrieve(&self, artifact_id: &str, filename: &str) -> Result<Vec<u8>>;
49
50 async fn exists(&self, artifact_id: &str, filename: &str) -> Result<bool>;
52
53 async fn delete(&self, artifact_id: &str, filename: &str) -> Result<()>;
56
57 fn url(&self, artifact_id: &str, filename: &str) -> String;
61
62 async fn cleanup_expired(&self, max_age: Duration) -> Result<usize>;
65
66 async fn cleanup_oldest(&self, max_count: usize) -> Result<usize>;
69
70 async fn list(&self) -> Result<Vec<StoredArtifactInfo>>;
72}
73
74#[derive(Debug, Clone)]
83pub struct FilesystemArtifactStorage {
84 base_path: PathBuf,
85 base_url: String,
86}
87
88impl FilesystemArtifactStorage {
89 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 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
112pub(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 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
313pub(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 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}