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