1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use thiserror::Error;
6
7use crate::core::auth_generator::{self, AuthCache, GenContext};
8use crate::core::keyring::Keyring;
9use crate::core::manifest::Provider;
10
11#[derive(Error, Debug)]
16pub enum CliError {
17 #[error("CLI config error: {0}")]
18 Config(String),
19 #[error("Missing keyring key: {0}")]
20 MissingKey(String),
21 #[error("Failed to spawn CLI process: {0}")]
22 Spawn(String),
23 #[error("CLI timed out after {0}s")]
24 Timeout(u64),
25 #[error("CLI exited with code {code}: {stderr}")]
26 NonZeroExit { code: i32, stderr: String },
27 #[error("IO error: {0}")]
28 Io(#[from] std::io::Error),
29 #[error("Credential file error: {0}")]
30 CredentialFile(String),
31}
32
33pub struct CredentialFile {
38 pub path: PathBuf,
39 wipe_on_drop: bool,
40}
41
42impl Drop for CredentialFile {
43 fn drop(&mut self) {
44 if self.wipe_on_drop {
45 if let Ok(meta) = std::fs::metadata(&self.path) {
47 let len = meta.len() as usize;
48 if len > 0 {
49 if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&self.path) {
50 use std::io::Write;
51 let zeros = vec![0u8; len];
52 let _ = (&file).write_all(&zeros);
53 let _ = file.sync_all();
54 }
55 }
56 }
57 let _ = std::fs::remove_file(&self.path);
58 }
59 }
60}
61
62pub fn materialize_credential_file(
72 key_name: &str,
73 content: &str,
74 wipe_on_drop: bool,
75 ati_dir: &Path,
76) -> Result<CredentialFile, CliError> {
77 use std::os::unix::fs::OpenOptionsExt;
78
79 let creds_dir = ati_dir.join(".creds");
80 std::fs::create_dir_all(&creds_dir).map_err(|e| {
81 CliError::CredentialFile(format!("failed to create {}: {e}", creds_dir.display()))
82 })?;
83
84 let path = if wipe_on_drop {
85 let suffix: u32 = rand::random();
86 creds_dir.join(format!("{key_name}_{suffix}"))
87 } else {
88 creds_dir.join(key_name)
89 };
90
91 let mut file = std::fs::OpenOptions::new()
92 .write(true)
93 .create(true)
94 .truncate(true)
95 .mode(0o600)
96 .open(&path)
97 .map_err(|e| {
98 CliError::CredentialFile(format!("failed to write {}: {e}", path.display()))
99 })?;
100
101 {
102 use std::io::Write;
103 file.write_all(content.as_bytes()).map_err(|e| {
104 CliError::CredentialFile(format!("failed to write {}: {e}", path.display()))
105 })?;
106 file.sync_all().map_err(|e| {
107 CliError::CredentialFile(format!("failed to sync {}: {e}", path.display()))
108 })?;
109 }
110
111 Ok(CredentialFile { path, wipe_on_drop })
112}
113
114fn resolve_env_value(value: &str, keyring: &Keyring) -> Result<String, CliError> {
121 let mut result = value.to_string();
122 while let Some(start) = result.find("${") {
123 let rest = &result[start + 2..];
124 if let Some(end) = rest.find('}') {
125 let key_name = &rest[..end];
126 let replacement = keyring
127 .get(key_name)
128 .ok_or_else(|| CliError::MissingKey(key_name.to_string()))?;
129 result = format!("{}{}{}", &result[..start], replacement, &rest[end + 1..]);
130 } else {
131 break; }
133 }
134 Ok(result)
135}
136
137pub fn resolve_cli_env(
147 env_map: &HashMap<String, String>,
148 keyring: &Keyring,
149 wipe_on_drop: bool,
150 ati_dir: &Path,
151) -> Result<(HashMap<String, String>, Vec<CredentialFile>), CliError> {
152 let mut resolved = HashMap::with_capacity(env_map.len());
153 let mut cred_files: Vec<CredentialFile> = Vec::new();
154
155 for (key, value) in env_map {
156 if let Some(key_ref) = value.strip_prefix("@{").and_then(|s| s.strip_suffix('}')) {
157 let content = keyring
159 .get(key_ref)
160 .ok_or_else(|| CliError::MissingKey(key_ref.to_string()))?;
161 let cf = materialize_credential_file(key_ref, content, wipe_on_drop, ati_dir)?;
162 resolved.insert(key.clone(), cf.path.to_string_lossy().into_owned());
163 cred_files.push(cf);
164 } else if value.contains("${") {
165 let val = resolve_env_value(value, keyring)?;
167 resolved.insert(key.clone(), val);
168 } else {
169 resolved.insert(key.clone(), value.clone());
171 }
172 }
173
174 Ok((resolved, cred_files))
175}
176
177pub async fn execute(
188 provider: &Provider,
189 raw_args: &[String],
190 keyring: &Keyring,
191) -> Result<serde_json::Value, CliError> {
192 execute_with_gen(provider, raw_args, keyring, None, None).await
193}
194
195pub async fn execute_with_gen(
197 provider: &Provider,
198 raw_args: &[String],
199 keyring: &Keyring,
200 gen_ctx: Option<&GenContext>,
201 auth_cache: Option<&AuthCache>,
202) -> Result<serde_json::Value, CliError> {
203 let cli_command = provider
204 .cli_command
205 .as_deref()
206 .ok_or_else(|| CliError::Config("provider missing cli_command".into()))?;
207
208 let timeout_secs = provider.cli_timeout_secs.unwrap_or(120);
209
210 let ati_dir = std::env::var("ATI_DIR")
211 .map(PathBuf::from)
212 .unwrap_or_else(|_| {
213 std::env::var("HOME")
214 .map(PathBuf::from)
215 .unwrap_or_else(|_| PathBuf::from("/tmp"))
216 .join(".ati")
217 });
218
219 let wipe_on_drop = keyring.ephemeral;
220
221 let (resolved_env, cred_files) =
224 resolve_cli_env(&provider.cli_env, keyring, wipe_on_drop, &ati_dir)?;
225
226 let mut final_env: HashMap<String, String> = HashMap::new();
228 for var in &["PATH", "HOME", "TMPDIR", "LANG", "USER", "TERM"] {
229 if let Ok(val) = std::env::var(var) {
230 final_env.insert(var.to_string(), val);
231 }
232 }
233 final_env.extend(resolved_env);
235
236 if let Some(gen) = &provider.auth_generator {
238 let default_ctx = GenContext::default();
239 let ctx = gen_ctx.unwrap_or(&default_ctx);
240 let default_cache = AuthCache::new();
241 let cache = auth_cache.unwrap_or(&default_cache);
242 match auth_generator::generate(provider, gen, ctx, keyring, cache).await {
243 Ok(cred) => {
244 final_env.insert("ATI_AUTH_TOKEN".to_string(), cred.value);
245 for (k, v) in &cred.extra_env {
246 final_env.insert(k.clone(), v.clone());
247 }
248 }
249 Err(e) => {
250 return Err(CliError::Config(format!("auth_generator failed: {e}")));
251 }
252 }
253 }
254
255 let command = cli_command.to_string();
257 let default_args = provider.cli_default_args.clone();
258 let extra_args = raw_args.to_vec();
259 let env_snapshot = final_env;
260 let timeout_dur = std::time::Duration::from_secs(timeout_secs);
261
262 let child = tokio::process::Command::new(&command)
266 .args(&default_args)
267 .args(&extra_args)
268 .env_clear()
269 .envs(&env_snapshot)
270 .stdout(Stdio::piped())
271 .stderr(Stdio::piped())
272 .kill_on_drop(true)
273 .spawn()
274 .map_err(|e| CliError::Spawn(format!("{command}: {e}")))?;
275
276 let output = tokio::time::timeout(timeout_dur, child.wait_with_output())
278 .await
279 .map_err(|_| CliError::Timeout(timeout_secs))?
280 .map_err(CliError::Io)?;
281
282 drop(cred_files);
284
285 if !output.status.success() {
286 let code = output.status.code().unwrap_or(-1);
287 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
288 return Err(CliError::NonZeroExit { code, stderr });
289 }
290
291 let stdout = String::from_utf8_lossy(&output.stdout);
292 let value = match serde_json::from_str::<serde_json::Value>(stdout.trim()) {
293 Ok(v) => v,
294 Err(_) => serde_json::Value::String(stdout.trim().to_string()),
295 };
296
297 Ok(value)
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::fs;
304
305 #[test]
306 fn test_materialize_credential_file_dev_mode() {
307 let tmp = tempfile::tempdir().unwrap();
308 let cf = materialize_credential_file("test_key", "secret123", false, tmp.path()).unwrap();
309 assert_eq!(cf.path, tmp.path().join(".creds/test_key"));
310 let content = fs::read_to_string(&cf.path).unwrap();
311 assert_eq!(content, "secret123");
312
313 #[cfg(unix)]
315 {
316 use std::os::unix::fs::PermissionsExt;
317 let mode = fs::metadata(&cf.path).unwrap().permissions().mode() & 0o777;
318 assert_eq!(mode, 0o600);
319 }
320 }
321
322 #[test]
323 fn test_materialize_credential_file_prod_mode_unique() {
324 let tmp = tempfile::tempdir().unwrap();
325 let cf1 = materialize_credential_file("key", "val1", true, tmp.path()).unwrap();
326 let cf2 = materialize_credential_file("key", "val2", true, tmp.path()).unwrap();
327 assert_ne!(cf1.path, cf2.path);
329 }
330
331 #[test]
332 fn test_credential_file_wipe_on_drop() {
333 let tmp = tempfile::tempdir().unwrap();
334 let path;
335 {
336 let cf = materialize_credential_file("wipe_me", "sensitive", true, tmp.path()).unwrap();
337 path = cf.path.clone();
338 assert!(path.exists());
339 }
340 assert!(!path.exists());
342 }
343}