1use crate::error::{Error, Result};
7use std::env;
8use std::fmt;
9use zeroize::{Zeroize, ZeroizeOnDrop};
10
11#[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
30pub trait AppleScriptRunner: Send + Sync {
32 fn run_osascript(&self, script: &str) -> std::result::Result<String, String>;
33}
34
35pub 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 format!(
58 r#"tell application "iTerm2" to request cookie and key for app named "{}""#,
59 app_name.replace('\\', "\\\\").replace('"', "\\\"")
60 )
61}
62
63pub 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 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 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}