From 61083d7f1d10f5b22bd8583ed762cbaf23dc5920 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:06:39 +0000 Subject: [PATCH 1/3] fix(store): let the AWS default chain resolve S3 credentials S3Store::new required AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and failed outright without them, then pinned a static provider, so the default chain was never consulted. A pod holding a projected service-account token could not authenticate at all and the backend demanded a long-lived key instead. GCS already resolves through ADC, so this closes the gap between the two. The env vars now override rather than gate: with both set the behaviour is unchanged, including AWS_SESSION_TOKEN; without them the default chain runs and picks up a web-identity token, an ECS task role or IMDS. DefaultCredentialsChain::build is async, so S3Store::new becomes async. GcsStore::new already is and open_store already awaits it. Both in-tree call sites are updated; note this is a breaking change for any caller outside the workspace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KaoVaspfci2kQbzQJLTPd7 --- crates/walgit-store/src/lib.rs | 2 +- crates/walgit-store/src/s3.rs | 45 ++++++++++++++++----------- crates/walgit-store/tests/contract.rs | 2 +- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/crates/walgit-store/src/lib.rs b/crates/walgit-store/src/lib.rs index 824196b..ccb6896 100644 --- a/crates/walgit-store/src/lib.rs +++ b/crates/walgit-store/src/lib.rs @@ -636,7 +636,7 @@ pub async fn open_store(cfg: &walgit_config::Config) -> anyhow::Result walgit_config::StoreBackend::S3 => { #[cfg(feature = "s3")] { - Arc::new(s3::S3Store::new(&cfg.store)?) + Arc::new(s3::S3Store::new(&cfg.store).await?) } #[cfg(not(feature = "s3"))] { diff --git a/crates/walgit-store/src/s3.rs b/crates/walgit-store/src/s3.rs index d788cc2..2cbb2b2 100644 --- a/crates/walgit-store/src/s3.rs +++ b/crates/walgit-store/src/s3.rs @@ -69,31 +69,38 @@ pub struct S3Store { impl S3Store { /// Build a store from `walgit-config::StoreConfig`. /// - /// Credentials are read from the env vars named in - /// `cfg.s3.access_key_env` / `cfg.s3.secret_key_env` - /// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), plus - /// `AWS_SESSION_TOKEN` when present. - pub fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result { - let access_key = std::env::var(&cfg.s3.access_key_env).map_err(|_| { - anyhow::anyhow!("s3: env var {} not set (access key)", cfg.s3.access_key_env) - })?; - let secret_key = std::env::var(&cfg.s3.secret_key_env).map_err(|_| { - anyhow::anyhow!("s3: env var {} not set (secret key)", cfg.s3.secret_key_env) - })?; - - let creds = static_credentials( - &access_key, - &secret_key, - std::env::var("AWS_SESSION_TOKEN").ok(), - ); + /// The env vars named in `cfg.s3.access_key_env` / `cfg.s3.secret_key_env` + /// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) override, plus + /// `AWS_SESSION_TOKEN` when present. Without both, the AWS default chain + /// resolves the credential, which is what reaches a role assumed from a + /// projected service-account token (IRSA) rather than a stored key. + pub async fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result { let region = aws_sdk_s3::config::Region::new(cfg.s3.region.clone()); let mut s3_config = aws_sdk_s3::Config::builder() - .region(region) - .credentials_provider(creds) + .region(region.clone()) .force_path_style(cfg.s3.force_path_style) .behavior_version_latest(); + s3_config = match ( + std::env::var(&cfg.s3.access_key_env).ok(), + std::env::var(&cfg.s3.secret_key_env).ok(), + ) { + (Some(access_key), Some(secret_key)) => s3_config.credentials_provider( + static_credentials( + &access_key, + &secret_key, + std::env::var("AWS_SESSION_TOKEN").ok(), + ), + ), + _ => s3_config.credentials_provider( + aws_config::default_provider::credentials::DefaultCredentialsChain::builder() + .region(region) + .build() + .await, + ), + }; + if !cfg.s3.endpoint.is_empty() { s3_config = s3_config.endpoint_url(&cfg.s3.endpoint); } diff --git a/crates/walgit-store/tests/contract.rs b/crates/walgit-store/tests/contract.rs index 768b860..2bfafa7 100644 --- a/crates/walgit-store/tests/contract.rs +++ b/crates/walgit-store/tests/contract.rs @@ -708,7 +708,7 @@ async fn s3_contract() { ..Default::default() }; - let store = walgit_store::s3::S3Store::new(&cfg).expect("S3Store::new"); + let store = walgit_store::s3::S3Store::new(&cfg).await.expect("S3Store::new"); let store: DynStore = Arc::new(store); run_contract(store.clone(), &prefix).await; From 24ecb13c2501b846d26c721f0c333f14e02b4eb8 Mon Sep 17 00:00:00 2001 From: Julian Date: Thu, 10 Sep 2026 00:28:39 -0700 Subject: [PATCH 2/3] fix(store): reject partial S3 credentials and test web identity refresh Require a complete, non-empty explicit key pair or neither key. A partial custom pair must not silently fall back to a different AWS identity. Exercise the real SDK credential chain in isolated child processes against synthetic STS and S3 endpoints. Verify projected-token rotation, refreshing session credentials for SDK and presigned requests, cache reuse, explicit custom-key precedence, and failure without network calls for invalid keys. Document credential selection in the example config. Bucket protocol depth is unchanged: one request per HEAD/GET, no extra bucket probes. STS exchanges occur on credential resolution/refresh only. --- crates/walgit-store/src/s3.rs | 35 ++- crates/walgit-store/tests/s3_credentials.rs | 269 ++++++++++++++++++++ walgit.example.toml | 4 +- 3 files changed, 294 insertions(+), 14 deletions(-) create mode 100644 crates/walgit-store/tests/s3_credentials.rs diff --git a/crates/walgit-store/src/s3.rs b/crates/walgit-store/src/s3.rs index 2cbb2b2..5e48de4 100644 --- a/crates/walgit-store/src/s3.rs +++ b/crates/walgit-store/src/s3.rs @@ -71,9 +71,10 @@ impl S3Store { /// /// The env vars named in `cfg.s3.access_key_env` / `cfg.s3.secret_key_env` /// (defaults `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) override, plus - /// `AWS_SESSION_TOKEN` when present. Without both, the AWS default chain - /// resolves the credential, which is what reaches a role assumed from a - /// projected service-account token (IRSA) rather than a stored key. + /// `AWS_SESSION_TOKEN` when present. When both key variables are unset, + /// the AWS default chain resolves and refreshes credentials (including + /// projected service-account tokens / IRSA). A partial or empty explicit + /// key pair is an error, not a fallback to another identity. pub async fn new(cfg: &walgit_config::StoreConfig) -> anyhow::Result { let region = aws_sdk_s3::config::Region::new(cfg.s3.region.clone()); @@ -83,21 +84,29 @@ impl S3Store { .behavior_version_latest(); s3_config = match ( - std::env::var(&cfg.s3.access_key_env).ok(), - std::env::var(&cfg.s3.secret_key_env).ok(), + std::env::var(&cfg.s3.access_key_env), + std::env::var(&cfg.s3.secret_key_env), ) { - (Some(access_key), Some(secret_key)) => s3_config.credentials_provider( - static_credentials( + (Ok(access_key), Ok(secret_key)) + if !access_key.is_empty() && !secret_key.is_empty() => + { + s3_config.credentials_provider(static_credentials( &access_key, &secret_key, std::env::var("AWS_SESSION_TOKEN").ok(), + )) + } + (Err(std::env::VarError::NotPresent), Err(std::env::VarError::NotPresent)) => s3_config + .credentials_provider( + aws_config::default_provider::credentials::DefaultCredentialsChain::builder() + .region(region) + .build() + .await, ), - ), - _ => s3_config.credentials_provider( - aws_config::default_provider::credentials::DefaultCredentialsChain::builder() - .region(region) - .build() - .await, + _ => anyhow::bail!( + "set both {} and {} to non-empty credentials, or leave both unset for the AWS default credential chain", + cfg.s3.access_key_env, + cfg.s3.secret_key_env, ), }; diff --git a/crates/walgit-store/tests/s3_credentials.rs b/crates/walgit-store/tests/s3_credentials.rs new file mode 100644 index 0000000..9cdcb6e --- /dev/null +++ b/crates/walgit-store/tests/s3_credentials.rs @@ -0,0 +1,269 @@ +//! Exercise the real credential chain with synthetic STS/S3 servers. Each case +//! runs in a child process with a clean environment: no global env mutation, +//! developer credentials, AWS network access or dependency on an AWS account. +#![cfg(feature = "s3")] +#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, SystemTime}; + +use aws_smithy_types::DateTime; +use aws_smithy_types::date_time::Format; +use axum::body::Bytes; +use axum::extract::{Request, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{any, post}; +use tokio::process::Command; +use walgit_config::{S3Config, StoreConfig}; +use walgit_store::{GetOptions, ObjectStore, s3::S3Store}; + +#[derive(Default)] +struct Requests { + tokens: Vec, + signed: Vec, + session_tokens: Vec, +} + +type Recorded = Arc>; + +async fn sts(State(recorded): State, body: String) -> (HeaderMap, String) { + assert!(body.contains("Action=AssumeRoleWithWebIdentity")); + let sequence = { + let mut requests = recorded.lock().unwrap(); + requests.tokens.push(body); + requests.tokens.len() + }; + // First identity is within the SDK's refresh window. The next is long-lived. + let lifetime = if sequence == 1 { 1 } else { 3600 }; + let expires = DateTime::from(SystemTime::now() + Duration::from_secs(lifetime)) + .fmt(Format::DateTime) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/xml".parse().unwrap()); + ( + headers, + format!( + "\ + \ + TESTIRSAKEY{sequence}synthetic-secret\ + synthetic-session-{sequence}{expires}\ + " + ), + ) +} + +async fn s3(State(recorded): State, request: Request) -> (HeaderMap, Bytes) { + let mut requests = recorded.lock().unwrap(); + if let Some(auth) = request.headers().get("authorization") { + requests.signed.push(auth.to_str().unwrap().to_owned()); + requests.session_tokens.push( + request + .headers() + .get("x-amz-security-token") + .unwrap() + .to_str() + .unwrap() + .to_owned(), + ); + } else { + let url = reqwest::Url::parse(&format!("http://localhost{}", request.uri())).unwrap(); + let query: std::collections::HashMap<_, _> = url.query_pairs().collect(); + requests + .signed + .push(query.get("X-Amz-Credential").unwrap().to_string()); + requests + .session_tokens + .push(query.get("X-Amz-Security-Token").unwrap().to_string()); + } + let mut headers = HeaderMap::new(); + headers.insert("etag", "\"test-etag\"".parse().unwrap()); + headers.insert("content-length", "7".parse().unwrap()); + (headers, Bytes::from_static(b"payload")) +} + +async fn run_case(case: &str, variables: &[(&str, &str)]) -> Recorded { + let home = tempfile::tempdir().unwrap(); + let token_file = home.path().join("token"); + tokio::fs::write(&token_file, "projected-token-1") + .await + .unwrap(); + let recorded = Recorded::default(); + let app = axum::Router::new() + .route("/", post(sts)) + .route("/{*path}", any(s3)) + .fallback(|| async { StatusCode::NOT_FOUND }) + .with_state(recorded.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let mut child = Command::new(std::env::current_exe().unwrap()); + child + .args(["--exact", "credential_child", "--nocapture"]) + .env_clear() + .env("HOME", home.path()) + .env("AWS_CONFIG_FILE", home.path().join("config")) + .env( + "AWS_SHARED_CREDENTIALS_FILE", + home.path().join("credentials"), + ) + .env("AWS_EC2_METADATA_DISABLED", "true") + .env("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/test-role") + .env("AWS_ROLE_SESSION_NAME", "walgit-test") + .env("AWS_WEB_IDENTITY_TOKEN_FILE", &token_file) + .env("AWS_ENDPOINT_URL_STS", &endpoint) + .env("WALGIT_CREDENTIAL_TEST", case) + .env("WALGIT_CREDENTIAL_ENDPOINT", &endpoint) + .envs(variables.iter().copied()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(30), child.output()) + .await + .expect("credential test timed out") + .expect("run child"); + server.abort(); + assert!( + output.status.success(), + "credential child failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + recorded +} + +#[tokio::test] +async fn web_identity_is_used_and_refreshed_for_sdk_and_presigned_requests() { + let recorded = run_case("irsa", &[]).await; + let requests = recorded.lock().unwrap(); + assert_eq!( + requests.tokens.len(), + 2, + "one initial exchange and one refresh" + ); + assert!( + requests + .tokens + .first() + .unwrap() + .contains("WebIdentityToken=projected-token-1") + ); + assert!( + requests + .tokens + .last() + .unwrap() + .contains("WebIdentityToken=projected-token-2") + ); + assert_eq!( + requests.signed.len(), + 3, + "HEAD, GET and a cached HEAD, no extra bucket probes" + ); + assert!(requests.signed.first().unwrap().contains("TESTIRSAKEY1/")); + assert!( + requests + .signed + .iter() + .skip(1) + .all(|s| s.contains("TESTIRSAKEY2/")) + ); + assert_eq!( + requests.session_tokens, + [ + "synthetic-session-1", + "synthetic-session-2", + "synthetic-session-2" + ] + ); +} + +#[tokio::test] +async fn custom_static_pair_overrides_default_chain_and_preserves_session_token() { + let recorded = run_case( + "static", + &[ + ("WALGIT_TEST_ACCESS_KEY", "CUSTOMKEY"), + ("WALGIT_TEST_SECRET_KEY", "custom-secret"), + ("AWS_ACCESS_KEY_ID", "IGNOREDKEY"), + ("AWS_SECRET_ACCESS_KEY", "ignored-secret"), + ("AWS_SESSION_TOKEN", "custom-session"), + ], + ) + .await; + let requests = recorded.lock().unwrap(); + assert!( + requests.tokens.is_empty(), + "explicit credentials must not call STS" + ); + assert_eq!(requests.signed.len(), 1); + assert!(requests.signed.first().unwrap().contains("CUSTOMKEY/")); + assert_eq!(requests.session_tokens, ["custom-session"]); +} + +#[tokio::test] +async fn partial_or_empty_explicit_keys_fail_without_falling_back() { + for variables in [ + vec![("WALGIT_TEST_ACCESS_KEY", "partial")], + vec![("WALGIT_TEST_SECRET_KEY", "partial")], + vec![ + ("WALGIT_TEST_ACCESS_KEY", ""), + ("WALGIT_TEST_SECRET_KEY", "secret"), + ], + vec![ + ("WALGIT_TEST_ACCESS_KEY", "access"), + ("WALGIT_TEST_SECRET_KEY", ""), + ], + ] { + let recorded = run_case("invalid", &variables).await; + let requests = recorded.lock().unwrap(); + assert!(requests.tokens.is_empty()); + assert!(requests.signed.is_empty()); + } +} + +// Re-entered only by run_case; never changes this test runner's environment. +#[tokio::test] +async fn credential_child() { + let Ok(case) = std::env::var("WALGIT_CREDENTIAL_TEST") else { + return; + }; + let cfg = StoreConfig { + bucket: "test-bucket".into(), + s3: S3Config { + endpoint: std::env::var("WALGIT_CREDENTIAL_ENDPOINT").unwrap(), + force_path_style: true, + access_key_env: "WALGIT_TEST_ACCESS_KEY".into(), + secret_key_env: "WALGIT_TEST_SECRET_KEY".into(), + ..Default::default() + }, + ..Default::default() + }; + let result = S3Store::new(&cfg).await; + if case == "invalid" { + let error = result + .err() + .expect("invalid credentials must fail construction"); + assert!(error.to_string().contains("leave both unset")); + return; + } + let store = result.expect("construct store"); + assert_eq!(store.head("key").await.unwrap().unwrap().size, 7); + if case == "irsa" { + tokio::fs::write( + std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").unwrap(), + "projected-token-2", + ) + .await + .unwrap(); + // Expire even if the SDK changes its refresh-window jitter policy. + tokio::time::sleep(Duration::from_secs(2)).await; + let (_, bytes) = store + .get("key", GetOptions::default()) + .await + .unwrap() + .bytes() + .await + .unwrap() + .unwrap(); + assert_eq!(&bytes[..], b"payload"); + assert_eq!(store.head("key").await.unwrap().unwrap().size, 7); + } +} diff --git a/walgit.example.toml b/walgit.example.toml index 304778b..e7fbcc5 100644 --- a/walgit.example.toml +++ b/walgit.example.toml @@ -75,8 +75,10 @@ multipart_part_size = "32MiB" [store.s3] endpoint = "https://s3.us-east-1.amazonaws.com" # or http://127.0.0.1:9000 for rustfs/MinIO region = "us-east-1" -access_key_env = "AWS_ACCESS_KEY_ID" # env var names read at runtime; AWS_SESSION_TOKEN is also honored when set +access_key_env = "AWS_ACCESS_KEY_ID" # explicit pair overrides the default chain; AWS_SESSION_TOKEN is also honored secret_key_env = "AWS_SECRET_ACCESS_KEY" +# Leave both key variables unset for AWS profiles, web identity / IRSA, container or instance roles. +# Credentials from the default chain refresh automatically. An incomplete or empty explicit pair is an error. force_path_style = false # true for most self-hosted S3 implementations [store.gcs] From 9a93067314ee7128fd298cc38507f94da785753f Mon Sep 17 00:00:00 2001 From: Julian Bright Date: Sat, 12 Sep 2026 22:55:16 +0000 Subject: [PATCH 3/3] fix(store): resolve the AWS default chain once at startup A host with no AWS identity used to fail on the first request, after the profile and IMDS lookups timed out, where the explicit-key path had failed at construction with a clear message. Probe the chain once in `new` so misconfiguration surfaces the same way. Also formats the S3 contract constructor. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01T6UZ2AaFQ6m7xgdZKRyHEV --- crates/walgit-store/src/s3.rs | 17 +++++++--- crates/walgit-store/tests/contract.rs | 4 ++- crates/walgit-store/tests/s3_credentials.rs | 35 ++++++++++++++++----- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/crates/walgit-store/src/s3.rs b/crates/walgit-store/src/s3.rs index 5e48de4..5ba6c1f 100644 --- a/crates/walgit-store/src/s3.rs +++ b/crates/walgit-store/src/s3.rs @@ -45,7 +45,7 @@ use std::ops::Range; use std::time::Duration; use aws_sdk_s3::Client as S3Client; -use aws_sdk_s3::config::Credentials; +use aws_sdk_s3::config::{Credentials, ProvideCredentials}; use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::ByteStream as S3ByteStream; use bytes::Bytes; @@ -96,13 +96,20 @@ impl S3Store { std::env::var("AWS_SESSION_TOKEN").ok(), )) } - (Err(std::env::VarError::NotPresent), Err(std::env::VarError::NotPresent)) => s3_config - .credentials_provider( + (Err(std::env::VarError::NotPresent), Err(std::env::VarError::NotPresent)) => { + let chain = aws_config::default_provider::credentials::DefaultCredentialsChain::builder() .region(region) .build() - .await, - ), + .await; + // Resolve once now, so a host with no AWS identity fails at startup + // with the chain's own reason rather than on the first request, + // after the profile and IMDS lookups have timed out. + chain.provide_credentials().await.map_err(|e| { + anyhow::anyhow!("s3: the AWS default credential chain resolved nothing: {e}") + })?; + s3_config.credentials_provider(chain) + } _ => anyhow::bail!( "set both {} and {} to non-empty credentials, or leave both unset for the AWS default credential chain", cfg.s3.access_key_env, diff --git a/crates/walgit-store/tests/contract.rs b/crates/walgit-store/tests/contract.rs index 2bfafa7..d12c264 100644 --- a/crates/walgit-store/tests/contract.rs +++ b/crates/walgit-store/tests/contract.rs @@ -708,7 +708,9 @@ async fn s3_contract() { ..Default::default() }; - let store = walgit_store::s3::S3Store::new(&cfg).await.expect("S3Store::new"); + let store = walgit_store::s3::S3Store::new(&cfg) + .await + .expect("S3Store::new"); let store: DynStore = Arc::new(store); run_contract(store.clone(), &prefix).await; diff --git a/crates/walgit-store/tests/s3_credentials.rs b/crates/walgit-store/tests/s3_credentials.rs index 9cdcb6e..fcf3ae3 100644 --- a/crates/walgit-store/tests/s3_credentials.rs +++ b/crates/walgit-store/tests/s3_credentials.rs @@ -33,8 +33,9 @@ async fn sts(State(recorded): State, body: String) -> (HeaderMap, Stri requests.tokens.push(body); requests.tokens.len() }; - // First identity is within the SDK's refresh window. The next is long-lived. - let lifetime = if sequence == 1 { 1 } else { 3600 }; + // The startup probe takes the first identity; the second, which signs the + // first request, is within the SDK's refresh window. The rest are long-lived. + let lifetime = if sequence <= 2 { 1 } else { 3600 }; let expires = DateTime::from(SystemTime::now() + Duration::from_secs(lifetime)) .fmt(Format::DateTime) .unwrap(); @@ -135,8 +136,8 @@ async fn web_identity_is_used_and_refreshed_for_sdk_and_presigned_requests() { let requests = recorded.lock().unwrap(); assert_eq!( requests.tokens.len(), - 2, - "one initial exchange and one refresh" + 3, + "the startup probe, the first request's exchange and one refresh" ); assert!( requests @@ -157,20 +158,20 @@ async fn web_identity_is_used_and_refreshed_for_sdk_and_presigned_requests() { 3, "HEAD, GET and a cached HEAD, no extra bucket probes" ); - assert!(requests.signed.first().unwrap().contains("TESTIRSAKEY1/")); + assert!(requests.signed.first().unwrap().contains("TESTIRSAKEY2/")); assert!( requests .signed .iter() .skip(1) - .all(|s| s.contains("TESTIRSAKEY2/")) + .all(|s| s.contains("TESTIRSAKEY3/")) ); assert_eq!( requests.session_tokens, [ - "synthetic-session-1", "synthetic-session-2", - "synthetic-session-2" + "synthetic-session-3", + "synthetic-session-3" ] ); } @@ -219,6 +220,14 @@ async fn partial_or_empty_explicit_keys_fail_without_falling_back() { } } +#[tokio::test] +async fn a_host_with_no_identity_fails_at_startup_not_on_the_first_request() { + let recorded = run_case("no-identity", &[]).await; + let requests = recorded.lock().unwrap(); + assert!(requests.tokens.is_empty()); + assert!(requests.signed.is_empty()); +} + // Re-entered only by run_case; never changes this test runner's environment. #[tokio::test] async fn credential_child() { @@ -236,6 +245,16 @@ async fn credential_child() { }, ..Default::default() }; + if case == "no-identity" { + // No web identity, no profile, no IMDS: the chain has nothing to offer. + std::fs::remove_file(std::env::var("AWS_WEB_IDENTITY_TOKEN_FILE").unwrap()).unwrap(); + let error = S3Store::new(&cfg) + .await + .err() + .expect("an unresolvable chain must fail construction"); + assert!(error.to_string().contains("resolved nothing")); + return; + } let result = S3Store::new(&cfg).await; if case == "invalid" { let error = result