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
9pub const ACTION_AUTHENTICATE: &str = "authenticate";
11pub const ACTION_STATUS: &str = "status";
13pub const ACTION_LOGOUT: &str = "logout";
15pub const ACTION_LIST_ENVIRONMENTS: &str = "list-environments";
17pub const ACTION_LIST_REALMS: &str = "list-realms";
19
20#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22pub struct AuthnRequest {
23 pub action: String,
25 pub provider: String,
27 pub env: String,
29 #[serde(skip_serializing_if = "String::is_empty")]
31 pub realm: String,
32 #[serde(skip_serializing_if = "String::is_empty")]
34 pub command: String,
35 #[serde(skip_serializing_if = "String::is_empty")]
37 pub tier: String,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
42pub struct EnvironmentsResponse {
43 pub environments: Vec<String>,
45}
46
47#[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 #[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 #[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 #[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 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 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}