78 78 | }
|
79 79 |
|
80 80 | pub(crate) fn builder() -> Builder {
|
81 81 | Builder::default()
|
82 82 | }
|
83 83 |
|
84 84 | async fn credentials(&self) -> provider::Result {
|
85 85 | // Security: command arguments must be redacted at debug level
|
86 86 | tracing::debug!(command = %self.command, "loading credentials from external process");
|
87 87 |
|
88 - | let command = if cfg!(windows) {
|
88 + | // On Windows, the command runs through `cmd.exe /C`. The command string
|
89 + | // is appended with `raw_arg` rather than as a normal argument so that
|
90 + | // Rust does not apply its own C runtime style escaping (which `cmd.exe`
|
91 + | // does not understand), and the whole command is wrapped in an extra
|
92 + | // pair of quotes as `cmd.exe` requires. This preserves a quoted first
|
93 + | // token containing spaces. Ex: for an executable installed under
|
94 + | // `C:\Program Files\...`, such as AppStream 2.0's machine-role provider.
|
95 + | // Previously the entire string was passed as a single normal argument,
|
96 + | // whose escaping combined with `cmd.exe`'s quote-stripping to mangle
|
97 + | // such paths.
|
98 + | #[cfg(windows)]
|
99 + | let command = {
|
100 + | use std::os::windows::process::CommandExt;
|
89 101 | let mut command = Command::new("cmd.exe");
|
90 - | command.args(["/C", self.command.unredacted()]);
|
102 + | command.arg("/C");
|
103 + | command.raw_arg(format!("\"{}\"", self.command.unredacted()));
|
91 104 | command
|
92 - | } else {
|
105 + | };
|
106 + | #[cfg(not(windows))]
|
107 + | let command = {
|
93 108 | let mut command = Command::new("sh");
|
94 109 | command.args(["-c", self.command.unredacted()]);
|
95 110 | command
|
96 111 | };
|
97 112 | let output = tokio::process::Command::from(command)
|
98 113 | .output()
|
99 114 | .await
|
100 115 | .map_err(|e| {
|
101 116 | CredentialsError::provider_error(format!(
|
102 117 | "Error retrieving credentials from external process: {e}",
|
329 344 | assert_eq!("012345678901", creds.account_id().unwrap().as_str());
|
330 345 | }
|
331 346 |
|
332 347 | #[tokio::test]
|
333 348 | async fn fallback_account_id_shadowed_by_account_id_in_process_output() {
|
334 349 | let provider = CredentialProcessProvider::builder()
|
335 350 | .command(CommandWithSensitiveArgs::new(String::from(
|
336 351 | r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
|
337 352 | )))
|
338 353 | .account_id("012345678901")
|
339 354 | .build();
|
340 355 | let creds = provider.provide_credentials().await.unwrap();
|
341 356 | assert_eq!("111122223333", creds.account_id().unwrap().as_str());
|
342 357 | }
|
343 358 |
|
344 359 | #[tokio::test]
|
345 360 | async fn credential_feature() {
|
346 361 | let provider = CredentialProcessProvider::builder()
|
347 362 | .command(CommandWithSensitiveArgs::new(String::from(
|
348 363 | r#"echo '{ "Version": 1, "AccessKeyId": "ASIARTESTID", "SecretAccessKey": "TESTSECRETKEY", "AccountId": "111122223333" }'"#,
|
349 364 | )))
|
350 365 | .account_id("012345678901")
|
351 366 | .build();
|
352 367 | let creds = provider.provide_credentials().await.unwrap();
|
353 368 | assert_eq!(
|
354 369 | &vec![AwsCredentialFeature::CredentialsProcess],
|
355 370 | creds.get_property::<Vec<AwsCredentialFeature>>().unwrap()
|
356 371 | );
|
357 372 | }
|
358 373 | }
|
374 + |
|
375 + | // Integration tests that actually spawn a process from a path containing a
|
376 + | // space. These run only on Windows: they are the regression tests for the
|
377 + | // `credential_process` quoting bug (internal: P491659165). The pre-existing
|
378 + | // `credential_process` tests above use the Unix `echo` builtin and are
|
379 + | // skipped on Windows.
|
380 + | #[cfg(all(test, windows))]
|
381 + | mod windows_tests {
|
382 + | use crate::credential_process::CredentialProcessProvider;
|
383 + | use aws_credential_types::provider::ProvideCredentials;
|
384 + | use std::path::{Path, PathBuf};
|
385 + |
|
386 + | const CREDS_JSON: &str = "{\"Version\":1,\"AccessKeyId\":\"ASIARTESTID\",\"SecretAccessKey\":\"TESTSECRETKEY\",\"SessionToken\":\"TESTSESSIONTOKEN\",\"Expiration\":\"2035-01-01T00:00:00Z\"}";
|
387 + |
|
388 + | // Write a `.cmd` provider that prints valid credential JSON to stdout into
|
389 + | // `dir`, returning the path to the script. `@echo off` keeps stdout clean so
|
390 + | // the only thing emitted is the JSON document.
|
391 + | fn write_provider(dir: &Path) -> PathBuf {
|
392 + | std::fs::create_dir_all(dir).unwrap();
|
393 + | let script = dir.join("provider.cmd");
|
394 + | std::fs::write(&script, format!("@echo off\r\necho {CREDS_JSON}\r\n")).unwrap();
|
395 + | script
|
396 + | }
|
397 + |
|
398 + | #[tokio::test]
|
399 + | async fn spaced_path_with_argument_resolves() {
|
400 + | let tmp = tempfile::TempDir::new().unwrap();
|
401 + | let script = write_provider(&tmp.path().join("Program Space"));
|
402 + | assert!(
|
403 + | script.to_string_lossy().contains(' '),
|
404 + | "test fixture path must contain a space: {}",
|
405 + | script.display()
|
406 + | );
|
407 + |
|
408 + | // Quote the path as a real config would, and pass an argument — exactly
|
409 + | // the AppStream shape: `"...PhotonRoleCredentialProvider.exe" --role=Machine`.
|
410 + | let command = format!("\"{}\" --role=Machine", script.display());
|
411 + | let provider = CredentialProcessProvider::new(command);
|
412 + |
|
413 + | let creds = provider
|
414 + | .provide_credentials()
|
415 + | .await
|
416 + | .expect("credentials should resolve from a quoted spaced path with an argument");
|
417 + | assert_eq!(creds.access_key_id(), "ASIARTESTID");
|
418 + | assert_eq!(creds.secret_access_key(), "TESTSECRETKEY");
|
419 + | assert_eq!(creds.session_token(), Some("TESTSESSIONTOKEN"));
|
420 + | }
|
421 + |
|
422 + | #[tokio::test]
|
423 + | async fn spaced_path_without_argument_resolves() {
|
424 + | let tmp = tempfile::TempDir::new().unwrap();
|
425 + | let script = write_provider(&tmp.path().join("Program Space"));
|
426 + |
|
427 + | let command = format!("\"{}\"", script.display());
|
428 + | let provider = CredentialProcessProvider::new(command);
|
429 + |
|
430 + | let creds = provider
|
431 + | .provide_credentials()
|
432 + | .await
|
433 + | .expect("credentials should resolve from a quoted spaced path with no argument");
|
434 + | assert_eq!(creds.access_key_id(), "ASIARTESTID");
|
435 + | }
|
436 + |
|
437 + | #[tokio::test]
|
438 + | async fn unquoted_unspaced_path_still_resolves() {
|
439 + | // Control: an unquoted path with no spaces continues to work.
|
440 + | let tmp = tempfile::TempDir::new().unwrap();
|
441 + | let script = write_provider(&tmp.path().join("nospace"));
|
442 + | // Only meaningful if no path component (including the temp root) has a
|
443 + | // space; otherwise the unquoted form is not a valid no-space control.
|
444 + | if script.to_string_lossy().contains(' ') {
|
445 + | return;
|
446 + | }
|
447 + |
|
448 + | let command = script.display().to_string();
|
449 + | let provider = CredentialProcessProvider::new(command);
|
450 + |
|
451 + | let creds = provider
|
452 + | .provide_credentials()
|
453 + | .await
|
454 + | .expect("control (unquoted, no spaces) should resolve");
|
455 + | assert_eq!(creds.access_key_id(), "ASIARTESTID");
|
456 + | }
|
457 + | }
|