Skip to main content

orch/
secret_ref.rs

1//! Config values that name a secret instead of holding it, so the config file holds no secret.
2//! `passbox:<name>` is read once through passbox, `${NAME}` reads an environment variable, any
3//! other value is used as written. A reference is the whole value, never part of a longer one
4
5/// Blocking, it waits on passbox's Touch ID prompt, so call it from `spawn_blocking`
6pub fn resolve(value: &str) -> anyhow::Result<String> {
7    resolve_with("passbox", value)
8}
9
10/// A config value resolved with [`resolve`], read once per daemon start: a passbox read waits on
11/// Touch ID, so a secret read in two places must not prompt twice
12pub async fn config_secret(key: &str) -> anyhow::Result<String> {
13    static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
14        std::sync::OnceLock::new();
15    let cache = CACHE.get_or_init(Default::default);
16
17    // The global config only: a project file must never point a secret at its own endpoint
18    let value = crate::config::get_global(key)?;
19    if let Some(secret) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&value) {
20        return Ok(secret.clone());
21    }
22
23    let raw = value.clone();
24    let secret = tokio::task::spawn_blocking(move || resolve(&raw))
25        .await?
26        .inspect_err(|e| tracing::warn!(key, err = %e, "config secret unavailable"))?;
27    cache
28        .lock()
29        .unwrap_or_else(|e| e.into_inner())
30        .insert(value, secret.clone());
31    Ok(secret)
32}
33
34fn expand_env(value: &str) -> anyhow::Result<String> {
35    let Some(name) = env_name(value) else {
36        return Ok(value.to_string());
37    };
38    match std::env::var(name) {
39        Ok(v) if !v.is_empty() => Ok(v),
40        _ => anyhow::bail!("environment variable {name} is unset or empty"),
41    }
42}
43
44/// `NAME` of a value that is exactly `${NAME}`
45fn env_name(value: &str) -> Option<&str> {
46    let name = value.trim().strip_prefix("${")?.strip_suffix('}')?;
47    let valid = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
48    valid.then_some(name)
49}
50
51fn resolve_with(passbox: &str, value: &str) -> anyhow::Result<String> {
52    let Some(name) = value.strip_prefix("passbox:") else {
53        return expand_env(value);
54    };
55
56    // `exec --stdin` hands the value to a child, so it works without a terminal and does not
57    // depend on a flag of `get` that passbox versions differ on
58    let out = std::process::Command::new(passbox)
59        .args(["exec", "--stdin", name, "--", "/bin/cat"])
60        .stdin(std::process::Stdio::null())
61        .output()?;
62
63    if !out.status.success() {
64        let reason = String::from_utf8_lossy(&out.stderr);
65        let reason = reason.lines().next().unwrap_or("no error output").trim();
66        anyhow::bail!("passbox could not read {name}: {reason}");
67    }
68
69    // The first line is the secret, later `key: value` lines are fields
70    let text = String::from_utf8(out.stdout)?;
71    let secret = text.lines().next().unwrap_or_default().to_string();
72    anyhow::ensure!(!secret.is_empty(), "passbox returned no value for {name}");
73    Ok(secret)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn stub(dir: &std::path::Path, script: &str) -> String {
81        use std::os::unix::fs::PermissionsExt;
82        let path = dir.join("passbox");
83        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
84        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
85        path.to_string_lossy().into_owned()
86    }
87
88    #[test]
89    fn a_plain_value_is_used_as_written() {
90        assert_eq!(resolve_with("/nonexistent", "plain").unwrap(), "plain");
91        assert_eq!(resolve_with("/nonexistent", "a${B}c").unwrap(), "a${B}c");
92    }
93
94    #[test]
95    fn an_env_reference_reads_the_variable() {
96        // A name no other test sets, so parallel tests cannot race on it
97        std::env::set_var("ORCH_SECRET_REF_TEST_SET", "from-env");
98        assert_eq!(
99            resolve_with("/nonexistent", "${ORCH_SECRET_REF_TEST_SET}").unwrap(),
100            "from-env"
101        );
102        let err = resolve_with("/nonexistent", "${ORCH_SECRET_REF_TEST_UNSET}")
103            .unwrap_err()
104            .to_string();
105        assert!(
106            err.contains("ORCH_SECRET_REF_TEST_UNSET is unset or empty"),
107            "{err}"
108        );
109        assert_eq!(env_name("${A-B}"), None);
110        assert_eq!(env_name("${}"), None);
111    }
112
113    #[test]
114    fn a_passbox_reference_reads_the_first_line() {
115        let dir = tempfile::tempdir().unwrap();
116        let bin = stub(
117            dir.path(),
118            r#"[ "$1 $2 $3 $4 $5" = "exec --stdin orch/relay-token -- /bin/cat" ] && printf 's3cret\nnote: x\n'"#,
119        );
120        assert_eq!(
121            resolve_with(&bin, "passbox:orch/relay-token").unwrap(),
122            "s3cret"
123        );
124    }
125
126    #[test]
127    fn a_failed_or_empty_read_is_an_error() {
128        let dir = tempfile::tempdir().unwrap();
129        let err = resolve_with(
130            &stub(dir.path(), "echo 'error: denied' >&2; exit 1"),
131            "passbox:x",
132        )
133        .unwrap_err()
134        .to_string();
135        assert!(
136            err.contains("passbox could not read x: error: denied"),
137            "{err}"
138        );
139        let empty = tempfile::tempdir().unwrap();
140        assert!(resolve_with(&stub(empty.path(), "true"), "passbox:x").is_err());
141    }
142}