From 98c626fbe194c1f5a28394c1cec88759b5103609 Mon Sep 17 00:00:00 2001 From: kazmosahebi Date: Mon, 25 May 2026 23:18:47 +1000 Subject: [PATCH 1/4] fix(credential): extract resolve() from dfe-fetcher Adds a new `credential` feature with `resolve` / `resolve_optional` / `CredentialError`, supporting `env:VAR`, `vault:path:key`, and literal specs. Vault arm is gated behind the existing `secrets` feature; when off, `vault:` returns `CredentialError::VaultUnsupported` cleanly. Closes #40. --- Cargo.toml | 3 + src/credential.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 8 +++ 3 files changed, 163 insertions(+) create mode 100644 src/credential.rs diff --git a/Cargo.toml b/Cargo.toml index e1175e31..cb52688a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,9 @@ secrets-vault = ["secrets", "vaultrs"] secrets-aws = ["secrets", "aws-config", "aws-sdk-secretsmanager"] secrets-all = ["secrets-vault", "secrets-aws"] +# Credential spec resolution (env:/vault:/literal) +credential = [] + # Full feature set full = ["config", "config-reload", "logger", "metrics", "metrics-dfe", "otel", "otel-metrics", "otel-tracing", "runtime", "shutdown", "health", "http", "http-server", "spool", "tiered-sink", "database", "cache", "transport-all", "transport-trace", "transport-grpc-vector-compat", "secrets-all", "directory-config", "directory-config-git", "deployment", "version-check", "scaling", "memory", "worker", "cli-service", "io", "dlq", "dlq-kafka", "dlq-http", "dlq-redis", "output-file", "expression"] diff --git a/src/credential.rs b/src/credential.rs new file mode 100644 index 00000000..dc48fd68 --- /dev/null +++ b/src/credential.rs @@ -0,0 +1,152 @@ +// Project: hyperi-rustlib +// File: src/credential.rs +// Purpose: Credential spec resolution (env, vault, literal) +// Language: Rust +// +// License: FSL-1.1-ALv2 +// Copyright: (c) 2026 HYPERI PTY LIMITED + +//! Credential specification resolution. +//! +//! Resolves credential specs in three formats: +//! - `vault:path:key` — fetch from OpenBao via [`crate::secrets`] (requires `secrets` feature) +//! - `env:VAR_NAME` — read from the environment; hard error if unset +//! - any other string — used as a literal value +//! +//! Extracted from `dfe-fetcher/src/credential.rs` so that other DFE +//! services (dfe-loader, etc.) can share the same syntax. + +use thiserror::Error; + +/// Errors that can arise resolving a credential spec. +#[derive(Debug, Error)] +pub enum CredentialError { + #[error("environment variable '{name}' is not set")] + MissingEnvVar { name: String }, + + #[error("vault resolution failed: {0}")] + Vault(String), + + #[error("invalid credential spec: {0}")] + BadSpec(String), + + #[error("vault: spec requires the `secrets` feature to be enabled")] + VaultUnsupported, +} + +/// Resolve a credential spec to its plaintext value. +pub async fn resolve(spec: &str) -> Result { + if let Some(rest) = spec.strip_prefix("vault:") { + resolve_vault(rest).await + } else if let Some(var_name) = spec.strip_prefix("env:") { + resolve_env(var_name) + } else { + Ok(spec.to_string()) + } +} + +/// Resolve an optional credential spec — returns `None` for `None`/empty. +pub async fn resolve_optional(spec: Option<&str>) -> Result, CredentialError> { + match spec { + Some("") | None => Ok(None), + Some(s) => Ok(Some(resolve(s).await?)), + } +} + +fn resolve_env(var_name: &str) -> Result { + std::env::var(var_name).map_err(|_| CredentialError::MissingEnvVar { + name: var_name.to_string(), + }) +} + +#[cfg(feature = "secrets")] +async fn resolve_vault(path_key: &str) -> Result { + use crate::secrets::{SecretSource, SecretsConfig, SecretsManager}; + use std::collections::HashMap; + + let parts: Vec<&str> = path_key.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(CredentialError::BadSpec(format!( + "invalid vault spec '{path_key}', expected 'path:key'" + ))); + } + let path = parts[0]; + let key = parts[1]; + + let mut sources = HashMap::new(); + sources.insert( + "_vault_lookup".to_string(), + SecretSource::OpenBao { + path: path.to_string(), + key: key.to_string(), + }, + ); + let config = SecretsConfig { + sources, + ..Default::default() + }; + + let secrets = SecretsManager::new(config) + .map_err(|e| CredentialError::Vault(format!("init failed: {e}")))?; + let value = secrets + .get("_vault_lookup") + .await + .map_err(|e| CredentialError::Vault(format!("lookup failed for {path}:{key}: {e}")))?; + let text = value + .as_str() + .map_err(|e| CredentialError::Vault(format!("not UTF-8: {e}")))?; + tracing::debug!(path = path, key = key, "resolved vault credential"); + Ok(text.to_string()) +} + +#[cfg(not(feature = "secrets"))] +async fn resolve_vault(_path_key: &str) -> Result { + Err(CredentialError::VaultUnsupported) +} + +#[cfg(test)] +#[allow(unsafe_code, clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[tokio::test] + async fn resolve_literal() { + let v = resolve("my-secret-value").await.unwrap(); + assert_eq!(v, "my-secret-value"); + } + + #[tokio::test] + async fn resolve_env_set() { + // SAFETY: test-only, single-threaded + unsafe { std::env::set_var("HYPERI_RUSTLIB_TEST_CRED", "value-123") }; + let v = resolve("env:HYPERI_RUSTLIB_TEST_CRED").await.unwrap(); + assert_eq!(v, "value-123"); + unsafe { std::env::remove_var("HYPERI_RUSTLIB_TEST_CRED") }; + } + + #[tokio::test] + async fn resolve_env_missing() { + let err = resolve("env:HYPERI_RUSTLIB_NONEXISTENT_XYZ").await.unwrap_err(); + match err { + CredentialError::MissingEnvVar { name } => assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ"), + other => panic!("expected MissingEnvVar, got {other:?}"), + } + } + + #[tokio::test] + async fn resolve_optional_none_returns_none() { + assert!(resolve_optional(None).await.unwrap().is_none()); + } + + #[tokio::test] + async fn resolve_optional_empty_returns_none() { + assert!(resolve_optional(Some("")).await.unwrap().is_none()); + } + + #[tokio::test] + #[cfg(not(feature = "secrets"))] + async fn vault_without_feature_returns_clear_error() { + let err = resolve("vault:secret/x:k").await.unwrap_err(); + assert!(matches!(err, CredentialError::VaultUnsupported)); + } +} diff --git a/src/lib.rs b/src/lib.rs index 74edf31b..2392dcbe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -153,6 +153,10 @@ pub mod tiered_sink; #[cfg_attr(docsrs, doc(cfg(feature = "secrets")))] pub mod secrets; +#[cfg(feature = "credential")] +#[cfg_attr(docsrs, doc(cfg(feature = "credential")))] +pub mod credential; + #[cfg(feature = "directory-config")] #[cfg_attr(docsrs, doc(cfg(feature = "directory-config")))] pub mod directory_config; @@ -289,6 +293,10 @@ pub use secrets::{ SecretValue, SecretsConfig, SecretsError, SecretsManager, SecretsResult, }; +#[cfg(feature = "credential")] +#[cfg_attr(docsrs, doc(cfg(feature = "credential")))] +pub use credential::{resolve, resolve_optional, CredentialError}; + #[cfg(feature = "secrets-vault")] #[cfg_attr(docsrs, doc(cfg(feature = "secrets-vault")))] pub use secrets::{OpenBaoAuth, OpenBaoConfig, OpenBaoProvider}; From 8434fb002d037d41727531b52e93be3c865cf5e3 Mon Sep 17 00:00:00 2001 From: kazmosahebi Date: Mon, 25 May 2026 23:32:35 +1000 Subject: [PATCH 2/4] fix(credential): add to full feature; restore vault error wording Addresses code-review feedback: - include `credential` in the `full` aggregator alongside other optional modules - restore prior-art's more specific vault error messages - correct the test comment about parallel-test safety --- Cargo.toml | 2 +- src/credential.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cb52688a..486d9c88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -166,7 +166,7 @@ secrets-all = ["secrets-vault", "secrets-aws"] credential = [] # Full feature set -full = ["config", "config-reload", "logger", "metrics", "metrics-dfe", "otel", "otel-metrics", "otel-tracing", "runtime", "shutdown", "health", "http", "http-server", "spool", "tiered-sink", "database", "cache", "transport-all", "transport-trace", "transport-grpc-vector-compat", "secrets-all", "directory-config", "directory-config-git", "deployment", "version-check", "scaling", "memory", "worker", "cli-service", "io", "dlq", "dlq-kafka", "dlq-http", "dlq-redis", "output-file", "expression"] +full = ["config", "config-reload", "logger", "metrics", "metrics-dfe", "otel", "otel-metrics", "otel-tracing", "runtime", "shutdown", "health", "http", "http-server", "spool", "tiered-sink", "database", "cache", "transport-all", "transport-trace", "transport-grpc-vector-compat", "secrets-all", "directory-config", "directory-config-git", "deployment", "version-check", "scaling", "memory", "worker", "cli-service", "io", "dlq", "dlq-kafka", "dlq-http", "dlq-redis", "output-file", "expression", "credential"] [dependencies] # Serialisation (always needed) diff --git a/src/credential.rs b/src/credential.rs index dc48fd68..0059338f 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -87,14 +87,14 @@ async fn resolve_vault(path_key: &str) -> Result { }; let secrets = SecretsManager::new(config) - .map_err(|e| CredentialError::Vault(format!("init failed: {e}")))?; + .map_err(|e| CredentialError::Vault(format!("failed to initialise secrets manager: {e}")))?; let value = secrets .get("_vault_lookup") .await .map_err(|e| CredentialError::Vault(format!("lookup failed for {path}:{key}: {e}")))?; let text = value .as_str() - .map_err(|e| CredentialError::Vault(format!("not UTF-8: {e}")))?; + .map_err(|e| CredentialError::Vault(format!("vault secret not valid UTF-8: {e}")))?; tracing::debug!(path = path, key = key, "resolved vault credential"); Ok(text.to_string()) } @@ -117,7 +117,7 @@ mod tests { #[tokio::test] async fn resolve_env_set() { - // SAFETY: test-only, single-threaded + // SAFETY: test-only; uses a unique var name to avoid interference with parallel tests unsafe { std::env::set_var("HYPERI_RUSTLIB_TEST_CRED", "value-123") }; let v = resolve("env:HYPERI_RUSTLIB_TEST_CRED").await.unwrap(); assert_eq!(v, "value-123"); From ebdd680b6cca22dd308f3bb74a5270f2074f26fe Mon Sep 17 00:00:00 2001 From: kazmosahebi Date: Wed, 27 May 2026 11:10:07 +1000 Subject: [PATCH 3/4] style: cargo fmt fixups for credential module --- src/credential.rs | 13 +++++++++---- src/lib.rs | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/credential.rs b/src/credential.rs index 0059338f..636d343a 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -86,8 +86,9 @@ async fn resolve_vault(path_key: &str) -> Result { ..Default::default() }; - let secrets = SecretsManager::new(config) - .map_err(|e| CredentialError::Vault(format!("failed to initialise secrets manager: {e}")))?; + let secrets = SecretsManager::new(config).map_err(|e| { + CredentialError::Vault(format!("failed to initialise secrets manager: {e}")) + })?; let value = secrets .get("_vault_lookup") .await @@ -126,9 +127,13 @@ mod tests { #[tokio::test] async fn resolve_env_missing() { - let err = resolve("env:HYPERI_RUSTLIB_NONEXISTENT_XYZ").await.unwrap_err(); + let err = resolve("env:HYPERI_RUSTLIB_NONEXISTENT_XYZ") + .await + .unwrap_err(); match err { - CredentialError::MissingEnvVar { name } => assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ"), + CredentialError::MissingEnvVar { name } => { + assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ") + } other => panic!("expected MissingEnvVar, got {other:?}"), } } diff --git a/src/lib.rs b/src/lib.rs index 2392dcbe..52add705 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,7 +295,7 @@ pub use secrets::{ #[cfg(feature = "credential")] #[cfg_attr(docsrs, doc(cfg(feature = "credential")))] -pub use credential::{resolve, resolve_optional, CredentialError}; +pub use credential::{CredentialError, resolve, resolve_optional}; #[cfg(feature = "secrets-vault")] #[cfg_attr(docsrs, doc(cfg(feature = "secrets-vault")))] From 79e08ae379ffc39b0d0b8497f06dd23ccfaed4fb Mon Sep 17 00:00:00 2001 From: kazmosahebi Date: Wed, 27 May 2026 11:20:17 +1000 Subject: [PATCH 4/4] fix(credential): semicolon-if-nothing-returned in resolve_env_missing test --- src/credential.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/credential.rs b/src/credential.rs index 636d343a..58436ad3 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -132,7 +132,7 @@ mod tests { .unwrap_err(); match err { CredentialError::MissingEnvVar { name } => { - assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ") + assert_eq!(name, "HYPERI_RUSTLIB_NONEXISTENT_XYZ"); } other => panic!("expected MissingEnvVar, got {other:?}"), }