src/http/secret.rs 2.2 K raw
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
}