Skip to main content

adk_studio/storage/
filesystem.rs

1use crate::schema::{ProjectMeta, ProjectSchema};
2use anyhow::{Context, Result, bail};
3use std::path::{Path, PathBuf};
4use tokio::fs;
5use uuid::Uuid;
6
7/// File-based project storage
8pub struct FileStorage {
9    base_dir: PathBuf,
10}
11
12impl FileStorage {
13    pub async fn new(base_dir: PathBuf) -> Result<Self> {
14        let base_dir = base_dir.canonicalize().unwrap_or(base_dir.clone());
15        fs::create_dir_all(&base_dir).await?;
16        let base_dir = base_dir.canonicalize().unwrap_or(base_dir);
17        Ok(Self { base_dir })
18    }
19
20    /// Build a safe project path from a UUID, ensuring it stays within base_dir.
21    fn project_path(&self, id: Uuid) -> Result<PathBuf> {
22        // UUID is guaranteed to be alphanumeric + hyphens, but we validate the
23        // resulting path stays within base_dir to satisfy path-injection checks.
24        let filename = format!("{}.json", id);
25        let path = self.base_dir.join(&filename);
26        let canonical = path
27            .canonicalize()
28            .unwrap_or_else(|_| self.base_dir.join(&filename));
29        if !canonical.starts_with(&self.base_dir) {
30            bail!("Path traversal detected for project id {}", id);
31        }
32        Ok(canonical)
33    }
34
35    pub async fn list(&self) -> Result<Vec<ProjectMeta>> {
36        let mut projects = Vec::new();
37        let mut entries = fs::read_dir(&self.base_dir).await?;
38
39        while let Some(entry) = entries.next_entry().await? {
40            let path = entry.path();
41            // Only read files directly inside base_dir (no subdirectories)
42            if !path.is_file() {
43                continue;
44            }
45            if let Ok(canonical) = path.canonicalize() {
46                if !canonical.starts_with(&self.base_dir) {
47                    continue;
48                }
49            }
50            if path.extension().is_some_and(|e| e == "json") {
51                if let Ok(content) = fs::read_to_string(&path).await {
52                    if let Ok(project) = serde_json::from_str::<ProjectSchema>(&content) {
53                        projects.push(ProjectMeta::from(&project));
54                    }
55                }
56            }
57        }
58
59        projects.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
60        Ok(projects)
61    }
62
63    pub async fn get(&self, id: Uuid) -> Result<ProjectSchema> {
64        let path = self.project_path(id)?;
65        let content = fs::read_to_string(&path)
66            .await
67            .with_context(|| format!("Project {} not found", id))?;
68        serde_json::from_str(&content).context("Invalid project format")
69    }
70
71    pub async fn save(&self, project: &ProjectSchema) -> Result<()> {
72        let path = self.project_path(project.id)?;
73        let content = serde_json::to_string_pretty(project)?;
74        // Atomic write: write to temp file then rename to avoid corruption on crash
75        let tmp_path = path.with_extension("json.tmp");
76        fs::write(&tmp_path, content).await?;
77        fs::rename(&tmp_path, &path)
78            .await
79            .with_context(|| format!("Failed to rename temp file to {}", path.display()))?;
80        Ok(())
81    }
82
83    pub async fn delete(&self, id: Uuid) -> Result<()> {
84        let path = self.project_path(id)?;
85        fs::remove_file(&path)
86            .await
87            .with_context(|| format!("Project {} not found", id))
88    }
89
90    pub async fn exists(&self, id: Uuid) -> bool {
91        self.project_path(id).map(|p| p.exists()).unwrap_or(false)
92    }
93
94    pub fn base_dir(&self) -> &Path {
95        &self.base_dir
96    }
97}