Skip to main content

cli_engine/auth/
exec.rs

1use std::{io::ErrorKind, path::PathBuf, process::Stdio, time::Duration};
2
3use serde::{Deserialize, Serialize};
4use tokio::{io::AsyncWriteExt, process::Command, time};
5
6use super::{AuthProvider, Credential};
7use crate::{CliCoreError, Result};
8
9/// Provider action requesting a credential.
10pub const ACTION_AUTHENTICATE: &str = "authenticate";
11/// Provider action requesting cached credential status.
12pub const ACTION_STATUS: &str = "status";
13/// Provider action clearing cached credentials.
14pub const ACTION_LOGOUT: &str = "logout";
15/// Provider action listing cached environments.
16pub const ACTION_LIST_ENVIRONMENTS: &str = "list-environments";
17/// Legacy provider action listing cached realms.
18pub const ACTION_LIST_REALMS: &str = "list-realms";
19
20/// JSON payload sent to an external auth provider.
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22pub struct AuthnRequest {
23    /// Provider action.
24    pub action: String,
25    /// Provider name.
26    pub provider: String,
27    /// Environment name.
28    pub env: String,
29    /// Deprecated alias of `env` kept for older provider binaries.
30    #[serde(skip_serializing_if = "String::is_empty")]
31    pub realm: String,
32    /// Colon-separated command path.
33    #[serde(skip_serializing_if = "String::is_empty")]
34    pub command: String,
35    /// Risk tier.
36    #[serde(skip_serializing_if = "String::is_empty")]
37    pub tier: String,
38}
39
40/// JSON payload returned by providers for `list-environments`.
41#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct EnvironmentsResponse {
43    /// Environment names with cached credentials.
44    pub environments: Vec<String>,
45}
46
47/// Auth provider implemented by spawning an external provider command.
48///
49/// The provider receives [`AuthnRequest`] JSON on stdin and returns credential
50/// JSON on stdout. This keeps auth flows language-agnostic and easy to test.
51#[derive(Clone, Debug)]
52pub struct ExecProvider {
53    provider_name: String,
54    command: PathBuf,
55    args: Vec<String>,
56    timeout: Option<Duration>,
57}
58
59impl ExecProvider {
60    /// Creates an exec provider with no extra arguments or timeout.
61    #[must_use]
62    pub fn new(provider_name: impl Into<String>, command: impl Into<PathBuf>) -> Self {
63        Self {
64            provider_name: provider_name.into(),
65            command: command.into(),
66            args: Vec::new(),
67            timeout: None,
68        }
69    }
70
71    /// Adds extra command-line arguments passed to the provider binary.
72    #[must_use]
73    pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
74        self.args = args.into_iter().map(Into::into).collect();
75        self
76    }
77
78    /// Sets a provider process timeout. A zero duration disables the timeout.
79    #[must_use]
80    pub fn with_timeout(mut self, timeout: Duration) -> Self {
81        self.timeout = (!timeout.is_zero()).then_some(timeout);
82        self
83    }
84
85    /// Executes an arbitrary provider request and decodes a credential response.
86    pub async fn exec_with_request(&self, request: &AuthnRequest) -> Result<Credential> {
87        let out = self.exec_raw(request).await?;
88        serde_json::from_slice(&out).map_err(|err| {
89            CliCoreError::message(format!(
90                "auth: parse credential from {}: {err}",
91                self.command.display()
92            ))
93        })
94    }
95
96    async fn exec_action(&self, request: &AuthnRequest) -> Result<Vec<u8>> {
97        self.exec_raw(request).await
98    }
99
100    async fn exec_raw(&self, request: &AuthnRequest) -> Result<Vec<u8>> {
101        let request_json = serde_json::to_vec(request)?;
102        let mut command = Command::new(&self.command);
103        command
104            .args(&self.args)
105            .kill_on_drop(true)
106            .stdin(Stdio::piped())
107            .stdout(Stdio::piped())
108            .stderr(Stdio::piped());
109
110        let mut child = self.spawn_retrying_text_busy(&mut command).await?;
111        let Some(mut stdin) = child.stdin.take() else {
112            return Err(CliCoreError::message("auth: provider stdin unavailable"));
113        };
114        if let Err(err) = stdin.write_all(&request_json).await
115            && err.kind() != ErrorKind::BrokenPipe
116        {
117            return Err(self.exec_error(err, ""));
118        }
119        drop(stdin);
120
121        let output_fut = child.wait_with_output();
122        let output = if let Some(timeout) = self.timeout {
123            match time::timeout(timeout, output_fut).await {
124                Ok(result) => result.map_err(|err| self.exec_error(err, ""))?,
125                Err(_) => {
126                    return Err(CliCoreError::message(format!(
127                        "auth: exec {}: signal: killed: ",
128                        self.command.display()
129                    )));
130                }
131            }
132        } else {
133            output_fut.await.map_err(|err| self.exec_error(err, ""))?
134        };
135
136        if output.status.success() {
137            return Ok(output.stdout);
138        }
139
140        let stderr = String::from_utf8_lossy(&output.stderr);
141        Err(CliCoreError::message(format!(
142            "auth: exec {}: {}: {stderr}",
143            self.command.display(),
144            compat_exit_status(&output.status)
145        )))
146    }
147
148    /// Spawns `command`, retrying on `ETXTBSY`.
149    ///
150    /// Linux can transiently report `ETXTBSY` for a just-written, freshly
151    /// chmod'd script when another thread in this process forks at the same
152    /// moment, even though nothing holds the file open for writing. The
153    /// retry with backoff clears once the kernel releases the transient
154    /// hold; a real "busy" (e.g. another process genuinely writing the
155    /// file) will still exhaust the attempts and surface as an error.
156    async fn spawn_retrying_text_busy(
157        &self,
158        command: &mut Command,
159    ) -> Result<tokio::process::Child> {
160        const MAX_RETRIES: u32 = 5;
161        let mut delay = Duration::from_millis(1);
162        for retry in 0..=MAX_RETRIES {
163            match command.spawn() {
164                Ok(child) => return Ok(child),
165                Err(err) if err.kind() == ErrorKind::ExecutableFileBusy && retry < MAX_RETRIES => {
166                    time::sleep(delay).await;
167                    delay *= 2;
168                }
169                Err(err) => return Err(self.exec_error(err, "")),
170            }
171        }
172        unreachable!("the loop above always returns before its range is exhausted")
173    }
174
175    fn request(&self, action: &str, env: &str, command: &str, tier: &str) -> AuthnRequest {
176        AuthnRequest {
177            action: action.to_owned(),
178            provider: self.provider_name.clone(),
179            env: env.to_owned(),
180            realm: env.to_owned(),
181            command: command.to_owned(),
182            tier: tier.to_owned(),
183        }
184    }
185
186    async fn list_realms_compat(&self) -> Result<Vec<String>> {
187        let out = self
188            .exec_raw(&AuthnRequest {
189                action: ACTION_LIST_REALMS.to_owned(),
190                provider: String::new(),
191                env: String::new(),
192                realm: String::new(),
193                command: String::new(),
194                tier: String::new(),
195            })
196            .await?;
197        #[derive(Deserialize)]
198        struct RealmsResponse {
199            #[serde(default)]
200            realms: Vec<String>,
201        }
202        let response: RealmsResponse = serde_json::from_slice(&out).map_err(|err| {
203            CliCoreError::message(format!(
204                "auth: parse realms from {}: {err}",
205                self.command.display()
206            ))
207        })?;
208        Ok(response.realms)
209    }
210
211    fn exec_error(&self, err: std::io::Error, stderr: &str) -> CliCoreError {
212        CliCoreError::message(format!(
213            "auth: exec {}: {err}: {stderr}",
214            self.command.display()
215        ))
216    }
217}
218
219#[cfg(unix)]
220fn compat_exit_status(status: &std::process::ExitStatus) -> String {
221    use std::os::unix::process::ExitStatusExt;
222    if let Some(code) = status.code() {
223        return format!("exit status {code}");
224    }
225    if let Some(signal) = status.signal() {
226        return format!("signal: {signal}");
227    }
228    status.to_string()
229}
230
231#[cfg(not(unix))]
232fn compat_exit_status(status: &std::process::ExitStatus) -> String {
233    if let Some(code) = status.code() {
234        return format!("exit status {code}");
235    }
236    status.to_string()
237}
238
239#[async_trait::async_trait]
240impl AuthProvider for ExecProvider {
241    fn name(&self) -> &str {
242        &self.provider_name
243    }
244
245    async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result<Credential> {
246        self.exec_with_request(&self.request(ACTION_AUTHENTICATE, env, command, tier))
247            .await
248    }
249
250    async fn status(&self, env: &str) -> Result<Credential> {
251        self.exec_with_request(&self.request(ACTION_STATUS, env, "", ""))
252            .await
253    }
254
255    async fn logout(&self, env: &str) -> Result<()> {
256        let _output = self
257            .exec_action(&self.request(ACTION_LOGOUT, env, "", ""))
258            .await?;
259        Ok(())
260    }
261
262    async fn list_environments(&self) -> Result<Vec<String>> {
263        let request = AuthnRequest {
264            action: ACTION_LIST_ENVIRONMENTS.to_owned(),
265            provider: String::new(),
266            env: String::new(),
267            realm: String::new(),
268            command: String::new(),
269            tier: String::new(),
270        };
271        let out = match self.exec_raw(&request).await {
272            Ok(out) => out,
273            Err(_) => return self.list_realms_compat().await,
274        };
275
276        if let Ok(response) = serde_json::from_slice::<EnvironmentsResponse>(&out)
277            && !response.environments.is_empty()
278        {
279            return Ok(response.environments);
280        }
281
282        #[derive(Deserialize, Default)]
283        struct RealmsResponse {
284            #[serde(default)]
285            realms: Vec<String>,
286        }
287        if let Ok(response) = serde_json::from_slice::<RealmsResponse>(&out) {
288            return Ok(response.realms);
289        }
290
291        Err(CliCoreError::message(format!(
292            "auth: parse environments from {}",
293            self.command.display()
294        )))
295    }
296}