| 1 | //! Resolving secret values that shell out, e.g. |
| 2 | //! `$(op read "op://vault/item/field")`. |
| 3 | |
| 4 | use anyhow::{Context, Result, bail}; |
| 5 | use std::process::Command; |
| 6 | |
| 7 | /// If `value` is *entirely* a single `$(…)` command substitution, run the inner |
| 8 | /// command through `sh -c` and return its trimmed stdout. Anything else is |
| 9 | /// returned unchanged — the whole value must be the substitution, so a literal |
| 10 | /// `$(...)` embedded in a longer string is never executed by accident. |
| 11 | pub fn resolve_secret(value: &str) -> Result<String> { |
| 12 | let Some(cmd) = command_substitution(value) else { |
| 13 | return Ok(value.to_string()); |
| 14 | }; |
| 15 | |
| 16 | let output = Command::new("sh") |
| 17 | .arg("-c") |
| 18 | .arg(cmd) |
| 19 | .output() |
| 20 | .with_context(|| format!("running secret command `{cmd}`"))?; |
| 21 | |
| 22 | if !output.status.success() { |
| 23 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 24 | bail!( |
| 25 | "secret command `{cmd}` failed ({}): {}", |
| 26 | output.status, |
| 27 | stderr.trim() |
| 28 | ); |
| 29 | } |
| 30 | |
| 31 | Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) |
| 32 | } |
| 33 | |
| 34 | /// The command inside a value shaped exactly like `$( … )`, or `None`. |
| 35 | fn command_substitution(value: &str) -> Option<&str> { |
| 36 | let trimmed = value.trim(); |
| 37 | let inner = trimmed.strip_prefix("$(")?.strip_suffix(')')?.trim(); |
| 38 | (!inner.is_empty()).then_some(inner) |
| 39 | } |
| 40 | |
| 41 | #[cfg(test)] |
| 42 | mod tests { |
| 43 | use super::*; |
| 44 | |
| 45 | #[test] |
| 46 | fn plain_values_pass_through() { |
| 47 | assert_eq!(resolve_secret("hunter2").unwrap(), "hunter2"); |
| 48 | // A `$(…)` that isn't the whole value is left untouched. |
| 49 | assert_eq!( |
| 50 | resolve_secret("Bearer $(echo x)").unwrap(), |
| 51 | "Bearer $(echo x)" |
| 52 | ); |
| 53 | assert_eq!(resolve_secret("$()").unwrap(), "$()"); |
| 54 | } |
| 55 | |
| 56 | #[test] |
| 57 | fn command_substitution_runs_and_trims() { |
| 58 | assert_eq!(resolve_secret("$(printf 'sk-123')").unwrap(), "sk-123"); |
| 59 | // Surrounding whitespace on the value and on the output are both dropped. |
| 60 | assert_eq!(resolve_secret(" $(echo padded) ").unwrap(), "padded"); |
| 61 | } |
| 62 | |
| 63 | #[test] |
| 64 | fn failing_command_is_an_error() { |
| 65 | let err = resolve_secret("$(exit 3)").unwrap_err().to_string(); |
| 66 | assert!(err.contains("failed"), "unexpected error: {err}"); |
| 67 | } |
| 68 | } |