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