Skip to main content

arbiter_credential/
file_provider.rs

1//! File-based credential provider.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use async_trait::async_trait;
7use secrecy::SecretString;
8use serde::Deserialize;
9use tracing::{debug, info, warn};
10
11use crate::error::CredentialError;
12use crate::provider::{CredentialProvider, CredentialRef};
13
14#[derive(Debug, Deserialize)]
15struct CredentialFile {
16    credentials: HashMap<String, String>,
17}
18
19#[derive(Debug)]
20pub struct FileProvider {
21    credentials: HashMap<String, String>,
22    #[allow(dead_code)]
23    source_path: PathBuf,
24}
25
26impl FileProvider {
27    pub async fn from_path(path: impl AsRef<Path>) -> Result<Self, CredentialError> {
28        let path = path.as_ref().to_path_buf();
29        info!(path = %path.display(), "loading credential file");
30
31        if path.extension().is_some_and(|ext| ext == "age") {
32            return Err(CredentialError::ProviderError(
33                "encrypted .age files are not supported".into(),
34            ));
35        }
36
37        let contents = tokio::fs::read_to_string(&path).await.map_err(|e| {
38            CredentialError::ProviderError(format!("reading {}: {e}", path.display()))
39        })?;
40
41        let parsed: CredentialFile = toml::from_str(&contents).map_err(|e| {
42            CredentialError::ProviderError(format!("parsing {}: {e}", path.display()))
43        })?;
44
45        let count = parsed.credentials.len();
46        debug!(count, "loaded credentials from file");
47
48        Ok(Self {
49            credentials: parsed.credentials,
50            source_path: path,
51        })
52    }
53}
54
55#[async_trait]
56impl CredentialProvider for FileProvider {
57    async fn resolve(&self, reference: &str) -> Result<SecretString, CredentialError> {
58        self.credentials
59            .get(reference)
60            .map(|v| SecretString::from(v.clone()))
61            .ok_or_else(|| {
62                warn!(reference, "credential not found in file provider");
63                CredentialError::NotFound(reference.to_string())
64            })
65    }
66
67    async fn list_refs(&self) -> Result<Vec<CredentialRef>, CredentialError> {
68        Ok(self
69            .credentials
70            .keys()
71            .map(|name| CredentialRef {
72                name: name.clone(),
73                provider: "file".into(),
74                last_rotated: None,
75            })
76            .collect())
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use secrecy::ExposeSecret;
84    use std::io::Write as _;
85    use tempfile::NamedTempFile;
86
87    fn write_temp_toml(content: &str) -> NamedTempFile {
88        let mut f = NamedTempFile::new().expect("create temp file");
89        f.write_all(content.as_bytes()).expect("write temp file");
90        f.flush().expect("flush temp file");
91        f
92    }
93
94    #[tokio::test]
95    async fn loads_plaintext_toml() {
96        let f = write_temp_toml(
97            r#"
98[credentials]
99aws_key = "AKIAIOSFODNN7EXAMPLE"
100github_token = "ghp_abc123"
101"#,
102        );
103
104        let provider = FileProvider::from_path(f.path()).await.unwrap();
105        assert_eq!(
106            provider.resolve("aws_key").await.unwrap().expose_secret(),
107            "AKIAIOSFODNN7EXAMPLE"
108        );
109        assert_eq!(
110            provider
111                .resolve("github_token")
112                .await
113                .unwrap()
114                .expose_secret(),
115            "ghp_abc123"
116        );
117    }
118
119    #[tokio::test]
120    async fn not_found_returns_error() {
121        let f = write_temp_toml(
122            r#"
123[credentials]
124one = "1"
125"#,
126        );
127        let provider = FileProvider::from_path(f.path()).await.unwrap();
128        let err = provider.resolve("nonexistent").await.unwrap_err();
129        assert!(matches!(err, CredentialError::NotFound(_)));
130    }
131
132    #[tokio::test]
133    async fn list_refs_returns_all_keys() {
134        let f = write_temp_toml(
135            r#"
136[credentials]
137a = "1"
138b = "2"
139c = "3"
140"#,
141        );
142        let provider = FileProvider::from_path(f.path()).await.unwrap();
143        let refs = provider.list_refs().await.unwrap();
144        assert_eq!(refs.len(), 3);
145        assert!(refs.iter().all(|r| r.provider == "file"));
146    }
147
148    #[tokio::test]
149    async fn rejects_age_extension() {
150        let dir = tempfile::tempdir().unwrap();
151        let age_path = dir.path().join("creds.toml.age");
152        std::fs::write(&age_path, b"not real age data").unwrap();
153        let err = FileProvider::from_path(&age_path).await.unwrap_err();
154        assert!(matches!(err, CredentialError::ProviderError(_)));
155    }
156
157    #[tokio::test]
158    async fn rejects_malformed_toml() {
159        let f = write_temp_toml("this is not valid toml {{{{");
160        let err = FileProvider::from_path(f.path()).await.unwrap_err();
161        assert!(matches!(err, CredentialError::ProviderError(_)));
162    }
163
164    #[tokio::test]
165    async fn toml_with_special_keys() {
166        let f = write_temp_toml(
167            r#"
168[credentials]
169"my.api-key" = "secret-value"
170"dots.and.hyphens-too" = "another-secret"
171simple_key = "plain"
172"#,
173        );
174        let provider = FileProvider::from_path(f.path()).await.unwrap();
175        assert_eq!(
176            provider
177                .resolve("my.api-key")
178                .await
179                .unwrap()
180                .expose_secret(),
181            "secret-value"
182        );
183        assert_eq!(
184            provider
185                .resolve("dots.and.hyphens-too")
186                .await
187                .unwrap()
188                .expose_secret(),
189            "another-secret"
190        );
191        assert_eq!(
192            provider
193                .resolve("simple_key")
194                .await
195                .unwrap()
196                .expose_secret(),
197            "plain"
198        );
199        assert_eq!(provider.list_refs().await.unwrap().len(), 3);
200    }
201
202    #[tokio::test]
203    async fn empty_credentials_table() {
204        let f = write_temp_toml(
205            r#"
206[credentials]
207"#,
208        );
209        let provider = FileProvider::from_path(f.path()).await.unwrap();
210        assert!(provider.list_refs().await.unwrap().is_empty());
211        assert!(matches!(
212            provider.resolve("anything").await.unwrap_err(),
213            CredentialError::NotFound(_)
214        ));
215    }
216}