qcs_api_client_common/configuration/
external_command.rs1use std::{path::PathBuf, process::Stdio, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use tokio::io::AsyncReadExt as _;
7
8use crate::configuration::tokens::ExternallyManaged;
9
10use super::secret_string::SecretAccessToken;
11
12const MAX_PIPE_BYTES: usize = 4 * 1024;
14
15const DEFAULT_TIMEOUT_SECONDS: u64 = 30;
17
18const fn default_timeout_seconds() -> u64 {
19 DEFAULT_TIMEOUT_SECONDS
20}
21
22#[allow(clippy::trivially_copy_pass_by_ref, reason = "serde needs a reference")]
23fn is_default_timeout_seconds(timeout_seconds: &u64) -> bool {
24 timeout_seconds == &DEFAULT_TIMEOUT_SECONDS
25}
26
27#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
63pub struct ExternallyManagedCredential {
64 pub command: PathBuf,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub args: Vec<String>,
69 #[serde(
71 default = "default_timeout_seconds",
72 skip_serializing_if = "is_default_timeout_seconds"
73 )]
74 pub timeout_seconds: u64,
75}
76
77impl From<ExternallyManagedCredential> for ExternallyManaged {
78 fn from(credential: ExternallyManagedCredential) -> Self {
79 Self::from_async(move |_auth_server| {
80 let credential = credential.clone();
81 async move {
82 credential
83 .request_access_token()
84 .await
85 .map(|token| token.secret().to_string())
86 .map_err(Into::into)
87 }
88 })
89 }
90}
91
92impl ExternallyManagedCredential {
93 pub async fn request_access_token(&self) -> Result<SecretAccessToken, ExternalCommandError> {
99 let output = Box::pin(self.run()).await?;
102
103 let token = String::from_utf8(output)
104 .map_err(|_| ExternalCommandError::InvalidUtf8 {
105 program: self.command.clone(),
106 })?
107 .trim()
108 .to_string();
109
110 if token.is_empty() {
111 return Err(ExternalCommandError::EmptyOutput {
112 program: self.command.clone(),
113 });
114 }
115
116 Ok(SecretAccessToken::from(token))
117 }
118
119 async fn run(&self) -> Result<Vec<u8>, ExternalCommandError> {
121 let mut command = tokio::process::Command::new(&self.command);
122
123 command
124 .args(&self.args)
125 .stdin(Stdio::null())
126 .stdout(Stdio::piped())
127 .stderr(Stdio::piped())
128 .kill_on_drop(true);
129
130 let mut child = command
131 .spawn()
132 .map_err(|source| ExternalCommandError::Spawn {
133 program: self.command.clone(),
134 source,
135 })?;
136
137 let stdout = child.stdout.take().expect("stdout is piped");
138 let stderr = child.stderr.take().expect("stderr is piped");
139
140 let timeout = Duration::from_secs(self.timeout_seconds);
141 let (status, stdout_buf, stderr_buf, stdout_truncated) =
142 tokio::time::timeout(timeout, async {
143 let (stdout_result, stderr_result) = futures::future::join(
144 read_capped(stdout, MAX_PIPE_BYTES),
145 read_capped(stderr, MAX_PIPE_BYTES),
146 )
147 .await;
148
149 let (stdout_buf, stdout_truncated) =
150 stdout_result.map_err(|source| ExternalCommandError::Read {
151 program: self.command.clone(),
152 source,
153 })?;
154
155 let (stderr_buf, _) = stderr_result.unwrap_or_default();
157
158 let status = child
159 .wait()
160 .await
161 .map_err(|source| ExternalCommandError::Read {
162 program: self.command.clone(),
163 source,
164 })?;
165
166 Ok::<_, ExternalCommandError>((status, stdout_buf, stderr_buf, stdout_truncated))
167 })
168 .await
169 .map_err(|_| ExternalCommandError::Timeout {
170 program: self.command.clone(),
171 timeout,
172 })??;
173
174 if !status.success() {
175 return Err(ExternalCommandError::ExitStatus {
176 program: self.command.clone(),
177 status: status.to_string(),
178 stderr: String::from_utf8_lossy(&stderr_buf).trim().to_string(),
179 });
180 }
181
182 if stdout_truncated {
183 return Err(ExternalCommandError::OutputTooLarge {
184 program: self.command.clone(),
185 limit: MAX_PIPE_BYTES,
186 });
187 }
188
189 Ok(stdout_buf)
190 }
191}
192
193async fn read_capped(
195 mut reader: impl tokio::io::AsyncRead + Unpin,
196 cap: usize,
197) -> std::io::Result<(Vec<u8>, bool)> {
198 let mut retained = Vec::new();
199
200 let mut chunk = vec![0_u8; 1024];
203 let mut truncated = false;
204
205 loop {
206 let read = reader.read(&mut chunk).await?;
207 if read == 0 {
208 return Ok((retained, truncated));
209 }
210
211 let room = cap - retained.len();
212 if read > room {
213 truncated = true;
214 }
215
216 retained.extend_from_slice(&chunk[..read.min(room)]);
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
222#[non_exhaustive]
223pub enum ExternalCommandError {
224 #[error("failed to run {program:?}: {source}")]
226 Spawn {
227 program: PathBuf,
229 source: std::io::Error,
231 },
232 #[error("failed to read the output of {program:?}: {source}")]
234 Read {
235 program: PathBuf,
237 source: std::io::Error,
239 },
240 #[error("{program:?} did not produce an access token within {timeout:?}")]
242 Timeout {
243 program: PathBuf,
245 timeout: Duration,
247 },
248 #[error("{program:?} failed with {status}: {stderr}")]
250 ExitStatus {
251 program: PathBuf,
253 status: String,
255 stderr: String,
257 },
258 #[error("{program:?} wrote more than {limit} bytes to stdout")]
260 OutputTooLarge {
261 program: PathBuf,
263 limit: usize,
265 },
266 #[error("{program:?} did not write a valid UTF-8 access token to stdout")]
268 InvalidUtf8 {
269 program: PathBuf,
271 },
272 #[error("{program:?} did not write an access token to stdout")]
274 EmptyOutput {
275 program: PathBuf,
277 },
278}
279
280#[cfg(test)]
281pub(super) use tests::shell;
282
283#[cfg(test)]
284mod tests {
285 use std::path::PathBuf;
286
287 use super::{DEFAULT_TIMEOUT_SECONDS, ExternalCommandError, ExternallyManagedCredential};
288
289 fn credential(command: impl Into<PathBuf>) -> ExternallyManagedCredential {
292 ExternallyManagedCredential {
293 command: command.into(),
294 args: Vec::new(),
295 timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
296 }
297 }
298
299 pub(in super::super) fn shell() -> (PathBuf, &'static str) {
302 #[cfg(windows)]
303 {
304 let comspec = std::env::var_os("COMSPEC")
305 .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into());
306 (PathBuf::from(comspec), "/C")
307 }
308 #[cfg(not(windows))]
309 {
310 (PathBuf::from("/bin/sh"), "-c")
311 }
312 }
313
314 fn script_credential(script: &str) -> ExternallyManagedCredential {
316 let (program, flag) = shell();
317 ExternallyManagedCredential {
318 args: vec![flag.to_string(), script.to_string()],
319 ..credential(program)
320 }
321 }
322
323 fn echo_credential(token: &str) -> ExternallyManagedCredential {
325 #[cfg(windows)]
326 let script = format!("echo {token}");
327 #[cfg(not(windows))]
328 let script = format!("printf '%s\\n' '{token}'");
329 script_credential(&script)
330 }
331
332 #[tokio::test]
333 async fn returns_trimmed_stdout_as_the_access_token() {
334 let token = echo_credential("an-access-token")
335 .request_access_token()
336 .await
337 .expect("the command should produce a token");
338
339 assert_eq!(token.secret(), "an-access-token");
340 }
341
342 #[tokio::test]
343 async fn reports_stderr_when_the_command_fails() {
344 #[cfg(windows)]
345 let script = "echo something went wrong 1>&2 && exit 3";
346 #[cfg(not(windows))]
347 let script = "echo 'something went wrong' >&2; exit 3";
348
349 let error = script_credential(script)
350 .request_access_token()
351 .await
352 .expect_err("a failing command should be an error");
353
354 let message = error.to_string();
355 assert!(
356 message.contains("something went wrong"),
357 "stderr should be reported: {message}"
358 );
359 }
360
361 #[tokio::test]
362 async fn times_out_a_command_that_hangs() {
363 #[cfg(windows)]
364 let script = "ping -n 30 127.0.0.1 > nul";
365 #[cfg(not(windows))]
366 let script = "sleep 30";
367
368 let error = ExternallyManagedCredential {
369 timeout_seconds: 1,
370 ..script_credential(script)
371 }
372 .request_access_token()
373 .await
374 .expect_err("a hanging command should time out");
375
376 assert!(
377 matches!(error, ExternalCommandError::Timeout { .. }),
378 "unexpected error: {error}"
379 );
380 }
381
382 #[tokio::test]
383 async fn rejects_empty_output() {
384 let error = script_credential("exit 0")
385 .request_access_token()
386 .await
387 .expect_err("a command that prints nothing should be an error");
388
389 assert!(
390 matches!(error, ExternalCommandError::EmptyOutput { .. }),
391 "unexpected error: {error}"
392 );
393 }
394
395 #[tokio::test]
396 async fn rejects_output_that_is_too_large() {
397 #[cfg(windows)]
398 let script = "for /L %i in (1,1,20000) do @echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
399 #[cfg(not(windows))]
400 let script = "yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | head -c 200000";
401
402 let error = script_credential(script)
403 .request_access_token()
404 .await
405 .expect_err("an oversized output should be an error");
406
407 assert!(
408 matches!(error, ExternalCommandError::OutputTooLarge { .. }),
409 "unexpected error: {error}"
410 );
411 }
412}