Skip to main content

alien_bindings/providers/vault/
local.rs

1use crate::error::{ErrorData, Result};
2use alien_error::{AlienError, Context, IntoAlienError};
3use async_trait::async_trait;
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7/// Local vault binding implementation for development and testing.
8///
9/// Secrets are stored in a JSON file in the vault directory without encryption.
10/// Encryption will be added in a future iteration.
11#[derive(Debug)]
12pub struct LocalVault {
13    vault_name: String,
14    vault_dir: PathBuf,
15}
16
17impl LocalVault {
18    /// Create a new local vault binding.
19    ///
20    /// # Arguments
21    /// * `vault_name` - Name of the vault
22    /// * `vault_dir` - Directory where secrets are stored
23    pub fn new(vault_name: String, vault_dir: PathBuf) -> Self {
24        Self {
25            vault_name,
26            vault_dir,
27        }
28    }
29
30    /// Get the path to the secrets file.
31    fn secrets_file_path(&self) -> PathBuf {
32        self.vault_dir.join("secrets.json")
33    }
34
35    /// Load secrets from disk.
36    async fn load_secrets(&self) -> Result<HashMap<String, String>> {
37        let secrets_file = self.secrets_file_path();
38
39        if !secrets_file.exists() {
40            return Ok(HashMap::new());
41        }
42
43        let content = tokio::fs::read_to_string(&secrets_file)
44            .await
45            .into_alien_error()
46            .context(ErrorData::CloudPlatformError {
47                message: format!(
48                    "Failed to read vault secrets file: {}",
49                    secrets_file.display()
50                ),
51                resource_id: None,
52            })?;
53
54        serde_json::from_str(&content)
55            .into_alien_error()
56            .context(ErrorData::CloudPlatformError {
57                message: format!(
58                    "Failed to parse vault secrets file: {}",
59                    secrets_file.display()
60                ),
61                resource_id: None,
62            })
63    }
64
65    /// Save secrets to disk.
66    async fn save_secrets(&self, secrets: &HashMap<String, String>) -> Result<()> {
67        let secrets_file = self.secrets_file_path();
68
69        // Ensure directory exists
70        if let Some(parent) = secrets_file.parent() {
71            tokio::fs::create_dir_all(parent)
72                .await
73                .into_alien_error()
74                .context(ErrorData::CloudPlatformError {
75                    message: format!("Failed to create vault directory: {}", parent.display()),
76                    resource_id: None,
77                })?;
78        }
79
80        let json = serde_json::to_string_pretty(secrets)
81            .into_alien_error()
82            .context(ErrorData::CloudPlatformError {
83                message: "Failed to serialize vault secrets".to_string(),
84                resource_id: None,
85            })?;
86
87        let path = secrets_file.clone();
88        let data = json.into_bytes();
89        tokio::task::spawn_blocking(move || alien_core::file_utils::write_secret_file(&path, &data))
90            .await
91            .into_alien_error()
92            .context(ErrorData::CloudPlatformError {
93                message: "Failed to spawn blocking write task".to_string(),
94                resource_id: None,
95            })?
96            .into_alien_error()
97            .context(ErrorData::CloudPlatformError {
98                message: format!(
99                    "Failed to write vault secrets file: {}",
100                    secrets_file.display()
101                ),
102                resource_id: None,
103            })
104    }
105}
106
107#[async_trait]
108impl crate::traits::Binding for LocalVault {}
109
110#[async_trait]
111impl crate::traits::Vault for LocalVault {
112    /// Get a secret value by name
113    async fn get_secret(&self, secret_name: &str) -> Result<String> {
114        let secrets = self.load_secrets().await?;
115
116        secrets.get(secret_name).cloned().ok_or_else(|| {
117            AlienError::new(ErrorData::CloudPlatformError {
118                message: format!(
119                    "Secret '{}' not found in vault '{}'",
120                    secret_name, self.vault_name
121                ),
122                resource_id: None,
123            })
124        })
125    }
126
127    /// Set a secret value
128    async fn set_secret(&self, secret_name: &str, value: &str) -> Result<()> {
129        let mut secrets = self.load_secrets().await?;
130        secrets.insert(secret_name.to_string(), value.to_string());
131        self.save_secrets(&secrets).await
132    }
133
134    /// Delete a secret
135    async fn delete_secret(&self, secret_name: &str) -> Result<()> {
136        let mut secrets = self.load_secrets().await?;
137
138        secrets.remove(secret_name);
139
140        self.save_secrets(&secrets).await
141    }
142
143    /// List the names of all secrets stored in this vault (the keys of the
144    /// backing `secrets.json`). An absent file yields an empty list.
145    async fn list_secrets(&self) -> Result<Vec<String>> {
146        let secrets = self.load_secrets().await?;
147        Ok(secrets.into_keys().collect())
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::traits::Vault as _;
155    use std::collections::BTreeSet;
156    use tempfile::TempDir;
157
158    fn test_vault() -> (LocalVault, TempDir) {
159        let temp_dir = TempDir::new().expect("tempdir");
160        let vault = LocalVault::new("secrets".to_string(), temp_dir.path().to_path_buf());
161        (vault, temp_dir)
162    }
163
164    #[tokio::test]
165    async fn list_secrets_empty_when_no_file() {
166        let (vault, _temp_dir) = test_vault();
167        let names = vault.list_secrets().await.expect("list should succeed");
168        assert!(names.is_empty(), "fresh vault must list no secrets");
169    }
170
171    #[tokio::test]
172    async fn list_secrets_returns_all_stored_names() {
173        let (vault, _temp_dir) = test_vault();
174
175        vault.set_secret("api-key", "a").await.expect("set api-key");
176        vault.set_secret("db-url", "b").await.expect("set db-url");
177
178        let names: BTreeSet<String> = vault
179            .list_secrets()
180            .await
181            .expect("list should succeed")
182            .into_iter()
183            .collect();
184        assert_eq!(
185            names,
186            BTreeSet::from(["api-key".to_string(), "db-url".to_string()]),
187            "list must return exactly the stored secret names"
188        );
189
190        // A listed name round-trips back through get_secret.
191        assert_eq!(vault.get_secret("api-key").await.expect("get"), "a");
192    }
193
194    #[tokio::test]
195    async fn list_secrets_reflects_deletion() {
196        let (vault, _temp_dir) = test_vault();
197
198        vault.set_secret("keep", "1").await.expect("set keep");
199        vault.set_secret("drop", "2").await.expect("set drop");
200        vault.delete_secret("drop").await.expect("delete drop");
201        vault
202            .delete_secret("drop")
203            .await
204            .expect("repeated delete is idempotent");
205
206        let names = vault.list_secrets().await.expect("list should succeed");
207        assert_eq!(
208            names,
209            vec!["keep".to_string()],
210            "deleted secret must be gone"
211        );
212    }
213}