Skip to main content

qli_ext/
secrets.rs

1//! Secret resolution trait used by the dispatcher.
2//!
3//! Phase 1F froze the trait surface; Phase 1G filled in the real
4//! [`OnePassword`](crate::manifest::SecretProvider::OnePassword) and
5//! [`Env`](crate::manifest::SecretProvider::Env) providers. Tests in this
6//! crate use a [`TestResolver`] that returns sentinel strings so the
7//! "secrets never leak" regression test can drive every guard path.
8//!
9//! Providers fail closed: any resolution error short-circuits
10//! [`SecretsResolver::resolve_all`] and the dispatcher aborts before
11//! spawning the child. Resolved values are never logged through
12//! [`tracing`]; the audit log records only env-var names.
13
14use 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/// A resolved secret pair (env-var name, value).
24#[derive(Debug, Clone)]
25pub struct ResolvedSecret {
26    pub env: String,
27    pub value: String,
28}
29
30/// Errors a [`SecretsResolver`] may raise. Variants are deliberately broad —
31/// callers surface them with manifest context.
32#[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
48/// Strategy for fetching secret values.
49///
50/// Implementations must be deterministic for a given input (the dispatcher
51/// resolves all secrets up-front and fails closed on the first error). They
52/// must not log resolved values — only references.
53pub trait SecretsResolver {
54    /// Resolve every secret in `specs`. On any failure, return immediately
55    /// — the dispatcher discards everything and aborts before spawning.
56    fn resolve_all(&self, specs: &[SecretSpec]) -> Result<Vec<ResolvedSecret>, SecretsError>;
57}
58
59/// In-process resolver used in tests. Backed by a fixed map keyed on the
60/// secret's `ref` field. Returns [`SecretsError::Resolution`] for any spec
61/// whose reference isn't in the map.
62#[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/// Production resolver that dispatches each [`SecretSpec`] to the right
102/// provider. Used by the `qli` binary; library callers can keep using
103/// [`TestResolver`] (or write their own) for unit tests.
104#[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
126/// `Env` provider: read `spec.reference` from the dispatcher's environment
127/// and bind the value into the child under `spec.env`.
128fn 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
147/// `OnePassword` provider: spawn `op read <reference>` and capture stdout.
148fn 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
153/// Map a captured `op read` invocation result into a [`ResolvedSecret`] or a
154/// targeted [`SecretsError`]. Split out from [`resolve_one_password`] so unit
155/// tests can construct `io::Result<Output>` values directly and exercise
156/// every error branch without a fake `op` binary on PATH.
157pub(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    // `op read` terminates the value with a single trailing newline.
205    // Strip exactly that — preserve any other whitespace the secret carries.
206    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    // ----- Env provider --------------------------------------------------
264    //
265    // Two layers of isolation, both deliberate:
266    //
267    //   1. Unique env var names per test (`QLI_ENV_PROVIDER_TEST_*`) — easy
268    //      to grep for, hard to collide accidentally.
269    //   2. `#[serial_test::serial]` — hard serialization across env-mutating
270    //      tests in this binary. Phase 1L added this layer because integration
271    //      tests under `tests/` (and `tests/common/mod.rs::XdgSandbox`) also
272    //      mutate env, and unique-name discipline can't protect against a
273    //      careless future test that forgets it.
274
275    #[test]
276    #[serial_test::serial]
277    fn env_provider_reads_reference_writes_env() {
278        // Crucial: env != reference, so a future swap fails this test.
279        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    // ----- OnePassword provider -----------------------------------------
338    //
339    // Tests construct fake `io::Result<Output>` values and feed them to
340    // `parse_op_output`. This exercises every error branch without
341    // depending on the user's actual `op` install or PATH.
342    //
343    // `ExitStatus::from_raw` is unix-only — these tests are gated to
344    // `#[cfg(unix)]`. The `op` CLI itself is unix-first (macOS / Linux);
345    // a Windows port of these tests would need a different status
346    // constructor.
347
348    #[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        // Exit 1 — what `op read` returns when not signed in or the ref
357        // doesn't resolve.
358        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                // Both pieces the user needs: which secret failed AND how
376                // to fix it.
377                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        // Permission denied (or any non-NotFound) must not be
393        // misclassified as "op missing" — it's still ProviderUnavailable
394        // but with a different message.
395        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        // Multi-line secret with no trailing newline — strip nothing.
492        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}