Skip to main content

arbiter_credential/
file_provider.rs

1//! File-based credential provider.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use secrecy::SecretString;
9use serde::Deserialize;
10use tokio::sync::RwLock;
11use tracing::{debug, info, warn};
12
13use crate::error::CredentialError;
14use crate::provider::{CredentialProvider, CredentialRef};
15
16#[derive(Deserialize)]
17struct CredentialFile {
18    credentials: HashMap<String, String>,
19}
20
21// Manual Debug that does not expose credential values.
22impl std::fmt::Debug for CredentialFile {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("CredentialFile")
25            .field(
26                "credentials",
27                &format!("{} entries", self.credentials.len()),
28            )
29            .finish()
30    }
31}
32
33pub struct FileProvider {
34    credentials: Arc<RwLock<HashMap<String, SecretString>>>,
35    source_path: PathBuf,
36}
37
38// Manual Debug that does not expose credential values.
39impl std::fmt::Debug for FileProvider {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.debug_struct("FileProvider")
42            .field("credentials", &"<locked>")
43            .field("source_path", &self.source_path)
44            .finish()
45    }
46}
47
48impl FileProvider {
49    pub async fn from_path(path: impl AsRef<Path>) -> Result<Self, CredentialError> {
50        let path = path.as_ref().to_path_buf();
51        info!(path = %path.display(), "loading credential file");
52
53        if path.extension().is_some_and(|ext| ext == "age") {
54            return Err(CredentialError::ProviderError(
55                "encrypted .age files are not yet supported; \
56                 age decryption support is planned (GAP-CRED-1)"
57                    .into(),
58            ));
59        }
60
61        // Emit a security warning for plaintext credential files.
62        warn!(
63            path = %path.display(),
64            "loading PLAINTEXT credential file. Credentials are stored \
65             unencrypted on disk. Use encrypted .age files (when supported) \
66             or set ARBITER_STORAGE_ENCRYPTION_KEY for at-rest protection."
67        );
68
69        let contents = tokio::fs::read_to_string(&path).await.map_err(|e| {
70            CredentialError::ProviderError(format!("reading {}: {e}", path.display()))
71        })?;
72
73        let parsed: CredentialFile = toml::from_str(&contents).map_err(|e| {
74            CredentialError::ProviderError(format!("parsing {}: {e}", path.display()))
75        })?;
76
77        let count = parsed.credentials.len();
78        debug!(count, "loaded credentials from file");
79
80        // Convert plaintext strings to SecretString at load time so they are
81        // zeroized on drop. The original HashMap<String, String> is dropped here.
82        let credentials: HashMap<String, SecretString> = parsed
83            .credentials
84            .into_iter()
85            .map(|(k, v)| (k, SecretString::from(v)))
86            .collect();
87
88        Ok(Self {
89            credentials: Arc::new(RwLock::new(credentials)),
90            source_path: path,
91        })
92    }
93
94    /// Reload credentials from the source file without restarting.
95    /// The credential map is swapped atomically under a write lock.
96    pub async fn reload(&self) -> Result<usize, CredentialError> {
97        let contents = tokio::fs::read_to_string(&self.source_path)
98            .await
99            .map_err(|e| {
100                CredentialError::ProviderError(format!(
101                    "reading {}: {e}",
102                    self.source_path.display()
103                ))
104            })?;
105        let parsed: CredentialFile = toml::from_str(&contents).map_err(|e| {
106            CredentialError::ProviderError(format!("parsing {}: {e}", self.source_path.display()))
107        })?;
108        let new_creds: HashMap<String, SecretString> = parsed
109            .credentials
110            .into_iter()
111            .map(|(k, v)| (k, SecretString::from(v)))
112            .collect();
113        let count = new_creds.len();
114        let mut creds = self.credentials.write().await;
115        *creds = new_creds;
116        info!(count, path = %self.source_path.display(), "reloaded credentials from file");
117        Ok(count)
118    }
119}
120
121#[async_trait]
122impl CredentialProvider for FileProvider {
123    async fn resolve(&self, reference: &str) -> Result<SecretString, CredentialError> {
124        use secrecy::ExposeSecret;
125        let creds = self.credentials.read().await;
126        creds
127            .get(reference)
128            .map(|v| SecretString::from(v.expose_secret().to_string()))
129            .ok_or_else(|| {
130                warn!(reference, "credential not found in file provider");
131                CredentialError::NotFound(reference.to_string())
132            })
133    }
134
135    async fn list_refs(&self) -> Result<Vec<CredentialRef>, CredentialError> {
136        let creds = self.credentials.read().await;
137        Ok(creds
138            .keys()
139            .map(|name| CredentialRef {
140                name: name.clone(),
141                provider: "file".into(),
142                last_rotated: None,
143            })
144            .collect())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use secrecy::ExposeSecret;
152    use std::io::Write as _;
153    use tempfile::NamedTempFile;
154
155    fn write_temp_toml(content: &str) -> NamedTempFile {
156        let mut f = NamedTempFile::new().expect("create temp file");
157        f.write_all(content.as_bytes()).expect("write temp file");
158        f.flush().expect("flush temp file");
159        f
160    }
161
162    #[tokio::test]
163    async fn loads_plaintext_toml() {
164        let f = write_temp_toml(
165            r#"
166[credentials]
167aws_key = "AKIAIOSFODNN7EXAMPLE"
168github_token = "ghp_abc123"
169"#,
170        );
171
172        let provider = FileProvider::from_path(f.path()).await.unwrap();
173        assert_eq!(
174            provider.resolve("aws_key").await.unwrap().expose_secret(),
175            "AKIAIOSFODNN7EXAMPLE"
176        );
177        assert_eq!(
178            provider
179                .resolve("github_token")
180                .await
181                .unwrap()
182                .expose_secret(),
183            "ghp_abc123"
184        );
185    }
186
187    #[tokio::test]
188    async fn not_found_returns_error() {
189        let f = write_temp_toml(
190            r#"
191[credentials]
192one = "1"
193"#,
194        );
195        let provider = FileProvider::from_path(f.path()).await.unwrap();
196        let err = provider.resolve("nonexistent").await.unwrap_err();
197        assert!(matches!(err, CredentialError::NotFound(_)));
198    }
199
200    #[tokio::test]
201    async fn list_refs_returns_all_keys() {
202        let f = write_temp_toml(
203            r#"
204[credentials]
205a = "1"
206b = "2"
207c = "3"
208"#,
209        );
210        let provider = FileProvider::from_path(f.path()).await.unwrap();
211        let refs = provider.list_refs().await.unwrap();
212        assert_eq!(refs.len(), 3);
213        assert!(refs.iter().all(|r| r.provider == "file"));
214    }
215
216    #[tokio::test]
217    async fn rejects_age_extension() {
218        let dir = tempfile::tempdir().unwrap();
219        let age_path = dir.path().join("creds.toml.age");
220        std::fs::write(&age_path, b"not real age data").unwrap();
221        let err = FileProvider::from_path(&age_path).await.unwrap_err();
222        assert!(matches!(err, CredentialError::ProviderError(_)));
223    }
224
225    #[tokio::test]
226    async fn rejects_malformed_toml() {
227        let f = write_temp_toml("this is not valid toml {{{{");
228        let err = FileProvider::from_path(f.path()).await.unwrap_err();
229        assert!(matches!(err, CredentialError::ProviderError(_)));
230    }
231
232    #[tokio::test]
233    async fn toml_with_special_keys() {
234        let f = write_temp_toml(
235            r#"
236[credentials]
237"my.api-key" = "secret-value"
238"dots.and.hyphens-too" = "another-secret"
239simple_key = "plain"
240"#,
241        );
242        let provider = FileProvider::from_path(f.path()).await.unwrap();
243        assert_eq!(
244            provider
245                .resolve("my.api-key")
246                .await
247                .unwrap()
248                .expose_secret(),
249            "secret-value"
250        );
251        assert_eq!(
252            provider
253                .resolve("dots.and.hyphens-too")
254                .await
255                .unwrap()
256                .expose_secret(),
257            "another-secret"
258        );
259        assert_eq!(
260            provider
261                .resolve("simple_key")
262                .await
263                .unwrap()
264                .expose_secret(),
265            "plain"
266        );
267        assert_eq!(provider.list_refs().await.unwrap().len(), 3);
268    }
269
270    #[tokio::test]
271    async fn empty_credentials_table() {
272        let f = write_temp_toml(
273            r#"
274[credentials]
275"#,
276        );
277        let provider = FileProvider::from_path(f.path()).await.unwrap();
278        assert!(provider.list_refs().await.unwrap().is_empty());
279        assert!(matches!(
280            provider.resolve("anything").await.unwrap_err(),
281            CredentialError::NotFound(_)
282        ));
283    }
284}