arbiter_credential/
file_provider.rs1use 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
21impl 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
38impl 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 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 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 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}