Skip to main content

stmo_cli/
config.rs

1#![allow(clippy::missing_errors_doc)]
2
3// On macOS, `resolve_api_key`/`login` shell out to `security` instead of linking the
4// Security framework directly. Keychain ACLs are keyed to the code signature of the
5// process making the Security call, and a child process does not inherit its parent's
6// signing identity for that check — so the accessing process is always Apple's
7// already-signed `/usr/bin/security`, regardless of stmo-cli's own signature. That means
8// stmo-cli needs no Developer ID signing or notarization for Keychain access, on any
9// install path (built from source, `cargo install`, or a `cargo binstall`'d release
10// tarball), and the one-time "Always Allow" grant (attached to `security`) survives
11// stmo-cli rebuilds/upgrades. Linking the framework directly would make stmo-cli itself
12// the accessing process, and every unsigned rebuild would present a different identity,
13// invalidating the grant and re-prompting.
14
15// Env var wins over the keychain; blank/whitespace values are treated as unset; the
16// result is trimmed. Platform-independent, so it's unit-tested directly.
17fn pick(env_key: Option<String>, keychain_key: Option<String>) -> Option<String> {
18    env_key
19        .filter(|k| !k.trim().is_empty())
20        .or(keychain_key.filter(|k| !k.trim().is_empty()))
21        .map(|k| k.trim().to_string())
22}
23
24#[cfg(not(target_os = "macos"))]
25pub fn resolve_api_key() -> anyhow::Result<String> {
26    pick(std::env::var("REDASH_API_KEY").ok(), None)
27        .ok_or_else(|| anyhow::anyhow!("REDASH_API_KEY environment variable not set"))
28}
29
30#[cfg(not(target_os = "macos"))]
31pub fn login() -> anyhow::Result<()> {
32    anyhow::bail!(
33        "`stmo-cli login` stores the key in the macOS Keychain and is only available on \
34         macOS. On this platform, set the REDASH_API_KEY environment variable instead."
35    )
36}
37
38#[cfg(target_os = "macos")]
39mod macos {
40    use super::pick;
41    use anyhow::{Context, Result, bail};
42    use std::io::IsTerminal;
43    use std::process::Command;
44
45    const KEYCHAIN_SERVICE: &str = "stmo-cli";
46
47    // Isolation seam: point `security` at a specific keychain file instead of the
48    // default login-keychain search list. Used by the hermetic e2e test in
49    // tests/keychain.rs; also usable as an advanced override. Empty/unset => default
50    // search list (the user's login keychain).
51    fn keychain_file() -> Option<String> {
52        std::env::var("STMO_KEYCHAIN_PATH")
53            .ok()
54            .filter(|p| !p.trim().is_empty())
55    }
56
57    // `security` accepts an optional trailing [keychain] positional arg on both
58    // find-generic-password and add-generic-password.
59    fn with_keychain(base: &[&str]) -> Vec<String> {
60        let mut args: Vec<String> = base.iter().map(|s| (*s).to_string()).collect();
61        if let Some(path) = keychain_file() {
62            args.push(path);
63        }
64        args
65    }
66
67    pub fn resolve_api_key() -> Result<String> {
68        if let Some(k) = pick(std::env::var("REDASH_API_KEY").ok(), None) {
69            return Ok(k);
70        }
71        if let Some(k) = pick(None, read_from_keychain()?) {
72            return Ok(k);
73        }
74        if std::io::stderr().is_terminal() {
75            store_in_keychain()?;
76            if let Some(k) = pick(None, read_from_keychain()?) {
77                return Ok(k);
78            }
79        }
80        bail!(
81            "REDASH_API_KEY is not set and no '{KEYCHAIN_SERVICE}' key is stored in the \
82             macOS Keychain.\nRun `stmo-cli login` in your own terminal once to store it."
83        )
84    }
85
86    pub fn login() -> Result<()> {
87        store_in_keychain()?;
88        // Trigger the one-time "Always Allow" dialog now, in the user's terminal.
89        read_from_keychain()?;
90        println!("Stored the Redash API key in the macOS Keychain (service '{KEYCHAIN_SERVICE}').");
91        Ok(())
92    }
93
94    fn read_from_keychain() -> Result<Option<String>> {
95        let output = Command::new("security")
96            .args(with_keychain(&[
97                "find-generic-password",
98                "-s",
99                KEYCHAIN_SERVICE,
100                "-w",
101            ]))
102            .output()
103            .context("Failed to run `security` to read the Redash API key from the Keychain")?;
104        if !output.status.success() {
105            return Ok(None);
106        }
107        let key = String::from_utf8(output.stdout)
108            .context("Keychain item was not valid UTF-8")?
109            .trim()
110            .to_string();
111        Ok((!key.is_empty()).then_some(key))
112    }
113
114    fn store_in_keychain() -> Result<()> {
115        let account = std::env::var("USER").unwrap_or_else(|_| KEYCHAIN_SERVICE.to_string());
116        eprintln!("Enter your Redash API key (https://sql.telemetry.mozilla.org/users/me):");
117        let status = Command::new("security")
118            .args(with_keychain(&[
119                "add-generic-password",
120                "-a",
121                &account,
122                "-s",
123                KEYCHAIN_SERVICE,
124                "-U",
125                "-w",
126            ]))
127            // Inherit stdio so `security` runs its own hidden prompt on the terminal.
128            .status()
129            .context("Failed to run `security` to store the Redash API key")?;
130        if !status.success() {
131            bail!("Failed to store the Redash API key in the macOS Keychain");
132        }
133        Ok(())
134    }
135}
136
137#[cfg(target_os = "macos")]
138pub use macos::{login, resolve_api_key};
139
140#[cfg(test)]
141mod tests {
142    use super::pick;
143
144    #[test]
145    fn env_key_wins_over_keychain() {
146        assert_eq!(
147            pick(
148                Some("env-key".to_string()),
149                Some("keychain-key".to_string())
150            ),
151            Some("env-key".to_string())
152        );
153    }
154
155    #[test]
156    fn blank_env_key_falls_through_to_keychain() {
157        assert_eq!(
158            pick(Some("   ".to_string()), Some("keychain-key".to_string())),
159            Some("keychain-key".to_string())
160        );
161    }
162
163    #[test]
164    fn keychain_used_when_env_unset() {
165        assert_eq!(
166            pick(None, Some("keychain-key".to_string())),
167            Some("keychain-key".to_string())
168        );
169    }
170
171    #[test]
172    fn both_unset_or_blank_returns_none() {
173        assert_eq!(pick(None, None), None);
174        assert_eq!(pick(Some(String::new()), Some("  ".to_string())), None);
175    }
176
177    #[test]
178    fn result_is_trimmed() {
179        assert_eq!(
180            pick(Some("  env-key  ".to_string()), None),
181            Some("env-key".to_string())
182        );
183        assert_eq!(
184            pick(None, Some("  keychain-key  ".to_string())),
185            Some("keychain-key".to_string())
186        );
187    }
188}