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