1use std::collections::HashMap;
15use std::env::VarError;
16use std::io;
17use std::process::{Command, Output};
18
19use thiserror::Error;
20
21use crate::manifest::{SecretProvider, SecretSpec};
22
23#[derive(Debug, Clone)]
25pub struct ResolvedSecret {
26 pub env: String,
27 pub value: String,
28}
29
30#[derive(Debug, Error)]
33pub enum SecretsError {
34 #[error("could not resolve secret for env `{env}` via {provider}: {message}")]
35 Resolution {
36 env: String,
37 provider: &'static str,
38 message: String,
39 },
40 #[error("provider tool not available for env `{env}`: {message}")]
41 ProviderUnavailable {
42 env: String,
43 provider: &'static str,
44 message: String,
45 },
46}
47
48pub trait SecretsResolver {
54 fn resolve_all(&self, specs: &[SecretSpec]) -> Result<Vec<ResolvedSecret>, SecretsError>;
57}
58
59#[derive(Debug, Default, Clone)]
63pub struct TestResolver {
64 by_ref: HashMap<String, String>,
65}
66
67impl TestResolver {
68 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 #[must_use]
74 pub fn with(mut self, reference: impl Into<String>, value: impl Into<String>) -> Self {
75 self.by_ref.insert(reference.into(), value.into());
76 self
77 }
78}
79
80impl SecretsResolver for TestResolver {
81 fn resolve_all(&self, specs: &[SecretSpec]) -> Result<Vec<ResolvedSecret>, SecretsError> {
82 specs
83 .iter()
84 .map(|spec| {
85 self.by_ref
86 .get(&spec.reference)
87 .map(|value| ResolvedSecret {
88 env: spec.env.clone(),
89 value: value.clone(),
90 })
91 .ok_or_else(|| SecretsError::Resolution {
92 env: spec.env.clone(),
93 provider: "test",
94 message: format!("no fixture for ref `{}`", spec.reference),
95 })
96 })
97 .collect()
98 }
99}
100
101#[derive(Debug, Default, Clone, Copy)]
105pub struct ProductionResolver;
106
107impl ProductionResolver {
108 #[must_use]
109 pub fn new() -> Self {
110 Self
111 }
112}
113
114impl SecretsResolver for ProductionResolver {
115 fn resolve_all(&self, specs: &[SecretSpec]) -> Result<Vec<ResolvedSecret>, SecretsError> {
116 specs
117 .iter()
118 .map(|spec| match spec.provider {
119 SecretProvider::OnePassword => resolve_one_password(spec),
120 SecretProvider::Env => resolve_env(spec),
121 })
122 .collect()
123 }
124}
125
126fn resolve_env(spec: &SecretSpec) -> Result<ResolvedSecret, SecretsError> {
129 match std::env::var(&spec.reference) {
130 Ok(value) => Ok(ResolvedSecret {
131 env: spec.env.clone(),
132 value,
133 }),
134 Err(VarError::NotPresent) => Err(SecretsError::Resolution {
135 env: spec.env.clone(),
136 provider: "env",
137 message: format!("env var `{}` is not set", spec.reference),
138 }),
139 Err(VarError::NotUnicode(_)) => Err(SecretsError::Resolution {
140 env: spec.env.clone(),
141 provider: "env",
142 message: format!("env var `{}` is not valid Unicode", spec.reference),
143 }),
144 }
145}
146
147fn resolve_one_password(spec: &SecretSpec) -> Result<ResolvedSecret, SecretsError> {
149 let result = Command::new("op").arg("read").arg(&spec.reference).output();
150 parse_op_output(spec, result)
151}
152
153pub(crate) fn parse_op_output(
158 spec: &SecretSpec,
159 result: io::Result<Output>,
160) -> Result<ResolvedSecret, SecretsError> {
161 let output = match result {
162 Ok(out) => out,
163 Err(err) if err.kind() == io::ErrorKind::NotFound => {
164 return Err(SecretsError::ProviderUnavailable {
165 env: spec.env.clone(),
166 provider: "one_password",
167 message: "`op` not found on PATH; install the 1Password CLI \
168 and run `op signin`, then retry"
169 .into(),
170 });
171 }
172 Err(err) => {
173 return Err(SecretsError::ProviderUnavailable {
174 env: spec.env.clone(),
175 provider: "one_password",
176 message: format!("could not spawn `op read`: {err}"),
177 });
178 }
179 };
180
181 if !output.status.success() {
182 let stderr = String::from_utf8_lossy(&output.stderr);
183 let trimmed = stderr.trim();
184 let hint = "is `op` signed in? run `op signin` and retry";
185 let message = if trimmed.is_empty() {
186 format!("`op read` failed (status: {}); {hint}", output.status)
187 } else {
188 format!("`op read` failed: {trimmed} ({hint})")
189 };
190 return Err(SecretsError::Resolution {
191 env: spec.env.clone(),
192 provider: "one_password",
193 message,
194 });
195 }
196
197 let Ok(mut value) = String::from_utf8(output.stdout) else {
198 return Err(SecretsError::Resolution {
199 env: spec.env.clone(),
200 provider: "one_password",
201 message: "secret value returned by `op read` is not valid UTF-8".into(),
202 });
203 };
204 if value.ends_with('\n') {
207 value.pop();
208 if value.ends_with('\r') {
209 value.pop();
210 }
211 }
212
213 Ok(ResolvedSecret {
214 env: spec.env.clone(),
215 value,
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::manifest::SecretProvider;
223
224 fn spec(env: &str, reference: &str) -> SecretSpec {
225 SecretSpec {
226 env: env.into(),
227 reference: reference.into(),
228 provider: SecretProvider::Env,
229 }
230 }
231
232 fn op_spec(env: &str, reference: &str) -> SecretSpec {
233 SecretSpec {
234 env: env.into(),
235 reference: reference.into(),
236 provider: SecretProvider::OnePassword,
237 }
238 }
239
240 #[test]
241 fn test_resolver_returns_fixture_values() {
242 let r = TestResolver::new()
243 .with("ref-a", "AAA")
244 .with("ref-b", "BBB");
245 let specs = vec![spec("A", "ref-a"), spec("B", "ref-b")];
246 let out = r.resolve_all(&specs).unwrap();
247 assert_eq!(out.len(), 2);
248 assert_eq!(out[0].env, "A");
249 assert_eq!(out[0].value, "AAA");
250 assert_eq!(out[1].value, "BBB");
251 }
252
253 #[test]
254 fn test_resolver_errors_on_missing_ref() {
255 let resolver = TestResolver::new();
256 let err = resolver.resolve_all(&[spec("A", "missing")]).unwrap_err();
257 match err {
258 SecretsError::Resolution { env, .. } => assert_eq!(env, "A"),
259 SecretsError::ProviderUnavailable { .. } => panic!("expected Resolution, got {err:?}"),
260 }
261 }
262
263 #[test]
276 #[serial_test::serial]
277 fn env_provider_reads_reference_writes_env() {
278 let var = "QLI_ENV_PROVIDER_TEST_READ";
280 std::env::set_var(var, "value-from-host");
281 let s = SecretSpec {
282 env: "TARGET_ENV".into(),
283 reference: var.into(),
284 provider: SecretProvider::Env,
285 };
286 let resolved = resolve_env(&s).unwrap();
287 std::env::remove_var(var);
288 assert_eq!(resolved.env, "TARGET_ENV");
289 assert_eq!(resolved.value, "value-from-host");
290 }
291
292 #[test]
293 #[serial_test::serial]
294 fn env_provider_errors_when_reference_unset() {
295 let var = "QLI_ENV_PROVIDER_TEST_MISSING";
296 std::env::remove_var(var);
297 let s = SecretSpec {
298 env: "TARGET_ENV".into(),
299 reference: var.into(),
300 provider: SecretProvider::Env,
301 };
302 match resolve_env(&s).unwrap_err() {
303 SecretsError::Resolution {
304 env,
305 provider,
306 message,
307 } => {
308 assert_eq!(env, "TARGET_ENV");
309 assert_eq!(provider, "env");
310 assert!(message.contains(var), "message: {message}");
311 }
312 err @ SecretsError::ProviderUnavailable { .. } => {
313 panic!("expected Resolution, got {err:?}")
314 }
315 }
316 }
317
318 #[test]
319 #[serial_test::serial]
320 fn production_resolver_dispatches_per_spec_provider() {
321 let var = "QLI_ENV_PROVIDER_TEST_DISPATCH";
322 std::env::set_var(var, "DISPATCHED");
323 let resolver = ProductionResolver::new();
324 let out = resolver
325 .resolve_all(&[SecretSpec {
326 env: "OUT".into(),
327 reference: var.into(),
328 provider: SecretProvider::Env,
329 }])
330 .expect("env provider should resolve");
331 std::env::remove_var(var);
332 assert_eq!(out.len(), 1);
333 assert_eq!(out[0].env, "OUT");
334 assert_eq!(out[0].value, "DISPATCHED");
335 }
336
337 #[cfg(unix)]
349 fn ok_status() -> std::process::ExitStatus {
350 use std::os::unix::process::ExitStatusExt;
351 std::process::ExitStatus::from_raw(0)
352 }
353
354 #[cfg(unix)]
355 fn fail_status() -> std::process::ExitStatus {
356 use std::os::unix::process::ExitStatusExt;
359 std::process::ExitStatus::from_raw(1 << 8)
360 }
361
362 #[test]
363 #[cfg(unix)]
364 fn op_provider_returns_provider_unavailable_when_op_missing() {
365 let s = op_spec("TOKEN", "op://Vault/Item/field");
366 let result: io::Result<Output> = Err(io::Error::new(io::ErrorKind::NotFound, "no op"));
367 match parse_op_output(&s, result).unwrap_err() {
368 SecretsError::ProviderUnavailable {
369 env,
370 provider,
371 message,
372 } => {
373 assert_eq!(env, "TOKEN");
374 assert_eq!(provider, "one_password");
375 assert!(message.contains("op"), "message: {message}");
378 assert!(
379 message.contains("signin") || message.contains("install"),
380 "expected install/signin hint, got: {message}",
381 );
382 }
383 err @ SecretsError::Resolution { .. } => {
384 panic!("expected ProviderUnavailable, got {err:?}")
385 }
386 }
387 }
388
389 #[test]
390 #[cfg(unix)]
391 fn op_provider_returns_provider_unavailable_for_other_spawn_errors() {
392 let s = op_spec("TOKEN", "op://Vault/Item/field");
396 let result: io::Result<Output> =
397 Err(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
398 match parse_op_output(&s, result).unwrap_err() {
399 SecretsError::ProviderUnavailable { message, .. } => {
400 assert!(message.contains("could not spawn"), "message: {message}");
401 assert!(message.contains("denied"), "message: {message}");
402 }
403 err @ SecretsError::Resolution { .. } => {
404 panic!("expected ProviderUnavailable, got {err:?}")
405 }
406 }
407 }
408
409 #[test]
410 #[cfg(unix)]
411 fn op_provider_maps_nonzero_exit_to_resolution_with_signin_hint() {
412 let s = op_spec("TOKEN", "op://Vault/Item/field");
413 let output = Output {
414 status: fail_status(),
415 stdout: Vec::new(),
416 stderr: b"[ERROR] not signed in\n".to_vec(),
417 };
418 match parse_op_output(&s, Ok(output)).unwrap_err() {
419 SecretsError::Resolution {
420 env,
421 provider,
422 message,
423 } => {
424 assert_eq!(env, "TOKEN");
425 assert_eq!(provider, "one_password");
426 assert!(message.contains("not signed in"), "message: {message}");
427 assert!(
428 message.contains("op signin"),
429 "expected signin hint: {message}"
430 );
431 }
432 err @ SecretsError::ProviderUnavailable { .. } => {
433 panic!("expected Resolution, got {err:?}")
434 }
435 }
436 }
437
438 #[test]
439 #[cfg(unix)]
440 fn op_provider_handles_failure_with_empty_stderr() {
441 let s = op_spec("TOKEN", "op://Vault/Item/field");
442 let output = Output {
443 status: fail_status(),
444 stdout: Vec::new(),
445 stderr: Vec::new(),
446 };
447 match parse_op_output(&s, Ok(output)).unwrap_err() {
448 SecretsError::Resolution { message, .. } => {
449 assert!(message.contains("status"), "message: {message}");
450 assert!(
451 message.contains("op signin"),
452 "expected signin hint: {message}"
453 );
454 }
455 err @ SecretsError::ProviderUnavailable { .. } => {
456 panic!("expected Resolution, got {err:?}")
457 }
458 }
459 }
460
461 #[test]
462 #[cfg(unix)]
463 fn op_provider_strips_single_trailing_newline_from_value() {
464 let s = op_spec("TOKEN", "op://Vault/Item/field");
465 let output = Output {
466 status: ok_status(),
467 stdout: b"sup3r-secret\n".to_vec(),
468 stderr: Vec::new(),
469 };
470 let resolved = parse_op_output(&s, Ok(output)).unwrap();
471 assert_eq!(resolved.env, "TOKEN");
472 assert_eq!(resolved.value, "sup3r-secret");
473 }
474
475 #[test]
476 #[cfg(unix)]
477 fn op_provider_strips_crlf_terminator() {
478 let s = op_spec("TOKEN", "op://Vault/Item/field");
479 let output = Output {
480 status: ok_status(),
481 stdout: b"sup3r-secret\r\n".to_vec(),
482 stderr: Vec::new(),
483 };
484 let resolved = parse_op_output(&s, Ok(output)).unwrap();
485 assert_eq!(resolved.value, "sup3r-secret");
486 }
487
488 #[test]
489 #[cfg(unix)]
490 fn op_provider_preserves_internal_newlines_and_no_terminator() {
491 let s = op_spec("TOKEN", "op://Vault/Item/field");
493 let output = Output {
494 status: ok_status(),
495 stdout: b"line-one\nline-two".to_vec(),
496 stderr: Vec::new(),
497 };
498 let resolved = parse_op_output(&s, Ok(output)).unwrap();
499 assert_eq!(resolved.value, "line-one\nline-two");
500 }
501
502 #[test]
503 #[cfg(unix)]
504 fn op_provider_rejects_non_utf8_value() {
505 let s = op_spec("TOKEN", "op://Vault/Item/field");
506 let output = Output {
507 status: ok_status(),
508 stdout: vec![0xff, 0xfe, 0xfd],
509 stderr: Vec::new(),
510 };
511 match parse_op_output(&s, Ok(output)).unwrap_err() {
512 SecretsError::Resolution {
513 env,
514 provider,
515 message,
516 } => {
517 assert_eq!(env, "TOKEN");
518 assert_eq!(provider, "one_password");
519 assert!(message.contains("UTF-8"), "message: {message}");
520 }
521 err @ SecretsError::ProviderUnavailable { .. } => {
522 panic!("expected Resolution, got {err:?}")
523 }
524 }
525 }
526}