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 = command.spawn().map_err(|err| self.exec_error(err, ""))?;
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 fn request(&self, action: &str, env: &str, command: &str, tier: &str) -> AuthnRequest {
149 AuthnRequest {
150 action: action.to_owned(),
151 provider: self.provider_name.clone(),
152 env: env.to_owned(),
153 realm: env.to_owned(),
154 command: command.to_owned(),
155 tier: tier.to_owned(),
156 }
157 }
158
159 async fn list_realms_compat(&self) -> Result<Vec<String>> {
160 let out = self
161 .exec_raw(&AuthnRequest {
162 action: ACTION_LIST_REALMS.to_owned(),
163 provider: String::new(),
164 env: String::new(),
165 realm: String::new(),
166 command: String::new(),
167 tier: String::new(),
168 })
169 .await?;
170 #[derive(Deserialize)]
171 struct RealmsResponse {
172 #[serde(default)]
173 realms: Vec<String>,
174 }
175 let response: RealmsResponse = serde_json::from_slice(&out).map_err(|err| {
176 CliCoreError::message(format!(
177 "auth: parse realms from {}: {err}",
178 self.command.display()
179 ))
180 })?;
181 Ok(response.realms)
182 }
183
184 fn exec_error(&self, err: std::io::Error, stderr: &str) -> CliCoreError {
185 CliCoreError::message(format!(
186 "auth: exec {}: {err}: {stderr}",
187 self.command.display()
188 ))
189 }
190}
191
192#[cfg(unix)]
193fn compat_exit_status(status: &std::process::ExitStatus) -> String {
194 use std::os::unix::process::ExitStatusExt;
195 if let Some(code) = status.code() {
196 return format!("exit status {code}");
197 }
198 if let Some(signal) = status.signal() {
199 return format!("signal: {signal}");
200 }
201 status.to_string()
202}
203
204#[cfg(not(unix))]
205fn compat_exit_status(status: &std::process::ExitStatus) -> String {
206 if let Some(code) = status.code() {
207 return format!("exit status {code}");
208 }
209 status.to_string()
210}
211
212#[async_trait::async_trait]
213impl AuthProvider for ExecProvider {
214 fn name(&self) -> &str {
215 &self.provider_name
216 }
217
218 async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result<Credential> {
219 self.exec_with_request(&self.request(ACTION_AUTHENTICATE, env, command, tier))
220 .await
221 }
222
223 async fn status(&self, env: &str) -> Result<Credential> {
224 self.exec_with_request(&self.request(ACTION_STATUS, env, "", ""))
225 .await
226 }
227
228 async fn logout(&self, env: &str) -> Result<()> {
229 let _output = self
230 .exec_action(&self.request(ACTION_LOGOUT, env, "", ""))
231 .await?;
232 Ok(())
233 }
234
235 async fn list_environments(&self) -> Result<Vec<String>> {
236 let request = AuthnRequest {
237 action: ACTION_LIST_ENVIRONMENTS.to_owned(),
238 provider: String::new(),
239 env: String::new(),
240 realm: String::new(),
241 command: String::new(),
242 tier: String::new(),
243 };
244 let out = match self.exec_raw(&request).await {
245 Ok(out) => out,
246 Err(_) => return self.list_realms_compat().await,
247 };
248
249 if let Ok(response) = serde_json::from_slice::<EnvironmentsResponse>(&out)
250 && !response.environments.is_empty()
251 {
252 return Ok(response.environments);
253 }
254
255 #[derive(Deserialize, Default)]
256 struct RealmsResponse {
257 #[serde(default)]
258 realms: Vec<String>,
259 }
260 if let Ok(response) = serde_json::from_slice::<RealmsResponse>(&out) {
261 return Ok(response.realms);
262 }
263
264 Err(CliCoreError::message(format!(
265 "auth: parse environments from {}",
266 self.command.display()
267 )))
268 }
269}