1use chrono::{DateTime, Utc};
2use serde::Deserialize;
3use std::path::PathBuf;
4use std::process::Command;
5
6use crate::auth::Role;
7
8fn default_project() -> String {
9 "default".to_string()
10}
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CredentialSource {
14 Env { var: String },
15 File { path: PathBuf },
16 Command { command: String },
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct KeyPolicy {
21 pub id: String,
22 pub role: Role,
23 pub project: String,
24 pub benchmark_regex: Option<String>,
25 pub expires_at: Option<DateTime<Utc>>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct LoadedCredential {
30 pub policy: KeyPolicy,
31 pub secret: String,
32}
33
34#[derive(Debug, thiserror::Error)]
35pub enum CredentialSourceError {
36 #[error("missing environment variable {0}")]
37 MissingEnvVar(String),
38
39 #[error("failed to read key material from {source_name}: {message}")]
40 ReadFailure {
41 source_name: String,
42 message: String,
43 },
44
45 #[error("command source failed (status {status})")]
46 CommandFailure { status: i32 },
47
48 #[error("key material document parse failed: {0}")]
49 ParseFailure(String),
50
51 #[error("invalid key policy document: {0}")]
52 InvalidDocument(String),
53}
54
55#[derive(Debug, Clone, Deserialize)]
56struct RawCredential {
57 id: String,
58 role: Role,
59 #[serde(default = "default_project")]
60 project: String,
61 #[serde(default)]
62 benchmark_regex: Option<String>,
63 #[serde(default)]
64 expires_at: Option<DateTime<Utc>>,
65 secret: String,
66}
67
68#[derive(Debug, Deserialize)]
69struct JsonDocument {
70 #[serde(default)]
71 keys: Vec<RawCredential>,
72 #[serde(default)]
73 api_keys: Vec<RawCredential>,
74}
75
76#[derive(Debug, Deserialize)]
77struct TomlDocument {
78 #[serde(default)]
79 keys: Vec<RawCredential>,
80 #[serde(default)]
81 api_keys: Vec<RawCredential>,
82}
83
84impl CredentialSource {
85 pub fn load(&self) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
86 let content = match self {
87 Self::Env { var } => {
88 std::env::var(var).map_err(|_| CredentialSourceError::MissingEnvVar(var.clone()))?
89 }
90 Self::File { path } => {
91 std::fs::read_to_string(path).map_err(|e| CredentialSourceError::ReadFailure {
92 source_name: format!("file {}", path.display()),
93 message: e.to_string(),
94 })?
95 }
96 Self::Command { command } => run_command(command)?,
97 };
98
99 parse_credentials_document(&content)
100 }
101}
102
103fn run_command(command: &str) -> Result<String, CredentialSourceError> {
104 if command.trim().is_empty() {
105 return Err(CredentialSourceError::ReadFailure {
106 source_name: "command".to_string(),
107 message: "command is empty".to_string(),
108 });
109 }
110
111 let output =
112 shell_command(command)
113 .output()
114 .map_err(|e| CredentialSourceError::ReadFailure {
115 source_name: "command".to_string(),
116 message: e.to_string(),
117 })?;
118
119 if !output.status.success() {
120 let status = output.status.code().unwrap_or(-1);
121 return Err(CredentialSourceError::CommandFailure { status });
122 }
123
124 Ok(String::from_utf8_lossy(&output.stdout).to_string())
125}
126
127#[cfg(windows)]
128fn shell_command(command: &str) -> Command {
129 let mut cmd = Command::new("powershell");
130 cmd.arg("-NoProfile").arg("-Command").arg(command);
131 cmd
132}
133
134#[cfg(not(windows))]
135fn shell_command(command: &str) -> Command {
136 let mut cmd = Command::new("sh");
137 cmd.arg("-c").arg(command);
138 cmd
139}
140
141fn unwrap_wrapper_credentials(
142 keys: Vec<RawCredential>,
143 api_keys: Vec<RawCredential>,
144) -> Result<Vec<RawCredential>, CredentialSourceError> {
145 match (keys.is_empty(), api_keys.is_empty()) {
146 (false, true) => Ok(keys),
147 (true, false) => Ok(api_keys),
148 (false, false) => Err(CredentialSourceError::InvalidDocument(
149 "document must not contain both 'keys' and 'api_keys'".to_string(),
150 )),
151 (true, true) => Err(CredentialSourceError::InvalidDocument(
152 "document must contain either 'keys' or 'api_keys'".to_string(),
153 )),
154 }
155}
156
157fn validate_loaded_credentials(
158 parsed: Vec<RawCredential>,
159) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
160 let mut out = Vec::with_capacity(parsed.len());
161 for entry in parsed {
162 if entry.id.trim().is_empty() {
163 return Err(CredentialSourceError::InvalidDocument(
164 "entry id must not be empty".to_string(),
165 ));
166 }
167 if entry.secret.trim().is_empty() {
168 return Err(CredentialSourceError::InvalidDocument(format!(
169 "entry '{}' has empty secret",
170 entry.id
171 )));
172 }
173
174 out.push(LoadedCredential {
175 policy: KeyPolicy {
176 id: entry.id,
177 role: entry.role,
178 project: entry.project,
179 benchmark_regex: entry.benchmark_regex,
180 expires_at: entry.expires_at,
181 },
182 secret: entry.secret,
183 });
184 }
185
186 Ok(out)
187}
188
189pub fn parse_credentials_document(
190 raw: &str,
191) -> Result<Vec<LoadedCredential>, CredentialSourceError> {
192 let trimmed = raw.trim();
193 if trimmed.is_empty() {
194 return Ok(Vec::new());
195 }
196
197 let mut parse_errors = Vec::new();
198
199 match serde_json::from_str::<Vec<RawCredential>>(trimmed) {
200 Ok(parsed) => return validate_loaded_credentials(parsed),
201 Err(err) => parse_errors.push(err.to_string()),
202 }
203
204 match serde_json::from_str::<JsonDocument>(trimmed) {
205 Ok(doc) => {
206 return validate_loaded_credentials(unwrap_wrapper_credentials(
207 doc.keys,
208 doc.api_keys,
209 )?);
210 }
211 Err(err) => parse_errors.push(err.to_string()),
212 }
213
214 match toml::from_str::<Vec<RawCredential>>(trimmed) {
215 Ok(parsed) => return validate_loaded_credentials(parsed),
216 Err(err) => parse_errors.push(err.to_string()),
217 }
218
219 match toml::from_str::<TomlDocument>(trimmed) {
220 Ok(doc) => {
221 return validate_loaded_credentials(unwrap_wrapper_credentials(
222 doc.keys,
223 doc.api_keys,
224 )?);
225 }
226 Err(err) => parse_errors.push(err.to_string()),
227 }
228
229 Err(CredentialSourceError::ParseFailure(parse_errors.join("; ")))
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use std::path::Path;
236
237 #[cfg(windows)]
238 fn read_file_command(path: &Path) -> String {
239 let path = path.display().to_string().replace('\'', "''");
240 format!("Get-Content -Raw -LiteralPath '{}'", path)
241 }
242
243 #[cfg(not(windows))]
244 fn read_file_command(path: &Path) -> String {
245 format!("cat \"{}\"", path.display())
246 }
247
248 #[cfg(windows)]
249 fn failing_command() -> String {
250 "Write-Error 'boom'; exit 9".to_string()
251 }
252
253 #[cfg(not(windows))]
254 fn failing_command() -> String {
255 "echo boom >&2; exit 9".to_string()
256 }
257
258 #[test]
259 fn parse_json_credentials() {
260 let doc = r#"[
261 {
262 "id":"ci-promoter",
263 "role":"promoter",
264 "project":"my-project",
265 "benchmark_regex":".*",
266 "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
267 }
268 ]"#;
269
270 let creds = parse_credentials_document(doc).unwrap();
271 assert_eq!(creds.len(), 1);
272 assert_eq!(creds[0].policy.id, "ci-promoter");
273 assert_eq!(creds[0].policy.role, Role::Promoter);
274 }
275
276 #[test]
277 fn parse_toml_credentials_table() {
278 let doc = r#"
279 [[keys]]
280 id = "dev"
281 role = "admin"
282 project = "default"
283 secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
284 "#;
285
286 let creds = parse_credentials_document(doc).unwrap();
287 assert_eq!(creds.len(), 1);
288 assert_eq!(creds[0].policy.role, Role::Admin);
289 }
290
291 #[test]
292 fn parse_json_credentials_object_wrapper() {
293 let doc = r#"{
294 "keys": [
295 {
296 "id":"ci-promoter",
297 "role":"promoter",
298 "project":"my-project",
299 "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
300 }
301 ]
302 }"#;
303
304 let creds = parse_credentials_document(doc).unwrap();
305 assert_eq!(creds.len(), 1);
306 assert_eq!(creds[0].policy.id, "ci-promoter");
307 assert_eq!(creds[0].policy.role, Role::Promoter);
308 }
309
310 #[test]
311 fn parse_json_credentials_wrapper_rejects_missing_expected_fields() {
312 let doc = r#"{
313 "apiKeys": [
314 {
315 "id":"ci-promoter",
316 "role":"promoter",
317 "project":"my-project",
318 "secret":"pg_live_abcdefghijklmnopqrstuvwxyz123456"
319 }
320 ]
321 }"#;
322
323 let err = parse_credentials_document(doc).unwrap_err().to_string();
324 assert!(err.contains("document must contain either 'keys' or 'api_keys'"));
325 }
326
327 #[test]
328 fn parse_toml_credentials_wrapper_rejects_duplicate_wrappers() {
329 let doc = r#"
330 [[keys]]
331 id = "dev"
332 role = "admin"
333 project = "default"
334 secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
335
336 [[api_keys]]
337 id = "ci"
338 role = "viewer"
339 project = "default"
340 secret = "pg_test_abcdefghijklmnopqrstuvwxyz123456"
341 "#;
342
343 let err = parse_credentials_document(doc).unwrap_err().to_string();
344 assert!(err.contains("document must not contain both 'keys' and 'api_keys'"));
345 }
346
347 #[test]
348 fn command_source_success() {
349 let temp_dir = tempfile::tempdir().unwrap();
350 let path = temp_dir.path().join("keys.json");
351 std::fs::write(
352 &path,
353 "[{\"id\":\"k1\",\"role\":\"viewer\",\"project\":\"p\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
354 )
355 .unwrap();
356 let src = CredentialSource::Command {
357 command: read_file_command(&path),
358 };
359 let creds = src.load().unwrap();
360 assert_eq!(creds.len(), 1);
361 assert_eq!(creds[0].policy.id, "k1");
362 }
363
364 #[test]
365 fn command_source_failure_hides_stderr() {
366 let src = CredentialSource::Command {
367 command: failing_command(),
368 };
369 let err = src.load().unwrap_err().to_string();
370 assert!(err.contains("status 9"));
371 assert!(!err.contains("boom"));
372 }
373
374 #[test]
375 fn file_source_loads_credentials() {
376 let temp = tempfile::NamedTempFile::new().unwrap();
377 std::fs::write(
378 temp.path(),
379 "[{\"id\":\"f1\",\"role\":\"viewer\",\"project\":\"x\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
380 )
381 .unwrap();
382
383 let src = CredentialSource::File {
384 path: temp.path().to_path_buf(),
385 };
386 let creds = src.load().unwrap();
387 assert_eq!(creds[0].policy.id, "f1");
388 }
389
390 #[test]
391 #[allow(unsafe_code)]
392 fn env_source_loads_credentials() {
393 let var = "PERFGATE_AUTH_TEST_KEYS";
394 unsafe {
396 std::env::set_var(
397 var,
398 "[{\"id\":\"e1\",\"role\":\"viewer\",\"project\":\"x\",\"secret\":\"pg_test_abcdefghijklmnopqrstuvwxyz123456\"}]",
399 );
400 }
401
402 let src = CredentialSource::Env {
403 var: var.to_string(),
404 };
405 let creds = src.load().unwrap();
406 assert_eq!(creds[0].policy.id, "e1");
407 }
408}