Skip to main content

iterm2_client/
auth.rs

1//! Authentication for the iTerm2 API.
2//!
3//! Credentials are resolved from `ITERM2_COOKIE` / `ITERM2_KEY` environment
4//! variables, falling back to an AppleScript request to iTerm2 via `osascript`.
5
6use crate::error::{Error, Result};
7use std::env;
8use std::fmt;
9use zeroize::{Zeroize, ZeroizeOnDrop};
10
11/// Cookie and key pair used to authenticate with the iTerm2 WebSocket API.
12///
13/// Credentials are zeroized from memory when dropped. The `Debug` implementation
14/// redacts the actual values to prevent accidental credential leakage in logs.
15#[derive(Zeroize, ZeroizeOnDrop)]
16pub struct Credentials {
17    pub cookie: String,
18    pub key: String,
19}
20
21impl fmt::Debug for Credentials {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.debug_struct("Credentials")
24            .field("cookie", &"[redacted]")
25            .field("key", &"[redacted]")
26            .finish()
27    }
28}
29
30/// Trait for running AppleScript commands. Inject a mock in tests.
31pub trait AppleScriptRunner: Send + Sync {
32    fn run_osascript(&self, script: &str) -> std::result::Result<String, String>;
33}
34
35/// Production [`AppleScriptRunner`] that invokes `/usr/bin/osascript`.
36pub struct OsascriptRunner;
37
38impl AppleScriptRunner for OsascriptRunner {
39    fn run_osascript(&self, script: &str) -> std::result::Result<String, String> {
40        let output = std::process::Command::new("osascript")
41            .arg("-e")
42            .arg(script)
43            .output()
44            .map_err(|e| format!("Failed to run osascript: {e}"))?;
45
46        if output.status.success() {
47            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
48        } else {
49            Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
50        }
51    }
52}
53
54fn applescript_request(app_name: &str) -> String {
55    // The "for app named" clause is required — without it, AppleScript parses
56    // "and key" as the boolean AND operator applied to the cookie string.
57    format!(
58        r#"tell application "iTerm2" to request cookie and key for app named "{}""#,
59        app_name.replace('\\', "\\\\").replace('"', "\\\"")
60    )
61}
62
63/// Resolve iTerm2 API credentials.
64///
65/// Checks `ITERM2_COOKIE` and `ITERM2_KEY` environment variables first,
66/// then falls back to requesting credentials from iTerm2 via AppleScript.
67/// The `app_name` is shown in iTerm2's authorization dialog.
68pub fn resolve_credentials(app_name: &str, runner: &dyn AppleScriptRunner) -> Result<Credentials> {
69    resolve_credentials_with_env(app_name, runner, |k| env::var(k).ok())
70}
71
72fn resolve_credentials_with_env(
73    app_name: &str,
74    runner: &dyn AppleScriptRunner,
75    env_fn: impl Fn(&str) -> Option<String>,
76) -> Result<Credentials> {
77    // Try env vars first
78    if let (Some(cookie), Some(key)) = (env_fn("ITERM2_COOKIE"), env_fn("ITERM2_KEY")) {
79        if !cookie.is_empty() && !key.is_empty() {
80            return Ok(Credentials { cookie, key });
81        }
82    }
83
84    // Fall back to osascript
85    let script = applescript_request(app_name);
86    let output = runner
87        .run_osascript(&script)
88        .map_err(|e| Error::Auth(format!("osascript failed: {e}")))?;
89
90    parse_cookie_key(&output)
91}
92
93fn parse_cookie_key(output: &str) -> Result<Credentials> {
94    let parts: Vec<&str> = output.split_whitespace().collect();
95    if parts.len() == 2 {
96        Ok(Credentials {
97            cookie: parts[0].to_string(),
98            key: parts[1].to_string(),
99        })
100    } else {
101        Err(Error::Auth(
102            "Failed to parse cookie/key from osascript output (expected two whitespace-separated tokens)".to_string()
103        ))
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    struct MockRunner {
112        result: std::result::Result<String, String>,
113    }
114
115    impl AppleScriptRunner for MockRunner {
116        fn run_osascript(&self, _script: &str) -> std::result::Result<String, String> {
117            self.result.clone()
118        }
119    }
120
121    #[test]
122    fn resolve_from_env_vars() {
123        let runner = MockRunner {
124            result: Err("should not be called".to_string()),
125        };
126        let creds = resolve_credentials_with_env("test-app", &runner, |key| match key {
127            "ITERM2_COOKIE" => Some("test_cookie".to_string()),
128            "ITERM2_KEY" => Some("test_key".to_string()),
129            _ => None,
130        })
131        .unwrap();
132        assert_eq!(creds.cookie, "test_cookie");
133        assert_eq!(creds.key, "test_key");
134    }
135
136    #[test]
137    fn empty_env_vars_fall_through_to_osascript() {
138        let runner = MockRunner {
139            result: Ok("abc123 def456".to_string()),
140        };
141        let creds = resolve_credentials_with_env("test-app", &runner, |key| match key {
142            "ITERM2_COOKIE" => Some("".to_string()),
143            "ITERM2_KEY" => Some("".to_string()),
144            _ => None,
145        })
146        .unwrap();
147        assert_eq!(creds.cookie, "abc123");
148        assert_eq!(creds.key, "def456");
149    }
150
151    #[test]
152    fn resolve_from_osascript() {
153        let runner = MockRunner {
154            result: Ok("abc123 def456".to_string()),
155        };
156        let creds =
157            resolve_credentials_with_env("test-app", &runner, |_| None).unwrap();
158        assert_eq!(creds.cookie, "abc123");
159        assert_eq!(creds.key, "def456");
160    }
161
162    #[test]
163    fn resolve_from_osascript_newline_separated() {
164        let runner = MockRunner {
165            result: Ok("abc123\ndef456".to_string()),
166        };
167        let creds =
168            resolve_credentials_with_env("test-app", &runner, |_| None).unwrap();
169        assert_eq!(creds.cookie, "abc123");
170        assert_eq!(creds.key, "def456");
171    }
172
173    #[test]
174    fn osascript_failure() {
175        let runner = MockRunner {
176            result: Err("connection refused".to_string()),
177        };
178        let err =
179            resolve_credentials_with_env("test-app", &runner, |_| None).unwrap_err();
180        assert!(err.to_string().contains("osascript failed"));
181    }
182
183    #[test]
184    fn parse_bad_output() {
185        let runner = MockRunner {
186            result: Ok("justonetoken".to_string()),
187        };
188        let err =
189            resolve_credentials_with_env("test-app", &runner, |_| None).unwrap_err();
190        assert!(err.to_string().contains("Failed to parse"));
191    }
192
193    #[test]
194    fn applescript_request_escapes_quotes() {
195        let script = applescript_request(r#"my "app""#);
196        assert!(script.contains(r#"my \"app\""#));
197        assert!(script.contains("for app named"));
198    }
199
200    #[test]
201    fn debug_redacts_credentials() {
202        let creds = Credentials {
203            cookie: "secret_cookie".to_string(),
204            key: "secret_key".to_string(),
205        };
206        let debug_output = format!("{:?}", creds);
207        assert!(!debug_output.contains("secret_cookie"));
208        assert!(!debug_output.contains("secret_key"));
209        assert!(debug_output.contains("[redacted]"));
210    }
211}