From 728188d6008307ff50d1fbf77d3ec2853978c42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Domen=20Ko=C5=BEar?= Date: Mon, 26 Oct 2026 22:36:26 +0000 Subject: [PATCH] feat: add native async provider API --- CHANGELOG.md | 8 + Cargo.lock | 1 + Cargo.toml | 1 + .../src/bin/ipc-provider-endpoint-rust.rs | 63 +- .../ipc/runner/tests/async_provider.rs | 215 +++++ .../docs/development/adding-providers.mdx | 103 ++- secretspec/Cargo.toml | 1 + secretspec/src/lib.rs | 5 +- secretspec/src/provider/aac.rs | 198 ++-- secretspec/src/provider/address.rs | 10 +- secretspec/src/provider/akv.rs | 143 ++- secretspec/src/provider/async_provider.rs | 596 ++++++++++++ secretspec/src/provider/awsps.rs | 132 +-- secretspec/src/provider/awssm.rs | 104 ++- secretspec/src/provider/base.rs | 374 ++++++++ secretspec/src/provider/cloudflare.rs | 154 ++-- secretspec/src/provider/credentials.rs | 4 +- secretspec/src/provider/doppler.rs | 246 ++--- secretspec/src/provider/external.rs | 849 +++++++++++------- secretspec/src/provider/factory.rs | 38 + secretspec/src/provider/gcsm.rs | 36 +- secretspec/src/provider/infisical.rs | 129 ++- secretspec/src/provider/kubernetes.rs | 63 +- secretspec/src/provider/macros.rs | 52 ++ secretspec/src/provider/mod.rs | 13 +- secretspec/src/provider/openbao.rs | 78 +- secretspec/src/provider/retry.rs | 8 - secretspec/src/provider/scaleway.rs | 56 +- secretspec/src/provider/setec.rs | 186 ++-- secretspec/src/provider/sync_provider.rs | 352 ++++++++ secretspec/src/provider/tests.rs | 11 +- secretspec/src/provider/traits.rs | 4 +- secretspec/src/provider/vault.rs | 78 +- secretspec/src/provider/vault_common.rs | 219 ++--- secretspec/tests/async_provider.rs | 535 +++++++++++ 35 files changed, 3840 insertions(+), 1225 deletions(-) create mode 100644 conformance/ipc/runner/tests/async_provider.rs create mode 100644 secretspec/src/provider/async_provider.rs create mode 100644 secretspec/src/provider/base.rs create mode 100644 secretspec/src/provider/sync_provider.rs create mode 100644 secretspec/tests/async_provider.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index df36cf512..2492e016e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Rust integrations can implement the awaitable `AsyncProvider` trait or use + `AsyncProviderAdapter` to call existing providers without blocking async tasks. + `SyncProviderAdapter` exposes native async backends to the existing sync API; + `ProviderBase` carries their shared configuration and identity. + Async SDK, HTTP, and external IPC providers implement the trait directly, + retaining batch reads, metadata, authentication, and retry behavior. The + async URI factory selects native implementations automatically. + - Bitwarden Password Manager can load its `session` credential from another provider, such as keyring, instead of requiring `BW_SESSION` in the shell. diff --git a/Cargo.lock b/Cargo.lock index 50f77cf32..22bb917f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6331,6 +6331,7 @@ dependencies = [ "dunce", "ed25519-dalek", "etcetera", + "futures-util", "google-cloud-gax", "google-cloud-secretmanager-v1", "http 1.4.0", diff --git a/Cargo.toml b/Cargo.toml index 9ee7c579c..cc2beb5b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ kube = { version = "4.2", default-features = false, features = ["client", "rustl k8s-openapi = { version = "0.28", features = ["v1_36"] } json-patch = "4.2.0" async-trait = "0.1" +futures-util = "0.3" tokio-util = { version = "0.7", features = ["rt"] } zeroize = { version = "1.8", features = ["derive"] } secretspec-ipc = { version = "0.21.1", path = "./secretspec-ipc" } diff --git a/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs b/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs index 967266e36..9fac0a98b 100644 --- a/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs +++ b/conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs @@ -47,6 +47,7 @@ struct StoredValue { struct MemoryProvider { values: Mutex>, errors_seen: Mutex>, + async_attempts: Mutex>, crash_on_get_once: Option, } @@ -107,6 +108,16 @@ impl MemoryProvider { } } + // Faults used by direct async client tests. Counters let tests distinguish + // a surfaced lost acknowledgement from an automatically replayed mutation. + fn async_attempt(&self, operation: &str, key: &str) -> usize { + let key = key.rsplit('/').next().unwrap(); + let mut counts = self.async_attempts.lock().unwrap(); + let count = counts.entry(format!("{operation}/{key}")).or_default(); + *count += 1; + *count + } + fn one_shot_error(&self, key: &str) -> Option { let kind = if key.ends_with("/__INTERACTION_REQUIRED__") { ErrorKind::InteractionRequired @@ -198,6 +209,24 @@ impl ProviderHandler for MemoryProvider { address: Address, ) -> RpcResult> { let key = Self::key(&address); + if key.ends_with("/__ASYNC_COUNTS__") { + let counts = serde_json::to_string(&*self.async_attempts.lock().unwrap()).unwrap(); + return Ok(Some(ProvidedSecret::new(counts, None))); + } + if key.ends_with("/__ASYNC_RETRY_ONCE__") { + if self.async_attempt("get", &key) == 1 { + return Err(RpcError::unavailable(Some(1000))); + } + return Ok(Some(ProvidedSecret::new("recovered".into(), None))); + } + if key.ends_with("/__ASYNC_RETRY_ALWAYS__") { + self.async_attempt("get", &key); + return Err(RpcError::unavailable(Some(1))); + } + if key.ends_with("/__ASYNC_DENIED__") { + self.async_attempt("get", &key); + return Err(RpcError::new(ErrorKind::PermissionDenied)); + } if key.ends_with("/__BLOCK__") { context.cancellation.cancelled().await; return Err(RpcError::new(ErrorKind::Cancelled)); @@ -254,6 +283,10 @@ impl ProviderHandler for MemoryProvider { return Err(error); } let key = Self::key(&address); + let lost_ack = key.ends_with("/__ASYNC_SET__"); + if lost_ack { + self.async_attempt("set", &key); + } let secret_expires_at_unix_ms = key .ends_with("/SECRET_EXPIRY") .then(|| { @@ -285,6 +318,9 @@ impl ProviderHandler for MemoryProvider { revision, }, ); + if lost_ack { + return Err(RpcError::unavailable(Some(1))); + } Ok(()) } @@ -295,8 +331,13 @@ impl ProviderHandler for MemoryProvider { value: SecretValue, ttl_ms: u64, ) -> RpcResult<()> { + let key = Self::key(&address); + let lost_ack = key.ends_with("/__ASYNC_SET_EXPIRING__"); + if lost_ack { + self.async_attempt("set_expiring", &key); + } self.values.lock().unwrap().insert( - Self::key(&address), + key, StoredValue { value: value.expose().to_string(), storage_expires_at: Some(Instant::now() + Duration::from_millis(ttl_ms)), @@ -304,16 +345,23 @@ impl ProviderHandler for MemoryProvider { revision: None, }, ); + if lost_ack { + return Err(RpcError::unavailable(Some(1))); + } Ok(()) } async fn delete(&self, _context: RequestContext, address: Address) -> RpcResult { - Ok(self - .values - .lock() - .unwrap() - .remove(&Self::key(&address)) - .is_some()) + let key = Self::key(&address); + let lost_ack = key.ends_with("/__ASYNC_DELETE__"); + if lost_ack { + self.async_attempt("delete", &key); + } + let removed = self.values.lock().unwrap().remove(&key).is_some(); + if lost_ack { + return Err(RpcError::unavailable(Some(1))); + } + Ok(removed) } async fn clear(&self, _context: RequestContext, params: ClearParams) -> RpcResult { @@ -448,6 +496,7 @@ async fn main() { let handler = MemoryProvider { values: Mutex::new(HashMap::new()), errors_seen: Mutex::new(HashSet::new()), + async_attempts: Mutex::new(HashMap::new()), crash_on_get_once, }; if serve_provider( diff --git a/conformance/ipc/runner/tests/async_provider.rs b/conformance/ipc/runner/tests/async_provider.rs new file mode 100644 index 000000000..2e712bfe0 --- /dev/null +++ b/conformance/ipc/runner/tests/async_provider.rs @@ -0,0 +1,215 @@ +use secretspec::{ + Address, AsyncProvider, DiscoveryContext, ExternalProvider, Provider, ProviderCredentialBroker, + ProviderCredentialPrincipal, ProviderCredentialRequest, ProviderEndpoint, RetryPolicy, + SecretBytes, SecretSpecError, +}; +use secretspec_ipc::error::ErrorKind; +use std::cell::Cell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +struct NoHostCredentials; +impl ProviderCredentialBroker for NoHostCredentials { + fn get( + &self, + _principal: &ProviderCredentialPrincipal, + _request: &ProviderCredentialRequest, + ) -> secretspec::Result> { + Ok(None) + } +} + +fn provider(attempts: u32) -> (Option, ExternalProvider) { + let endpoint = PathBuf::from(env!("CARGO_BIN_EXE_ipc-provider-endpoint-rust")); + #[cfg(windows)] + let (endpoint, directory) = { + let directory = tempfile::tempdir().unwrap(); + secretspec::windows_security::make_path_private(directory.path()).unwrap(); + let staged = directory.path().join(endpoint.file_name().unwrap()); + std::fs::copy(&endpoint, &staged).unwrap(); + (staged, Some(directory)) + }; + #[cfg(not(windows))] + let directory = None; + let mut provider = ExternalProvider::new( + ProviderEndpoint { + scheme: "memory".into(), + executable: endpoint, + arguments: Vec::new(), + environment: Vec::new(), + }, + "memory://conformance", + ) + .unwrap(); + provider.with_credential_broker(Arc::new(NoHostCredentials)); + Provider::set_retry_policy(&mut provider, RetryPolicy::new(attempts).unwrap()); + (directory, provider) +} + +fn address(key: &str) -> Address<'_> { + Address::convention("async-tests", "default", key) +} + +async fn counts(provider: &dyn AsyncProvider) -> HashMap { + let value = provider + .get(address("__ASYNC_COUNTS__")) + .await + .unwrap() + .unwrap(); + serde_json::from_slice(value.expose_secret()).unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn native_ipc_crud_batches_and_metadata() { + let (_directory, provider) = provider(3); + provider.initialize_async().await.unwrap(); + let provider: &dyn AsyncProvider = provider.as_async(); + assert!(provider.supports_read()); + assert!(provider.supports_delete()); + let token = address("TOKEN"); + assert!(provider.get(token).await.unwrap().is_none()); + provider.check_writable(token).await.unwrap(); + provider.set(token, &"value".into()).await.unwrap(); + assert!(provider.exists(token).await.unwrap()); + assert_eq!( + provider.get(token).await.unwrap().unwrap().expose_secret(), + b"value" + ); + let requests = [ + ("first", token), + ("alias", token), + ("missing", address("MISSING")), + ]; + let batch = provider.get_many(&requests).await.unwrap(); + assert_eq!(batch.len(), 2); + assert_eq!(batch["first"].expose_secret(), b"value"); + assert_eq!(batch["alias"].expose_secret(), b"value"); + let present = provider.exists_many(&requests).await.unwrap(); + assert_eq!(present.len(), 2); + assert!(present.contains("first") && present.contains("alias")); + + let expiry = address("SECRET_EXPIRY"); + provider.set(expiry, &"expiring".into()).await.unwrap(); + let metadata = provider.get_with_metadata(expiry).await.unwrap().unwrap(); + assert!(metadata.expires_at_unix_ms.is_some()); + assert!(metadata.revision.is_some()); + let batch = provider + .get_many_with_metadata(&[("expiry", expiry)]) + .await + .unwrap(); + assert_eq!(batch["expiry"].revision, metadata.revision); + assert_eq!( + batch["expiry"].expires_at_unix_ms, + metadata.expires_at_unix_ms + ); + provider + .set_expiring(token, &"retained".into(), Duration::from_secs(60)) + .await + .unwrap(); + let retained = provider.get_with_metadata(token).await.unwrap().unwrap(); + assert_eq!(retained.value.expose_secret(), b"retained"); + assert!(retained.expires_at_unix_ms.is_none()); + assert!( + provider + .reflect(DiscoveryContext::new("async-tests", "default")) + .await + .unwrap() + .contains_key("TOKEN") + ); + provider.check_deletable(token).await.unwrap(); + assert!(provider.delete(token).await.unwrap()); + assert!(!provider.delete(token).await.unwrap()); + assert!(!provider.exists(token).await.unwrap()); +} + +#[tokio::test(flavor = "current_thread")] +async fn native_ipc_retry_backoff_yields_to_the_callers_runtime() { + let (_directory, provider) = provider(3); + provider.initialize_async().await.unwrap(); + let provider: &dyn AsyncProvider = provider.as_async(); + // The endpoint requests one second of backoff. A blocking sleep would keep + // this current-thread runtime from polling the heartbeat until it finishes. + let started = Instant::now(); + let finished = Cell::new(false); + let read = async { + let result = provider.get(address("__ASYNC_RETRY_ONCE__")).await; + finished.set(true); + result + }; + let heartbeat = async { + let mut previous = started; + loop { + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(previous.elapsed() < Duration::from_millis(750)); + previous = Instant::now(); + if finished.get() { + break; + } + } + }; + let (value, ()) = tokio::join!(read, heartbeat); + assert!(started.elapsed() >= Duration::from_secs(1)); + assert_eq!(value.unwrap().unwrap().expose_secret(), b"recovered"); + assert_eq!(counts(provider).await["get/__ASYNC_RETRY_ONCE__"], 2); +} + +#[tokio::test(flavor = "current_thread")] +async fn native_ipc_obeys_retry_budget_and_does_not_retry_permission_errors() { + for attempts in [1, 3] { + let (_directory, provider) = provider(attempts); + let provider: &dyn AsyncProvider = provider.as_async(); + let error = provider + .get(address("__ASYNC_RETRY_ALWAYS__")) + .await + .unwrap_err(); + assert_eq!(error.protocol_kind(), Some(ErrorKind::Unavailable)); + assert!(matches!( + error, + SecretSpecError::ProviderTransient { + exhausted: true, + .. + } + )); + let error = provider.get(address("__ASYNC_DENIED__")).await.unwrap_err(); + assert_eq!(error.protocol_kind(), Some(ErrorKind::PermissionDenied)); + let counts = counts(provider).await; + assert_eq!(counts["get/__ASYNC_RETRY_ALWAYS__"], attempts as usize); + assert_eq!(counts["get/__ASYNC_DENIED__"], 1); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn native_ipc_does_not_replay_mutations_after_lost_acknowledgements() { + let (_directory, provider) = provider(3); + let provider: &dyn AsyncProvider = provider.as_async(); + let value = "stored once".into(); + let set = address("__ASYNC_SET__"); + let error = provider.set(set, &value).await.unwrap_err(); + assert_eq!(error.protocol_kind(), Some(ErrorKind::Unavailable)); + assert_eq!( + provider.get(set).await.unwrap().unwrap().expose_secret(), + b"stored once" + ); + let expiring = address("__ASYNC_SET_EXPIRING__"); + let error = provider + .set_expiring(expiring, &value, Duration::from_secs(60)) + .await + .unwrap_err(); + assert_eq!(error.protocol_kind(), Some(ErrorKind::Unavailable)); + assert!(provider.exists(expiring).await.unwrap()); + let delete = address("__ASYNC_DELETE__"); + provider.set(delete, &value).await.unwrap(); + let error = provider.delete(delete).await.unwrap_err(); + assert_eq!(error.protocol_kind(), Some(ErrorKind::Unavailable)); + assert!(!provider.exists(delete).await.unwrap()); + let counts = counts(provider).await; + for operation in [ + "set/__ASYNC_SET__", + "set_expiring/__ASYNC_SET_EXPIRING__", + "delete/__ASYNC_DELETE__", + ] { + assert_eq!(counts[operation], 1, "replayed {operation}"); + } +} diff --git a/docs/src/content/docs/development/adding-providers.mdx b/docs/src/content/docs/development/adding-providers.mdx index 925486214..42dd988c0 100644 --- a/docs/src/content/docs/development/adding-providers.mdx +++ b/docs/src/content/docs/development/adding-providers.mdx @@ -172,9 +172,110 @@ extension of the provider trait. 1. **Create provider module** in `src/provider/mybackend.rs` 2. **Define config struct** with `Serialize`, `Deserialize`, `Default`, and `TryFrom<&Url>` 3. **Implement provider struct** and use the `register_provider!` macro for automatic registration -4. **Implement Provider trait** for your provider struct +4. **Implement the storage interface**: `Provider` for sync backends, or follow + [Async backends](#async-backends) for async SDKs (0.22+) 5. **Export from mod.rs**: Add `pub mod mybackend;` +## Async backends + + + +Each backend implements its native storage interface once: `Provider` for +synchronous backends, or `AsyncProvider` with `#[async_trait::async_trait]` for +async SDKs and HTTP clients. Async backends also implement `ProviderBase` for +configuration, naming, capabilities, credentials, and storage identity. Supply +`name` and `uri` there, and `get` and `set` in `AsyncProvider`. Define the +store's convention layout with `ProviderBase::convention_address`, or override +`AsyncProvider::resolve_address` when compiling an address requires async I/O. + +The shared adapters expose either kind of backend to either kind of caller: + +| Backend | Synchronous caller | Async caller | +| --- | --- | --- | +| `Provider` | Direct call | `AsyncProviderAdapter` with `spawn_blocking` | +| `AsyncProvider` + `ProviderBase` | `SyncProviderAdapter` with `block_on` | Direct await | + +The `Secrets` resolver, CLI, and existing synchronous provider implementations +keep using `Provider`. Native async backends do not implement that trait or +write their own sync bridges. Registration installs `SyncProviderAdapter` for +the synchronous registry and exposes the native backend to async callers. +Methods accept the same borrowed `Address` and arbitrary `SecretBytes` values. +Text-only stores must reject non-UTF-8 bytes without including the value in an +error. + +Register a native async factory alongside the synchronous one using the shared +catalog form of the registration macro: + +```rust +crate::register_provider! { + struct: YourProvider, + config: YourConfig, + metadata: &super::catalog::YOUR_PROVIDER, + native_async: true, +} +``` + +The async URI factory reuses the configuration parser, injects credentials, and +sets the default retry policy before returning the provider. URI parsing, +credential rejection, and disabled-feature errors follow the synchronous +factory. Native providers own their SDK or HTTP retry handling. + +Await backend I/O directly. Use async mutexes for gates held during I/O and +async subprocess APIs for credential commands. Default batch reads deduplicate +addresses and poll futures with the existing provider concurrency cap. Override +`get_many` for a backend's bulk API, and both `get_with_metadata` and +`get_many_with_metadata` when the backend reports expiry or revision metadata. + +Read-only providers must reject writes in both `set` and `check_writable`. +Write-only providers must override `supports_read` and implement value-free +`exists`. Deletion is opt-in: override both `delete` and `supports_delete`, +and validate address restrictions in `check_deletable`. + +Exercise the native registration in tests through `Box`. +For example, with the `awsps` Cargo feature enabled, run this inside an async +function on a Tokio runtime: + +```rust +use secretspec::{Address, AsyncProvider}; + +let provider = Box::::try_from("awsps://us-east-1")?; +let value = provider + .get(Address::convention("myapp", "default", "API_KEY")) + .await?; +``` + +Include tests on a current-thread runtime to verify that another task can make +progress while the provider awaits I/O or retry backoff. Check that the shared +adapter preserves write policies, batch results, and metadata. + +For external IPC providers, `initialize_async().await` negotiates capabilities +before inspecting `supports_read` or `supports_delete` on the backend returned +by `as_async()`. Storage operations also +initialize lazily; the async capability accessors report cached capabilities +without starting an endpoint. + +For a manually constructed async backend, use +`SyncProviderAdapter::from_async(backend)` to expose the synchronous interface. +Its `as_async()` accessor borrows the backend directly, and `into_async()` +returns ownership of it. Configure the provider before sharing it. + +Synchronous backends use `AsyncProviderAdapter` through their existing +registration and preflight checks. To exercise an existing provider instance +through the async interface: + +```rust +use secretspec::{AsyncProviderAdapter, Provider}; +use std::sync::Arc; + +let provider = Box::::try_from("dotenv://.env")?; +let provider = AsyncProviderAdapter::new(Arc::from(provider)); +``` + +The adapter runs storage operations, preflight, and retries on Tokio's blocking +pool, preserving batch and metadata overrides. Configure provider credentials, +paths, and policies before sharing an instance. Dropping a future does not stop +an operation that has already started; writes may complete after cancellation. + ## Documentation and Release Visibility The documentation site is built from `main`, so it can describe code that has diff --git a/secretspec/Cargo.toml b/secretspec/Cargo.toml index bab0153fb..df212e57f 100644 --- a/secretspec/Cargo.toml +++ b/secretspec/Cargo.toml @@ -68,6 +68,7 @@ azure_security_keyvault_secrets = { workspace = true, optional = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "process", "sync", "time"] } secretspec-ipc.workspace = true async-trait.workspace = true +futures-util.workspace = true reqwest = { workspace = true, optional = true } http = { workspace = true, optional = true } rand.workspace = true diff --git a/secretspec/src/lib.rs b/secretspec/src/lib.rs index 46cf1ed7f..b4b8dc61d 100644 --- a/secretspec/src/lib.rs +++ b/secretspec/src/lib.rs @@ -129,8 +129,9 @@ pub use provider::external::{ ProviderDiscovery, ProviderEndpoint, RegistrationScope, set_provider_discovery, }; pub use provider::{ - Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderInfo, ProviderValue, - providers, + Address, AsyncProvider, AsyncProviderAdapter, DiscoveryContext, ProducedValuePersistence, + Provider, ProviderBase, ProviderCredentials, ProviderInfo, ProviderValue, RetryOperation, + RetryOwnership, SyncProviderAdapter, providers, }; pub use report::{ Derivation, RESOLUTION_REPORT_SCHEMA_VERSION, ResolutionReport, ResolutionStatus, diff --git a/secretspec/src/provider/aac.rs b/secretspec/src/provider/aac.rs index c2a08109a..4f2b5e5c8 100644 --- a/secretspec/src/provider/aac.rs +++ b/secretspec/src/provider/aac.rs @@ -62,10 +62,14 @@ //! secretspec check --provider 'aac://shared?tag=app=payments&tag=stage=production' //! ``` +use super::ProviderBase as _; +use super::async_provider::map_concurrently; use super::{ - Address, DiscoveryContext, Provider, ProviderCredentials, ProviderUrl, credential_or_env, - get_each_concurrency, map_concurrently, + Address, DiscoveryContext, ProviderCredentials, ProviderUrl, credential_or_env, + get_each_concurrency, }; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -465,6 +469,7 @@ crate::register_provider! { struct: AacProvider, config: AacConfig, metadata: &super::catalog::AAC, + native_async: true, } impl AacProvider { @@ -1467,27 +1472,29 @@ impl AacProvider { Ok(provider) } - fn resolve_vault_reference(&self, reference: &VaultReference) -> Result { + async fn resolve_vault_reference(&self, reference: &VaultReference) -> Result { let provider = self.vault_provider(reference)?; let address = NativeAddress { item: reference.secret_name.clone(), version: reference.version.clone(), ..Default::default() }; - provider.get(Address::Native(&address))?.ok_or_else(|| { - operation_error(format!( - "Azure App Configuration contains a dangling Key Vault reference to host '{}'", - reference.vault_host - )) - }) + super::AsyncProvider::get(provider.as_ref(), Address::Native(&address)) + .await? + .ok_or_else(|| { + operation_error(format!( + "Azure App Configuration contains a dangling Key Vault reference to host '{}'", + reference.vault_host + )) + }) } - fn resolve_selected_reference( + async fn resolve_selected_reference( &self, key: &str, reference: &VaultReference, ) -> Result { - self.resolve_vault_reference(reference).map_err(|error| { + self.resolve_vault_reference(reference).await.map_err(|error| { operation_error(format!( "failed to resolve Key Vault reference from Azure App Configuration key '{key}' through vault '{}': {error}", reference.vault_host @@ -1495,12 +1502,13 @@ impl AacProvider { }) } - fn get_selected(&self, addr: Address<'_>) -> Result> { + async fn get_selected(&self, addr: Address<'_>) -> Result> { self.initial_request - .run(|| super::block_on(self.selected_value_async(addr))) + .run_async(|| self.selected_value_async(addr)) + .await } - fn get_many_selected( + async fn get_many_selected( &self, requests: &[(&str, Address<'_>)], ) -> Result> { @@ -1509,9 +1517,11 @@ impl AacProvider { groups.entry(*address).or_default().push(name); } let groups = groups.into_iter().collect::>(); - let selected = map_concurrently(&groups, get_each_concurrency(), |(address, names)| { - (names.clone(), self.get_selected(*address)) - }); + let selected = + map_concurrently(&groups, get_each_concurrency(), |(address, names)| async { + (names.clone(), self.get_selected(*address).await) + }) + .await; let mut values = HashMap::new(); let mut references: HashMap, BTreeSet)> = HashMap::new(); @@ -1538,8 +1548,8 @@ impl AacProvider { let resolved = map_concurrently( &references, get_each_concurrency(), - |(reference, names, keys)| { - let result = self.resolve_vault_reference(reference).map_err(|error| { + |(reference, names, keys)| async { + let result = self.resolve_vault_reference(reference).await.map_err(|error| { operation_error(format!( "failed to resolve Key Vault reference for {} from Azure App Configuration key(s) {} through vault '{}': {error}", names.join(", "), @@ -1549,7 +1559,7 @@ impl AacProvider { }); (names.clone(), result) }, - ); + ).await; for (names, result) in resolved { let value = result?; for name in names { @@ -1695,7 +1705,7 @@ impl AacProvider { } } -impl Provider for AacProvider { +impl super::ProviderBase for AacProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -1715,69 +1725,10 @@ impl Provider for AacProvider { self.credentials = credentials; } - fn get(&self, addr: Address<'_>) -> Result> { - match self.get_selected(addr)? { - Some(SelectedValue::Direct(value)) => Ok(Some(value)), - Some(SelectedValue::Reference { key, reference }) => { - self.resolve_selected_reference(&key, &reference).map(Some) - } - None => Ok(None), - } - } - - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { - self.get_many_selected(requests) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - if matches!(addr, Address::Native(_)) { - return self.check_writable(addr); - } - let key = self.resolve_key(addr)?; - self.initial_request - .run(|| super::block_on(self.set_async(&key, value))) - } - - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - self.resolve_coords(addr)?; - if matches!(addr, Address::Native(_)) { - return Err(operation_error( - "aac native references are read-only and cannot be written".to_string(), - )); - } - let key = self.resolve_key(addr)?; - self.initial_request - .run(|| super::block_on(self.mutation_record(&key)).map(|_| ())) - } - - fn delete(&self, addr: Address<'_>) -> Result { - if matches!(addr, Address::Native(_)) { - self.check_deletable(addr)?; - return Err(operation_error( - "aac native deletion is not implemented".to_string(), - )); - } - let key = self.resolve_key(addr)?; - self.initial_request - .run(|| super::block_on(self.delete_async(&key))) - } - fn supports_delete(&self) -> bool { true } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - self.resolve_coords(addr)?; - if matches!(addr, Address::Native(_)) { - return Err(operation_error( - "aac native references are read-only and cannot be deleted".to_string(), - )); - } - let key = self.resolve_key(addr)?; - self.initial_request - .run(|| super::block_on(self.mutation_record(&key)).map(|_| ())) - } - fn describe_write_target(&self, addr: Address<'_>) -> Result { if matches!(addr, Address::Native(_)) { self.resolve_coords(addr)?; @@ -1850,10 +1801,84 @@ impl Provider for AacProvider { fn entry_container_identity(&self) -> String { format!("{}|label={:?}", self.config.endpoint, self.config.label) } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for AacProvider { + async fn get(&self, addr: Address<'_>) -> Result> { + match self.get_selected(addr).await? { + Some(SelectedValue::Direct(value)) => Ok(Some(value)), + Some(SelectedValue::Reference { key, reference }) => self + .resolve_selected_reference(&key, &reference) + .await + .map(Some), + None => Ok(None), + } + } + + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + self.get_many_selected(requests).await + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + if matches!(addr, Address::Native(_)) { + return super::AsyncProvider::check_writable(self, addr).await; + } + let key = self.resolve_key(addr)?; + self.initial_request + .run_async(|| async { self.set_async(&key, value).await }) + .await + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.resolve_coords(addr)?; + if matches!(addr, Address::Native(_)) { + return Err(operation_error( + "aac native references are read-only and cannot be written".to_string(), + )); + } + let key = self.resolve_key(addr)?; + self.initial_request + .run_async(|| async { self.mutation_record(&key).await.map(|_| ()) }) + .await + } + + async fn delete(&self, addr: Address<'_>) -> Result { + if matches!(addr, Address::Native(_)) { + super::AsyncProvider::check_deletable(self, addr).await?; + return Err(operation_error( + "aac native deletion is not implemented".to_string(), + )); + } + let key = self.resolve_key(addr)?; + self.initial_request + .run_async(|| async { self.delete_async(&key).await }) + .await + } + + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + self.resolve_coords(addr)?; + if matches!(addr, Address::Native(_)) { + return Err(operation_error( + "aac native references are read-only and cannot be deleted".to_string(), + )); + } + let key = self.resolve_key(addr)?; + self.initial_request + .run_async(|| async { self.mutation_record(&key).await.map(|_| ()) }) + .await + } - fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { + async fn reflect( + &self, + context: DiscoveryContext<'_>, + ) -> Result> { self.initial_request - .run(|| super::block_on(self.reflect_async(context))) + .run_async(|| async { self.reflect_async(context).await }) + .await } } @@ -2084,11 +2109,11 @@ mod tests { AacConfig::try_from(&ProviderUrl::new(Url::parse(uri).unwrap())).unwrap() } - fn provider(uri: &str) -> AacProvider { - AacProvider::new(config(uri)) + fn provider(uri: &str) -> SyncProviderAdapter { + SyncProviderAdapter::from_async(AacProvider::new(config(uri))) } - fn fixture_provider(endpoint: &str, uri: &str) -> AacProvider { + fn fixture_provider(endpoint: &str, uri: &str) -> SyncProviderAdapter { let mut provider = provider(uri); provider.config.endpoint = endpoint.to_string(); provider.allow_insecure_loopback = true; @@ -3358,8 +3383,7 @@ mod tests { let SelectedValue::Reference { key, reference } = selected else { panic!("expected Key Vault reference"); }; - let error = provider - .resolve_selected_reference(&key, &reference) + let error = super::super::block_on(provider.resolve_selected_reference(&key, &reference)) .unwrap_err(); assert!( error.to_string().contains( diff --git a/secretspec/src/provider/address.rs b/secretspec/src/provider/address.rs index ab125a1bb..bd480df59 100644 --- a/secretspec/src/provider/address.rs +++ b/secretspec/src/provider/address.rs @@ -143,8 +143,12 @@ pub(crate) fn flat_item<'a, P: Provider + ?Sized>( provider: &P, addr: Address<'a>, ) -> Result> { - match provider.resolve_coords(addr)? { - Cow::Borrowed(native) => Ok(Cow::Borrowed(native.item.as_str())), - Cow::Owned(native) => Ok(Cow::Owned(native.item)), + Ok(flat_item_from_coords(provider.resolve_coords(addr)?)) +} + +pub(crate) fn flat_item_from_coords(coords: Cow<'_, NativeAddress>) -> Cow<'_, str> { + match coords { + Cow::Borrowed(native) => Cow::Borrowed(native.item.as_str()), + Cow::Owned(native) => Cow::Owned(native.item), } } diff --git a/secretspec/src/provider/akv.rs b/secretspec/src/provider/akv.rs index 0ffa6b7ba..07d449d73 100644 --- a/secretspec/src/provider/akv.rs +++ b/secretspec/src/provider/akv.rs @@ -62,7 +62,10 @@ //! secretspec check --provider akv://myvault?auth=managed_identity //! ``` -use super::{Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env}; +use super::ProviderBase as _; +use super::{Address, ProviderCredentials, ProviderUrl, credential_or_env}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::{Result, SecretSpecError}; use azure_core::credentials::{Secret, TokenCredential}; @@ -78,7 +81,7 @@ use azure_security_keyvault_secrets::{ use data_encoding::BASE32_NOPAD; use serde::{Deserialize, Serialize}; use std::sync::{ - Arc, Mutex, OnceLock, + Arc, OnceLock, atomic::{AtomicBool, Ordering}, }; @@ -94,25 +97,35 @@ use std::sync::{ #[derive(Default)] pub(crate) struct InitialRequestGate { ready: AtomicBool, - lock: Mutex<()>, + lock: tokio::sync::Mutex<()>, } impl InitialRequestGate { + #[cfg(test)] pub(crate) fn run(&self, request: F) -> Result where - F: FnOnce() -> Result, + F: FnOnce() -> Result + Send, + T: Send, + { + super::block_on(self.run_async(|| async { request() })) + } + + pub(crate) async fn run_async(&self, request: F) -> Result + where + F: FnOnce() -> Fut, + Fut: std::future::Future>, { if self.ready.load(Ordering::Acquire) { - return request(); + return request().await; } - let guard = self.lock.lock().unwrap(); + let guard = self.lock.lock().await; if self.ready.load(Ordering::Acquire) { drop(guard); - return request(); + return request().await; } - let result = request(); + let result = request().await; if result.is_ok() { self.ready.store(true, Ordering::Release); } @@ -394,6 +407,7 @@ crate::register_provider! { struct: AkvProvider, config: AkvConfig, metadata: &super::catalog::AKV, + native_async: true, } impl AkvProvider { @@ -665,16 +679,15 @@ impl AkvProvider { } } -impl Provider for AkvProvider { +impl super::ProviderBase for AkvProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.retry_policy = policy; } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } - /// Convention names use lowercase Base32 components so they remain - /// injective despite Azure Key Vault's restricted, case-insensitive names. fn convention_address( &self, project: &str, @@ -715,27 +728,32 @@ impl Provider for AkvProvider { self.config.vault_url.clone() } - /// An optional `version` pins the secret version to read. fn supported_coords(&self) -> &'static [&'static str] { &["version"] } +} - fn get(&self, addr: Address<'_>) -> Result> { +#[async_trait::async_trait] +impl super::AsyncProvider for AkvProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let coords = self.resolve_address(addr)?; self.initial_request - .run(|| super::block_on(self.get_secret_async(&coords.item, coords.version.as_deref()))) + .run_async(|| async { + self.get_secret_async(&coords.item, coords.version.as_deref()) + .await + }) + .await } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_address(addr)?; self.initial_request - .run(|| super::block_on(self.set_secret_async(&coords.item, value))) + .run_async(|| async { self.set_secret_async(&coords.item, value).await }) + .await } - /// Native addresses are read-only: they name a secret managed outside - /// SecretSpec, and writing would mint a new version of it. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { match addr { Address::Convention { .. } => Ok(()), Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed( @@ -867,6 +885,31 @@ mod tests { assert!(gate.ready.load(Ordering::Acquire)); } + #[tokio::test(flavor = "current_thread")] + async fn native_async_gate_releases_a_cancelled_cold_request() { + let gate = Arc::new(InitialRequestGate::default()); + let started = Arc::new(tokio::sync::Notify::new()); + let worker_gate = Arc::clone(&gate); + let worker_started = Arc::clone(&started); + let first = tokio::spawn(async move { + worker_gate + .run_async(|| async { + worker_started.notify_one(); + std::future::pending::>().await + }) + .await + }); + started.notified().await; + assert!(!gate.ready.load(Ordering::Acquire)); + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(2), gate.run_async(|| async { Ok(()) })) + .await + .unwrap() + .unwrap(); + assert!(gate.ready.load(Ordering::Acquire)); + } + #[test] fn test_format_secret_name() { let name = AkvProvider::format_secret_name("myapp", "prod", "DB_URL").unwrap(); @@ -946,7 +989,8 @@ mod tests { let _tenant = EnvVarGuard::set(AZURE_TENANT_ID_ENV, "tenant-from-env"); let _client = EnvVarGuard::set(AZURE_CLIENT_ID_ENV, "client-from-env"); let _secret = EnvVarGuard::set(AZURE_CLIENT_SECRET_ENV, "secret-from-env"); - let mut provider = AkvProvider::new(config("akv://myvault")); + let mut provider = + SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); provider.with_credentials(credentials(&[ (TENANT_ID, "tenant-from-provider"), (CLIENT_SECRET, "secret-from-provider"), @@ -1043,15 +1087,17 @@ mod tests { #[test] fn test_uri_roundtrips_suffix() { - let p = AkvProvider::new(config("akv://myvault?suffix=vault.azure.cn")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config( + "akv://myvault?suffix=vault.azure.cn", + ))); assert_eq!(p.uri(), "akv://myvault?suffix=vault.azure.cn"); } #[test] fn test_uri_roundtrips_auth_and_suffix() { - let p = AkvProvider::new(config( + let p = SyncProviderAdapter::from_async(AkvProvider::new(config( "akv://myvault?auth=managed_identity&suffix=vault.azure.cn", - )); + ))); assert_eq!( p.uri(), "akv://myvault?auth=managed_identity&suffix=vault.azure.cn" @@ -1060,10 +1106,15 @@ mod tests { #[test] fn storage_identity_uses_effective_vault_url_without_auth() { - let public = AkvProvider::new(config("akv://myvault")); - let cli = AkvProvider::new(config("akv://myvault?auth=cli")); - let sovereign = AkvProvider::new(config("akv://myvault?suffix=vault.azure.cn")); - let sovereign_fqdn = AkvProvider::new(config("akv://myvault.vault.azure.cn")); + let public = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); + let cli = + SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault?auth=cli"))); + let sovereign = SyncProviderAdapter::from_async(AkvProvider::new(config( + "akv://myvault?suffix=vault.azure.cn", + ))); + let sovereign_fqdn = SyncProviderAdapter::from_async(AkvProvider::new(config( + "akv://myvault.vault.azure.cn", + ))); assert_eq!(public.storage_identity(), cli.storage_identity()); assert_eq!( @@ -1083,7 +1134,10 @@ mod tests { assert_eq!(config.vault_host, "shared.vault.azure.net"); let credential: Arc = DeveloperToolsCredential::new(None).unwrap(); - let provider = AkvProvider::with_token_credential(config, Arc::clone(&credential)); + let provider = SyncProviderAdapter::from_async(AkvProvider::with_token_credential( + config, + Arc::clone(&credential), + )); assert!(Arc::ptr_eq( &credential, &provider.resolve_credential().unwrap() @@ -1114,7 +1168,7 @@ mod tests { #[test] fn test_convention_address() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); let coords = p.convention_address("proj", "default", "A").unwrap(); assert_eq!(coords.item, "secretspec--obzg62q--mrswmylvnr2a--ie"); assert_eq!(coords.field, None); @@ -1122,7 +1176,7 @@ mod tests { #[test] fn version_coordinate_is_supported_and_distinguishes_entries() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); assert_eq!(p.supported_coords(), &["version"]); let first = crate::config::NativeAddress { @@ -1151,8 +1205,8 @@ mod tests { ); } - #[test] - fn native_reads_request_pinned_or_latest_version() { + #[tokio::test] + async fn native_async_reads_request_pinned_or_latest_version() { let transport = Arc::new(RecordingHttpClient::default()); let credential = DeveloperToolsCredential::new(None).unwrap(); let client = SecretClient::new( @@ -1167,7 +1221,10 @@ mod tests { }), ) .unwrap(); - let provider = AkvProvider::with_client(config("akv://myvault"), client); + let provider = SyncProviderAdapter::from_async(AkvProvider::with_client( + config("akv://myvault"), + client, + )); let pinned_version = "0123456789abcdef0123456789abcdef"; let pinned = crate::config::NativeAddress { @@ -1175,14 +1232,20 @@ mod tests { version: Some(pinned_version.into()), ..Default::default() }; - let value = provider.get(Address::Native(&pinned)).unwrap().unwrap(); + let value = super::super::AsyncProvider::get(provider.as_async(), Address::Native(&pinned)) + .await + .unwrap() + .unwrap(); assert_eq!(value.expose_secret(), b"secret-value"); let latest = crate::config::NativeAddress { version: None, ..pinned }; - let value = provider.get(Address::Native(&latest)).unwrap().unwrap(); + let value = super::super::AsyncProvider::get(provider.as_async(), Address::Native(&latest)) + .await + .unwrap() + .unwrap(); assert_eq!(value.expose_secret(), b"secret-value"); assert_eq!( @@ -1196,7 +1259,7 @@ mod tests { #[test] fn native_address_rejects_invalid_version_before_client_creation() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); for version in [ "", "3", @@ -1223,7 +1286,7 @@ mod tests { /// SecretSpec. #[test] fn native_address_is_read_only() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); let addr = crate::config::NativeAddress { item: "existing-secret".into(), version: Some("0123456789abcdef0123456789abcdef".into()), @@ -1241,7 +1304,7 @@ mod tests { /// before any network I/O. #[test] fn native_address_rejects_field() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); let addr = crate::config::NativeAddress { item: "existing-secret".into(), field: Some("x".into()), @@ -1256,7 +1319,7 @@ mod tests { /// I/O. #[test] fn native_address_rejects_invalid_azure_chars() { - let p = AkvProvider::new(config("akv://myvault")); + let p = SyncProviderAdapter::from_async(AkvProvider::new(config("akv://myvault"))); let addr = crate::config::NativeAddress { item: "existing_secret".into(), ..Default::default() diff --git a/secretspec/src/provider/async_provider.rs b/secretspec/src/provider/async_provider.rs new file mode 100644 index 000000000..b7bd97be3 --- /dev/null +++ b/secretspec/src/provider/async_provider.rs @@ -0,0 +1,596 @@ +use super::address::OwnedAddress; +use super::{ + Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderCredentials, + ProviderValue, +}; +use crate::NativeAddress; +use crate::{Result, SecretBytes, SecretSpecError}; +use async_trait::async_trait; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +/// Polls independent operations together in bounded waves without spawning +/// threads or requiring owned addresses. Results retain the input order. +pub(crate) async fn map_concurrently<'a, T, R, F, Fut>( + items: &'a [T], + concurrency: usize, + map: F, +) -> Vec +where + F: Fn(&'a T) -> Fut, + Fut: Future, +{ + let mut results = Vec::with_capacity(items.len()); + for chunk in items.chunks(concurrency.max(1)) { + results.extend(futures_util::future::join_all(chunk.iter().map(&map)).await); + } + results +} + +/// Shares each distinct address's result among its declaration names. +pub(crate) async fn get_each_with<'a, F, Fut, T>( + requests: &[(&str, Address<'a>)], + fetch: F, +) -> Result> +where + F: Fn(Address<'a>) -> Fut, + Fut: Future>>, + T: Clone, +{ + let mut groups: HashMap, Vec<&str>> = HashMap::new(); + for (name, addr) in requests { + groups.entry(*addr).or_default().push(name); + } + let groups: Vec<_> = groups.into_iter().collect(); + let fetched = map_concurrently(&groups, super::get_each_concurrency(), |(addr, _)| { + fetch(*addr) + }) + .await; + let mut values = HashMap::new(); + for ((_, names), result) in groups.into_iter().zip(fetched) { + if let Some(value) = result? { + for name in names { + values.insert(name.to_owned(), value.clone()); + } + } + } + Ok(values) +} + +/// Awaitable secret storage interface, available starting with SecretSpec 0.22. +/// +/// Implement with `#[async_trait::async_trait]` to support `dyn AsyncProvider` +/// and `Send` futures. Implementations should await I/O without blocking the +/// executor. Implement [`super::ProviderBase`] for configuration, naming, and +/// identity. [`super::SyncProviderAdapter`] supplies the synchronous interface; +/// the backend implements its storage operations only once. +/// Addresses and byte values have the same meaning as in [`Provider`]. +/// Methods that write text-only stores must reject non-UTF-8 bytes without +/// including the value in an error. +/// +/// This interface is independent of the synchronous resolver and its provider +/// registry. Existing synchronous providers can be used through +/// [`AsyncProviderAdapter`]. Native async implementations need no Tokio runtime +/// unless their underlying I/O library requires one. +/// +/// URI factories select native async SDK, HTTP, and IPC implementations and +/// adapt synchronous backends automatically: +/// +/// ```no_run +/// use secretspec::{Address, AsyncProvider}; +/// # async fn example() -> secretspec::Result<()> { +/// let provider = Box::::try_from("dotenv://.env")?; +/// let value = provider.get(Address::convention("myapp", "default", "API_KEY")).await?; +/// # Ok(()) +/// # } +/// ``` +#[async_trait] +pub trait AsyncProvider: super::ProviderBase { + /// Retrieves a secret, returning `None` when it does not exist. + async fn get(&self, addr: Address<'_>) -> Result>; + + /// Retrieves bytes and their authoritative expiry and revision metadata. + /// Override this and [`Self::get_many_with_metadata`] when metadata is known. + async fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + self.get(addr) + .await + .map(|value| value.map(|value| ProviderValue::new(value, None))) + } + + /// Tests presence. Write-only providers must override this without reading bytes. + async fn exists(&self, addr: Address<'_>) -> Result { + Ok(self.get(addr).await?.is_some()) + } + + /// Stores arbitrary secret bytes. Enforce the same policy as [`Self::check_writable`]. + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()>; + + /// Stores a value with an optional store-side lifetime hint, as in + /// [`Provider::set_expiring`]. The default stores an ordinary value. + async fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretBytes, + max_age: Duration, + ) -> Result<()> { + let _ = max_age; + self.set(addr, value).await + } + + /// Deletes a secret, returning whether it existed. Deletion is opt-in. + async fn delete(&self, addr: Address<'_>) -> Result { + let _ = addr; + Err(self.deletion_unsupported()) + } + + /// Checks deletion policy without mutating storage. + /// Deletable providers should override this to validate the address. + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + if !self.supports_delete() { + return Err(self.deletion_unsupported()); + } + AsyncProvider::resolve_address(self, addr).await.map(|_| ()) + } + + /// Checks write policy without writing. Read-only providers must override it. + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + let _ = addr; + Ok(()) + } + + /// Discovers secret declarations within the supplied project and profile. + async fn reflect( + &self, + context: DiscoveryContext<'_>, + ) -> Result> { + let _ = context; + Err(SecretSpecError::ProviderOperationFailed(format!( + "Provider '{}' does not support reflection", + self.name() + ))) + } + + /// Retrieves several secrets, omitting missing values. The default awaits + /// each unique address once with bounded concurrency; override for native batch I/O. + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + get_each_with(requests, |addr| self.get(addr)).await + } + + /// Batch reads with metadata. The default preserves optimized + /// [`Self::get_many`] overrides and reports unknown expiry and revision, + /// matching [`Provider::get_many_with_metadata`]. + async fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + self.get_many(requests).await.map(|values| { + values + .into_iter() + .map(|(name, value)| (name, ProviderValue::new(value, None))) + .collect() + }) + } + + /// Tests batch presence, deduplicating addresses. Readable stores reuse + /// [`Self::get_many`]; write-only stores use [`Self::exists`]. + async fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + if self.supports_read() { + return Ok(self.get_many(requests).await?.into_keys().collect()); + } + let mut fetched = HashMap::new(); + let mut names = HashSet::new(); + for &(name, addr) in requests { + if let std::collections::hash_map::Entry::Vacant(entry) = fetched.entry(addr) { + entry.insert(self.exists(addr).await?); + } + if fetched[&addr] { + names.insert(name.to_owned()); + } + } + Ok(names) + } + + /// Negotiates dynamic capabilities before synchronous compatibility access. + async fn initialize(&self) -> Result<()> { + Ok(()) + } + + /// Resolves an address, awaiting backend lookups when required. + async fn resolve_address<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + super::ProviderBase::resolve_coords(self, addr) + } + + /// Resolves the physical entry used by destructive preflight. + async fn entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.configured_entry_coordinates(addr) + } + + async fn entry_coordinates_many( + &self, + addrs: &[Address<'_>], + ) -> Result> { + let mut entries = Vec::with_capacity(addrs.len()); + for addr in addrs { + entries.push(self.entry_coordinates(*addr).await?.into_owned()); + } + Ok(entries) + } + + async fn write_target(&self, addr: Address<'_>) -> Result { + super::ProviderBase::describe_write_target(self, addr) + } +} + +/// Runs a synchronous [`Provider`] on Tokio's blocking pool (0.22+). +/// +/// Requires an active Tokio runtime when awaiting I/O methods. Configure the +/// provider before sharing it. Every operation, including batch overrides, +/// preflight, and retries, is forwarded to the supplied instance. +/// +/// Dropping a future does not stop an operation that has already started on +/// the blocking pool. In particular, cancelled writes may still complete. +/// Panics from the provider are resumed in the awaiting task. +/// +/// ```no_run +/// use secretspec::{Address, AsyncProvider, AsyncProviderAdapter, Provider}; +/// use std::sync::Arc; +/// +/// # async fn example() -> secretspec::Result<()> { +/// let provider = Box::::try_from("dotenv://.env")?; +/// let provider = AsyncProviderAdapter::new(Arc::from(provider)); +/// let value = provider.get(Address::convention("myapp", "default", "API_KEY")).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct AsyncProviderAdapter { + inner: Arc, +} + +impl AsyncProviderAdapter { + /// Adapts a configured synchronous provider without changing its policies. + pub fn new(provider: Arc) -> Self { + Self { inner: provider } + } + + async fn run(&self, operation: F) -> Result + where + T: Send + 'static, + F: FnOnce(&dyn Provider) -> Result + Send + 'static, + { + let provider = Arc::clone(&self.inner); + match tokio::task::spawn_blocking(move || operation(provider.as_ref())).await { + Ok(result) => result, + Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), + Err(_) => Err(SecretSpecError::ProviderOperationFailed( + "async provider blocking task was cancelled".into(), + )), + } + } +} + +fn own_address(addr: Address<'_>) -> OwnedAddress { + match addr { + Address::Convention { + project, + profile, + key, + } => OwnedAddress::convention(project, profile, key), + Address::Native(native) => OwnedAddress::Native(native.clone()), + } +} + +fn own_requests(requests: &[(&str, Address<'_>)]) -> Vec<(String, OwnedAddress)> { + requests + .iter() + .map(|(name, addr)| ((*name).to_owned(), own_address(*addr))) + .collect() +} + +fn borrowed_requests(requests: &[(String, OwnedAddress)]) -> Vec<(&str, Address<'_>)> { + requests + .iter() + .map(|(name, addr)| (name.as_str(), addr.as_address())) + .collect() +} + +#[async_trait] +impl AsyncProvider for AsyncProviderAdapter { + async fn get(&self, addr: Address<'_>) -> Result> { + let addr = own_address(addr); + self.run(move |provider| provider.get(addr.as_address())) + .await + } + + async fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + let addr = own_address(addr); + self.run(move |provider| provider.get_with_metadata(addr.as_address())) + .await + } + + async fn exists(&self, addr: Address<'_>) -> Result { + let addr = own_address(addr); + self.run(move |provider| provider.exists(addr.as_address())) + .await + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + let addr = own_address(addr); + let value = value.clone(); + self.run(move |provider| provider.set(addr.as_address(), &value)) + .await + } + + async fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretBytes, + max_age: Duration, + ) -> Result<()> { + let addr = own_address(addr); + let value = value.clone(); + self.run(move |provider| provider.set_expiring(addr.as_address(), &value, max_age)) + .await + } + + async fn delete(&self, addr: Address<'_>) -> Result { + let addr = own_address(addr); + self.run(move |provider| provider.delete(addr.as_address())) + .await + } + + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + let addr = own_address(addr); + self.run(move |provider| provider.check_deletable(addr.as_address())) + .await + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + let addr = own_address(addr); + self.run(move |provider| provider.check_writable(addr.as_address())) + .await + } + + async fn reflect( + &self, + context: DiscoveryContext<'_>, + ) -> Result> { + let project = context.project.to_owned(); + let profile = context.profile.to_owned(); + self.run(move |provider| provider.reflect(DiscoveryContext::new(&project, &profile))) + .await + } + + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + let requests = own_requests(requests); + self.run(move |provider| provider.get_many(&borrowed_requests(&requests))) + .await + } + + async fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + let requests = own_requests(requests); + self.run(move |provider| provider.get_many_with_metadata(&borrowed_requests(&requests))) + .await + } + + async fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + let requests = own_requests(requests); + self.run(move |provider| provider.exists_many(&borrowed_requests(&requests))) + .await + } + + async fn resolve_address<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + let addr = own_address(addr); + self.run(move |provider| { + provider + .resolve_coords(addr.as_address()) + .map(|coords| coords.into_owned()) + }) + .await + .map(std::borrow::Cow::Owned) + } + + async fn entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + let addr = own_address(addr); + self.run(move |provider| { + provider + .entry_coordinates(addr.as_address()) + .map(|coords| coords.into_owned()) + }) + .await + .map(std::borrow::Cow::Owned) + } + + async fn entry_coordinates_many( + &self, + addrs: &[Address<'_>], + ) -> Result> { + let requests: Vec<_> = addrs.iter().map(|addr| own_address(*addr)).collect(); + self.run(move |provider| { + provider.entry_coordinates_many( + &requests + .iter() + .map(OwnedAddress::as_address) + .collect::>(), + ) + }) + .await + } + + async fn write_target(&self, addr: Address<'_>) -> Result { + let addr = own_address(addr); + self.run(move |provider| provider.describe_write_target(addr.as_address())) + .await + } +} + +impl super::ProviderBase for AsyncProviderAdapter { + fn set_retry_policy(&mut self, _policy: super::RetryPolicy) { + if let Some(provider) = Arc::get_mut(&mut self.inner) { + provider.set_retry_policy(_policy); + } + } + + fn retry_ownership(&self) -> super::RetryOwnership { + self.inner.retry_ownership() + } + + fn retry_safe(&self, _operation: super::RetryOperation, _addr: Address<'_>) -> bool { + self.inner.retry_safe(_operation, _addr) + } + + fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { + self.inner.convention_address(project, profile, key) + } + + fn supported_coords(&self) -> &'static [&'static str] { + self.inner.supported_coords() + } + + fn supports_coord(&self, name: &str) -> bool { + self.inner.supports_coord(name) + } + + fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { + self.inner.resolve_coords(addr) + } + + fn configured_entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.inner.configured_entry_coordinates(addr) + } + + fn supports_read(&self) -> bool { + self.inner.supports_read() + } + + fn supports_delete(&self) -> bool { + self.inner.supports_delete() + } + + fn deletion_unsupported(&self) -> SecretSpecError { + self.inner.deletion_unsupported() + } + + fn generated_value_persistence(&self) -> ProducedValuePersistence { + self.inner.generated_value_persistence() + } + + fn prompted_value_persistence(&self) -> ProducedValuePersistence { + self.inner.prompted_value_persistence() + } + + fn describe_write_target(&self, addr: Address<'_>) -> Result { + self.inner.describe_write_target(addr) + } + + fn auth_scope_key(&self) -> Option { + self.inner.auth_scope_key() + } + + fn name(&self) -> &str { + self.inner.name() + } + + fn uri(&self) -> String { + self.inner.uri() + } + + fn storage_identity(&self) -> String { + self.inner.storage_identity() + } + + fn entry_container_identity(&self) -> String { + self.inner.entry_container_identity() + } + + fn physical_store_path(&self) -> Option<&std::path::Path> { + self.inner.physical_store_path() + } + + fn configured_physical_store_path(&self) -> Option<&std::path::Path> { + self.inner.configured_physical_store_path() + } + + fn set_reason(&self, _reason: Option) { + self.inner.set_reason(_reason) + } + + fn set_requested_authorization_duration(&self, _duration: Option) { + self.inner.set_requested_authorization_duration(_duration) + } + + fn set_caller(&self, _caller: Option) { + self.inner.set_caller(_caller) + } + + fn set_project(&self, _project: &str) { + self.inner.set_project(_project) + } + + fn set_profile(&self, _profile: &str) { + self.inner.set_profile(_profile) + } + + fn with_base_dir(&mut self, _base_dir: &std::path::Path) { + if let Some(provider) = Arc::get_mut(&mut self.inner) { + provider.with_base_dir(_base_dir); + } + } + + fn with_credentials(&mut self, _credentials: ProviderCredentials) { + if let Some(provider) = Arc::get_mut(&mut self.inner) { + provider.with_credentials(_credentials); + } + } +} + +#[cfg(test)] +mod tests { + use super::map_concurrently; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test(flavor = "current_thread")] + async fn native_async_batches_are_bounded_and_preserve_order() { + let active = AtomicUsize::new(0); + let peak = AtomicUsize::new(0); + let items = [5, 4, 3, 2, 1]; + let values = map_concurrently(&items, 2, |item| async { + let count = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(count, Ordering::SeqCst); + tokio::task::yield_now().await; + active.fetch_sub(1, Ordering::SeqCst); + *item * 2 + }) + .await; + assert_eq!(values, [10, 8, 6, 4, 2]); + assert_eq!(peak.load(Ordering::SeqCst), 2); + assert_eq!(active.load(Ordering::SeqCst), 0); + } +} diff --git a/secretspec/src/provider/awsps.rs b/secretspec/src/provider/awsps.rs index 0ccf1d8ce..b44bf8ea0 100644 --- a/secretspec/src/provider/awsps.rs +++ b/secretspec/src/provider/awsps.rs @@ -21,7 +21,10 @@ //! Parameters use the path `[/prefix]/secretspec/{project}/{profile}/{key}` by //! default. `template` replaces that complete layout. -use super::{Address, DiscoveryContext, Provider, ProviderUrl}; +use super::ProviderBase as _; +use super::{Address, DiscoveryContext, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::{Result, SecretSpecError}; use aws_sdk_ssm::Client; @@ -180,6 +183,7 @@ crate::register_provider! { struct: AwspsProvider, config: AwspsConfig, metadata: &super::catalog::AWSPS, + native_async: true, } impl AwspsProvider { @@ -589,10 +593,11 @@ impl AwspsProvider { } } -impl Provider for AwspsProvider { +impl super::ProviderBase for AwspsProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.retry_policy = policy; } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -619,40 +624,6 @@ impl Provider for AwspsProvider { &["version"] } - fn get(&self, addr: Address<'_>) -> Result> { - let coordinates = self.resolve_coords(addr)?; - let name = Self::selected_name(&coordinates.item, coordinates.version.as_deref()); - super::block_on(self.get_parameter_async(&name)) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; - let coordinates = self.resolve_coords(addr)?; - super::block_on(self.set_parameter_async(&coordinates.item, value)) - } - - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - match addr { - Address::Native(native) if native.item.starts_with("arn:") => { - Err(SecretSpecError::ProviderOperationFailed( - "awsps refs using a parameter ARN are read-only: use the parameter's name to \ - write it in the provider's configured account and region." - .to_string(), - )) - } - Address::Native(native) if native.version.is_some() => { - Err(SecretSpecError::ProviderOperationFailed( - "awsps refs pinning a `version` are read-only: a Parameter Store version or \ - label cannot be overwritten. Drop `version` to write a new latest version." - .to_string(), - )) - } - _ => self - .resolve_coords(addr) - .and_then(|coordinates| Self::validate_parameter_name(&coordinates.item)), - } - } - fn name(&self) -> &str { Self::PROVIDER_NAME } @@ -699,8 +670,26 @@ impl Provider for AwspsProvider { format!("{base}{separator}{}", parameters.join("&")) } } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for AwspsProvider { + async fn get(&self, addr: Address<'_>) -> Result> { + let coordinates = self.resolve_coords(addr)?; + let name = Self::selected_name(&coordinates.item, coordinates.version.as_deref()); + self.get_parameter_async(&name).await + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; + let coordinates = self.resolve_coords(addr)?; + self.set_parameter_async(&coordinates.item, value).await + } - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { if requests.is_empty() { return Ok(HashMap::new()); } @@ -708,11 +697,36 @@ impl Provider for AwspsProvider { for (name, address) in requests { resolved.push((*name, self.resolve_coords(*address)?.into_owned())); } - super::block_on(self.get_many_async(&resolved)) + self.get_many_async(&resolved).await } - fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { - super::block_on(self.reflect_async(context)) + async fn reflect( + &self, + context: DiscoveryContext<'_>, + ) -> Result> { + self.reflect_async(context).await + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + match addr { + Address::Native(native) if native.item.starts_with("arn:") => { + Err(SecretSpecError::ProviderOperationFailed( + "awsps refs using a parameter ARN are read-only: use the parameter's name to \ + write it in the provider's configured account and region." + .to_string(), + )) + } + Address::Native(native) if native.version.is_some() => { + Err(SecretSpecError::ProviderOperationFailed( + "awsps refs pinning a `version` are read-only: a Parameter Store version or \ + label cannot be overwritten. Drop `version` to write a new latest version." + .to_string(), + )) + } + _ => self + .resolve_coords(addr) + .and_then(|coordinates| Self::validate_parameter_name(&coordinates.item)), + } } } @@ -728,7 +742,8 @@ mod tests { #[test] fn convention_uses_parameter_hierarchy() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let address = provider .convention_address("myapp", "production", "DATABASE_URL") .unwrap(); @@ -774,9 +789,9 @@ mod tests { #[test] fn custom_template_replaces_default_hierarchy() { - let provider = AwspsProvider::new(config( + let provider = SyncProviderAdapter::from_async(AwspsProvider::new(config( "awsps://us-east-1?template=/{profile}/{project}/{key}", - )); + ))); let address = provider .convention_address("payments", "production", "DATABASE_URL") .unwrap(); @@ -869,9 +884,9 @@ mod tests { #[test] fn uri_round_trips_all_options() { - let provider = AwspsProvider::new(config( + let provider = SyncProviderAdapter::from_async(AwspsProvider::new(config( "awsps://production@us-east-1?prefix=/team/platform&kms_key_id=alias/team&tier=intelligent-tiering", - )); + ))); let uri = provider.uri(); assert_eq!( uri, @@ -884,9 +899,9 @@ mod tests { #[test] fn uri_round_trips_template() { - let provider = AwspsProvider::new(config( + let provider = SyncProviderAdapter::from_async(AwspsProvider::new(config( "awsps://production@us-east-1?template=/{profile}/{project}/{key}", - )); + ))); let uri = provider.uri(); assert_eq!( uri, @@ -901,9 +916,13 @@ mod tests { #[test] fn uri_without_region_uses_bare_provider_name() { - assert_eq!(AwspsProvider::new(config("awsps://")).uri(), "awsps"); assert_eq!( - AwspsProvider::new(config("awsps://?prefix=/team")).uri(), + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://"))).uri(), + "awsps" + ); + assert_eq!( + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://?prefix=/team"))) + .uri(), "awsps://?prefix=/team" ); } @@ -950,7 +969,8 @@ mod tests { #[test] fn unversioned_native_name_ref_is_writable() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let reference = crate::config::NativeAddress { item: "/prod/token".to_string(), ..Default::default() @@ -962,7 +982,8 @@ mod tests { #[test] fn versioned_native_ref_is_read_only() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let reference = crate::config::NativeAddress { item: "/prod/token".to_string(), version: Some("3".to_string()), @@ -976,7 +997,8 @@ mod tests { #[test] fn native_arn_ref_is_read_only() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let reference = crate::config::NativeAddress { item: "arn:aws:ssm:us-east-1:123456789012:parameter/prod/token".to_string(), ..Default::default() @@ -990,7 +1012,8 @@ mod tests { #[test] fn versioned_native_arn_ref_reports_arn_remediation() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let reference = crate::config::NativeAddress { item: "arn:aws:ssm:us-east-1:123456789012:parameter/prod/token".to_string(), version: Some("3".to_string()), @@ -1006,7 +1029,8 @@ mod tests { #[test] fn native_ref_rejects_unsupported_coordinates_during_writability_check() { - let provider = AwspsProvider::new(config("awsps://us-east-1")); + let provider = + SyncProviderAdapter::from_async(AwspsProvider::new(config("awsps://us-east-1"))); let references = [ ( "field", diff --git a/secretspec/src/provider/awssm.rs b/secretspec/src/provider/awssm.rs index 47267f906..f7eb6574f 100644 --- a/secretspec/src/provider/awssm.rs +++ b/secretspec/src/provider/awssm.rs @@ -47,7 +47,10 @@ //! secretspec check --provider awssm://production@us-east-1 //! ``` -use super::{Address, Provider, ProviderUrl, join_slash_path}; +use super::ProviderBase as _; +use super::{Address, ProviderUrl, join_slash_path}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::ProviderValue; use crate::SecretBytes; use crate::{Result, SecretSpecError}; @@ -189,6 +192,7 @@ crate::register_provider! { struct: AwssmProvider, config: AwssmConfig, metadata: &super::catalog::AWSSM, + native_async: true, } impl AwssmProvider { @@ -509,15 +513,15 @@ impl AwssmProvider { } } -impl Provider for AwssmProvider { +impl super::ProviderBase for AwssmProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.retry_policy = policy; } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } - /// Convention secrets are named `[{prefix}/]secretspec/{project}/{profile}/{key}`. fn convention_address( &self, project: &str, @@ -577,52 +581,47 @@ impl Provider for AwssmProvider { } } - /// An optional `field` extracts one key from a JSON secret value. fn supported_coords(&self) -> &'static [&'static str] { &["field"] } +} - fn get(&self, addr: Address<'_>) -> Result> { - self.get_with_metadata(addr) +#[async_trait::async_trait] +impl super::AsyncProvider for AwssmProvider { + async fn get(&self, addr: Address<'_>) -> Result> { + super::AsyncProvider::get_with_metadata(self, addr) + .await .map(|value| value.map(|value| value.value)) } - fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + async fn get_with_metadata(&self, addr: Address<'_>) -> Result> { // `item` is the secret name or ARN. let coords = self.resolve_coords(addr)?; - super::block_on(self.get_coords_async(&coords.item, coords.field.as_deref())) + self.get_coords_async(&coords.item, coords.field.as_deref()) + .await } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - super::block_on(self.set_secret_async(&coords.item, value)) - } - - /// Native addresses are read-only: they name a secret managed outside - /// SecretSpec (and a JSON-key write could not happen without clobbering - /// the other keys). - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - match addr { - Address::Convention { .. } => Ok(()), - Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed( - "awssm secret references are read-only and cannot be written".to_string(), - )), - } + self.set_secret_async(&coords.item, value).await } - /// Batches every request, convention or `ref`, through - /// BatchGetSecretValue. - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { - self.get_many_with_metadata(requests).map(|values| { - values - .into_iter() - .map(|(name, value)| (name, value.value)) - .collect() - }) + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + super::AsyncProvider::get_many_with_metadata(self, requests) + .await + .map(|values| { + values + .into_iter() + .map(|(name, value)| (name, value.value)) + .collect() + }) } - fn get_many_with_metadata( + async fn get_many_with_metadata( &self, requests: &[(&str, Address<'_>)], ) -> Result> { @@ -633,7 +632,16 @@ impl Provider for AwssmProvider { for (name, addr) in requests { resolved.push((*name, self.resolve_coords(*addr)?.into_owned())); } - super::block_on(self.get_many_async(&resolved)) + self.get_many_async(&resolved).await + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + match addr { + Address::Convention { .. } => Ok(()), + Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed( + "awssm secret references are read-only and cannot be written".to_string(), + )), + } } } @@ -799,7 +807,7 @@ mod tests { #[test] fn test_convention_address() { - let p = AwssmProvider::new(config("awssm://us-east-1")); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config("awssm://us-east-1"))); let coords = p.convention_address("proj", "default", "A").unwrap(); assert_eq!(coords.item, "secretspec/proj/default/A"); assert_eq!(coords.field, None); @@ -807,22 +815,30 @@ mod tests { #[test] fn test_convention_address_with_prefix() { - let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam")); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config( + "awssm://us-east-1?prefix=myteam", + ))); let coords = p.convention_address("proj", "default", "A").unwrap(); assert_eq!(coords.item, "myteam/secretspec/proj/default/A"); } #[test] fn test_convention_address_with_trailing_slash_prefix() { - let p = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam/")); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config( + "awssm://us-east-1?prefix=myteam/", + ))); let coords = p.convention_address("proj", "default", "A").unwrap(); assert_eq!(coords.item, "myteam/secretspec/proj/default/A"); } #[test] fn trailing_slash_prefix_has_canonical_storage_identity() { - let canonical = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam")); - let trailing_slash = AwssmProvider::new(config("awssm://us-east-1?prefix=myteam/")); + let canonical = SyncProviderAdapter::from_async(AwssmProvider::new(config( + "awssm://us-east-1?prefix=myteam", + ))); + let trailing_slash = SyncProviderAdapter::from_async(AwssmProvider::new(config( + "awssm://us-east-1?prefix=myteam/", + ))); assert_eq!(trailing_slash.config.prefix.as_deref(), Some("myteam")); assert_eq!(trailing_slash.uri(), canonical.uri()); @@ -862,7 +878,7 @@ mod tests { #[test] fn uri_round_trips_kms_and_sorted_tags() { let c = config("awssm://prod@us-east-1?tag.team=platform&kms_key_id=alias/k&tag.env=prod"); - let p = AwssmProvider::new(c); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(c)); assert_eq!( p.uri(), "awssm://prod@us-east-1?kms_key_id=alias/k&tag.env=prod&tag.team=platform" @@ -879,9 +895,9 @@ mod tests { #[test] fn uri_round_trips_prefix_with_kms_and_tags() { - let p = AwssmProvider::new(config( + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config( "awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod", - )); + ))); assert_eq!( p.uri(), "awssm://us-east-1?prefix=myteam&kms_key_id=alias/k&tag.env=prod" @@ -934,7 +950,7 @@ mod tests { /// SecretSpec. #[test] fn native_address_is_read_only() { - let p = AwssmProvider::new(config("awssm://us-east-1")); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config("awssm://us-east-1"))); let addr = crate::config::NativeAddress { item: "prod/db-credentials".into(), field: Some("password".into()), @@ -953,7 +969,7 @@ mod tests { /// network I/O. #[test] fn native_address_rejects_section() { - let p = AwssmProvider::new(config("awssm://us-east-1")); + let p = SyncProviderAdapter::from_async(AwssmProvider::new(config("awssm://us-east-1"))); let addr = crate::config::NativeAddress { item: "prod/db-credentials".into(), section: Some("x".into()), diff --git a/secretspec/src/provider/base.rs b/secretspec/src/provider/base.rs new file mode 100644 index 000000000..8e0135087 --- /dev/null +++ b/secretspec/src/provider/base.rs @@ -0,0 +1,374 @@ +//! Shared configuration and identity for provider backends and adapters. +use super::address::unsupported_coord_error; +use super::{Address, ProducedValuePersistence, ProviderCredentials}; +use crate::{NativeAddress, Result, SecretSpecError}; +use std::borrow::Cow; + +/// Configuration, capabilities, and identity shared by provider adapters (0.22+). +/// +/// Implement this alongside `AsyncProvider`. Existing synchronous `Provider` +/// implementations keep their interface and are forwarded by `AsyncProviderAdapter`. +/// Configuration and identity accessors must not fetch secrets. In particular, +/// configured entry coordinates and configured physical paths must not perform I/O. +pub trait ProviderBase: Send + Sync { + /// Whether sync capability accessors need async initialization first. + /// Native async accessors always report already cached capabilities. + fn requires_initialization(&self) -> bool { + false + } + /// Configures the shared retry policy (0.22+). Called before first I/O. + fn set_retry_policy(&mut self, _policy: super::RetryPolicy) {} + + /// Native SDK/session retry owners must not receive an outer retry loop (0.22+). + fn retry_ownership(&self) -> super::RetryOwnership { + super::RetryOwnership::Shared + } + + /// Whether a mutation may be repeated without changing its observable result (0.22+). + /// Reads are always handled independently. Mutations require explicit opt-in. + fn retry_safe(&self, _operation: super::RetryOperation, _addr: Address<'_>) -> bool { + false + } + + /// Compiles SecretSpec's `{project}/{profile}/{key}` naming convention into + /// this store's native coordinates: the same address space a secret's + /// `ref` uses. + /// + /// This is the single owner of the provider's convention layout (format + /// strings, path shapes, default vaults); the operation methods resolve + /// every address through [`resolve_coords`](super::Provider::resolve_coords) and + /// never re-derive names. Pure naming, no I/O. + /// + /// # Errors + /// + /// Returns an error when the convention inputs cannot form a valid name in + /// this store (e.g. empty components, length limits). + fn convention_address( + &self, + _project: &str, + _profile: &str, + _key: &str, + ) -> Result { + Err(SecretSpecError::ProviderOperationFailed(format!( + "provider '{}' does not support convention addresses", + self.name() + ))) + } + + /// The optional [`NativeAddress`] coordinates this store can honor, beyond + /// the universally consumed `item` (e.g. `["field"]`). + /// + /// Declared as data rather than checked per operation: the default + /// [`resolve_coords`](super::Provider::resolve_coords) rejects every coordinate a + /// provider does not name here, so a store whose secrets have no + /// sub-components gets the correct behavior from the empty default without + /// writing any validation. + fn supported_coords(&self) -> &'static [&'static str] { + &[] + } + + /// Returns whether this provider understands an optional native-address + /// coordinate. Available since SecretSpec 0.21. + /// + /// Static providers inherit the existing slice-based behavior. External + /// providers override this hook because their coordinate list is selected + /// during protocol initialization and is therefore owned by the instance. + fn supports_coord(&self, name: &str) -> bool { + self.supported_coords().contains(&name) + } + + /// Resolves any [`Address`] to this store's native coordinates: a `ref`'s + /// coordinates pass through as-is, a convention address is compiled via + /// [`convention_address`](super::Provider::convention_address). Coordinates + /// outside [`supported_coords`](super::Provider::supported_coords) are rejected, + /// so every operation that resolves an address inherits the check. + fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { + let coords = match addr { + Address::Native(native) => Cow::Borrowed(native), + Address::Convention { + project, + profile, + key, + } => Cow::Owned(self.convention_address(project, profile, key)?), + }; + for (name, value) in coords.coordinates() { + if name == "item" || value.is_none() { + continue; + } + if !self.supports_coord(name) { + return Err(unsupported_coord_error(self.name(), &coords, name)); + } + } + Ok(coords) + } + + /// Resolves entry coordinates using only configuration and address defaults. + /// Available since SecretSpec 0.21. + /// + /// This must not read provider storage or invoke a provider CLI. Planning + /// uses it before consulting the cache. Providers should fill in implicit + /// coordinates here; aliases that require a lookup, such as an item title + /// and UUID, are resolved only by [`Self::entry_coordinates`]. + fn configured_entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.resolve_coords(addr) + } + + /// Whether this provider can return plaintext values through + /// [`get`](super::Provider::get) or [`get_many`](super::Provider::get_many). + /// + /// Available starting with SecretSpec 0.21. Write-only stores override this + /// to return `false`; callers that only need presence can then use + /// [`exists`](super::Provider::exists) without accidentally attempting a value + /// read. This capability check never returns a secret value. + fn supports_read(&self) -> bool { + true + } + + /// Whether this provider implements [`delete`](super::Provider::delete) at all. + /// + /// Defaults to `false`, matching the default `delete`. A provider that + /// overrides `delete` must override this too, so that + /// [`check_deletable`](super::Provider::check_deletable) can answer the coarse + /// question, *can this provider delete anything?*, without performing a + /// deletion to find out. + /// + /// This is deliberately separate from `check_deletable`: that method + /// answers whether one specific address is deletable, which a provider may + /// refuse for reasons of its own, while this one reports a static property + /// of the implementation. + fn supports_delete(&self) -> bool { + false + } + + /// The error both `delete` and `check_deletable` report for a provider that + /// cannot delete. Shared so the preflight and the operation cannot disagree + /// about the reason. + fn deletion_unsupported(&self) -> SecretSpecError { + SecretSpecError::ProviderOperationFailed(format!( + "provider '{}' does not support deleting secrets", + self.name() + )) + } + + /// Controls whether SecretSpec persists a value produced by a declaration's + /// `generate` configuration after this provider's read route misses. + /// Available since SecretSpec 0.19. + /// + /// [`ProducedValuePersistence::Ephemeral`] affects only automatic + /// generation. Ordinary [`set`](super::Provider::set), deletion, imports, and + /// provider reads keep their usual behavior. The capability must be pure: + /// callers may inspect it without running authentication preflight or other + /// provider I/O. + fn generated_value_persistence(&self) -> ProducedValuePersistence { + ProducedValuePersistence::Persist + } + + /// Controls whether a value entered for a `prompt = true` declaration is + /// stored after the provider's read route misses. Available since + /// SecretSpec 0.19. + /// + /// The default persists the answer through [`super::Provider::set`], making the + /// prompt a first-use provisioning step. A provider that cannot or must not + /// retain values can return [`ProducedValuePersistence::Ephemeral`] so the + /// answer is used only by the current `run` resolution. + fn prompted_value_persistence(&self) -> ProducedValuePersistence { + ProducedValuePersistence::Persist + } + + /// Describes the provider-native destination that a write to `addr` will + /// change. Available since SecretSpec 0.19. + /// + /// The description is intended for a pre-write CLI preview and must not + /// contain credentials. Providers with file-backed or otherwise structured + /// storage should override this when their URI plus native coordinates do + /// not identify the resolved file/container and selector clearly. The + /// default renders the provider-native coordinates. + fn describe_write_target(&self, addr: Address<'_>) -> Result { + Ok(self.resolve_coords(addr)?.render()) + } + + /// Identifies the shared authentication state this instance's preflight + /// check probes, when that state outlives the instance. + /// + /// Instances of the same provider returning equal keys share one probe + /// result process-wide. This matters because a secret's `providers` chain + /// builds a fresh provider instance per (secret, URI) pair; without a + /// scope key, N secrets would run N identical auth probes (each typically a + /// CLI round-trip). The default `None` keeps the probe per-instance. + fn auth_scope_key(&self) -> Option { + None + } + + /// Returns the name of this provider. + /// + /// This should match the name registered with the provider macro. + fn name(&self) -> &str; + + /// Returns the full URI representation of this provider. + /// + /// This includes any configuration like vault names, paths, etc. + /// For example: "onepassword://VaultName" or "dotenv://.env.production" + /// + /// # Contract: the returned URI must be credential-free + /// + /// The audit log records this URI and the fallback-chain warnings print it, + /// so it must never contain a secret the user embedded in the source URI + /// (e.g. a `:password` or service-account token). Reconstruct the URI from + /// non-secret attribution only (account, profile, namespace, host, path) + /// and drop any credential, which authentication resolves from the + /// environment or a token field instead. This contract is enforced for every + /// registered scheme by `uri_never_echoes_a_userinfo_password` in + /// `provider::tests`. + fn uri(&self) -> String; + + /// Returns a credential-free identity for the physical store this provider + /// addresses. + /// + /// Unlike [`Self::uri`], this value is not user-facing attribution. It is + /// used when SecretSpec must decide whether two differently configured + /// providers can read and write the same storage location, such as when + /// ensuring a cache is distinct from its authoritative sources. Authentication + /// choices that do not change the store must therefore not change this + /// identity, and protocol-compatible provider names should return the same + /// identity when they target the same store. + /// + /// Most providers have one public spelling for a store, so the default uses + /// their canonical URI. Providers with equivalent spellings or compatible + /// identities should override this method. + fn storage_identity(&self) -> String { + self.uri() + } + + /// Returns the identity of the container holding a resolved secret entry. + /// Available since SecretSpec 0.18. + /// + /// This differs from [`Self::storage_identity`] only for providers whose + /// public URI contains an addressing template. Cache routing must retain + /// that template so sibling address spaces remain distinct, while + /// destructive operations compare the template's resolved native + /// coordinates separately and need the identity of the underlying + /// container here. + fn entry_container_identity(&self) -> String { + self.storage_identity() + } + + /// Returns the path that identifies a filesystem-backed store, if any. + /// Available since SecretSpec 0.18. + /// + /// The path is compared using filesystem identity when it exists, catching + /// lexical aliases, symlinks, and hard links. Providers that are not backed + /// by one path keep the default and are identified by + /// [`storage_identity`](super::Provider::storage_identity). + fn physical_store_path(&self) -> Option<&std::path::Path> { + None + } + + /// [`Self::physical_store_path`] as known from configuration alone. + /// Available since SecretSpec 0.21. + /// + /// Like [`Self::configured_entry_coordinates`], this must not contact the + /// store; planning compares containers before consulting the cache. + /// Providers that only learn their path from the store report what they + /// already know, or `None`. + fn configured_physical_store_path(&self) -> Option<&std::path::Path> { + self.physical_store_path() + } + + /// Records a human-readable reason for the secrets access happening in this + /// session (e.g. "secretspec run: deploy"), set via [`Secrets::with_reason`]. + /// + /// Providers that support audit logging use this; for example the Proton Pass + /// provider forwards it to `pass-cli` agent sessions, which require a reason + /// for every audited item operation. The default implementation ignores it. + /// + /// Takes `&self` (relying on interior mutability) so it can be applied after + /// the provider is wrapped in an `Arc` (as preflight-enabled providers are). + /// + /// [`Secrets::with_reason`]: crate::Secrets::with_reason + fn set_reason(&self, _reason: Option) {} + + /// Records the app-requested authorization lifetime for approval surfaces. + /// Available starting with SecretSpec 0.21. + /// + /// This is an untrusted default: the provider and user decide the actual + /// grant lifetime. The default implementation ignores it. + fn set_requested_authorization_duration(&self, _duration: Option) {} + + /// Records structured context about the software integration invoking + /// SecretSpec, such as `git` performing `credential_get` for `github.com`. + /// + /// This metadata is distinct from the user-supplied access reason and never + /// satisfies `require_reason`. Providers may use it for their own audit or + /// approval surfaces. The default implementation ignores it. + /// + /// Takes `&self` for the same reason as [`super::Provider::set_reason`]: SecretSpec + /// applies it after providers may have been wrapped in `Arc`. + /// + /// Available since SecretSpec 0.20. + fn set_caller(&self, _caller: Option) {} + + /// Records the declared project for this provider session. Available + /// starting with SecretSpec 0.21. + /// + /// This is resolver-declared context for provider policy, audit, and + /// approval surfaces. It is not an authenticated application identity and + /// must not be used as one. Convention addresses carry the same project; + /// native addresses need this session context because their coordinates do + /// not. + fn set_project(&self, _project: &str) {} + + /// Records the profile this session resolves under. Available starting with + /// SecretSpec 0.20. + /// + /// A [`Convention`](Address::Convention) address carries the profile, so a + /// provider whose namespace varies by profile reads it there. A + /// [`Native`](Address::Native) address carries coordinates only, and a + /// provider that needs the profile for something the coordinates do not name + /// (Infisical's environment, for example) would otherwise have to reject every + /// native address. Such a provider keeps this as a fallback, behind whatever + /// its URI states explicitly. + /// + /// This is context, not naming: it must not change + /// [`uri`](super::Provider::uri) or + /// [`storage_identity`](super::Provider::storage_identity), or one store would take + /// a different identity under each profile and invalidate its own cache + /// entries. + /// + /// Takes `&self` (relying on interior mutability) so it can be applied after + /// the provider is wrapped in an `Arc` (as preflight-enabled providers are). + /// The default implementation ignores it, which is correct for providers + /// whose addresses already carry everything they need. + fn set_profile(&self, _profile: &str) {} + + /// Rebases any relative filesystem paths the provider holds against + /// `base_dir`, the directory containing the `secretspec.toml` that + /// configured it. + /// + /// File-backed providers (e.g. `dotenv`) take paths from the config or its + /// provider aliases. Those paths must resolve relative to the project root, + /// not the process's current working directory; otherwise running from a + /// subdirectory with `--file ../secretspec.toml` looks for the `.env` file + /// in the wrong place. [`Secrets`] calls this once at construction, before + /// the provider performs any I/O. The default implementation does nothing, + /// which is correct for providers that hold no relative paths. + /// + /// [`Secrets`]: crate::Secrets + fn with_base_dir(&mut self, _base_dir: &std::path::Path) {} + + /// Hands semantic credentials to the provider. + /// + /// Called once inside the registration factory, on the concrete provider + /// value *before* any `Arc`/`Box` wrapping. This must not be a + /// post-construction call on a `Box`: like [`with_base_dir`], + /// a `&mut self` hook cannot be forwarded through the blanket + /// `impl Provider for Arc` (an `Arc` gives no `&mut` access to its + /// inner value), so a preflight provider wrapped as `Box>` would + /// silently receive the default no-op. The default implementation ignores + /// the values, which is correct for providers that need no credentials. + /// + /// [`with_base_dir`]: Provider::with_base_dir + fn with_credentials(&mut self, _credentials: ProviderCredentials) {} +} diff --git a/secretspec/src/provider/cloudflare.rs b/secretspec/src/provider/cloudflare.rs index c6c3ace95..a0f83989d 100644 --- a/secretspec/src/provider/cloudflare.rs +++ b/secretspec/src/provider/cloudflare.rs @@ -6,7 +6,10 @@ //! an `api_token` provider credential, `CLOUDFLARE_API_TOKEN`, or Wrangler's //! current OAuth/API credentials via `wrangler auth token --json`. -use super::{Address, DiscoveryContext, Provider, ProviderCredentials, ProviderUrl}; +use super::ProviderBase as _; +use super::{Address, DiscoveryContext, ProviderCredentials, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, Secret, SecretSpecError}; @@ -15,7 +18,7 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::borrow::Cow; use std::collections::HashMap; use std::io; -use std::process::Command; +use tokio::process::Command; const API_TOKEN: &str = "api_token"; const API_TOKEN_ENV: &str = "CLOUDFLARE_API_TOKEN"; @@ -240,6 +243,7 @@ crate::register_provider! { struct: CloudflareProvider, config: CloudflareConfig, metadata: &super::catalog::CLOUDFLARE, + native_async: true, } impl CloudflareProvider { @@ -273,12 +277,13 @@ impl CloudflareProvider { super::credential_or_env(&self.credentials, API_TOKEN, API_TOKEN_ENV) } - fn wrangler_credentials(&self) -> Result { + async fn wrangler_credentials(&self) -> Result { self.retry_policy - .run("cloudflare", || self.wrangler_credentials_once()) + .run_async("cloudflare", || self.wrangler_credentials_once()) + .await } - fn wrangler_credentials_once(&self) -> Result { + async fn wrangler_credentials_once(&self) -> Result { let mut command = Command::new(&self.wrangler_binary_path); command.args(["auth", "token", "--json"]); if let Some(profile) = &self.config.wrangler_profile { @@ -286,6 +291,7 @@ impl CloudflareProvider { } let output = command .output() + .await .map_err(|error| self.wrangler_spawn_error(error))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -319,7 +325,7 @@ impl CloudflareProvider { } } - fn auth_headers(&self) -> Result { + async fn auth_headers(&self) -> Result { let mut headers = HeaderMap::new(); if self.config.auth != CloudflareAuth::Wrangler { if let Some(token) = self.api_token() { @@ -336,7 +342,7 @@ impl CloudflareProvider { } } - let credentials = self.wrangler_credentials()?; + let credentials = self.wrangler_credentials().await?; match credentials { WranglerCredentials::ApiToken { token } | WranglerCredentials::Oauth { token } => { headers.insert( @@ -359,9 +365,9 @@ impl CloudflareProvider { Ok(headers) } - fn client(&self) -> Result { + async fn client(&self) -> Result { super::http::client_builder() - .default_headers(self.auth_headers()?) + .default_headers(self.auth_headers().await?) // Account-secret values must remain confined to Cloudflare's fixed // API origin. In particular, never replay a PATCH/POST body after // a 307/308 redirect to an origin selected by a response. @@ -384,7 +390,7 @@ impl CloudflareProvider { } fn secret_name<'a>(&self, addr: Address<'a>) -> Result> { - let name = super::flat_item(self, addr)?; + let name = super::address::flat_item_from_coords(self.resolve_coords(addr)?); if name.is_empty() || name.chars().any(char::is_whitespace) || name.contains('\0') { return Err(operation_error(format!( "'{name}' is not a valid Cloudflare secret name: names must be non-empty and cannot contain whitespace or NUL" @@ -484,7 +490,7 @@ impl CloudflareProvider { async fn set_async(&self, name: &str, value: &SecretBytes) -> Result<()> { let value = super::require_utf8("cloudflare", value)?; let account_id = self.account_id()?; - let client = self.client()?; + let client = self.client().await?; if let Some(existing) = self.lookup_secret(&client, &account_id, name).await? { return self .update_secret(&client, &account_id, &existing.id, value) @@ -520,7 +526,7 @@ impl CloudflareProvider { async fn delete_async(&self, name: &str) -> Result { let account_id = self.account_id()?; - let client = self.client()?; + let client = self.client().await?; let Some(existing) = self.lookup_secret(&client, &account_id, name).await? else { return Ok(false); }; @@ -533,7 +539,7 @@ impl CloudflareProvider { } } -impl Provider for CloudflareProvider { +impl super::ProviderBase for CloudflareProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -542,8 +548,6 @@ impl Provider for CloudflareProvider { self.retry_policy = policy; } - /// The selected store supplies project/environment isolation; convention - /// writes use the SecretSpec key directly as the account-secret name. fn convention_address( &self, _project: &str, @@ -607,67 +611,79 @@ impl Provider for CloudflareProvider { format!("cloudflare://{account_id}/{}", self.config.store_id) } - fn get(&self, addr: Address<'_>) -> Result> { + fn supports_delete(&self) -> bool { + true + } + + fn describe_write_target(&self, addr: Address<'_>) -> Result { + let name = self.secret_name(addr)?; + Ok(format!( + "Cloudflare account '{}' Secrets Store '{}' secret '{}' with scopes [{}]", + self.account_id()?, + self.config.store_id, + name, + self.config.scopes.join(", ") + )) + } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for CloudflareProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let _ = self.secret_name(addr)?; Err(operation_error( "Cloudflare Secrets Store is write-only at the management API: plaintext values can only be read by bound Cloudflare services; use this provider with `secretspec set`, `secretspec delete`, or `secretspec init --from`", )) } - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - self.secret_name(addr).map(|_| ()) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; if value.expose_secret().len() > MAX_SECRET_BYTES { return Err(operation_error(format!( "Cloudflare secret values cannot exceed {MAX_SECRET_BYTES} bytes" ))); } let name = self.secret_name(addr)?; - super::block_on(self.set_async(&name, value)) + self.set_async(&name, value).await } - fn supports_delete(&self) -> bool { - true + async fn delete(&self, addr: Address<'_>) -> Result { + super::AsyncProvider::check_deletable(self, addr).await?; + let name = self.secret_name(addr)?; + self.delete_async(&name).await } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - self.secret_name(addr).map(|_| ()) + async fn reflect(&self, _context: DiscoveryContext<'_>) -> Result> { + let account_id = self.account_id()?; + let client = self.client().await?; + Ok(self + .list_secrets(&client, &account_id, None) + .await? + .into_iter() + .map(|listed| { + let name = listed.name; + let secret = Secret::required(format!("{name} Cloudflare Secrets Store secret")); + (name, secret) + }) + .collect()) } - fn delete(&self, addr: Address<'_>) -> Result { - self.check_deletable(addr)?; - let name = self.secret_name(addr)?; - super::block_on(self.delete_async(&name)) + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.secret_name(addr).map(|_| ()) } - fn describe_write_target(&self, addr: Address<'_>) -> Result { - let name = self.secret_name(addr)?; - Ok(format!( - "Cloudflare account '{}' Secrets Store '{}' secret '{}' with scopes [{}]", - self.account_id()?, - self.config.store_id, - name, - self.config.scopes.join(", ") - )) + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + self.secret_name(addr).map(|_| ()) } - fn reflect(&self, _context: DiscoveryContext<'_>) -> Result> { + async fn exists(&self, addr: Address<'_>) -> Result { + let name = self.secret_name(addr)?; let account_id = self.account_id()?; - let client = self.client()?; - Ok( - super::block_on(self.list_secrets(&client, &account_id, None))? - .into_iter() - .map(|listed| { - let name = listed.name; - let secret = - Secret::required(format!("{name} Cloudflare Secrets Store secret")); - (name, secret) - }) - .collect(), - ) + let client = self.client().await?; + Ok(self + .lookup_secret(&client, &account_id, &name) + .await? + .is_some()) } } @@ -733,9 +749,9 @@ mod tests { CloudflareConfig::try_from(&ProviderUrl::new(Url::parse(spec).unwrap())).unwrap() } - fn provider_with_token(endpoint: SocketAddr) -> CloudflareProvider { - let mut provider = CloudflareProvider::new(config(&format!( - "cloudflare://{STORE}?account_id={ACCOUNT}&auth=token" + fn provider_with_token(endpoint: SocketAddr) -> SyncProviderAdapter { + let mut provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config( + &format!("cloudflare://{STORE}?account_id={ACCOUNT}&auth=token"), ))); provider.api_base = format!("http://{endpoint}"); provider.with_credentials(ProviderCredentials::from([( @@ -749,15 +765,15 @@ mod tests { fn explicit_token_bytes_never_fall_back_to_environment_or_wrangler() { let _lock = crate::tests::scrub_resolution_env(); let _env = crate::tests::EnvVarGuard::set(API_TOKEN_ENV, "another-identity"); - let mut provider = CloudflareProvider::new(config(&format!( - "cloudflare://{STORE}?account_id={ACCOUNT}" + let mut provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config( + &format!("cloudflare://{STORE}?account_id={ACCOUNT}"), ))); provider.wrangler_binary_path = "/must-not-run-wrangler".into(); provider.with_credentials(ProviderCredentials::from([( API_TOKEN.into(), SecretBytes::from_slice(b"private-token\xff"), )])); - let headers = provider.auth_headers().unwrap(); + let headers = super::super::block_on(provider.auth_headers()).unwrap(); assert_eq!( headers[AUTHORIZATION].as_bytes(), b"Bearer private-token\xff" @@ -767,7 +783,7 @@ mod tests { API_TOKEN.into(), SecretBytes::from_slice(b"private-token\r\n"), )])); - let error = provider.auth_headers().unwrap_err(); + let error = super::super::block_on(provider.auth_headers()).unwrap_err(); assert!(error.to_string().contains("HTTP Authorization header")); assert!(!format!("{error:?}: {error}").contains("private-token")); } @@ -832,7 +848,7 @@ mod tests { let canonical = format!( "cloudflare://{STORE}?account_id={ACCOUNT}&scopes=workers,containers&auth=wrangler&wrangler_profile=production" ); - let provider = CloudflareProvider::new(config(&encoded)); + let provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config(&encoded))); assert_eq!(provider.uri(), canonical); assert_eq!(config(&provider.uri()), provider.config); assert_eq!(provider.config.scopes, ["workers", "containers"]); @@ -858,7 +874,9 @@ mod tests { #[test] fn convention_is_flat_and_reads_explain_the_limitation() { - let provider = CloudflareProvider::new(config(&format!("cloudflare://{STORE}"))); + let provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config(&format!( + "cloudflare://{STORE}" + )))); let address = provider .convention_address("project", "production", "DATABASE_URL") .unwrap(); @@ -886,7 +904,9 @@ mod tests { #[test] fn rejects_values_larger_than_cloudflares_limit_before_authentication() { - let provider = CloudflareProvider::new(config(&format!("cloudflare://{STORE}"))); + let provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config(&format!( + "cloudflare://{STORE}" + )))); let oversized = SecretBytes::from_utf8("x".repeat(MAX_SECRET_BYTES + 1)); let error = provider .set( @@ -1022,11 +1042,13 @@ printf '%s' '{"type":"oauth","token":"oauth-token"}' "#, ); - let mut provider = CloudflareProvider::new(config(&format!( - "cloudflare://{STORE}?account_id={ACCOUNT}&auth=wrangler&wrangler_profile=production" + let mut provider = SyncProviderAdapter::from_async(CloudflareProvider::new(config( + &format!( + "cloudflare://{STORE}?account_id={ACCOUNT}&auth=wrangler&wrangler_profile=production" + ), ))); provider.wrangler_binary_path = binary.to_string_lossy().into_owned(); - let headers = provider.auth_headers().unwrap(); + let headers = super::super::block_on(provider.auth_headers()).unwrap(); assert_eq!( headers.get(AUTHORIZATION).unwrap().to_str().unwrap(), "Bearer oauth-token" diff --git a/secretspec/src/provider/credentials.rs b/secretspec/src/provider/credentials.rs index f2ccf2a06..d4d1c005e 100644 --- a/secretspec/src/provider/credentials.rs +++ b/secretspec/src/provider/credentials.rs @@ -2,12 +2,12 @@ use crate::{Result, SecretBytes, SecretSpecError}; use std::collections::HashMap; use std::ffi::OsStr; -/// Credentials handed to a provider at construction. +/// Credentials handed to a provider at construction (0.22+). /// /// Maps semantic provider-specific names (for example `access_token`) to /// secret values. Providers may retain environment-variable fallback for /// standalone compatibility, but environment names are not part of this API. -pub(crate) type ProviderCredentials = HashMap; +pub type ProviderCredentials = HashMap; /// Resolves a semantic provider credential, falling back to the provider's /// conventional environment variable when no explicit credential was supplied. diff --git a/secretspec/src/provider/doppler.rs b/secretspec/src/provider/doppler.rs index 295f5d013..07a32b498 100644 --- a/secretspec/src/provider/doppler.rs +++ b/secretspec/src/provider/doppler.rs @@ -84,7 +84,9 @@ //! Doppler projects and configs must already exist: a write cannot create them, //! the way a filesystem-shaped store creates a folder. -use super::{Address, Provider, ProviderCredentials, ProviderUrl, flat_item}; +use super::{Address, ProviderCredentials, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -273,12 +275,12 @@ fn parse_item<'i>( /// /// That lowercasing is why an uppercase name is refused here rather than passed /// on. Doppler would accept it, so `doppler://MyApp` and `doppler://myapp` name -/// one store while rendering two different [`uri`](Provider::uri) strings -- and +/// one store while rendering two different [`uri`](super::Provider::uri) strings -- and /// the cache route fingerprint and the audit log both treat that string as the /// store's identity. Refusing is a one-word fix for the user and keeps one store /// to one name. /// -/// Rejecting `/` is also what lets [`uri`](Provider::uri) interpolate the project +/// Rejecting `/` is also what lets [`uri`](super::Provider::uri) interpolate the project /// without escaping: the URI's own delimiter cannot appear in a validated name. /// /// # Errors @@ -992,7 +994,7 @@ pub struct DopplerProvider { http: OnceLock, /// The active SecretSpec profile, when resolution has announced one. Names /// the config for a bare `ref` under a URI that pins none: see - /// [`set_profile`](Provider::set_profile). + /// [`set_profile`](super::Provider::set_profile). profile: Mutex>, /// Doppler's API root. [`API_BASE`] in every build; tests point it at an /// in-process fixture to exercise the transport without a token. @@ -1006,6 +1008,7 @@ crate::register_provider! { struct: DopplerProvider, config: DopplerConfig, metadata: &super::catalog::DOPPLER, + native_async: true, } impl DopplerProvider { @@ -1024,7 +1027,7 @@ impl DopplerProvider { } /// The profile resolution announced through - /// [`set_profile`](Provider::set_profile), if any. + /// [`set_profile`](super::Provider::set_profile), if any. /// /// A poisoned lock is read through rather than treated as empty. The lock /// guards a plain `Option` that is assigned in one step, so a @@ -1105,7 +1108,8 @@ impl DopplerProvider { /// Returns an error when the address names something Doppler cannot spell, /// or when a `ref` names no config and none is pinned. fn locate(&self, addr: Address<'_>) -> Result { - let item = flat_item(self, addr)?; + let item = + super::address::flat_item_from_coords(super::ProviderBase::resolve_coords(self, addr)?); let session = self.session_profile(); let fallback = self.implied_config(session.as_deref()); let (config, name) = parse_item(&item, fallback)?; @@ -1137,7 +1141,7 @@ impl DopplerProvider { /// [`implied_config`](Self::implied_config) where a profile is always known, /// validated up front. /// - /// This is what [`convention_address`](Provider::convention_address) builds + /// This is what [`convention_address`](super::Provider::convention_address) builds /// a declared secret's address from and what `reflect` lists, so discovery /// and resolution name one config. [`locate`](Self::locate) leaves the /// check to [`parse_item`] instead, because a `ref` that names its own @@ -1164,10 +1168,10 @@ impl DopplerProvider { /// Resolves an address and applies the write policy to it. /// - /// The one owner of that policy: [`check_writable`](Provider::check_writable) - /// and [`check_deletable`](Provider::check_deletable) are this with the - /// location dropped, and [`set`](Provider::set) and - /// [`delete`](Provider::delete) open with it, so the trait's requirement + /// The one owner of that policy: [`check_writable`](super::Provider::check_writable) + /// and [`check_deletable`](super::Provider::check_deletable) are this with the + /// location dropped, and [`set`](super::Provider::set) and + /// [`delete`](super::Provider::delete) open with it, so the trait's requirement /// that preflight and operation refuse for the same reason holds by /// construction rather than by four call sites agreeing. /// @@ -1359,7 +1363,7 @@ impl DopplerProvider { } } -impl Provider for DopplerProvider { +impl super::ProviderBase for DopplerProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -1372,20 +1376,6 @@ impl Provider for DopplerProvider { operation == super::RetryOperation::Set } - /// A profile's secrets share one Doppler config, each under its own name, - /// verbatim. - /// - /// `item` carries the config and the name together so a convention address - /// and a `ref` share one spelling of the layout; [`parse_item`] splits them - /// back apart. SecretSpec's own project name is unused -- the Doppler - /// project from the URI is the namespace. Doppler secrets are single values - /// with no sub-components, so every coordinate but `item` is rejected by - /// the default `resolve_coords`. - /// - /// # Errors - /// - /// Returns an error when Doppler cannot spell the key, or cannot spell the - /// config the profile names. fn convention_address( &self, _project: &str, @@ -1400,18 +1390,6 @@ impl Provider for DopplerProvider { }) } - /// Fills in the config an address left implicit, so the identity a - /// destructive preflight compares is the entry `get`, `set` and `delete` - /// actually operate on. - /// - /// A `ref` may name a secret alone (`SHARED_CA`) and take its config from - /// the URI, or from the active profile when the URI pins none. The default - /// returns - /// such an address unchanged, which would render two secrets in different - /// configs as one identity -- and `import --delete-source` consults exactly - /// that identity before removing a source entry. Every operation resolves - /// through [`locate`](Self::locate), which supplies the same default, so - /// this does too. fn configured_entry_coordinates<'a>( &self, addr: Address<'a>, @@ -1423,27 +1401,10 @@ impl Provider for DopplerProvider { })) } - /// Refuses, before anything is removed, every deletion Doppler would refuse - /// anyway. - /// - /// Deleting in Doppler *is* a write -- of a null value -- so the write - /// policy applies unchanged, and a reserved name is as unwritable to null - /// as to a value. Without this, a reserved name would pass preflight and - /// fail at the API partway through a multi-secret deletion, which is the - /// failure [`Provider::check_deletable`] exists to prevent. [`delete`] - /// applies the same rule, as the trait requires of the pair. - /// - /// [`delete`]: Provider::delete - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - self.locate_writable(addr).map(|_| ()) - } - fn supports_delete(&self) -> bool { true } - /// Names the config too: it is half the destination, and a bare `ref` - /// shows no config in its coordinates. fn describe_write_target(&self, addr: Address<'_>) -> Result { let loc = self.locate(addr)?; Ok(format!( @@ -1456,10 +1417,6 @@ impl Provider for DopplerProvider { self.credentials = credentials; } - /// Remembers the active profile so a bare `ref` under an unpinned URI can - /// take its config from it, as every convention address already does. - /// Recorded even through a poisoned lock: see - /// [`session_profile`](Self::session_profile). fn set_profile(&self, profile: &str) { *self .profile @@ -1471,10 +1428,6 @@ impl Provider for DopplerProvider { Self::PROVIDER_NAME } - /// The inverse of parsing, and credential-free by construction: the token - /// lives in a credential or the environment, never in the URI, so there is - /// nothing here to redact. The audit log and the fallback-chain warnings - /// both persist this. fn uri(&self) -> String { // Neither component needs escaping: both are validated to // `[a-z0-9_-]+`, which contains no URI delimiter, so this is an exact @@ -1489,40 +1442,22 @@ impl Provider for DopplerProvider { } } - /// The Doppler *project* holds every entry; which config within it is part - /// of the resolved coordinates, not of the container. - /// - /// This is what makes [`same_entry`](Provider::same_entry) correct across the - /// two ways one secret can be addressed: `doppler://myapp` under profile - /// `dev` and `doppler://myapp/dev` are the same secret, and both compile to - /// the coordinates `dev/API_KEY`. Leaving the config in the container would - /// report them as different entries, and `secretspec import --delete-source` - /// consults exactly that before deleting -- so it would write a value to its - /// destination and then delete the secret it had just written. - /// - /// [`storage_identity`](Provider::storage_identity) deliberately keeps the - /// config: that one is compared without an address, for cache routing, where - /// `doppler://myapp/cache` and `doppler://myapp/prd` must stay distinct - /// stores. fn entry_container_identity(&self) -> String { format!("doppler://{}", self.config.project) } +} - fn get(&self, addr: Address<'_>) -> Result> { +#[async_trait::async_trait] +impl super::AsyncProvider for DopplerProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let loc = self.locate(addr)?; - super::block_on(self.get_async(&loc, AbsentConfig::IsAnError)) + self.get_async(&loc, AbsentConfig::IsAnError).await } - /// Secrets sharing a config are read with one request for the whole config, - /// rather than one round trip per secret. - /// - /// This override is the point of the provider: Doppler answers for every - /// requested secret in one response, so a 23-secret resolution costs one HTTP - /// call per config. Falling back to the default would cost 23. Several - /// configs go out concurrently, in the same capped waves the default uses - /// for independent addresses, so the cost is one round trip rather than one - /// per config. - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { if requests.is_empty() { return Ok(HashMap::new()); } @@ -1545,25 +1480,30 @@ impl Provider for DopplerProvider { // uses for independent addresses. A manifest whose `ref`s span several // Doppler configs otherwise pays one serialized round trip each. let groups: Vec<(String, Vec<(&str, String)>)> = by_config.into_iter().collect(); - let listings = super::map_concurrently(&groups, super::get_each_concurrency(), |group| { - let (config, wanted) = group; - // Only the declared names are requested, so this reads no secret - // the manifest did not ask for: see `list_async`. A reserved name - // is answered without asking, as `get_async` answers it, so it - // never reaches the filter -- and a config wanted for nothing else - // is not asked at all, since an empty filter reads the whole config. - let mut names: Vec = wanted - .iter() - .map(|(_, key)| key.clone()) - .filter(|key| !is_reserved(key)) - .collect(); - names.sort_unstable(); - names.dedup(); - if names.is_empty() { - return Ok(HashMap::new()); - } - super::block_on(self.list_async(config, &names)) - }); + let listings = super::async_provider::map_concurrently( + &groups, + super::get_each_concurrency(), + |group| async { + let (config, wanted) = group; + // Only the declared names are requested, so this reads no secret + // the manifest did not ask for: see `list_async`. A reserved name + // is answered without asking, as `get_async` answers it, so it + // never reaches the filter -- and a config wanted for nothing else + // is not asked at all, since an empty filter reads the whole config. + let mut names: Vec = wanted + .iter() + .map(|(_, key)| key.clone()) + .filter(|key| !is_reserved(key)) + .collect(); + names.sort_unstable(); + names.dedup(); + if names.is_empty() { + return Ok(HashMap::new()); + } + self.list_async(config, &names).await + }, + ) + .await; let mut resolved = HashMap::new(); for ((_, wanted), listed) in groups.iter().zip(listings) { @@ -1577,20 +1517,7 @@ impl Provider for DopplerProvider { Ok(resolved) } - /// Refuses, before a caller prompts for a value, every write this provider - /// would refuse anyway: a name or config Doppler cannot spell, a `ref` - /// naming no config, a coordinate Doppler has no equivalent for, and a name - /// Doppler reserves for itself. - /// - /// The whole point of the pre-check is that the refusal arrives *before* the - /// secret is typed (see [`Provider::check_writable`]); leaving it at the - /// permissive default would let `secretspec set` prompt for a value it is - /// then guaranteed to throw away. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - self.locate_writable(addr).map(|_| ()) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { let loc = self.locate_writable(addr)?; // The address is writable but the value may still not be storable // unchanged: Doppler stores text, so the bytes must be UTF-8, and @@ -1598,40 +1525,12 @@ impl Provider for DopplerProvider { // which never sees a value. See `validate_secret_value`. let value = super::require_utf8("doppler", value)?; validate_secret_value(&loc.name, value)?; - super::block_on(self.write_async(&loc, Some(value))) + self.write_async(&loc, Some(value)).await } - /// Deletes a secret, reporting whether one was there to delete. - /// - /// Doppler's write endpoint deletes on a null value and is idempotent about - /// it, answering the same way whether or not the name existed. The secret is - /// therefore read first, so the `bool` distinguishes a real invalidation - /// from a no-op instead of always claiming one. - /// - /// That read treats an absent config as an absent secret - /// ([`AbsentConfig::HoldsNothing`]): deleting is idempotent, and cache - /// invalidation runs over addresses that may never have been written, so a - /// config that was never created must answer `Ok(false)` rather than fail. - /// - /// A read Doppler answers with a refusal -- a `restricted` value withheld - /// from this token, or a body that is not Doppler's shape -- refuses the - /// deletion too. Such an answer is positive evidence that the secret - /// exists and is not this token's to read, and nulling it would destroy a - /// value nobody could verify. That matters most for the cache: the - /// ownership check before `cache clear` treats a read error as "ours", so - /// this refusal is what keeps a `restricted` secret sitting at a cache - /// address in a Doppler config from being nulled. The cost is accepted - /// deliberately: [`check_deletable`](Provider::check_deletable) cannot - /// foresee the refusal without a round trip, so an `import - /// --delete-source` over such a secret aborts at the deletion rather than - /// in preflight. Destroying a value outranks preflight parity. - /// - /// A reserved name is refused here rather than at Doppler, so `delete` and - /// `check_deletable` refuse for the same reason: see - /// [`locate_writable`](Self::locate_writable). - fn delete(&self, addr: Address<'_>) -> Result { + async fn delete(&self, addr: Address<'_>) -> Result { let loc = self.locate_writable(addr)?; - super::block_on(async { + (async { if self .get_async(&loc, AbsentConfig::HoldsNothing) .await? @@ -1642,30 +1541,15 @@ impl Provider for DopplerProvider { self.write_async(&loc, None).await?; Ok(true) }) + .await } - /// Names one config's secrets, so `secretspec import` can adopt them. - /// - /// Discovery reads the config the URI pins, else the one the profile names, - /// so it stays in the namespace that resolving those secrets would later - /// read -- which is what [`DiscoveryContext`] exists for. - /// - /// Only names are fetched, never values: importing decides *what* to declare, - /// and reading every plaintext to build a list of names would put secrets - /// nobody asked for through this process. See [`names_async`]. - /// - /// [`names_async`]: DopplerProvider::names_async - /// [`DiscoveryContext`]: super::DiscoveryContext - /// - /// # Errors - /// - /// Returns an error when the profile cannot name a Doppler config. - fn reflect( + async fn reflect( &self, context: super::DiscoveryContext<'_>, ) -> Result> { let config = self.config_for_profile(context.profile)?; - let names = super::block_on(self.names_async(&config))?; + let names = self.names_async(&config).await?; Ok(names .into_iter() .map(|name| { @@ -1674,6 +1558,14 @@ impl Provider for DopplerProvider { }) .collect()) } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.locate_writable(addr).map(|_| ()) + } + + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + self.locate_writable(addr).map(|_| ()) + } } #[cfg(test)] @@ -1687,8 +1579,8 @@ mod tests { DopplerConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap() } - fn provider(s: &str) -> DopplerProvider { - DopplerProvider::new(config(s)) + fn provider(s: &str) -> SyncProviderAdapter { + SyncProviderAdapter::from_async(DopplerProvider::new(config(s))) } fn native(item: &str) -> NativeAddress { @@ -1787,7 +1679,7 @@ mod tests { /// /// `doppler://myapp` under profile `dev` and `doppler://myapp/dev` address /// the identical secret. `secretspec import --delete-source` asks - /// [`Provider::same_entry`] before deleting anything, so answering "different + /// [`super::Provider::same_entry`] before deleting anything, so answering "different /// entry" here would let it write the value to its destination and then /// delete the very secret it just wrote. /// @@ -2185,7 +2077,7 @@ mod tests { let unpinned = provider("doppler://myapp"); let bare = native("DATABASE_URL"); - let cases: [(&DopplerProvider, Address<'_>); 4] = [ + let cases: [(&dyn Provider, Address<'_>); 4] = [ (&p, Address::convention("proj", "dev", "lowercase")), (&p, Address::convention("proj", "dev", "DOPPLER_CONFIG")), (&p, Address::Native(&field)), @@ -3010,7 +2902,7 @@ mod tests { } /// A provider aimed at the fixture instead of Doppler, holding a token. - fn fixture_provider(spec: &str, endpoint: SocketAddr) -> DopplerProvider { + fn fixture_provider(spec: &str, endpoint: SocketAddr) -> SyncProviderAdapter { let mut p = provider(spec); p.api_base = format!("http://{endpoint}"); p.allow_insecure_loopback = true; diff --git a/secretspec/src/provider/external.rs b/secretspec/src/provider/external.rs index d09a2d5b8..6a3147033 100644 --- a/secretspec/src/provider/external.rs +++ b/secretspec/src/provider/external.rs @@ -4,7 +4,7 @@ use super::{ Address, DiscoveryContext, ProducedValuePersistence, Provider, ProviderCredentials, - ProviderUrl, ProviderValue, exists_each, get_each_with, + ProviderUrl, ProviderValue, }; use crate::SecretBytes; use crate::config::NativeAddress; @@ -951,18 +951,41 @@ impl ExternalState { /// Endpoint startup is lazy so project/profile context, `with_base_dir`, the /// credential broker, and the initial `set_reason` are applied to immutable /// initialization state first. -pub struct ExternalProvider { +/// Synchronous external-provider facade. Use `as_async()` for native async I/O. +pub type ExternalProvider = super::SyncProviderAdapter; + +impl super::SyncProviderAdapter { + pub fn new(endpoint: ProviderEndpoint, uri: &str) -> Result { + ExternalBackend::new(endpoint, uri).map(Self::from_async) + } + pub(crate) fn from_url(endpoint: ProviderEndpoint, url: &ProviderUrl) -> Self { + Self::from_async(ExternalBackend::from_url(endpoint, url)) + } + #[cfg(any(feature = "cli", test))] + pub(crate) fn initialize(&self) -> Result<()> { + super::block_on(self.as_async().initialize_async()) + } + pub fn exists(&self, address: Address<'_>) -> Result { + Provider::exists(self, address) + } + pub fn clear(&self, scope: wire::ClearScope) -> Result { + super::block_on(self.as_async().clear_async(scope)) + } +} + +pub struct ExternalBackend { endpoint: ProviderEndpoint, scheme: String, configured_uri: String, state: Mutex, /// Serializes endpoint startup. Held instead of `state` while an endpoint /// initializes, because initialization may wait on a credential prompt. - launch: Mutex<()>, + launch: tokio::sync::Mutex<()>, metadata: OnceLock, + retry_policy: Option, } -impl ExternalProvider { +impl ExternalBackend { /// Constructs a provider from an explicit endpoint and configured URI. /// The executable is canonicalized and checked before it is retained. pub fn new(endpoint: ProviderEndpoint, uri: &str) -> Result { @@ -1002,8 +1025,9 @@ impl ExternalProvider { session: None, generation: 0, }), - launch: Mutex::new(()), + launch: tokio::sync::Mutex::new(()), metadata: OnceLock::new(), + retry_policy: None, } } @@ -1031,9 +1055,12 @@ impl ExternalProvider { } } - #[cfg(any(feature = "cli", test))] - pub(crate) fn initialize(&self) -> Result<()> { - self.ensure_session().map(|_| ()) + /// Negotiates endpoint capabilities without blocking the caller (0.22+). + /// + /// Async capability accessors report the last negotiated capabilities; + /// call this before inspecting them without performing a storage operation. + pub async fn initialize_async(&self) -> Result<()> { + self.ensure_session_async().await.map(|_| ()) } /// The absolute deadline for a request that may trigger credential @@ -1047,24 +1074,21 @@ impl ExternalProvider { }) } - fn ensure_session(&self) -> Result> { - if let Some(session) = self.live_session()? { + async fn ensure_session_async(&self) -> Result> { + if let Some(session) = self.live_session().await? { return Ok(session); } // Serialize startup under a dedicated lock so `state` stays free while // the endpoint initializes: initialization may wait on a credential // prompt, and setters or other accessors must not block behind it. - let _launch = self - .launch - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _launch = self.launch.lock().await; loop { // Another caller may have finished starting the endpoint while // this one waited for the launch lock. - if let Some(session) = self.live_session()? { + if let Some(session) = self.live_session().await? { return Ok(session); } - if let Some(session) = self.launch_session()? { + if let Some(session) = self.launch_session().await? { return Ok(session); } // Initialization inputs changed while the endpoint started, so @@ -1074,7 +1098,7 @@ impl ExternalProvider { } /// The current usable session, detaching one that has closed. - fn live_session(&self) -> Result>> { + async fn live_session(&self) -> Result>> { let stale = { let mut state = self.state(); if let Some(message) = state.configuration_error() { @@ -1087,44 +1111,52 @@ impl ExternalProvider { } }; if let Some(stale) = stale { - close_live_session(stale); + close_live_session_async(stale).await; } Ok(None) } /// Starts one endpoint from a snapshot of the initialization inputs. /// Returns `None` when those inputs changed during startup. - fn launch_session(&self) -> Result>> { - let state = self.state(); - let generation = state.generation; - let interactive = state.credential_broker.interactive(); - let credential_error = state.credential_error.clone(); - let application = InitializeApplication { - scheme: self.scheme.clone(), - uri: self.configured_uri.clone(), - context: ApplicationContext { - project: state.project.clone(), - profile: state.profile.clone(), - base_dir: state - .base_dir - .as_ref() - .map(|path| path.to_string_lossy().into_owned()), - reason: state.reason.clone(), - requested_authorization_duration_ms: state - .requested_authorization_duration - .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)), - }, + async fn launch_session(&self) -> Result>> { + let (generation, interactive, credential_error, application, responder) = { + let state = self.state(); + let generation = state.generation; + let interactive = state.credential_broker.interactive(); + let credential_error = state.credential_error.clone(); + let application = InitializeApplication { + scheme: self.scheme.clone(), + uri: self.configured_uri.clone(), + context: ApplicationContext { + project: state.project.clone(), + profile: state.profile.clone(), + base_dir: state + .base_dir + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + reason: state.reason.clone(), + requested_authorization_duration_ms: state + .requested_authorization_duration + .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)), + }, + }; + let responder = Arc::new(ExternalCredentialResponder { + principal: ProviderCredentialPrincipal::new(&self.scheme, &self.configured_uri), + explicit: state.credentials.clone(), + broker: state.credential_broker.clone(), + names: Mutex::new(HashSet::new()), + broker_error: credential_error.clone(), + }); + ( + generation, + interactive, + credential_error, + application, + responder, + ) }; - let responder = Arc::new(ExternalCredentialResponder { - principal: ProviderCredentialPrincipal::new(&self.scheme, &self.configured_uri), - explicit: state.credentials.clone(), - broker: state.credential_broker.clone(), - names: Mutex::new(HashSet::new()), - broker_error: credential_error.clone(), - }); - drop(state); let launch = self.launch_options(std::env::vars_os()); - let launched = super::block_on(ProviderSession::launch_with_credential_broker( + let launched = ProviderSession::launch_with_credential_broker( launch, Product { name: "secretspec".to_string(), @@ -1137,7 +1169,8 @@ impl ExternalProvider { application, self.request_deadline(interactive, STARTUP_TIMEOUT), Some(responder.clone()), - )); + ) + .await; let session = match launched { Ok(session) => session, Err(error) => { @@ -1159,24 +1192,27 @@ impl ExternalProvider { .unwrap_or_else(|poisoned| poisoned.into_inner()) .take(); let session = Arc::new(session); - { + let accepted = { let mut state = self.state(); - if state.generation == generation { - if let Some(existing) = self.metadata.get() { - if existing != session.metadata() { - drop(state); - close_live_session(session); - return Err(discovery_error("provider metadata changed after reconnect")); - } - } else { - let _ = self.metadata.set(session.metadata().clone()); - } + if state.generation != generation { + Ok(false) + } else if self + .metadata + .get() + .is_some_and(|existing| existing != session.metadata()) + { + Err(discovery_error("provider metadata changed after reconnect")) + } else { + let _ = self.metadata.set(session.metadata().clone()); state.session = Some(session.clone()); - return Ok(Some(session)); + Ok(true) } + }; + if matches!(accepted, Ok(true)) { + return Ok(Some(session)); } - close_live_session(session); - Ok(None) + close_live_session_async(session).await; + accepted.map(|_| None) } /// Launch options for this endpoint, with the child environment filtered @@ -1197,21 +1233,8 @@ impl ExternalProvider { } } - /// Endpoint-reported metadata, starting the session if it has not run yet. - /// - /// Only for accessors that are allowed to contact the store. The identity - /// accessors deliberately do not use this: `Secrets` reconstructs canonical - /// URIs and storage identities from a freshly built, *uncredentialed* - /// provider while planning, and that path is documented as touching no - /// store. Launching an endpoint there would both break that contract and - /// derive an identity from a session that never received its credentials. - fn endpoint_metadata(&self) -> Option<&wire::Metadata> { - let _ = self.ensure_session(); - self.metadata.get() - } - - fn require(&self, method: &str) -> Result> { - let session = self.ensure_session()?; + async fn require(&self, method: &str) -> Result> { + let session = self.ensure_session_async().await?; if session.supports(method) { Ok(session) } else { @@ -1222,20 +1245,49 @@ impl ExternalProvider { } } - fn call(&self, params: &M::Params) -> Result + async fn call_async(&self, params: &M::Params) -> Result where M: wire::method::Method, M::Params: Sync, M::Result: Send, { - let session = self.require(M::NAME)?; + let Some(policy) = self.retry_policy else { + return self.call_once_async::(params).await; + }; + if matches!( + M::NAME, + wire::method::GET + | wire::method::GET_MANY + | wire::method::EXISTS + | wire::method::RESOLVE_ADDRESS + | wire::method::CHECK_WRITABLE + | wire::method::CHECK_DELETABLE + | wire::method::DESCRIBE_WRITE_TARGET + | wire::method::REFLECT + ) { + policy + .run_async(&self.scheme, || self.call_once_async::(params)) + .await + } else { + self.call_once_async::(params).await + } + } + + async fn call_once_async(&self, params: &M::Params) -> Result + where + M: wire::method::Method, + M::Params: Sync, + M::Result: Send, + { + let session = self.require(M::NAME).await?; // Endpoints may request a credential again mid-operation, for example // to refresh a token, so an interactive broker widens this deadline too. let interactive = self.state().credential_broker.interactive(); - let result = super::block_on(session.execute::( + let result = (session.execute::( params, self.request_deadline(interactive, OPERATION_TIMEOUT), - )); + )) + .await; if result.is_err() && session.is_closed() { let stale = { let mut state = self.state(); @@ -1250,7 +1302,7 @@ impl ExternalProvider { } }; if let Some(stale) = stale { - close_live_session(stale); + close_live_session_async(stale).await; } } match result { @@ -1278,53 +1330,40 @@ impl ExternalProvider { } } - fn resolve_remote(&self, address: Address<'_>) -> Result { - let result = self.call::(&AddressParams { - address: to_wire_address(address), - })?; - from_wire_coordinates(result.coordinates) - } - - /// Optional protocol presence check, without exposing a value. - pub fn exists(&self, address: Address<'_>) -> Result { - // Probe the capability set once. Re-acquiring the session per branch - // would let the capability check and the call it guards observe two - // different endpoints if the session were replaced in between. - let session = self.ensure_session()?; - if session.supports(wire::method::EXISTS) { - let result = self.call::(&AddressParams { + async fn resolve_remote(&self, address: Address<'_>) -> Result { + let result = self + .call_async::(&AddressParams { address: to_wire_address(address), - })?; - Ok(result.exists) - } else if session.supports(wire::method::GET) { - Ok(self.get(address)?.is_some()) - } else { - Err(discovery_error( - "external provider cannot perform a presence check", - )) - } + }) + .await?; + from_wire_coordinates(result.coordinates) } - /// Optional bounded protocol cache clear. This is deliberately not - /// emulated through reflection. - pub fn clear(&self, scope: wire::ClearScope) -> Result { - let result = self.call::(&wire::ClearParams { scope })?; + async fn clear_async(&self, scope: wire::ClearScope) -> Result { + let result = self + .call_async::(&wire::ClearParams { scope }) + .await?; Ok(result.cleared) } } -impl Provider for ExternalProvider { - fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { - self.resolve_remote(Address::Convention { - project, - profile, - key, - }) +impl super::ProviderBase for ExternalBackend { + fn set_retry_policy(&mut self, policy: super::RetryPolicy) { + self.retry_policy = Some(policy); + } + + fn retry_ownership(&self) -> super::RetryOwnership { + if self.retry_policy.is_some() { + super::RetryOwnership::Managed + } else { + super::RetryOwnership::Shared + } } fn supports_coord(&self, name: &str) -> bool { - self.ensure_session() - .ok() + self.state() + .session + .as_ref() .and_then(|_| self.metadata.get()) .is_some_and(|metadata| { metadata @@ -1335,33 +1374,18 @@ impl Provider for ExternalProvider { } fn supports_read(&self) -> bool { - // Initialization errors must surface from the attempted operation, not - // be misreported by core workflows as a healthy write-only endpoint. - // Until negotiation succeeds, preserve the trait's readable default. - self.ensure_session().map_or(true, |session| { + self.state().session.as_ref().is_none_or(|session| { session.supports(wire::method::GET) || session.supports(wire::method::GET_MANY) }) } - fn exists(&self, addr: Address<'_>) -> Result { - ExternalProvider::exists(self, addr) - } - fn supports_delete(&self) -> bool { - self.ensure_session() - .is_ok_and(|session| session.supports(wire::method::DELETE)) - } - - fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { - self.resolve_remote(addr).map(std::borrow::Cow::Owned) + self.state() + .session + .as_ref() + .is_some_and(|session| session.supports(wire::method::DELETE)) } - /// Planning compares entries before any cache hit and must not start the - /// endpoint (which may prompt for credentials). A native address is - /// already the endpoint's coordinates. A convention address is compiled - /// only by the endpoint, so without contacting it the address is - /// identified by its logical coordinates under a marker no native `item` - /// can contain. fn configured_entry_coordinates<'a>( &self, addr: Address<'a>, @@ -1379,182 +1403,10 @@ impl Provider for ExternalProvider { }) } - fn entry_coordinates<'a>( - &self, - addr: Address<'a>, - ) -> Result> { - self.resolve_remote(addr).map(std::borrow::Cow::Owned) - } - - fn get(&self, addr: Address<'_>) -> Result> { - self.get_with_metadata(addr) - .map(|value| value.map(|value| value.value)) - } - - fn get_with_metadata(&self, addr: Address<'_>) -> Result> { - let result = self.call::(&AddressParams { - address: to_wire_address(addr), - })?; - Ok(match result { - GetResult::Found { - value, - expires_at_unix_ms, - revision, - } => Some( - ProviderValue::new(SecretBytes::from_utf8(value), expires_at_unix_ms) - .with_revision(revision), - ), - GetResult::Missing => None, - }) - } - - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { - self.get_many_with_metadata(requests).map(|values| { - values - .into_iter() - .map(|(name, value)| (name, value.value)) - .collect() - }) - } - - fn get_many_with_metadata( - &self, - requests: &[(&str, Address<'_>)], - ) -> Result> { - if !self.ensure_session()?.supports(wire::method::GET_MANY) { - return get_each_with(requests, |address| self.get_with_metadata(address)); - } - let params = GetManyParams { - requests: requests - .iter() - .map(|(name, address)| NamedRequest { - name: (*name).to_string(), - address: to_wire_address(*address), - }) - .collect(), - }; - let result = self.call::(¶ms)?; - if result.results.len() != requests.len() - || result - .results - .iter() - .zip(requests) - .any(|(actual, (expected, _))| actual.name != *expected) - { - return Err(discovery_error( - "provider batch response did not preserve request names", - )); - } - Ok(result - .results - .into_iter() - .filter_map(|item| match item.outcome { - GetResult::Found { - value, - expires_at_unix_ms, - revision, - } => Some(( - item.name, - ProviderValue::new(SecretBytes::from_utf8(value), expires_at_unix_ms) - .with_revision(revision), - )), - GetResult::Missing => None, - }) - .collect()) - } - - fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { - if self.ensure_session()?.supports(wire::method::EXISTS) { - return exists_each(self, requests); - } - self.get_many(requests) - .map(|values| values.into_keys().collect()) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; - let result = self.call::(&SetParams { - address: to_wire_address(addr), - value: value - .try_as_utf8_for(match addr { - Address::Convention { key, .. } => key, - Address::Native(native) => &native.item, - })? - .to_owned(), - })?; - if result.stored { - Ok(()) - } else { - Err(discovery_error("provider did not confirm the write")) - } - } - - fn set_expiring( - &self, - addr: Address<'_>, - value: &SecretBytes, - max_age: Duration, - ) -> Result<()> { - if !self.ensure_session()?.supports(wire::method::SET_EXPIRING) { - return self.set(addr, value); - } - self.check_writable(addr)?; - let ttl_ms = wire_ttl_ms(max_age); - if ttl_ms == 0 { - return Err(discovery_error("external provider expiry must be positive")); - } - let result = self.call::(&SetExpiringParams { - address: to_wire_address(addr), - value: value - .try_as_utf8_for(match addr { - Address::Convention { key, .. } => key, - Address::Native(native) => &native.item, - })? - .to_owned(), - ttl_ms, - })?; - if result.stored { - Ok(()) - } else { - Err(discovery_error( - "provider did not confirm the expiring write", - )) - } - } - - fn delete(&self, addr: Address<'_>) -> Result { - self.check_deletable(addr)?; - let result = self.call::(&AddressParams { - address: to_wire_address(addr), - })?; - Ok(result.deleted) - } - - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - let session = self.require(wire::method::SET)?; - if !session.supports(wire::method::CHECK_WRITABLE) { - return Ok(()); - } - self.call::(&AddressParams { - address: to_wire_address(addr), - }) - .map(|_| ()) - } - - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - let session = self.require(wire::method::DELETE)?; - if !session.supports(wire::method::CHECK_DELETABLE) { - return Ok(()); - } - self.call::(&AddressParams { - address: to_wire_address(addr), - }) - .map(|_| ()) - } - fn generated_value_persistence(&self) -> ProducedValuePersistence { - self.ensure_session() - .ok() + self.state() + .session + .as_ref() .and_then(|_| self.metadata.get()) .map_or(ProducedValuePersistence::Persist, |metadata| { map_persistence(metadata.generated_value_persistence) @@ -1562,28 +1414,15 @@ impl Provider for ExternalProvider { } fn prompted_value_persistence(&self) -> ProducedValuePersistence { - self.ensure_session() - .ok() + self.state() + .session + .as_ref() .and_then(|_| self.metadata.get()) .map_or(ProducedValuePersistence::Persist, |metadata| { map_persistence(metadata.prompted_value_persistence) }) } - fn describe_write_target(&self, addr: Address<'_>) -> Result { - if self - .ensure_session()? - .supports(wire::method::DESCRIBE_WRITE_TARGET) - { - let result = self.call::(&AddressParams { - address: to_wire_address(addr), - })?; - Ok(result.description) - } else { - Ok(self.resolve_remote(addr)?.render()) - } - } - fn auth_scope_key(&self) -> Option { Some(format!( "{}:{}:{}", @@ -1597,12 +1436,6 @@ impl Provider for ExternalProvider { &self.scheme } - // The three identity accessors below never start a session, because route - // planning derives canonical URIs and storage identities from a freshly - // built, uncredentialed provider and is documented as touching no store. - // They report the endpoint's own spelling once a session exists for another - // reason, and the configured URI until then. - fn uri(&self) -> String { self.metadata .get() @@ -1624,8 +1457,6 @@ impl Provider for ExternalProvider { .unwrap_or_else(|| self.storage_identity()) } - /// Known only from the endpoint, so planning sees it once a session has - /// reported it and otherwise compares configured identities. fn configured_physical_store_path(&self) -> Option<&Path> { self.metadata .get() @@ -1634,7 +1465,8 @@ impl Provider for ExternalProvider { } fn physical_store_path(&self) -> Option<&Path> { - self.endpoint_metadata() + self.metadata + .get() .and_then(|metadata| metadata.physical_store_path.as_deref()) .map(Path::new) } @@ -1726,11 +1558,248 @@ impl Provider for ExternalProvider { } } - fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { - let result = self.call::(&ReflectParams { - project: context.project.to_string(), - profile: context.profile.to_string(), - })?; + fn requires_initialization(&self) -> bool { + true + } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for ExternalBackend { + async fn exists(&self, addr: Address<'_>) -> Result { + // Probe the capability set once. Re-acquiring the session per branch + // would let the capability check and the call it guards observe two + // different endpoints if the session were replaced in between. + let session = self.ensure_session_async().await?; + if session.supports(wire::method::EXISTS) { + let result = self + .call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await?; + Ok(result.exists) + } else if session.supports(wire::method::GET) { + Ok(super::AsyncProvider::get(self, addr).await?.is_some()) + } else { + Err(discovery_error( + "external provider cannot perform a presence check", + )) + } + } + + async fn get(&self, addr: Address<'_>) -> Result> { + super::AsyncProvider::get_with_metadata(self, addr) + .await + .map(|value| value.map(|value| value.value)) + } + + async fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + let result = self + .call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await?; + Ok(match result { + GetResult::Found { + value, + expires_at_unix_ms, + revision, + } => Some( + ProviderValue::new(SecretBytes::from_utf8(value), expires_at_unix_ms) + .with_revision(revision), + ), + GetResult::Missing => None, + }) + } + + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + super::AsyncProvider::get_many_with_metadata(self, requests) + .await + .map(|values| { + values + .into_iter() + .map(|(name, value)| (name, value.value)) + .collect() + }) + } + + async fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + if !self + .ensure_session_async() + .await? + .supports(wire::method::GET_MANY) + { + return super::async_provider::get_each_with(requests, |address| { + super::AsyncProvider::get_with_metadata(self, address) + }) + .await; + } + let params = GetManyParams { + requests: requests + .iter() + .map(|(name, address)| NamedRequest { + name: (*name).to_string(), + address: to_wire_address(*address), + }) + .collect(), + }; + let result = self.call_async::(¶ms).await?; + if result.results.len() != requests.len() + || result + .results + .iter() + .zip(requests) + .any(|(actual, (expected, _))| actual.name != *expected) + { + return Err(discovery_error( + "provider batch response did not preserve request names", + )); + } + Ok(result + .results + .into_iter() + .filter_map(|item| match item.outcome { + GetResult::Found { + value, + expires_at_unix_ms, + revision, + } => Some(( + item.name, + ProviderValue::new(SecretBytes::from_utf8(value), expires_at_unix_ms) + .with_revision(revision), + )), + GetResult::Missing => None, + }) + .collect()) + } + + async fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + if self + .ensure_session_async() + .await? + .supports(wire::method::EXISTS) + { + return Ok( + super::async_provider::get_each_with(requests, |address| async move { + Ok(super::AsyncProvider::exists(self, address) + .await? + .then_some(())) + }) + .await? + .into_keys() + .collect(), + ); + } + super::AsyncProvider::get_many(self, requests) + .await + .map(|values| values.into_keys().collect()) + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; + let result = self + .call_async::(&SetParams { + address: to_wire_address(addr), + value: value + .try_as_utf8_for(match addr { + Address::Convention { key, .. } => key, + Address::Native(native) => &native.item, + })? + .to_owned(), + }) + .await?; + if result.stored { + Ok(()) + } else { + Err(discovery_error("provider did not confirm the write")) + } + } + + async fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretBytes, + max_age: Duration, + ) -> Result<()> { + if !self + .ensure_session_async() + .await? + .supports(wire::method::SET_EXPIRING) + { + return super::AsyncProvider::set(self, addr, value).await; + } + super::AsyncProvider::check_writable(self, addr).await?; + let ttl_ms = wire_ttl_ms(max_age); + if ttl_ms == 0 { + return Err(discovery_error("external provider expiry must be positive")); + } + let result = self + .call_async::(&SetExpiringParams { + address: to_wire_address(addr), + value: value + .try_as_utf8_for(match addr { + Address::Convention { key, .. } => key, + Address::Native(native) => &native.item, + })? + .to_owned(), + ttl_ms, + }) + .await?; + if result.stored { + Ok(()) + } else { + Err(discovery_error( + "provider did not confirm the expiring write", + )) + } + } + + async fn delete(&self, addr: Address<'_>) -> Result { + super::AsyncProvider::check_deletable(self, addr).await?; + let result = self + .call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await?; + Ok(result.deleted) + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + let session = self.require(wire::method::SET).await?; + if !session.supports(wire::method::CHECK_WRITABLE) { + return Ok(()); + } + self.call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await + .map(|_| ()) + } + + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + let session = self.require(wire::method::DELETE).await?; + if !session.supports(wire::method::CHECK_DELETABLE) { + return Ok(()); + } + self.call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await + .map(|_| ()) + } + + async fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { + let result = self + .call_async::(&ReflectParams { + project: context.project.to_string(), + profile: context.profile.to_string(), + }) + .await?; if result.schema_version != 1 { return Err(discovery_error( "provider reflection schema version is unsupported", @@ -1751,9 +1820,44 @@ impl Provider for ExternalProvider { }) .collect() } + + async fn resolve_address<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.resolve_remote(addr).await.map(std::borrow::Cow::Owned) + } + + async fn entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + self.resolve_remote(addr).await.map(std::borrow::Cow::Owned) + } + + async fn write_target(&self, addr: Address<'_>) -> Result { + if self + .ensure_session_async() + .await? + .supports(wire::method::DESCRIBE_WRITE_TARGET) + { + let result = self + .call_async::(&AddressParams { + address: to_wire_address(addr), + }) + .await?; + Ok(result.description) + } else { + Ok(self.resolve_remote(addr).await?.render()) + } + } + + async fn initialize(&self) -> Result<()> { + self.initialize_async().await + } } -impl Drop for ExternalProvider { +impl Drop for ExternalBackend { fn drop(&mut self) { if let Some(session) = self .state @@ -1767,6 +1871,12 @@ impl Drop for ExternalProvider { } } +async fn close_live_session_async(session: Arc) { + let _ = session + .close(deadline_unix_ms_after(Duration::from_secs(2))) + .await; +} + fn close_live_session(session: Arc) { run_to_completion_or_detach(async move { let _ = session @@ -1931,6 +2041,49 @@ mod tests { assert_eq!(wire_ttl_ms(Duration::MAX), secretspec_ipc::MAX_JSON_INTEGER); } + #[test] + fn stale_metadata_does_not_restore_capabilities_without_a_live_session() { + let provider = ExternalBackend::from_url( + ProviderEndpoint { + scheme: "example".into(), + executable: "must-not-run-provider".into(), + arguments: Vec::new(), + environment: Vec::new(), + }, + &ProviderUrl::new(url::Url::parse("example://store").unwrap()), + ); + // Session invalidation retains identities for planning, but old + // capability and persistence declarations must not authorize operations. + assert!( + provider + .metadata + .set(wire::Metadata { + name: "example".into(), + display_uri: "example://store".into(), + supported_coordinates: vec![wire::CoordinateName::Field], + generated_value_persistence: Persistence::Ephemeral, + prompted_value_persistence: Persistence::Ephemeral, + storage_identity: "example://store".into(), + entry_container_identity: "example://store".into(), + physical_store_path: None, + }) + .is_ok() + ); + use crate::ProviderBase; + assert_eq!(provider.storage_identity(), "example://store"); + assert!(!provider.supports_coord("field")); + assert!(!provider.supports_delete()); + assert!(provider.supports_read()); + assert_eq!( + provider.generated_value_persistence(), + ProducedValuePersistence::Persist + ); + assert_eq!( + provider.prompted_value_persistence(), + ProducedValuePersistence::Persist + ); + } + fn endpoint(directory: &Path, name: &str, argument: &str) -> ProviderEndpoint { let executable = directory.join(name); std::fs::write(&executable, name).unwrap(); @@ -2680,8 +2833,8 @@ mod tests { /// The child really starts with the filtered environment: a variable the /// test process has but the base set excludes never reaches it. #[cfg(unix)] - #[test] - fn a_launched_endpoint_does_not_see_undeclared_parent_variables() { + #[tokio::test(flavor = "current_thread")] + async fn native_async_endpoint_does_not_see_undeclared_parent_variables() { use std::os::unix::fs::PermissionsExt; // Cargo sets both for the test process. One is excluded; the other is // declared by the endpoint and must pass through. @@ -2711,7 +2864,7 @@ mod tests { ); provider.with_credential_broker(Arc::new(NoCredentials)); assert!( - provider.initialize().is_err(), + provider.initialize_async().await.is_err(), "the script is not an endpoint" ); diff --git a/secretspec/src/provider/factory.rs b/secretspec/src/provider/factory.rs index 3c7b067d7..a6946ca92 100644 --- a/secretspec/src/provider/factory.rs +++ b/secretspec/src/provider/factory.rs @@ -55,6 +55,44 @@ impl TryFrom<&str> for Box { } } +/// Constructs an awaitable provider from a URI (0.22+). +/// +/// SDK, HTTP, and external IPC backends use their native async implementation. +/// Other backends use [`super::AsyncProviderAdapter`] and require a Tokio runtime +/// when awaited. Construction is lazy and performs no async I/O. +impl TryFrom<&str> for Box { + type Error = SecretSpecError; + + fn try_from(spec: &str) -> Result { + let url = provider_url_from_spec(spec)?; + reject_uri_credential(&url)?; + if let Some(registration) = super::macros::ASYNC_PROVIDER_REGISTRY + .iter() + .find(|registration| registration.metadata.schemes.contains(&url.scheme())) + { + return (registration.factory)(&url, ProviderCredentials::new()); + } + if registration_for_scheme(url.scheme()).is_some() { + return Ok(Box::new(super::AsyncProviderAdapter::new(Arc::from( + provider_from_url(&url, ProviderCredentials::new())?, + )))); + } + let endpoint = super::external::discover(url.scheme())? + .ok_or_else(|| SecretSpecError::ProviderNotFound(url.scheme().to_owned()))?; + let mut provider = super::external::ExternalProvider::from_url(endpoint, &url); + provider.set_retry_policy(super::RetryPolicy::default()); + Ok(Box::new(provider.into_async())) + } +} + +impl TryFrom for Box { + type Error = SecretSpecError; + + fn try_from(spec: String) -> Result { + Self::try_from(spec.as_str()) + } +} + /// Builds a boxed provider from a spec string (a bare name, `scheme:...` /// shorthand, or full URI), handing it the supplied credentials. The shared /// body of the string `TryFrom` impls: construction funnels here so URL diff --git a/secretspec/src/provider/gcsm.rs b/secretspec/src/provider/gcsm.rs index 7a4a4dd6d..2ebb60f6b 100644 --- a/secretspec/src/provider/gcsm.rs +++ b/secretspec/src/provider/gcsm.rs @@ -38,7 +38,10 @@ //! secretspec check --provider gcsm://my-gcp-project //! ``` -use super::{Address, Provider, ProviderUrl}; +use super::ProviderBase as _; +use super::{Address, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::{Result, SecretSpecError}; use google_cloud_secretmanager_v1::client::SecretManagerService; @@ -266,6 +269,7 @@ crate::register_provider! { struct: GcsmProvider, config: GcsmConfig, metadata: &super::catalog::GCSM, + native_async: true, } impl GcsmProvider { @@ -538,16 +542,15 @@ impl GcsmProvider { } } -impl Provider for GcsmProvider { +impl super::ProviderBase for GcsmProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.retry_policy = policy; } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } - /// Convention names use validated `--` boundaries so distinct accepted - /// project/profile/key triples always produce distinct GCSM secret ids. fn convention_address( &self, project: &str, @@ -568,35 +571,34 @@ impl Provider for GcsmProvider { format!("gcsm://{}", self.config.project_id) } - /// An optional `version` pins the secret version to read. fn supported_coords(&self) -> &'static [&'static str] { &["version"] } +} - fn get(&self, addr: Address<'_>) -> Result> { +#[async_trait::async_trait] +impl super::AsyncProvider for GcsmProvider { + async fn get(&self, addr: Address<'_>) -> Result> { match addr { Address::Convention { project, profile, key, - } => super::block_on(self.get_convention_async(project, profile, key)), + } => self.get_convention_async(project, profile, key).await, Address::Native(_) => { let coords = self.resolve_coords(addr)?; - super::block_on(self.get_coords_async(&coords)) + self.get_coords_async(&coords).await } } } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - super::block_on(self.set_secret_async(&coords.item, value)) + self.set_secret_async(&coords.item, value).await } - /// Native addresses are read-only: they name an existing (often - /// version-pinned) secret, and writing would mean minting a new version of - /// someone else's secret. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { match addr { Address::Convention { .. } => Ok(()), Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed( @@ -630,7 +632,7 @@ mod reference_tests { fn native_address_is_read_only() { let c = GcsmConfig::try_from(&ProviderUrl::new(Url::parse("gcsm://my-project").unwrap())) .unwrap(); - let p = GcsmProvider::new(c); + let p = SyncProviderAdapter::from_async(GcsmProvider::new(c)); let addr = crate::config::NativeAddress { item: "db-url".into(), ..Default::default() @@ -650,7 +652,7 @@ mod reference_tests { fn native_address_rejects_field() { let c = GcsmConfig::try_from(&ProviderUrl::new(Url::parse("gcsm://my-project").unwrap())) .unwrap(); - let p = GcsmProvider::new(c); + let p = SyncProviderAdapter::from_async(GcsmProvider::new(c)); let addr = crate::config::NativeAddress { item: "db-url".into(), field: Some("x".into()), diff --git a/secretspec/src/provider/infisical.rs b/secretspec/src/provider/infisical.rs index 6c313f328..e7a8c60ec 100644 --- a/secretspec/src/provider/infisical.rs +++ b/secretspec/src/provider/infisical.rs @@ -73,9 +73,10 @@ //! and folders. The resulting error names the environment and whether the //! profile or `?env=` selected it. -use super::{ - Address, Provider, ProviderCredentials, ProviderUrl, credential_or_env, join_slash_path, -}; +use super::ProviderBase as _; +use super::{Address, ProviderCredentials, ProviderUrl, credential_or_env, join_slash_path}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -132,7 +133,7 @@ impl InfisicalConfig { /// /// The URI names a host, so the endpoint is a host too: a domain carrying /// a path is refused rather than accepted into a config that - /// [`uri`](Provider::uri) could not render back. + /// [`uri`](super::Provider::uri) could not render back. /// /// A trailing `/api` is the exception, and is dropped rather than refused. /// Infisical's own CLI appends `/api` to the domain unless it is already @@ -296,13 +297,13 @@ pub struct InfisicalProvider { /// One HTTP client for every request, so a run of secrets reuses the /// connection rather than building a pool per call. http: OnceLock, - /// The session's profile, set by [`Provider::set_profile`] before any I/O. + /// The session's profile, set by [`super::Provider::set_profile`] before any I/O. /// /// A convention address names the profile itself, so this only answers for a /// `ref`, whose coordinates name a folder and key but never an environment. /// It is deliberately not part of [`InfisicalConfig`]: the environment a - /// profile implies must not reach [`uri`](Provider::uri) or - /// [`storage_identity`](Provider::storage_identity), or the same store would + /// profile implies must not reach [`uri`](super::Provider::uri) or + /// [`storage_identity`](super::Provider::storage_identity), or the same store would /// take one identity per profile. profile: Mutex>, } @@ -311,6 +312,7 @@ crate::register_provider! { struct: InfisicalProvider, config: InfisicalConfig, metadata: &super::catalog::INFISICAL, + native_async: true, } impl InfisicalProvider { @@ -331,7 +333,7 @@ impl InfisicalProvider { self.http.get_or_init(super::http::default_client) } - /// The profile this session resolves under, if [`Provider::set_profile`] has + /// The profile this session resolves under, if [`super::Provider::set_profile`] has /// run. /// /// A blank profile is treated as absent: an empty environment slug would @@ -1037,7 +1039,7 @@ impl InfisicalProvider { } } -impl Provider for InfisicalProvider { +impl super::ProviderBase for InfisicalProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -1046,21 +1048,12 @@ impl Provider for InfisicalProvider { self.retry_policy = policy; } - /// Keeps the session's profile as the environment a `ref` reads from when - /// the URI names none. Last write wins, matching the other session hooks. fn set_profile(&self, profile: &str) { if let Ok(mut held) = self.profile.lock() { *held = Some(profile.to_string()); } } - /// Names the environment as well as the folder and key. - /// - /// The environment is half the destination and is not in the coordinates, - /// which the default rendering shows; where the URI does not pin it, it is - /// not in [`uri`](Provider::uri) either, so a preview built from those two - /// would leave the profile-derived environment invisible on the one - /// operation that overwrites a value. fn describe_write_target(&self, addr: Address<'_>) -> Result { let loc = self.locate(addr)?; Ok(format!( @@ -1071,10 +1064,6 @@ impl Provider for InfisicalProvider { )) } - /// A profile's secrets share one folder under the configured prefix, each - /// under its own key. `item` carries the folder and the key together; the - /// environment is resolved separately in [`locate`](Self::locate), since - /// SecretSpec's `ref` table has no coordinate for it. fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { for (label, value) in [("project", project), ("profile", profile), ("key", key)] { if value.is_empty() { @@ -1118,9 +1107,6 @@ impl Provider for InfisicalProvider { }) } - /// Resolves every part of the entry identity that Infisical sends to the - /// API. In particular, the environment may come from session profile - /// context rather than the provider URI or native coordinates. fn configured_entry_coordinates<'a>( &self, addr: Address<'a>, @@ -1156,10 +1142,6 @@ impl Provider for InfisicalProvider { Self::PROVIDER_NAME } - /// The inverse of parsing: every knob the config holds is rendered, so the - /// URI naming this store in an audit record or a warning is one that reads - /// back as the same store. `tls` is the one that bites -- a plain-HTTP - /// endpoint rendered without it comes back as HTTPS. fn uri(&self) -> String { self.render_uri( self.config.environment.as_deref(), @@ -1167,30 +1149,24 @@ impl Provider for InfisicalProvider { ) } - /// The environment and path are part of the resolved entry coordinates, - /// not the Infisical instance and project container. Omitting both here - /// lets an unpinned provider under profile `prod` compare equal to - /// `?env=prod`, and lets an absolute ref override different path defaults; - /// [`storage_identity`](Provider::storage_identity) remains the public URI - /// with its configured defaults and never absorbs session profile context. fn entry_container_identity(&self) -> String { self.render_uri(None, None) } - /// Infisical versions its secrets, so a `ref` may pin one. Its values are - /// single strings with no sub-components, so `field`, `section` and - /// `vault` are rejected. fn supported_coords(&self) -> &'static [&'static str] { &["version"] } +} - fn get(&self, addr: Address<'_>) -> Result> { +#[async_trait::async_trait] +impl super::AsyncProvider for InfisicalProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let loc = self.locate(addr)?; let version = match addr { Address::Native(native) => native.version.as_deref(), Address::Convention { .. } => None, }; - super::block_on(async { + (async { let read = self.get_async(&loc, version).await?; if matches!(&read, SecretRead::NotFound) { self.ensure_environment_exists_async(&loc.environment) @@ -1201,12 +1177,13 @@ impl Provider for InfisicalProvider { SecretRead::NotFound => None, }) }) + .await } - /// Secrets sharing a folder and environment are read with one list call - /// each, rather than one round trip per secret: Infisical's cloud rate - /// limits are per-minute, and a fan-out of single reads burns them. - fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + async fn get_many( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { // A pinned version names one historical value, which the folder // listing (always latest) cannot answer. let (versioned, listable): (Vec<_>, Vec<_>) = requests.iter().partition( @@ -1222,27 +1199,31 @@ impl Provider for InfisicalProvider { let mut resolved = if versioned.is_empty() { HashMap::new() } else { - super::get_each_with(&versioned, |addr| { - let loc = self.locate(addr)?; - let version = match addr { - Address::Native(native) => native.version.as_deref(), - Address::Convention { .. } => None, - }; - match super::block_on(self.get_async(&loc, version))? { - SecretRead::Response(value) => Ok(value), - SecretRead::NotFound => { - versioned_misses - .lock() - .map_err(|_| { - SecretSpecError::ProviderOperationFailed( - "Infisical batch diagnostic state was poisoned".to_string(), - ) - })? - .insert(loc.environment); - Ok(None) + super::async_provider::get_each_with(&versioned, |addr| { + let versioned_misses = &versioned_misses; + async move { + let loc = self.locate(addr)?; + let version = match addr { + Address::Native(native) => native.version.as_deref(), + Address::Convention { .. } => None, + }; + match self.get_async(&loc, version).await? { + SecretRead::Response(value) => Ok(value), + SecretRead::NotFound => { + versioned_misses + .lock() + .map_err(|_| { + SecretSpecError::ProviderOperationFailed( + "Infisical batch diagnostic state was poisoned".to_string(), + ) + })? + .insert(loc.environment); + Ok(None) + } } } - })? + }) + .await? }; let versioned_environments = versioned_misses.into_inner().map_err(|_| { SecretSpecError::ProviderOperationFailed( @@ -1250,7 +1231,7 @@ impl Provider for InfisicalProvider { ) })?; - super::block_on(async { + (async { // One list call per distinct folder and environment. let mut folders: HashMap<(String, String), Vec<(&str, String)>> = HashMap::new(); for (name, addr) in &listable { @@ -1301,17 +1282,16 @@ impl Provider for InfisicalProvider { Ok(resolved) }) + .await } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let loc = self.locate(addr)?; - super::block_on(self.set_async(&loc, value)) + self.set_async(&loc, value).await } - /// A pinned version names a value that already exists; writing one would - /// mean rewriting history, which Infisical does not offer. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { match addr { Address::Native(native) if native.version.is_some() => { Err(SecretSpecError::ProviderOperationFailed( @@ -1345,8 +1325,8 @@ mod tests { InfisicalConfig::try_from(&ProviderUrl::new(Url::parse(s).unwrap())).unwrap() } - fn provider(s: &str) -> InfisicalProvider { - InfisicalProvider::new(config(s)) + fn provider(s: &str) -> SyncProviderAdapter { + SyncProviderAdapter::from_async(InfisicalProvider::new(config(s))) } fn read_request(reader: &mut BufReader) -> Option { @@ -1507,7 +1487,10 @@ mod tests { ); } - fn provider_with_token(endpoint: SocketAddr, environment: Option<&str>) -> InfisicalProvider { + fn provider_with_token( + endpoint: SocketAddr, + environment: Option<&str>, + ) -> SyncProviderAdapter { let environment = environment.map_or_else(String::new, |environment| format!("&env={environment}")); let mut provider = provider(&format!( diff --git a/secretspec/src/provider/kubernetes.rs b/secretspec/src/provider/kubernetes.rs index aac6c9780..2d0f7915d 100644 --- a/secretspec/src/provider/kubernetes.rs +++ b/secretspec/src/provider/kubernetes.rs @@ -1,7 +1,12 @@ //! Kubernetes provider +use super::ProviderBase as _; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::{Result, SecretSpecError}; -use super::{Address, Provider, ProviderUrl, block_on}; +#[cfg(test)] +use super::block_on; +use super::{Address, ProviderUrl}; use crate::SecretBytes; use base64::{Engine, engine::general_purpose::STANDARD}; use json_patch::jsonptr::Token; @@ -106,6 +111,7 @@ crate::register_provider! { struct: KubernetesProvider, config: KubernetesConfig, metadata: &super::catalog::KUBERNETES, + native_async: true, } impl KubernetesProvider { @@ -356,7 +362,7 @@ impl KubernetesProvider { } } -impl Provider for KubernetesProvider { +impl super::ProviderBase for KubernetesProvider { fn name(&self) -> &'static str { Self::PROVIDER_NAME } @@ -377,26 +383,33 @@ impl Provider for KubernetesProvider { }) } - fn get(&self, addr: Address<'_>) -> Result> { + fn supports_delete(&self) -> bool { + true + } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for KubernetesProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let coords = self.resolve_coords(addr)?; - block_on(self.get_coords_async(&coords.item)) + self.get_coords_async(&coords.item).await } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - block_on(self.set_secret_async(&coords.item, value)) + self.set_secret_async(&coords.item, value).await } - fn delete(&self, addr: Address<'_>) -> Result { - self.check_deletable(addr)?; + async fn delete(&self, addr: Address<'_>) -> Result { + super::AsyncProvider::check_deletable(self, addr).await?; let coords = self.resolve_coords(addr)?; - block_on(self.delete_secret_async(&coords.item)) + self.delete_secret_async(&coords.item).await } - fn check_writable(&self, addr: Address<'_>) -> Result<()> { + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { self.resolve_coords(addr)?; - let can_i_patch = block_on(self.can_i_patch())?; + let can_i_patch = self.can_i_patch().await?; if !can_i_patch { let err_msg = if let Some(namespace) = &self.config.namespace { format!( @@ -411,12 +424,8 @@ impl Provider for KubernetesProvider { Ok(()) } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - self.check_writable(addr) - } - - fn supports_delete(&self) -> bool { - true + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await } } @@ -480,7 +489,10 @@ mod tests { fn provider_with_access_reviews( allowed: bool, expected_requests: usize, - ) -> (KubernetesProvider, JoinHandle>) { + ) -> ( + SyncProviderAdapter, + JoinHandle>, + ) { provider_with_responses(vec![ serde_json::json!({ "apiVersion": "authorization.k8s.io/v1", @@ -493,7 +505,10 @@ mod tests { fn provider_with_responses( responses: Vec, - ) -> (KubernetesProvider, JoinHandle>) { + ) -> ( + SyncProviderAdapter, + JoinHandle>, + ) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); @@ -535,11 +550,11 @@ mod tests { let config = kube::Config::new(format!("http://{address}").parse().unwrap()); let client = block_on(async { Client::try_from(config).unwrap() }); - let provider = KubernetesProvider::new(KubernetesConfig { + let provider = SyncProviderAdapter::from_async(KubernetesProvider::new(KubernetesConfig { kind: KubernetesKind::Secret, name: "app-secrets".to_string(), namespace: Some("app".to_string()), - }); + })); assert!(provider.client.set(client).is_ok()); (provider, server) @@ -574,7 +589,9 @@ mod tests { #[test] fn configmap_rejects_binary_before_connecting() { - let provider = KubernetesProvider::new(config("k8s+configmap://app@default")); + let provider = SyncProviderAdapter::from_async(KubernetesProvider::new(config( + "k8s+configmap://app@default", + ))); let error = block_on( provider.set_secret_async("BINARY", &SecretBytes::from_slice(b"do-not-leak\xff")), ) diff --git a/secretspec/src/provider/macros.rs b/secretspec/src/provider/macros.rs index 38529843b..0642df9fd 100644 --- a/secretspec/src/provider/macros.rs +++ b/secretspec/src/provider/macros.rs @@ -32,6 +32,16 @@ pub struct ProviderRegistration { pub factory: fn(&ProviderUrl, ProviderCredentials) -> Result, } +/// Native async factories are registered alongside their synchronous factory. +#[doc(hidden)] +pub struct AsyncProviderRegistration { + pub metadata: &'static ProviderMetadata, + pub factory: fn(&ProviderUrl, ProviderCredentials) -> Result>, +} + +#[linkme::distributed_slice] +pub static ASYNC_PROVIDER_REGISTRY: [AsyncProviderRegistration]; + /// Folds an optional macro flag into a plain `bool`: `register_provider!` passes /// an empty slice when the field is omitted and a one-element slice otherwise. #[doc(hidden)] @@ -162,6 +172,48 @@ macro_rules! register_disabled_provider { #[doc(hidden)] #[macro_export] macro_rules! register_provider { + // Each native backend implements AsyncProvider and ProviderBase once. + ( + struct: $struct_name:ident, + config: $config_type:ty, + metadata: $metadata:expr, + native_async: true $(,)? + ) => { + impl $struct_name { + const PROVIDER_NAME: &'static str = $metadata.info.name; + } + const _: () = { + #[linkme::distributed_slice($crate::provider::PROVIDER_REGISTRY)] + static SYNC_REGISTRATION: $crate::provider::ProviderRegistration = + $crate::provider::ProviderRegistration { + metadata: $metadata, + factory: |url, credentials| { + let config = <$config_type>::try_from(url)?; + let mut provider = $struct_name::new(config); + $crate::provider::ProviderBase::with_credentials(&mut provider, credentials); + Ok($crate::provider::ProviderWithPreflight { + provider: Box::new($crate::provider::SyncProviderAdapter::from_async(provider)), + preflight: None, + }) + }, + }; + #[linkme::distributed_slice($crate::provider::macros::ASYNC_PROVIDER_REGISTRY)] + static ASYNC_REGISTRATION: $crate::provider::macros::AsyncProviderRegistration = + $crate::provider::macros::AsyncProviderRegistration { + metadata: $metadata, + factory: |url, credentials| { + let config = <$config_type>::try_from(url)?; + let mut provider = $struct_name::new(config); + $crate::provider::ProviderBase::with_credentials(&mut provider, credentials); + $crate::provider::ProviderBase::set_retry_policy( + &mut provider, $crate::provider::RetryPolicy::default(), + ); + Ok(Box::new(provider)) + }, + }; + }; + }; + // Shared catalog metadata, without preflight. ( struct: $struct_name:ident, diff --git a/secretspec/src/provider/mod.rs b/secretspec/src/provider/mod.rs index b287a01ec..d8a915bf3 100644 --- a/secretspec/src/provider/mod.rs +++ b/secretspec/src/provider/mod.rs @@ -84,8 +84,11 @@ //! ``` mod address; +mod async_provider; +mod base; mod catalog; mod credentials; +mod sync_provider; // Every item inside is cfg-gated out in an all-features build. #[allow(unused_imports, unused_macros)] mod disabled; @@ -115,12 +118,16 @@ mod url; // Public provider API. pub use address::Address; +pub use async_provider::{AsyncProvider, AsyncProviderAdapter}; +pub use base::ProviderBase; +pub use credentials::ProviderCredentials; pub use macros::{ PROVIDER_REGISTRY, ProviderMetadata, ProviderRegistration, declared_flag, declared_read_capability, }; pub use registry::ProviderInfo; pub use registry::providers; +pub use sync_provider::SyncProviderAdapter; #[cfg(test)] pub(crate) use traits::get_each; pub use traits::{DiscoveryContext, ProducedValuePersistence, Provider, ProviderValue}; @@ -152,9 +159,7 @@ pub(crate) use address::{OwnedAddress, flat_item}; feature = "vault" ))] pub(crate) use credentials::preferred_env; -pub(crate) use credentials::{ - ProviderCredentials, credential_env_value, credential_or_env, credential_or_envs, -}; +pub(crate) use credentials::{credential_env_value, credential_or_env, credential_or_envs}; pub(crate) use factory::{ external_provider_from_spec, provider_from_spec, provider_url_from_spec, reject_uri_credential, }; @@ -172,7 +177,7 @@ pub(crate) use registry::{ pub(crate) use runtime::block_on; #[cfg(test)] pub(crate) use traits::GET_EACH_CONCURRENCY_ENV; -pub(crate) use traits::exists_each; +#[cfg(test)] pub(crate) use traits::get_each_with; pub(crate) use traits::{ get_each_concurrency, map_concurrently, same_configured_entries, same_storage_container, diff --git a/secretspec/src/provider/openbao.rs b/secretspec/src/provider/openbao.rs index b2aafa462..ada5c8c0d 100644 --- a/secretspec/src/provider/openbao.rs +++ b/secretspec/src/provider/openbao.rs @@ -74,8 +74,11 @@ //! secretspec check --provider openbao://team-a@bao.example.com:8200/secret //! ``` +use super::ProviderBase as _; use super::vault_common::{KvConfig, KvProvider, Product}; -use super::{Address, Provider, ProviderCredentials, ProviderUrl}; +use super::{Address, ProviderCredentials, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -108,6 +111,7 @@ crate::register_provider! { struct: OpenBaoProvider, config: OpenBaoConfig, metadata: &super::catalog::OPENBAO, + native_async: true, } impl OpenBaoProvider { @@ -119,15 +123,15 @@ impl OpenBaoProvider { } } -impl Provider for OpenBaoProvider { +impl super::ProviderBase for OpenBaoProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.core.set_retry_policy(policy); } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } - /// Convention secrets use one KV path per secret and the `value` map field. fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { self.core.convention_address(project, profile, key) } @@ -152,63 +156,55 @@ impl Provider for OpenBaoProvider { self.core.supported_coords() } - /// A native reference must identify the field inside the KV entry's map. - fn get(&self, addr: Address<'_>) -> Result> { + fn supports_delete(&self) -> bool { + true + } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for OpenBaoProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let coords = self.resolve_coords(addr)?; - self.core.get(&coords) + self.core.get(&coords).await } - /// Reuses one operation-scoped login across the batch while retaining the - /// default address deduplication and concurrency cap. - fn get_many( + async fn get_many( &self, requests: &[(&str, Address<'_>)], ) -> Result> { - self.core.get_many(requests) + self.core.get_many(requests).await } - /// Only convention addresses are writable; see [`Self::check_writable`]. - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - self.core.set(&coords, value) + self.core.set(&coords, value).await } - /// KV v2 expires the written version through the path's - /// `delete_version_after` metadata, so a cached copy of another store's - /// secret disappears on its own. - fn set_expiring( + async fn set_expiring( &self, addr: Address<'_>, value: &SecretBytes, max_age: std::time::Duration, ) -> Result<()> { - self.check_writable(addr)?; + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - self.core.set_expiring(&coords, value, max_age) + self.core.set_expiring(&coords, value, max_age).await } - /// Deletes the whole KV path, so it is confined to entries SecretSpec owns; - /// see [`Self::check_writable`] for the same reasoning about `ref`s. - fn delete(&self, addr: Address<'_>) -> Result { + async fn delete(&self, addr: Address<'_>) -> Result { self.core.check_deletable(addr)?; let coords = self.resolve_coords(addr)?; - self.core.delete(&coords) + self.core.delete(&coords).await } - fn supports_delete(&self) -> bool { - true + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.core.check_writable(addr) } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { self.core.check_deletable(addr) } - - /// Refuses native writes because replacing a KV entry to change one field - /// would silently discard every sibling field. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - self.core.check_writable(addr) - } } #[cfg(test)] @@ -222,7 +218,9 @@ mod tests { #[test] fn has_an_independent_identity_and_uri() { - let provider = OpenBaoProvider::new(config("openbao://bao.example.com:8200/team")); + let provider = SyncProviderAdapter::from_async(OpenBaoProvider::new(config( + "openbao://bao.example.com:8200/team", + ))); assert_eq!(provider.name(), "openbao"); assert_eq!(provider.uri(), "openbao://bao.example.com:8200/team"); } @@ -238,7 +236,9 @@ mod tests { #[test] fn convention_address_is_the_writable_value_field() { - let provider = OpenBaoProvider::new(config("openbao://bao.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(OpenBaoProvider::new(config( + "openbao://bao.example.com:8200/secret", + ))); let address = provider .resolve_coords(Address::convention("app", "prod", "DATABASE_URL")) .unwrap(); @@ -248,7 +248,9 @@ mod tests { #[test] fn native_address_requires_a_field() { - let provider = OpenBaoProvider::new(config("openbao://bao.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(OpenBaoProvider::new(config( + "openbao://bao.example.com:8200/secret", + ))); let address = NativeAddress { item: "myapp/config".into(), ..Default::default() @@ -260,7 +262,9 @@ mod tests { #[test] fn native_address_is_read_only_with_an_openbao_error() { - let provider = OpenBaoProvider::new(config("openbao://bao.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(OpenBaoProvider::new(config( + "openbao://bao.example.com:8200/secret", + ))); let address = NativeAddress { item: "myapp/config".into(), field: Some("db_password".into()), diff --git a/secretspec/src/provider/retry.rs b/secretspec/src/provider/retry.rs index 324b55986..35a900bd6 100644 --- a/secretspec/src/provider/retry.rs +++ b/secretspec/src/provider/retry.rs @@ -69,14 +69,6 @@ impl RetryPolicy { .min(Duration::from_secs(10)), ) } - #[cfg(any( - feature = "aac", - feature = "cloudflare", - feature = "doppler", - feature = "infisical", - feature = "scaleway", - feature = "setec" - ))] pub(crate) async fn run_async(self, provider: &str, mut call: F) -> Result where F: FnMut() -> Fut, diff --git a/secretspec/src/provider/scaleway.rs b/secretspec/src/provider/scaleway.rs index b5e4fcbf6..97623ce4f 100644 --- a/secretspec/src/provider/scaleway.rs +++ b/secretspec/src/provider/scaleway.rs @@ -37,10 +37,12 @@ //! secretspec check --provider scaleway://fr-par?project_id=… //! ``` +use super::ProviderBase as _; use super::{ - Address, Provider, ProviderCredentials, ProviderUrl, credential_or_envs, join_slash_path, - preferred_env, + Address, ProviderCredentials, ProviderUrl, credential_or_envs, join_slash_path, preferred_env, }; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -138,6 +140,7 @@ crate::register_provider! { struct: ScalewayProvider, config: ScalewayConfig, metadata: &super::catalog::SCALEWAY, + native_async: true, } /// One secret in a `ListSecrets` response (only the fields we consume). @@ -509,7 +512,7 @@ fn is_duplicate_name(body: &str) -> bool { }) } -impl Provider for ScalewayProvider { +impl super::ProviderBase for ScalewayProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -518,7 +521,6 @@ impl Provider for ScalewayProvider { self.retry_policy = policy; } - /// Convention secrets live at `[{base}/]secretspec/{project}/{profile}/{key}`. fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { Ok(NativeAddress { item: Self::format_item(&self.config.path, project, profile, key)?, @@ -555,31 +557,30 @@ impl Provider for ScalewayProvider { } } - /// `field` extracts a key from a JSON (`key_value`) secret; `version` pins a - /// revision (default `latest_enabled`). fn supported_coords(&self) -> &'static [&'static str] { &["field", "version"] } +} - fn get(&self, addr: Address<'_>) -> Result> { +#[async_trait::async_trait] +impl super::AsyncProvider for ScalewayProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let coords = self.resolve_coords(addr)?; - super::block_on(self.get_async( + self.get_async( &coords.item, coords.field.as_deref(), coords.version.as_deref(), - )) + ) + .await } - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - super::block_on(self.set_async(&coords.item, value)) + self.set_async(&coords.item, value).await } - /// Native references name secrets managed outside SecretSpec and are - /// read-only: a `field` write would append a version that discards the - /// other JSON keys, and the revision is server-assigned. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { match addr { Address::Convention { .. } => Ok(()), Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed( @@ -685,7 +686,9 @@ mod tests { #[test] fn convention_address_uses_the_folder_hierarchy() { - let p = ScalewayProvider::new(config("scaleway://fr-par?path=/myteam")); + let p = SyncProviderAdapter::from_async(ScalewayProvider::new(config( + "scaleway://fr-par?path=/myteam", + ))); let addr = p.convention_address("app", "default", "A").unwrap(); assert_eq!(addr.item, "/myteam/secretspec/app/default/A"); assert_eq!(addr.field, None); @@ -694,16 +697,17 @@ mod tests { #[test] fn uri_round_trips() { - let p = ScalewayProvider::new(config( + let p = SyncProviderAdapter::from_async(ScalewayProvider::new(config( "scaleway://nl-ams?project_id=11111111-2222-3333-4444-555555555555&path=/myteam", - )); + ))); assert_eq!( p.uri(), "scaleway://nl-ams?project_id=11111111-2222-3333-4444-555555555555&path=/myteam" ); // Bare region, default path: no query string. assert_eq!( - ScalewayProvider::new(config("scaleway://fr-par")).uri(), + SyncProviderAdapter::from_async(ScalewayProvider::new(config("scaleway://fr-par"))) + .uri(), "scaleway://fr-par" ); } @@ -712,7 +716,9 @@ mod tests { fn uri_omits_no_secret_material() { // The secret key comes from a credential/env, never the URI, so uri() // cannot leak it regardless of config. - let p = ScalewayProvider::new(config("scaleway://fr-par?project_id=abc")); + let p = SyncProviderAdapter::from_async(ScalewayProvider::new(config( + "scaleway://fr-par?project_id=abc", + ))); assert!(!p.uri().contains("SCW_SECRET_KEY")); } @@ -766,7 +772,9 @@ mod tests { #[test] fn native_reference_is_read_only() { - let p = ScalewayProvider::new(config("scaleway://fr-par?project_id=abc")); + let p = SyncProviderAdapter::from_async(ScalewayProvider::new(config( + "scaleway://fr-par?project_id=abc", + ))); let addr = NativeAddress { item: "/prod/db-url".into(), ..Default::default() @@ -807,7 +815,9 @@ mod tests { #[test] fn native_reference_rejects_unsupported_coordinate() { - let p = ScalewayProvider::new(config("scaleway://fr-par?project_id=abc")); + let p = SyncProviderAdapter::from_async(ScalewayProvider::new(config( + "scaleway://fr-par?project_id=abc", + ))); let addr = NativeAddress { item: "/prod/db-url".into(), section: Some("x".into()), diff --git a/secretspec/src/provider/setec.rs b/secretspec/src/provider/setec.rs index 950676b6d..80d7d5aa8 100644 --- a/secretspec/src/provider/setec.rs +++ b/secretspec/src/provider/setec.rs @@ -11,7 +11,10 @@ //! HTTPS is used by default. `tls=false` is intended for local development. //! Convention secrets use `[prefix/]secretspec/{project}/{profile}/{key}`. -use super::{Address, DiscoveryContext, Provider, ProviderUrl}; +use super::ProviderBase as _; +use super::{Address, DiscoveryContext, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::config::NativeAddress; use crate::{Result, Secret, SecretBytes, SecretSpecError}; use data_encoding::BASE64; @@ -179,9 +182,21 @@ crate::register_provider! { struct: SetecProvider, config: SetecConfig, metadata: &super::catalog::SETEC, + native_async: true, } impl SetecProvider { + fn validate_write(&self, addr: Address<'_>) -> Result<()> { + let coordinates = self.resolve_coords(addr)?; + validate_name(&coordinates.item)?; + if coordinates.version.is_some() { + return Err(operation_error( + "setec refs pinning a `version` are read-only; drop `version` to create and activate a new version", + )); + } + Ok(()) + } + pub fn new(config: SetecConfig) -> Self { Self { config, @@ -414,7 +429,7 @@ impl SetecProvider { } } -impl Provider for SetecProvider { +impl super::ProviderBase for SetecProvider { fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } @@ -435,57 +450,12 @@ impl Provider for SetecProvider { &["version"] } - fn get(&self, addr: Address<'_>) -> Result> { - let coordinates = self.resolved(addr)?; - let version = coordinates - .version - .as_deref() - .map(parse_version) - .transpose()? - .unwrap_or(0); - super::block_on(self.get_async(&coordinates.item, version)) - } - - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - let coordinates = self.resolve_coords(addr)?; - validate_name(&coordinates.item)?; - if coordinates.version.is_some() { - return Err(operation_error( - "setec refs pinning a `version` are read-only; drop `version` to create and activate a new version", - )); - } - Ok(()) - } - - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; - let coordinates = self.resolve_coords(addr)?; - super::block_on(self.set_async(&coordinates.item, value)) - } - fn supports_delete(&self) -> bool { true } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { - let coordinates = self.resolve_coords(addr)?; - validate_name(&coordinates.item)?; - if coordinates.version.is_some() { - return Err(operation_error( - "setec refs pinning a `version` cannot be deleted through SecretSpec; drop `version` to delete the whole secret", - )); - } - Ok(()) - } - - fn delete(&self, addr: Address<'_>) -> Result { - self.check_deletable(addr)?; - let coordinates = self.resolve_coords(addr)?; - super::block_on(self.delete_async(&coordinates.item)) - } - fn describe_write_target(&self, addr: Address<'_>) -> Result { - self.check_writable(addr)?; + self.validate_write(addr)?; let coordinates = self.resolve_coords(addr)?; Ok(format!( "Setec server '{}' secret '{}' (new active version)", @@ -513,9 +483,50 @@ impl Provider for SetecProvider { format!("{base}?{}", parameters.join("&")) } } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for SetecProvider { + async fn get(&self, addr: Address<'_>) -> Result> { + let coordinates = self.resolved(addr)?; + let version = coordinates + .version + .as_deref() + .map(parse_version) + .transpose()? + .unwrap_or(0); + self.get_async(&coordinates.item, version).await + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; + let coordinates = self.resolve_coords(addr)?; + self.set_async(&coordinates.item, value).await + } - fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { - super::block_on(self.reflect_async(context)) + async fn delete(&self, addr: Address<'_>) -> Result { + super::AsyncProvider::check_deletable(self, addr).await?; + let coordinates = self.resolve_coords(addr)?; + self.delete_async(&coordinates.item).await + } + + async fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { + self.reflect_async(context).await + } + + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.validate_write(addr) + } + + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + let coordinates = self.resolve_coords(addr)?; + validate_name(&coordinates.item)?; + if coordinates.version.is_some() { + return Err(operation_error( + "setec refs pinning a `version` cannot be deleted through SecretSpec; drop `version` to delete the whole secret", + )); + } + Ok(()) } } @@ -575,8 +586,65 @@ mod tests { SetecConfig::try_from(&ProviderUrl::new(url::Url::parse(spec).unwrap())).unwrap() } - fn provider(endpoint: SocketAddr) -> SetecProvider { - SetecProvider::new(config(&format!("setec://{endpoint}?tls=false"))) + fn provider(endpoint: SocketAddr) -> SyncProviderAdapter { + SyncProviderAdapter::from_async(SetecProvider::new(config(&format!( + "setec://{endpoint}?tls=false" + )))) + } + + #[tokio::test(flavor = "current_thread")] + async fn native_async_read_keeps_the_callers_runtime_responsive() { + use std::time::Duration; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = listener.local_addr().unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (progress_tx, progress_rx) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut reader = BufReader::new(&mut stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert_eq!(line.trim_end(), "POST /api/get HTTP/1.1"); + let mut length = 0; + loop { + line.clear(); + reader.read_line(&mut line).unwrap(); + if line == "\r\n" || line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + length = value.trim().parse::().unwrap(); + } + } + let mut request = vec![0; length]; + reader.read_exact(&mut request).unwrap(); + started_tx.send(()).unwrap(); + // The response depends on another task on the caller's runtime. + // A blocking bridge makes this bounded wait fail instead of hanging. + progress_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let body = r#"{"Value":"AP8=","Version":1}"#; + write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let provider = provider(endpoint); + let read = tokio::spawn(async move { + super::super::AsyncProvider::get( + provider.as_async(), + Address::convention("app", "prod", "KEY"), + ) + .await + }); + started_rx.await.unwrap(); + progress_tx.send(()).unwrap(); + assert_eq!( + read.await.unwrap().unwrap().unwrap().expose_secret(), + &[0, 255] + ); + server.join().unwrap(); } fn response_server( @@ -627,9 +695,9 @@ mod tests { #[test] fn parses_and_canonicalizes_uri() { - let provider = SetecProvider::new(config( + let provider = SyncProviderAdapter::from_async(SetecProvider::new(config( "setec://secrets.example.ts.net:8443?prefix=%2Fteam%2Fplatform%2F&tls=false", - )); + ))); assert_eq!(provider.config.prefix.as_deref(), Some("team/platform")); assert_eq!( provider.uri(), @@ -661,7 +729,8 @@ mod tests { #[test] fn convention_and_native_coordinates_are_distinct() { - let provider = SetecProvider::new(config("setec://host?prefix=team")); + let provider = + SyncProviderAdapter::from_async(SetecProvider::new(config("setec://host?prefix=team"))); assert_eq!( provider .convention_address("app", "production", "DATABASE_URL") @@ -797,7 +866,7 @@ mod tests { version: Some("4".into()), ..Default::default() }; - let provider = SetecProvider::new(config("setec://host")); + let provider = SyncProviderAdapter::from_async(SetecProvider::new(config("setec://host"))); assert!(provider.check_writable(Address::Native(&native)).is_err()); assert!(provider.check_deletable(Address::Native(&native)).is_err()); } @@ -861,7 +930,8 @@ mod tests { #[test] fn ipv6_hosts_are_bracketed_once() { - let provider = SetecProvider::new(config("setec://[::1]:8080")); + let provider = + SyncProviderAdapter::from_async(SetecProvider::new(config("setec://[::1]:8080"))); assert_eq!(provider.config.host, "::1"); assert_eq!(provider.server_url(), "https://[::1]:8080"); assert_eq!(provider.uri(), "setec://[::1]:8080"); @@ -870,7 +940,7 @@ mod tests { #[test] fn http_client_bounds_request_time() { - let provider = SetecProvider::new(config("setec://host")); + let provider = SyncProviderAdapter::from_async(SetecProvider::new(config("setec://host"))); crate::provider::http::assert_bounded(provider.client().unwrap()); } diff --git a/secretspec/src/provider/sync_provider.rs b/secretspec/src/provider/sync_provider.rs new file mode 100644 index 000000000..0c0fe1662 --- /dev/null +++ b/secretspec/src/provider/sync_provider.rs @@ -0,0 +1,352 @@ +//! The shared synchronous compatibility boundary for native async backends. +use super::{ + Address, AsyncProvider, DiscoveryContext, ProducedValuePersistence, Provider, + ProviderCredentials, ProviderValue, +}; +use crate::{NativeAddress, Result, SecretBytes, SecretSpecError}; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +/// Exposes an async backend through the existing synchronous provider API (0.22+). +/// +/// All storage operations run through the shared provider runtime. Configuration, +/// planning, and identity hooks are forwarded without starting backend I/O. +/// Async callers can borrow `as_async()` to bypass the blocking boundary. +pub struct SyncProviderAdapter

{ + inner: P, +} + +impl

SyncProviderAdapter

{ + pub fn from_async(provider: P) -> Self { + Self { inner: provider } + } + pub fn as_async(&self) -> &P { + &self.inner + } + pub fn into_async(self) -> P { + self.inner + } +} + +impl

std::ops::Deref for SyncProviderAdapter

{ + type Target = P; + fn deref(&self) -> &P { + &self.inner + } +} +impl

std::ops::DerefMut for SyncProviderAdapter

{ + fn deref_mut(&mut self) -> &mut P { + &mut self.inner + } +} + +impl SyncProviderAdapter

{ + fn prepare_capabilities(&self) { + if self.inner.requires_initialization() { + let _ = super::block_on(self.inner.initialize()); + } + } +} + +impl Provider for SyncProviderAdapter

{ + fn set_retry_policy(&mut self, _policy: super::RetryPolicy) { + super::ProviderBase::set_retry_policy(&mut self.inner, _policy) + } + + fn retry_ownership(&self) -> super::RetryOwnership { + super::ProviderBase::retry_ownership(&self.inner) + } + + fn retry_safe(&self, _operation: super::RetryOperation, _addr: Address<'_>) -> bool { + super::ProviderBase::retry_safe(&self.inner, _operation, _addr) + } + + fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { + super::block_on(AsyncProvider::resolve_address( + &self.inner, + Address::convention(project, profile, key), + )) + .map(Cow::into_owned) + } + + fn supported_coords(&self) -> &'static [&'static str] { + super::ProviderBase::supported_coords(&self.inner) + } + + fn supports_coord(&self, name: &str) -> bool { + self.prepare_capabilities(); + super::ProviderBase::supports_coord(&self.inner, name) + } + + fn resolve_coords<'a>(&self, addr: Address<'a>) -> Result> { + super::block_on(AsyncProvider::resolve_address(&self.inner, addr)) + } + + fn configured_entry_coordinates<'a>( + &self, + addr: Address<'a>, + ) -> Result> { + super::ProviderBase::configured_entry_coordinates(&self.inner, addr) + } + + fn entry_coordinates<'a>(&self, addr: Address<'a>) -> Result> { + super::block_on(AsyncProvider::entry_coordinates(&self.inner, addr)) + } + + fn entry_coordinates_many(&self, addrs: &[Address<'_>]) -> Result> { + super::block_on(AsyncProvider::entry_coordinates_many(&self.inner, addrs)) + } + + fn get(&self, addr: Address<'_>) -> Result> { + super::block_on(AsyncProvider::get(&self.inner, addr)) + } + + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + super::block_on(AsyncProvider::get_with_metadata(&self.inner, addr)) + } + + fn supports_read(&self) -> bool { + self.prepare_capabilities(); + super::ProviderBase::supports_read(&self.inner) + } + + fn exists(&self, addr: Address<'_>) -> Result { + super::block_on(AsyncProvider::exists(&self.inner, addr)) + } + + fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::block_on(AsyncProvider::set(&self.inner, addr, value)) + } + + fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretBytes, + max_age: std::time::Duration, + ) -> Result<()> { + super::block_on(AsyncProvider::set_expiring( + &self.inner, + addr, + value, + max_age, + )) + } + + fn delete(&self, addr: Address<'_>) -> Result { + super::block_on(AsyncProvider::delete(&self.inner, addr)) + } + + fn supports_delete(&self) -> bool { + self.prepare_capabilities(); + super::ProviderBase::supports_delete(&self.inner) + } + + fn deletion_unsupported(&self) -> SecretSpecError { + super::ProviderBase::deletion_unsupported(&self.inner) + } + + fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + super::block_on(AsyncProvider::check_deletable(&self.inner, addr)) + } + + fn check_writable(&self, addr: Address<'_>) -> Result<()> { + super::block_on(AsyncProvider::check_writable(&self.inner, addr)) + } + + fn generated_value_persistence(&self) -> ProducedValuePersistence { + self.prepare_capabilities(); + super::ProviderBase::generated_value_persistence(&self.inner) + } + + fn prompted_value_persistence(&self) -> ProducedValuePersistence { + self.prepare_capabilities(); + super::ProviderBase::prompted_value_persistence(&self.inner) + } + + fn describe_write_target(&self, addr: Address<'_>) -> Result { + super::block_on(AsyncProvider::write_target(&self.inner, addr)) + } + + fn auth_scope_key(&self) -> Option { + super::ProviderBase::auth_scope_key(&self.inner) + } + + fn name(&self) -> &str { + super::ProviderBase::name(&self.inner) + } + + fn uri(&self) -> String { + super::ProviderBase::uri(&self.inner) + } + + fn storage_identity(&self) -> String { + super::ProviderBase::storage_identity(&self.inner) + } + + fn entry_container_identity(&self) -> String { + super::ProviderBase::entry_container_identity(&self.inner) + } + + fn physical_store_path(&self) -> Option<&std::path::Path> { + self.prepare_capabilities(); + super::ProviderBase::physical_store_path(&self.inner) + } + + fn configured_physical_store_path(&self) -> Option<&std::path::Path> { + super::ProviderBase::configured_physical_store_path(&self.inner) + } + + fn set_reason(&self, _reason: Option) { + super::ProviderBase::set_reason(&self.inner, _reason) + } + + fn set_requested_authorization_duration(&self, _duration: Option) { + super::ProviderBase::set_requested_authorization_duration(&self.inner, _duration) + } + + fn set_caller(&self, _caller: Option) { + super::ProviderBase::set_caller(&self.inner, _caller) + } + + fn set_project(&self, _project: &str) { + super::ProviderBase::set_project(&self.inner, _project) + } + + fn set_profile(&self, _profile: &str) { + super::ProviderBase::set_profile(&self.inner, _profile) + } + + fn with_base_dir(&mut self, _base_dir: &std::path::Path) { + super::ProviderBase::with_base_dir(&mut self.inner, _base_dir) + } + + fn with_credentials(&mut self, _credentials: ProviderCredentials) { + super::ProviderBase::with_credentials(&mut self.inner, _credentials) + } + + fn reflect(&self, _context: DiscoveryContext<'_>) -> Result> { + super::block_on(AsyncProvider::reflect(&self.inner, _context)) + } + + fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + super::block_on(AsyncProvider::get_many(&self.inner, requests)) + } + + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + super::block_on(AsyncProvider::get_many_with_metadata(&self.inner, requests)) + } + + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + super::block_on(AsyncProvider::exists_many(&self.inner, requests)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::{ProviderBase, RetryPolicy}; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + + #[derive(Default)] + struct ConfiguredBackend { + credentials: ProviderCredentials, + base: PathBuf, + policy: RetryPolicy, + context: Mutex<(Option, String, String)>, + } + + impl ProviderBase for ConfiguredBackend { + fn name(&self) -> &str { + "configured" + } + fn uri(&self) -> String { + "configured://account".into() + } + fn storage_identity(&self) -> String { + "shared-store".into() + } + fn entry_container_identity(&self) -> String { + "shared-container".into() + } + fn with_credentials(&mut self, credentials: ProviderCredentials) { + self.credentials = credentials; + } + fn with_base_dir(&mut self, base: &Path) { + self.base = base.into(); + } + fn set_retry_policy(&mut self, policy: RetryPolicy) { + self.policy = policy; + } + fn set_reason(&self, reason: Option) { + self.context.lock().unwrap().0 = reason; + } + fn set_project(&self, project: &str) { + self.context.lock().unwrap().1 = project.into(); + } + fn set_profile(&self, profile: &str) { + self.context.lock().unwrap().2 = profile.into(); + } + fn configured_physical_store_path(&self) -> Option<&Path> { + Some(&self.base) + } + } + + #[async_trait::async_trait] + impl AsyncProvider for ConfiguredBackend { + async fn get(&self, _addr: Address<'_>) -> Result> { + assert_eq!(self.policy.max_attempts(), 2); + assert_eq!( + *self.context.lock().unwrap(), + (Some("deployment".into()), "app".into(), "prod".into()) + ); + assert!(self.base.is_absolute()); + Ok(self.credentials.get("token").cloned()) + } + async fn set(&self, _addr: Address<'_>, _value: &SecretBytes) -> Result<()> { + unreachable!() + } + } + + #[test] + fn forwards_configuration_before_backend_io_and_keeps_planning_pure() { + let mut provider = SyncProviderAdapter::from_async(ConfiguredBackend::default()); + let base = tempfile::tempdir().unwrap(); + // Identity and planning must work before credentials or session context + // have been configured. Starting a read here would fail its assertions. + assert_eq!(provider.storage_identity(), "shared-store"); + assert_eq!(provider.entry_container_identity(), "shared-container"); + let addr = NativeAddress { + item: "token".into(), + ..Default::default() + }; + assert_eq!( + provider + .configured_entry_coordinates(Address::Native(&addr)) + .unwrap() + .as_ref(), + &addr + ); + provider.with_base_dir(base.path()); + assert_eq!(provider.configured_physical_store_path(), Some(base.path())); + provider.with_credentials(ProviderCredentials::from([( + "token".into(), + "credential".into(), + )])); + provider.set_retry_policy(RetryPolicy::new(2).unwrap()); + provider.set_reason(Some("deployment".into())); + provider.set_project("app"); + provider.set_profile("prod"); + assert_eq!( + provider + .get(Address::Native(&addr)) + .unwrap() + .unwrap() + .expose_secret(), + b"credential" + ); + } +} diff --git a/secretspec/src/provider/tests.rs b/secretspec/src/provider/tests.rs index 25cafefe3..88d32972b 100644 --- a/secretspec/src/provider/tests.rs +++ b/secretspec/src/provider/tests.rs @@ -2093,13 +2093,14 @@ mod integration_tests { project: &str, config: Option<&str>, token: &str, - ) -> crate::provider::doppler::DopplerProvider { + ) -> crate::SyncProviderAdapter { use crate::provider::doppler::{DopplerConfig, DopplerProvider}; - let mut provider = DopplerProvider::new(DopplerConfig { - project: project.to_string(), - config: config.map(str::to_string), - }); + let mut provider = + crate::SyncProviderAdapter::from_async(DopplerProvider::new(DopplerConfig { + project: project.to_string(), + config: config.map(str::to_string), + })); let mut credentials = crate::provider::ProviderCredentials::new(); // "token" is the provider's declared credential name. credentials.insert("token".to_string(), SecretBytes::from_utf8(token)); diff --git a/secretspec/src/provider/traits.rs b/secretspec/src/provider/traits.rs index 8a178dcdb..60ec00fd2 100644 --- a/secretspec/src/provider/traits.rs +++ b/secretspec/src/provider/traits.rs @@ -72,7 +72,9 @@ impl ProviderValue { /// Trait defining the interface for secret storage providers. /// -/// All secret storage backends must implement this trait to integrate with SecretSpec. +/// Synchronous storage backends implement this trait directly. Native async +/// backends implement [`super::AsyncProvider`] and [`super::ProviderBase`]; +/// [`super::SyncProviderAdapter`] exposes them to the synchronous resolver. /// The trait is designed to be flexible enough to support various storage mechanisms /// while maintaining a consistent interface. /// diff --git a/secretspec/src/provider/vault.rs b/secretspec/src/provider/vault.rs index 1112a6981..d5c65de6f 100644 --- a/secretspec/src/provider/vault.rs +++ b/secretspec/src/provider/vault.rs @@ -62,8 +62,11 @@ //! secretspec check --provider vault://team-a@vault.example.com:8200/secret //! ``` +use super::ProviderBase as _; use super::vault_common::{KvConfig, KvProvider, Product}; -use super::{Address, Provider, ProviderCredentials, ProviderUrl}; +use super::{Address, ProviderCredentials, ProviderUrl}; +#[cfg(test)] +use super::{Provider, SyncProviderAdapter}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -96,6 +99,7 @@ crate::register_provider! { struct: VaultProvider, config: VaultConfig, metadata: &super::catalog::VAULT, + native_async: true, } impl VaultProvider { @@ -107,15 +111,15 @@ impl VaultProvider { } } -impl Provider for VaultProvider { +impl super::ProviderBase for VaultProvider { fn set_retry_policy(&mut self, policy: super::RetryPolicy) { self.core.set_retry_policy(policy); } + fn retry_ownership(&self) -> super::RetryOwnership { super::RetryOwnership::Managed } - /// Convention secrets use one KV path per secret and the `value` map field. fn convention_address(&self, project: &str, profile: &str, key: &str) -> Result { self.core.convention_address(project, profile, key) } @@ -140,63 +144,55 @@ impl Provider for VaultProvider { self.core.supported_coords() } - /// A native reference must identify the field inside the KV entry's map. - fn get(&self, addr: Address<'_>) -> Result> { + fn supports_delete(&self) -> bool { + true + } +} + +#[async_trait::async_trait] +impl super::AsyncProvider for VaultProvider { + async fn get(&self, addr: Address<'_>) -> Result> { let coords = self.resolve_coords(addr)?; - self.core.get(&coords) + self.core.get(&coords).await } - /// Reuses one operation-scoped login across the batch while retaining the - /// default address deduplication and concurrency cap. - fn get_many( + async fn get_many( &self, requests: &[(&str, Address<'_>)], ) -> Result> { - self.core.get_many(requests) + self.core.get_many(requests).await } - /// Only convention addresses are writable; see [`Self::check_writable`]. - fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { - self.check_writable(addr)?; + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - self.core.set(&coords, value) + self.core.set(&coords, value).await } - /// KV v2 expires the written version through the path's - /// `delete_version_after` metadata, so a cached copy of another store's - /// secret disappears on its own. - fn set_expiring( + async fn set_expiring( &self, addr: Address<'_>, value: &SecretBytes, max_age: std::time::Duration, ) -> Result<()> { - self.check_writable(addr)?; + super::AsyncProvider::check_writable(self, addr).await?; let coords = self.resolve_coords(addr)?; - self.core.set_expiring(&coords, value, max_age) + self.core.set_expiring(&coords, value, max_age).await } - /// Deletes the whole KV path, so it is confined to entries SecretSpec owns; - /// see [`Self::check_writable`] for the same reasoning about `ref`s. - fn delete(&self, addr: Address<'_>) -> Result { + async fn delete(&self, addr: Address<'_>) -> Result { self.core.check_deletable(addr)?; let coords = self.resolve_coords(addr)?; - self.core.delete(&coords) + self.core.delete(&coords).await } - fn supports_delete(&self) -> bool { - true + async fn check_writable(&self, addr: Address<'_>) -> Result<()> { + self.core.check_writable(addr) } - fn check_deletable(&self, addr: Address<'_>) -> Result<()> { + async fn check_deletable(&self, addr: Address<'_>) -> Result<()> { self.core.check_deletable(addr) } - - /// Refuses native writes because replacing a KV entry to change one field - /// would silently discard every sibling field. - fn check_writable(&self, addr: Address<'_>) -> Result<()> { - self.core.check_writable(addr) - } } #[cfg(test)] @@ -219,7 +215,9 @@ mod tests { #[test] fn convention_address_is_the_writable_value_field() { - let provider = VaultProvider::new(config("vault://vault.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(VaultProvider::new(config( + "vault://vault.example.com:8200/secret", + ))); let address = provider .resolve_coords(Address::convention("app", "prod", "DATABASE_URL")) .unwrap(); @@ -234,7 +232,9 @@ mod tests { #[test] fn native_address_requires_a_field() { - let provider = VaultProvider::new(config("vault://vault.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(VaultProvider::new(config( + "vault://vault.example.com:8200/secret", + ))); let address = NativeAddress { item: "myapp/config".into(), ..Default::default() @@ -245,7 +245,9 @@ mod tests { #[test] fn native_address_is_read_only() { - let provider = VaultProvider::new(config("vault://vault.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(VaultProvider::new(config( + "vault://vault.example.com:8200/secret", + ))); let address = NativeAddress { item: "myapp/config".into(), field: Some("db_password".into()), @@ -263,7 +265,9 @@ mod tests { #[test] fn native_address_rejects_version() { - let provider = VaultProvider::new(config("vault://vault.example.com:8200/secret")); + let provider = SyncProviderAdapter::from_async(VaultProvider::new(config( + "vault://vault.example.com:8200/secret", + ))); let address = NativeAddress { item: "myapp/config".into(), field: Some("db_password".into()), diff --git a/secretspec/src/provider/vault_common.rs b/secretspec/src/provider/vault_common.rs index 0db6a859e..b7f62fd5b 100644 --- a/secretspec/src/provider/vault_common.rs +++ b/secretspec/src/provider/vault_common.rs @@ -4,9 +4,9 @@ //! configuration conventions. This module contains only the compatible KV, //! authentication-exchange, and HTTP mechanics used by both providers. -use super::{ - Address, ProviderCredentials, ProviderUrl, block_on, credential_or_envs, preferred_env, -}; +#[cfg(test)] +use super::block_on; +use super::{Address, ProviderCredentials, ProviderUrl, credential_or_envs, preferred_env}; use crate::SecretBytes; use crate::config::NativeAddress; use crate::{Result, SecretSpecError}; @@ -554,8 +554,8 @@ impl KvProvider { /// Additional logins are performed only when the issued token's reported /// use budget is exhausted. Dropping the session drops all authentication /// state, so a later operation cannot unknowingly reuse an expired token. - fn session(&self) -> Result> { - let token = block_on(self.resolve_token())?; + async fn session(&self) -> Result> { + let token = self.resolve_token().await?; Ok(KvSession { provider: self, tokens: Mutex::new(TokenPool::new(token)), @@ -695,9 +695,9 @@ impl KvProvider { /// Reads the requested field from a resolved native KV address. /// Convention addresses also arrive here after resolving to field `value`. - pub(crate) fn get(&self, coords: &NativeAddress) -> Result> { + pub(crate) async fn get(&self, coords: &NativeAddress) -> Result> { let field = self.require_field(coords)?; - self.session()?.get_field(&coords.item, field) + self.session().await?.get_field(&coords.item, field).await } /// Validates and resolves the parts of a read address that can fail without @@ -726,7 +726,7 @@ impl KvProvider { /// Reads a batch through one operation-scoped authentication session while /// retaining the provider-wide address deduplication and concurrency cap. - pub(crate) fn get_many( + pub(crate) async fn get_many( &self, requests: &[(&str, Address<'_>)], ) -> Result> { @@ -738,32 +738,38 @@ impl KvProvider { for (_, addr) in requests { self.validate_read_address(*addr)?; } - let session = self.session()?; - super::get_each_with(requests, |addr| match addr { - Address::Convention { - project, - profile, - key, - } => { - let coords = self.convention_address(project, profile, key)?; - session.get(&coords) - } - Address::Native(coords) => { - super::address::reject_unsupported_coords( - self.product.scheme(), - coords, - self.supported_coords(), - )?; - session.get(coords) + let session = self.session().await?; + super::async_provider::get_each_with(requests, |addr| { + let session = &session; + async move { + match addr { + Address::Convention { + project, + profile, + key, + } => { + let coords = self.convention_address(project, profile, key)?; + session.get(&coords).await + } + Address::Native(coords) => { + super::address::reject_unsupported_coords( + self.product.scheme(), + coords, + self.supported_coords(), + )?; + session.get(coords).await + } + } } }) + .await } /// Writes a complete convention-owned KV entry. /// /// Callers must run [`Self::check_writable`] before reaching this method. - pub(crate) fn set(&self, coords: &NativeAddress, value: &SecretBytes) -> Result<()> { - self.session()?.set(&coords.item, value) + pub(crate) async fn set(&self, coords: &NativeAddress, value: &SecretBytes) -> Result<()> { + self.session().await?.set(&coords.item, value).await } /// Writes a convention-owned KV entry the store itself will drop once @@ -776,7 +782,7 @@ impl KvProvider { /// /// KV v1 has no expiry at all, so it refuses: the alternative is an /// unexpiring copy of another store's secret. - pub(crate) fn set_expiring( + pub(crate) async fn set_expiring( &self, coords: &NativeAddress, value: &SecretBytes, @@ -789,7 +795,10 @@ impl KvProvider { self.product.scheme() ))); } - self.session()?.set_expiring(&coords.item, value, max_age) + self.session() + .await? + .set_expiring(&coords.item, value, max_age) + .await } /// Destroys a convention-owned KV path, reporting whether it held anything. @@ -801,9 +810,9 @@ impl KvProvider { /// one extra round trip on a path only cache maintenance takes. /// /// Callers must run [`Self::check_deletable`] before reaching this method. - pub(crate) fn delete(&self, coords: &NativeAddress) -> Result { + pub(crate) async fn delete(&self, coords: &NativeAddress) -> Result { let field = self.require_field(coords)?; - self.session()?.delete(&coords.item, field) + self.session().await?.delete(&coords.item, field).await } /// Native references are read-only because the current write API replaces @@ -1545,89 +1554,81 @@ impl KvSession<'_> { } /// Reads one address with authentication scoped to this operation. - fn get(&self, coords: &NativeAddress) -> Result> { + async fn get(&self, coords: &NativeAddress) -> Result> { let field = self.provider.require_field(coords)?; - self.get_field(&coords.item, field) + self.get_field(&coords.item, field).await } - fn get_field(&self, secret_path: &str, field: &str) -> Result> { - block_on(async { - let token = self.claim_token().await?; - self.provider - .get_field_async(secret_path, field, self, token) - .await - }) + async fn get_field(&self, secret_path: &str, field: &str) -> Result> { + let token = self.claim_token().await?; + self.provider + .get_field_async(secret_path, field, self, token) + .await } - fn set(&self, secret_path: &str, value: &SecretBytes) -> Result<()> { - block_on(async { - let token = self.claim_token().await?; - self.provider - .set_secret_async(secret_path, value, self, token) - .await - }) + async fn set(&self, secret_path: &str, value: &SecretBytes) -> Result<()> { + let token = self.claim_token().await?; + self.provider + .set_secret_async(secret_path, value, self, token) + .await } - fn set_expiring( + async fn set_expiring( &self, secret_path: &str, value: &SecretBytes, max_age: std::time::Duration, ) -> Result<()> { - block_on(async { - // Ensure both request claims before changing metadata. If a - // limited-use role can no longer log in, no partial expiry policy - // is left behind. Claim immediately before each request so an - // already preflighted short-lease token can still be refreshed. - self.ensure_claims(2).await?; - let metadata_token = self.claim_token().await?; - self.provider - .set_version_ttl_async(secret_path, max_age, self, metadata_token) - .await?; - let data_token = self.claim_token().await?; - self.provider - .set_secret_async(secret_path, value, self, data_token) - .await - }) + // Ensure both request claims before changing metadata. If a + // limited-use role can no longer log in, no partial expiry policy + // is left behind. Claim immediately before each request so an + // already preflighted short-lease token can still be refreshed. + self.ensure_claims(2).await?; + let metadata_token = self.claim_token().await?; + self.provider + .set_version_ttl_async(secret_path, max_age, self, metadata_token) + .await?; + let data_token = self.claim_token().await?; + self.provider + .set_secret_async(secret_path, value, self, data_token) + .await } - fn delete(&self, secret_path: &str, field: &str) -> Result { - block_on(async { - let existence_token = self.claim_token().await?; - match self.provider.config.kv_version { - // An expired KV v2 version is no longer readable from the data - // endpoint, but its metadata and recoverable version history - // still exist. Check the metadata path so cache clearing - // permanently destroys that history. - KvVersion::V2 => { - if !self - .provider - .metadata_exists_async(secret_path, self, existence_token) - .await? - { - return Ok(false); - } + async fn delete(&self, secret_path: &str, field: &str) -> Result { + let existence_token = self.claim_token().await?; + match self.provider.config.kv_version { + // An expired KV v2 version is no longer readable from the data + // endpoint, but its metadata and recoverable version history + // still exist. Check the metadata path so cache clearing + // permanently destroys that history. + KvVersion::V2 => { + if !self + .provider + .metadata_exists_async(secret_path, self, existence_token) + .await? + { + return Ok(false); } - KvVersion::V1 => { - if self - .provider - .get_field_async(secret_path, field, self, existence_token) - .await? - .is_none() - { - return Ok(false); - } + } + KvVersion::V1 => { + if self + .provider + .get_field_async(secret_path, field, self, existence_token) + .await? + .is_none() + { + return Ok(false); } } - // The existence check is read-only. Resolve the destructive - // request's claim before issuing the delete so an exhausted role - // cannot turn into a post-mutation authentication failure. - let delete_token = self.claim_token().await?; - self.provider - .delete_path_async(secret_path, self, delete_token) - .await?; - Ok(true) - }) + } + // The existence check is read-only. Resolve the destructive + // request's claim before issuing the delete so an exhausted role + // cannot turn into a post-mutation authentication failure. + let delete_token = self.claim_token().await?; + self.provider + .delete_path_async(secret_path, self, delete_token) + .await?; + Ok(true) } } @@ -1678,23 +1679,23 @@ mod tests { } #[cfg(feature = "vault")] - fn vault_approle_provider(endpoint: SocketAddr) -> VaultProvider { + fn vault_approle_provider(endpoint: SocketAddr) -> crate::SyncProviderAdapter { let config = VaultConfig::try_from(&provider_url(&format!( "vault://{endpoint}/secret?tls=false&auth=approle" ))) .unwrap(); - let mut provider = VaultProvider::new(config); + let mut provider = crate::SyncProviderAdapter::from_async(VaultProvider::new(config)); provider.with_credentials(approle_credentials()); provider } #[cfg(feature = "openbao")] - fn openbao_jwt_provider(endpoint: SocketAddr) -> OpenBaoProvider { + fn openbao_jwt_provider(endpoint: SocketAddr) -> crate::SyncProviderAdapter { let config = OpenBaoConfig::try_from(&provider_url(&format!( "openbao://{endpoint}/secret?tls=false&auth=jwt&role=ci" ))) .unwrap(); - OpenBaoProvider::new(config) + crate::SyncProviderAdapter::from_async(OpenBaoProvider::new(config)) } fn read_request(stream: &mut TcpStream) -> String { @@ -2161,8 +2162,8 @@ mod tests { assert_eq!(server.join().unwrap().len(), 1); } - #[test] - fn get_many_validates_every_address_before_authenticating() { + #[tokio::test] + async fn get_many_validates_every_address_before_authenticating() { let _lock = crate::tests::scrub_resolution_env(); let config = KvConfig::parse( &provider_url("vault://127.0.0.1:1/secret?tls=false&auth=approle"), @@ -2180,6 +2181,7 @@ mod tests { ("VALID", Address::convention("project", "default", "VALID")), ("INVALID", Address::Native(&invalid)), ]) + .await .unwrap_err(); assert!(error.to_string().contains("references need a `field`")); @@ -2267,8 +2269,8 @@ mod tests { ); } - #[test] - fn kv_v2_delete_destroys_metadata_when_the_current_version_is_unreadable() { + #[tokio::test] + async fn kv_v2_delete_destroys_metadata_when_the_current_version_is_unreadable() { use std::io::{Read, Write}; use std::net::TcpListener; @@ -2305,7 +2307,7 @@ mod tests { ..Default::default() }; - assert!(provider.delete(&coords).unwrap()); + assert!(provider.delete(&coords).await.unwrap()); assert_eq!( server.join().unwrap(), [ @@ -2315,8 +2317,8 @@ mod tests { ); } - #[test] - fn kv_v1_refuses_to_hold_an_expiring_value() { + #[tokio::test] + async fn kv_v1_refuses_to_hold_an_expiring_value() { let _lock = crate::tests::scrub_resolution_env(); let _vault_addr = EnvVarGuard::remove("VAULT_ADDR"); let config = KvConfig::parse( @@ -2339,6 +2341,7 @@ mod tests { &SecretBytes::from_utf8("value"), std::time::Duration::from_secs(3600), ) + .await .unwrap_err(); assert!(error.to_string().contains("KV v1 cannot expire"), "{error}"); } diff --git a/secretspec/tests/async_provider.rs b/secretspec/tests/async_provider.rs new file mode 100644 index 000000000..2edb38967 --- /dev/null +++ b/secretspec/tests/async_provider.rs @@ -0,0 +1,535 @@ +use async_trait::async_trait; +use secretspec::{ + Address, AsyncProvider, AsyncProviderAdapter, DiscoveryContext, NativeAddress, Provider, + ProviderBase, ProviderValue, Result, Secret, SecretBytes, SecretSpecError, SyncProviderAdapter, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +fn native(item: &str) -> NativeAddress { + NativeAddress { + item: item.into(), + field: None, + vault: None, + section: None, + version: None, + } +} + +#[derive(Default)] +struct NativeAsync { + reads: AtomicUsize, + values: Mutex>, + write_only: bool, +} + +fn key(addr: Address<'_>) -> String { + match addr { + Address::Native(addr) => addr.item.clone(), + Address::Convention { + project, + profile, + key, + } => format!("{project}/{profile}/{key}"), + } +} + +#[async_trait] +impl AsyncProvider for NativeAsync { + async fn get(&self, addr: Address<'_>) -> Result> { + assert!(!self.write_only, "write-only stores must not read values"); + tokio::task::yield_now().await; + self.reads.fetch_add(1, Ordering::SeqCst); + Ok(self.values.lock().unwrap().get(&key(addr)).cloned()) + } + + async fn set(&self, addr: Address<'_>, value: &SecretBytes) -> Result<()> { + self.values.lock().unwrap().insert(key(addr), value.clone()); + Ok(()) + } + + async fn exists(&self, addr: Address<'_>) -> Result { + self.reads.fetch_add(1, Ordering::SeqCst); + Ok(self.values.lock().unwrap().contains_key(&key(addr))) + } +} + +#[tokio::test] +async fn native_async_trait_objects_accept_borrowed_addresses_and_binary_values() { + let provider: Box = Box::new(NativeAsync::default()); + let reference = native("binary"); + let addr = Address::Native(&reference); + let bytes = SecretBytes::from(vec![0, 255, 128, 10]); + provider.set(addr, &bytes).await.unwrap(); + assert!(provider.exists(addr).await.unwrap()); + let value = provider.get_with_metadata(addr).await.unwrap().unwrap(); + assert_eq!(value.value.expose_secret(), bytes.expose_secret()); + assert!(value.expires_at_unix_ms.is_none()); + assert!(value.revision.is_none()); + assert!( + provider + .get(Address::Native(&native("missing"))) + .await + .unwrap() + .is_none() + ); + assert!(!provider.supports_delete()); + let delete = provider.delete(addr).await.unwrap_err(); + let preflight = provider.check_deletable(addr).await.unwrap_err(); + assert_eq!(delete.to_string(), preflight.to_string()); + assert!( + provider + .reflect(DiscoveryContext::new("app", "dev")) + .await + .is_err() + ); +} + +#[tokio::test] +async fn default_batches_deduplicate_and_omit_missing_values() { + let provider = NativeAsync::default(); + let addr = Address::convention("app", "dev", "KEY"); + provider + .set(addr, &SecretBytes::from("value")) + .await + .unwrap(); + let requests = [ + ("first", addr), + ("alias", addr), + ("missing", Address::Native(&native("missing"))), + ]; + let values = provider.get_many(&requests).await.unwrap(); + assert_eq!(provider.reads.load(Ordering::SeqCst), 2); + assert_eq!(values.len(), 2); + assert_eq!( + values["first"].expose_secret(), + values["alias"].expose_secret() + ); + let metadata = provider.get_many_with_metadata(&requests).await.unwrap(); + assert_eq!(metadata.len(), 2); + assert!(metadata["first"].expires_at_unix_ms.is_none()); + assert_eq!( + provider.exists_many(&requests).await.unwrap(), + HashSet::from(["first".into(), "alias".into()]) + ); +} + +#[tokio::test] +async fn write_only_batch_presence_does_not_read_values() { + let provider = NativeAsync { + write_only: true, + ..Default::default() + }; + let addr = Address::convention("app", "dev", "KEY"); + provider + .set(addr, &SecretBytes::from("value")) + .await + .unwrap(); + let requests = [ + ("first", addr), + ("alias", addr), + ("missing", Address::Native(&native("missing"))), + ]; + assert_eq!(provider.exists_many(&requests).await.unwrap().len(), 2); + assert_eq!(provider.reads.load(Ordering::SeqCst), 2); +} + +struct BlockingRead { + started: Mutex>>, + release: Mutex>, +} + +impl Provider for BlockingRead { + fn convention_address(&self, _: &str, _: &str, key: &str) -> Result { + Ok(native(key)) + } + fn name(&self) -> &str { + "blocking" + } + fn uri(&self) -> String { + "blocking://".into() + } + fn get(&self, _: Address<'_>) -> Result> { + self.started + .lock() + .unwrap() + .take() + .unwrap() + .send(std::thread::current().id()) + .unwrap(); + self.release + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + Ok(Some("value".into())) + } + fn set(&self, _: Address<'_>, _: &SecretBytes) -> Result<()> { + unreachable!() + } +} + +#[tokio::test(flavor = "current_thread")] +async fn adapter_allows_other_tasks_to_progress_on_a_current_thread_runtime() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let provider: Arc = + Arc::new(AsyncProviderAdapter::new(Arc::new(BlockingRead { + started: Mutex::new(Some(started_tx)), + release: Mutex::new(release_rx), + }))); + let task = + tokio::spawn(async move { provider.get(Address::convention("app", "dev", "KEY")).await }); + let worker_id = tokio::time::timeout(Duration::from_secs(5), started_rx) + .await + .unwrap() + .unwrap(); + assert_ne!(worker_id, std::thread::current().id()); + release_tx.send(()).unwrap(); + assert_eq!( + task.await.unwrap().unwrap().unwrap().expose_secret(), + b"value" + ); +} + +struct Overrides; + +impl Provider for Overrides { + fn convention_address(&self, _: &str, _: &str, key: &str) -> Result { + Ok(native(key)) + } + fn name(&self) -> &str { + "overrides" + } + fn uri(&self) -> String { + "overrides://".into() + } + fn get(&self, _: Address<'_>) -> Result> { + panic!("batch and metadata overrides must be forwarded") + } + fn set(&self, _: Address<'_>, _: &SecretBytes) -> Result<()> { + panic!("expiry override must be forwarded") + } + fn supports_delete(&self) -> bool { + true + } + fn get_with_metadata(&self, addr: Address<'_>) -> Result> { + assert_eq!(key(addr), "remote"); + Ok(Some( + ProviderValue::new(vec![0, 255].into(), Some(1234)).with_revision(Some( + secretspec_ipc::Revision::new("remote:v3".into()).unwrap(), + )), + )) + } + fn get_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + assert_eq!(key(requests[0].1), "remote"); + Ok(HashMap::from([(requests[0].0.into(), "batch".into())])) + } + fn get_many_with_metadata( + &self, + requests: &[(&str, Address<'_>)], + ) -> Result> { + Ok(HashMap::from([( + requests[0].0.into(), + self.get_with_metadata(requests[0].1)?.unwrap(), + )])) + } + fn exists(&self, _: Address<'_>) -> Result { + Ok(true) + } + fn exists_many(&self, requests: &[(&str, Address<'_>)]) -> Result> { + Ok(requests.iter().map(|(name, _)| (*name).into()).collect()) + } + fn set_expiring( + &self, + addr: Address<'_>, + value: &SecretBytes, + max_age: Duration, + ) -> Result<()> { + assert_eq!(key(addr), "remote"); + assert_eq!(value.expose_secret(), &[0, 255]); + assert_eq!(max_age, Duration::from_secs(30)); + Ok(()) + } + fn check_writable(&self, _: Address<'_>) -> Result<()> { + Err(SecretSpecError::ProviderOperationFailed( + "read-only address".into(), + )) + } + fn check_deletable(&self, _: Address<'_>) -> Result<()> { + Err(SecretSpecError::ProviderOperationFailed( + "protected address".into(), + )) + } + fn delete(&self, _: Address<'_>) -> Result { + Ok(false) + } + fn reflect(&self, context: DiscoveryContext<'_>) -> Result> { + assert_eq!(context, DiscoveryContext::new("app", "dev")); + Ok(HashMap::from([("KEY".into(), Secret::required("key"))])) + } +} + +#[tokio::test] +async fn adapter_preserves_batch_metadata_expiry_and_policy_overrides() { + let provider = AsyncProviderAdapter::new(Arc::new(Overrides)); + let reference = native("remote"); + let addr = Address::Native(&reference); + let requests = [("KEY", addr)]; + assert_eq!(provider.name(), "overrides"); + assert_eq!(provider.uri(), "overrides://"); + assert!(provider.supports_read()); + assert!(provider.supports_delete()); + let value = provider.get_with_metadata(addr).await.unwrap().unwrap(); + assert_eq!(value.expires_at_unix_ms, Some(1234)); + assert_eq!(value.revision.unwrap().as_str(), "remote:v3"); + assert_eq!(value.value.expose_secret(), &[0, 255]); + assert_eq!( + provider.get_many(&requests).await.unwrap()["KEY"].expose_secret(), + b"batch" + ); + let metadata = provider.get_many_with_metadata(&requests).await.unwrap(); + assert_eq!(metadata["KEY"].expires_at_unix_ms, Some(1234)); + assert_eq!( + metadata["KEY"].revision.as_ref().unwrap().as_str(), + "remote:v3" + ); + assert!(provider.exists(addr).await.unwrap()); + assert_eq!( + provider.exists_many(&requests).await.unwrap(), + HashSet::from(["KEY".into()]) + ); + provider + .set_expiring(addr, &vec![0, 255].into(), Duration::from_secs(30)) + .await + .unwrap(); + assert!( + provider + .check_writable(addr) + .await + .unwrap_err() + .to_string() + .contains("read-only address") + ); + assert!( + provider + .check_deletable(addr) + .await + .unwrap_err() + .to_string() + .contains("protected address") + ); + assert!(!provider.delete(addr).await.unwrap()); + assert_eq!( + provider + .reflect(DiscoveryContext::new("app", "dev")) + .await + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn adapter_resumes_provider_panics_in_the_awaiting_task() { + let provider = AsyncProviderAdapter::new(Arc::new(Overrides)); + let task = tokio::spawn(async move { provider.get(Address::Native(&native("panic"))).await }); + assert!(task.await.unwrap_err().is_panic()); +} + +#[tokio::test] +async fn existing_dotenv_provider_round_trips_through_async_factory() { + let directory = tempfile::tempdir().unwrap(); + let provider = Box::::try_from(format!( + "dotenv://{}", + directory.path().join(".env").display() + )) + .unwrap(); + let addr = Address::convention("app", "dev", "KEY"); + provider.check_writable(addr).await.unwrap(); + provider.set(addr, &"hello".into()).await.unwrap(); + assert_eq!( + provider.get(addr).await.unwrap().unwrap().expose_secret(), + b"hello" + ); + assert!(provider.delete(addr).await.unwrap()); + assert!(!provider.exists(addr).await.unwrap()); +} + +// Policy checks finish before I/O. Polling outside a runtime also detects an +// accidental fallback to spawn_blocking instead of a native async registration. +macro_rules! native_sdk_factory { + ($test:ident, $feature:literal, $uri:literal) => { + #[cfg(feature = $feature)] + #[test] + fn $test() { + use futures_util::FutureExt; + let provider = Box::::try_from($uri).unwrap(); + let mut reference = native("test-secret"); + reference.field = Some("unsupported".into()); + reference.version = Some("3".into()); + let addr = Address::Native(&reference); + let preflight = provider + .check_writable(addr) + .now_or_never() + .unwrap() + .unwrap_err(); + let value = "replacement".into(); + let write = provider + .set(addr, &value) + .now_or_never() + .unwrap() + .unwrap_err(); + assert_eq!(preflight.to_string(), write.to_string()); + } + + #[cfg(not(feature = $feature))] + #[test] + fn $test() { + let Err(error) = Box::::try_from($uri) else { + panic!("disabled SDK was constructed"); + }; + assert!(matches!( + error, + SecretSpecError::ProviderFeatureDisabled { + feature: $feature, + .. + } + )); + } + }; +} + +native_sdk_factory!(aac_factory, "aac", "aac://payments-production"); +native_sdk_factory!(akv_factory, "akv", "akv://myvault"); +native_sdk_factory!(awssm_factory, "awssm", "awssm://us-east-1"); +native_sdk_factory!(awsps_factory, "awsps", "awsps://us-east-1"); +native_sdk_factory!(gcsm_factory, "gcsm", "gcsm://my-gcp-project"); +native_sdk_factory!( + kubernetes_secret_factory, + "kubernetes", + "k8s+secret://db-config" +); +native_sdk_factory!( + kubernetes_configmap_factory, + "kubernetes", + "k8s+configmap://db-config@default" +); +native_sdk_factory!( + cloudflare_factory, + "cloudflare", + "cloudflare://STORE_ID?account_id=ACCOUNT_ID" +); +native_sdk_factory!(doppler_factory, "doppler", "doppler://myapp/prd"); +native_sdk_factory!( + infisical_factory, + "infisical", + "infisical://app.infisical.com/11111111-2222-4333-8444-555555555555" +); +native_sdk_factory!(scaleway_factory, "scaleway", "scaleway://fr-par"); +native_sdk_factory!(setec_factory, "setec", "setec://secrets.example.ts.net"); +native_sdk_factory!( + vault_factory, + "vault", + "vault://vault.example.com:8200/secret" +); +native_sdk_factory!( + openbao_factory, + "openbao", + "openbao://bao.example.com:8200/secret" +); + +#[test] +fn async_factory_rejects_uri_passwords_without_echoing_them() { + let Err(error) = Box::::try_from("awsps://user:private-password@us-east-1") + else { + panic!("credential-bearing URI was accepted"); + }; + assert!(!format!("{error:?}: {error}").contains("private-password")); +} + +impl ProviderBase for NativeAsync { + fn supports_read(&self) -> bool { + !self.write_only + } + fn name(&self) -> &str { + "native-async" + } + fn uri(&self) -> String { + "native-async://".into() + } +} + +#[test] +fn sync_adapter_runs_native_async_storage_and_preserves_batch_deduplication() { + let provider = SyncProviderAdapter::from_async(NativeAsync::default()); + let addr = Address::Native(&native("binary")); + let value = SecretBytes::from(vec![0, 255]); + provider.set(addr, &value).unwrap(); + assert_eq!( + provider.get(addr).unwrap().unwrap().expose_secret(), + &[0, 255] + ); + let values = provider + .get_many(&[("first", addr), ("alias", addr)]) + .unwrap(); + assert_eq!(values.len(), 2); + assert_eq!(provider.as_async().reads.load(Ordering::SeqCst), 2); + assert_eq!(provider.name(), "native-async"); + assert_eq!(provider.uri(), "native-async://"); + assert_eq!( + provider.delete(addr).unwrap_err().to_string(), + provider.check_deletable(addr).unwrap_err().to_string() + ); +} + +#[test] +fn adapters_preserve_overrides_in_both_directions() { + let provider = SyncProviderAdapter::from_async(AsyncProviderAdapter::new(Arc::new(Overrides))); + let reference = native("remote"); + let addr = Address::Native(&reference); + let requests = [("KEY", addr)]; + let value = provider.get_with_metadata(addr).unwrap().unwrap(); + assert_eq!(value.value.expose_secret(), &[0, 255]); + assert_eq!(value.expires_at_unix_ms, Some(1234)); + assert_eq!(value.revision.unwrap().as_str(), "remote:v3"); + assert_eq!( + provider.get_many(&requests).unwrap()["KEY"].expose_secret(), + b"batch" + ); + let values = provider.get_many_with_metadata(&requests).unwrap(); + assert_eq!( + values["KEY"].revision.as_ref().unwrap().as_str(), + "remote:v3" + ); + assert!(provider.exists(addr).unwrap()); + assert!(provider.exists_many(&requests).unwrap().contains("KEY")); + provider + .set_expiring(addr, &vec![0, 255].into(), Duration::from_secs(30)) + .unwrap(); + assert!(provider.supports_delete()); + assert!(!provider.delete(addr).unwrap()); + assert!( + provider + .check_deletable(addr) + .unwrap_err() + .to_string() + .contains("protected address") + ); + assert!( + provider + .check_writable(addr) + .unwrap_err() + .to_string() + .contains("read-only address") + ); + assert_eq!( + provider + .reflect(DiscoveryContext::new("app", "dev")) + .unwrap() + .len(), + 1 + ); +}