use serde_json::Value;
use crate::errors::OrionError;
#[async_trait::async_trait]
pub trait SecretResolver: Send + Sync {
fn scheme(&self) -> &'static str;
async fn resolve(&self, reference: &str) -> Result<String, OrionError>;
}
pub struct EnvSecretResolver;
#[async_trait::async_trait]
impl SecretResolver for EnvSecretResolver {
fn scheme(&self) -> &'static str {
"env"
}
async fn resolve(&self, reference: &str) -> Result<String, OrionError> {
if reference.is_empty()
|| !reference
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(OrionError::Config {
message: format!(
"Invalid env-var name '{reference}' in env:// reference (allowed: [A-Z0-9_])"
),
});
}
std::env::var(reference).map_err(|_| OrionError::Config {
message: format!(
"env-var '{reference}' is not set (referenced via env:// in a connector config)"
),
})
}
}
pub const RESERVED_SCHEMES: &[&str] = &["vault", "aws-sm", "gcp-sm", "azure-kv"];
pub struct ReservedSchemeResolver {
scheme: &'static str,
}
#[async_trait::async_trait]
impl SecretResolver for ReservedSchemeResolver {
fn scheme(&self) -> &'static str {
self.scheme
}
async fn resolve(&self, _reference: &str) -> Result<String, OrionError> {
Err(OrionError::Config {
message: format!(
"secret scheme '{}://' is reserved but not supported in this build; \
supply the value via env:// or a literal instead",
self.scheme
),
})
}
}
pub fn is_resolvable_reference(s: &str) -> bool {
parse_reference(s).is_some_and(|(scheme, reference)| {
!reference.is_empty()
&& (scheme == EnvSecretResolver.scheme() || RESERVED_SCHEMES.contains(&scheme))
})
}
pub fn default_resolvers() -> &'static [Box<dyn SecretResolver>] {
static RESOLVERS: std::sync::OnceLock<Vec<Box<dyn SecretResolver>>> =
std::sync::OnceLock::new();
RESOLVERS.get_or_init(|| {
let mut resolvers: Vec<Box<dyn SecretResolver>> = vec![
Box::new(EnvSecretResolver),
Box::new(VaultSecretResolver::from_env()),
];
for scheme in RESERVED_SCHEMES {
if *scheme == "vault" {
continue;
}
resolvers.push(Box::new(ReservedSchemeResolver { scheme }));
}
resolvers
})
}
pub struct VaultSecretResolver {
endpoint: VaultEndpoint,
client: reqwest::Client,
}
const MAX_VAULT_RESPONSE_BYTES: usize = 262_144;
enum VaultEndpoint {
Environment,
Fixed { addr: String, token: String },
}
impl VaultEndpoint {
fn settings(&self) -> Result<(String, String), OrionError> {
match self {
VaultEndpoint::Fixed { addr, token } => Ok((addr.clone(), token.clone())),
VaultEndpoint::Environment => {
let addr = std::env::var("VAULT_ADDR").map_err(|_| unconfigured("VAULT_ADDR"))?;
let token =
std::env::var("VAULT_TOKEN").map_err(|_| unconfigured("VAULT_TOKEN"))?;
Ok((addr.trim_end_matches('/').to_string(), token))
}
}
}
}
fn unconfigured(var: &str) -> OrionError {
OrionError::Config {
message: format!(
"vault:// is not usable in this process: {var} is not set. \
Set VAULT_ADDR and VAULT_TOKEN, or supply the value via env:// \
or a literal instead"
),
}
}
impl VaultSecretResolver {
pub fn from_env() -> Self {
Self {
endpoint: VaultEndpoint::Environment,
client: vault_http_client(),
}
}
pub fn new(addr: impl Into<String>, token: impl Into<String>) -> Self {
Self {
endpoint: VaultEndpoint::Fixed {
addr: addr.into().trim_end_matches('/').to_string(),
token: token.into(),
},
client: vault_http_client(),
}
}
}
fn vault_http_client() -> reqwest::Client {
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
CLIENT
.get_or_init(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("reqwest client with static config")
})
.clone()
}
#[async_trait::async_trait]
impl SecretResolver for VaultSecretResolver {
fn scheme(&self) -> &'static str {
"vault"
}
async fn resolve(&self, reference: &str) -> Result<String, OrionError> {
let (path, field) = reference
.split_once('#')
.ok_or_else(|| OrionError::Config {
message: format!(
"vault:// reference '{reference}' must name a field: \
vault://<api-path>#<field> (e.g. vault://secret/data/db#password)"
),
})?;
if path.is_empty() || field.is_empty() {
return Err(OrionError::Config {
message: format!("vault:// reference '{reference}' has an empty path or field"),
});
}
let (addr, token) = self.endpoint.settings()?;
let url = format!("{addr}/v1/{path}");
let response = self
.client
.get(&url)
.header("X-Vault-Token", &token)
.send()
.await
.map_err(|e| OrionError::Config {
message: format!("vault://{path}: request to Vault failed: {e}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(OrionError::Config {
message: format!(
"vault://{path}: Vault answered {status} (check VAULT_ADDR, \
VAULT_TOKEN and the secret path)"
),
});
}
let bytes = crate::http_body::read_bounded(response, MAX_VAULT_RESPONSE_BYTES)
.await
.map_err(|e| OrionError::Config {
message: format!("vault://{path}: reading the Vault response failed: {e}"),
})?;
let body: Value = serde_json::from_slice(&bytes).map_err(|_| OrionError::Config {
message: format!("vault://{path}: Vault response was not JSON"),
})?;
let secret = body
.get("data")
.and_then(|d| d.get("data"))
.and_then(|d| d.get(field))
.or_else(|| body.get("data").and_then(|d| d.get(field)));
match secret {
Some(Value::String(v)) => Ok(v.clone()),
Some(other) => Ok(other.to_string()),
None => Err(OrionError::Config {
message: format!(
"vault://{path}#{field}: the secret exists but carries no \
field '{field}'"
),
}),
}
}
}
pub async fn resolve_secret_string(value: &str, field: &str) -> Result<String, String> {
let mut json = Value::String(value.to_string());
resolve_in_place(&mut json, default_resolvers(), field)
.await
.map_err(|e| e.to_string())?;
json.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{field} did not resolve to a string"))
}
pub async fn resolve_in_place(
value: &mut Value,
resolvers: &[Box<dyn SecretResolver>],
source_label: &str,
) -> Result<(), OrionError> {
let mut wanted: Vec<String> = Vec::new();
collect_references(value, resolvers, &mut wanted);
if wanted.is_empty() {
return Ok(());
}
let mut resolved: std::collections::HashMap<String, String> = Default::default();
for reference_string in wanted {
let (scheme, reference) =
parse_reference(&reference_string).expect("collected as a reference");
let resolver = resolvers
.iter()
.find(|r| r.scheme() == scheme)
.expect("collected against this registry");
let secret = resolver.resolve(reference).await.map_err(|e| match e {
OrionError::Config { message } => OrionError::Config {
message: format!("{source_label}: {message}"),
},
other => other,
})?;
resolved.insert(reference_string, secret);
}
substitute(value, &resolved);
Ok(())
}
fn collect_references(value: &Value, resolvers: &[Box<dyn SecretResolver>], out: &mut Vec<String>) {
match value {
Value::String(s) => {
if let Some((scheme, _)) = parse_reference(s)
&& resolvers.iter().any(|r| r.scheme() == scheme)
&& !out.iter().any(|seen| seen == s)
{
out.push(s.clone());
}
}
Value::Object(map) => {
for v in map.values() {
collect_references(v, resolvers, out);
}
}
Value::Array(arr) => {
for v in arr {
collect_references(v, resolvers, out);
}
}
_ => {}
}
}
fn substitute(value: &mut Value, resolved: &std::collections::HashMap<String, String>) {
match value {
Value::String(s) => {
if let Some(secret) = resolved.get(s.as_str()) {
*s = secret.clone();
}
}
Value::Object(map) => {
for v in map.values_mut() {
substitute(v, resolved);
}
}
Value::Array(arr) => {
for v in arr {
substitute(v, resolved);
}
}
_ => {}
}
}
pub fn embedded_reference(s: &str) -> Option<&'static str> {
if s.starts_with("${") && s.ends_with('}') {
return None;
}
std::iter::once("env")
.chain(RESERVED_SCHEMES.iter().copied())
.find(|scheme| {
let needle = format!("{scheme}://");
s.match_indices(&needle).any(|(at, _)| {
at > 0
&& !s[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-'))
&& !s[at + needle.len()..].trim().is_empty()
})
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmbeddedReference {
pub path: String,
pub scheme: &'static str,
pub authorization_header: bool,
}
impl EmbeddedReference {
pub fn remedy(&self) -> &'static str {
if self.authorization_header {
"use \"auth\": {\"type\": \"bearer\", \"token\": \"env://API_KEY\"} instead of an \
Authorization header"
} else {
"make the reference the whole value, or build the string in the deployment \
environment"
}
}
pub fn message(&self) -> String {
format!(
"`{}://…` sits inside a longer string, so it is not a reference: a reference must be \
the whole value, and this text will be sent literally",
self.scheme
)
}
}
pub fn embedded_references(config: &Value) -> Vec<EmbeddedReference> {
fn walk(value: &Value, path: &mut Vec<String>, out: &mut Vec<EmbeddedReference>) {
match value {
Value::String(s) => {
if let Some(scheme) = embedded_reference(s) {
out.push(EmbeddedReference {
path: path.join("."),
scheme,
authorization_header: matches!(
path.as_slice(),
[headers, name] if headers == "headers"
&& name.eq_ignore_ascii_case("authorization")
),
});
}
}
Value::Object(map) => {
for (key, v) in map {
path.push(key.clone());
walk(v, path, out);
path.pop();
}
}
Value::Array(items) => {
for (index, v) in items.iter().enumerate() {
path.push(index.to_string());
walk(v, path, out);
path.pop();
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(config, &mut Vec::new(), &mut out);
out
}
pub fn is_load_time_reference(s: &str) -> bool {
s.starts_with(crate::config::vars::VAR_SCHEME) || is_resolvable_reference(s)
}
pub struct UnresolvedReferences;
impl super::VariantParse for UnresolvedReferences {
type Error = String;
fn parse<T: serde::de::DeserializeOwned>(&self, value: &Value) -> Result<T, String> {
crate::config::vars::parse_with_unresolved_refs(value, &|_| false, &is_load_time_reference)
}
}
pub(crate) fn reference_sites(
value: &Value,
resolvers: &[Box<dyn SecretResolver>],
) -> Vec<Vec<crate::config::vars::Seg>> {
use crate::config::vars::Seg;
fn walk(
value: &Value,
resolvers: &[Box<dyn SecretResolver>],
path: &mut Vec<Seg>,
out: &mut Vec<Vec<Seg>>,
) {
match value {
Value::String(s) => {
if let Some((scheme, _)) = parse_reference(s)
&& resolvers.iter().any(|r| r.scheme() == scheme)
{
out.push(path.clone());
}
}
Value::Object(map) => {
for (key, v) in map {
path.push(Seg::Key(key.clone()));
walk(v, resolvers, path, out);
path.pop();
}
}
Value::Array(items) => {
for (index, v) in items.iter().enumerate() {
path.push(Seg::Index(index));
walk(v, resolvers, path, out);
path.pop();
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(value, resolvers, &mut Vec::new(), &mut out);
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedParseError {
NotABoolean { field: String },
ShapeAtSite { field: String },
Shape(String),
}
impl std::fmt::Display for ResolvedParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotABoolean { field } => write!(
f,
"{field}: the reference resolved to something other than true or false"
),
Self::ShapeAtSite { field } => write!(
f,
"{field}: the reference resolved to a value this field cannot take (the value \
is withheld: it may be a secret)"
),
Self::Shape(message) => f.write_str(message),
}
}
}
pub struct ResolvedReferences<'a> {
pub(crate) sites: &'a [Vec<crate::config::vars::Seg>],
}
impl super::VariantParse for ResolvedReferences<'_> {
type Error = ResolvedParseError;
fn parse<T: serde::de::DeserializeOwned>(
&self,
value: &Value,
) -> Result<T, ResolvedParseError> {
use crate::config::vars::{Seg, display_path, slot_at};
let mut doc = value.clone();
let mut coerced: Vec<Vec<Seg>> = Vec::new();
loop {
let err = match serde_path_to_error::deserialize::<_, T>(doc.clone()) {
Ok(typed) => return Ok(typed),
Err(err) => err,
};
let at: Option<Vec<Seg>> = err.path().iter().map(Seg::from_segment).collect();
let Some(at) = at.filter(|at| self.sites.contains(at)) else {
return Err(ResolvedParseError::Shape(err.to_string()));
};
let field = display_path(&at);
let text = slot_at(&mut doc, &at)
.and_then(|slot| slot.as_str())
.map(|s| s.trim().to_ascii_lowercase());
let wants_bool = err.inner().to_string().contains("expected a boolean");
match text.as_deref() {
Some(b @ ("true" | "false")) if !coerced.contains(&at) => {
let b = b == "true";
if let Some(slot) = slot_at(&mut doc, &at) {
*slot = Value::Bool(b);
}
coerced.push(at);
}
_ if wants_bool => return Err(ResolvedParseError::NotABoolean { field }),
_ => return Err(ResolvedParseError::ShapeAtSite { field }),
}
}
}
}
pub(crate) fn parse_reference(s: &str) -> Option<(&str, &str)> {
let (scheme, rest) = s.split_once("://")?;
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '+')
{
return None;
}
Some((scheme, rest))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct StubResolver {
scheme: &'static str,
values: std::collections::HashMap<&'static str, &'static str>,
}
#[async_trait::async_trait]
impl SecretResolver for StubResolver {
fn scheme(&self) -> &'static str {
self.scheme
}
async fn resolve(&self, reference: &str) -> Result<String, OrionError> {
self.values
.get(reference)
.map(|v| (*v).to_string())
.ok_or_else(|| OrionError::Config {
message: format!("stub: '{reference}' not registered"),
})
}
}
fn stub(values: &[(&'static str, &'static str)]) -> Vec<Box<dyn SecretResolver>> {
vec![Box::new(StubResolver {
scheme: "env",
values: values.iter().copied().collect(),
})]
}
#[test]
fn parse_reference_recognizes_scheme() {
assert_eq!(parse_reference("env://FOO"), Some(("env", "FOO")));
assert_eq!(
parse_reference("https://example.com"),
Some(("https", "example.com"))
);
}
#[test]
fn parse_reference_rejects_uppercase_scheme() {
assert_eq!(parse_reference("ENV://FOO"), None);
}
#[tokio::test]
async fn parse_reference_returns_none_for_plain_string() {
assert_eq!(parse_reference("plain text"), None);
assert_eq!(parse_reference(""), None);
}
#[tokio::test]
async fn resolve_in_place_replaces_string() {
let mut v = json!({ "token": "env://API_TOKEN" });
resolve_in_place(&mut v, &stub(&[("API_TOKEN", "s3cret")]), "test")
.await
.expect("test");
assert_eq!(v["token"], "s3cret");
}
#[tokio::test]
async fn resolve_in_place_leaves_unknown_schemes_alone() {
let mut v = json!({ "url": "https://example.com/api" });
resolve_in_place(&mut v, &stub(&[]), "test")
.await
.expect("test");
assert_eq!(v["url"], "https://example.com/api");
}
#[tokio::test]
async fn reserved_scheme_errors_instead_of_becoming_the_literal_password() {
let mut v = json!({ "auth": { "password": "aws-sm://prod/db#password" } });
let err = resolve_in_place(&mut v, default_resolvers(), "connector 'db'")
.await
.expect_err(
"an unimplemented scheme must fail loudly, not pass through as the password",
);
let OrionError::Config { message } = err else {
unreachable!("expected Config error");
};
assert!(message.contains("aws-sm"), "{message}");
assert!(message.contains("not supported"), "{message}");
assert!(message.contains("connector 'db'"), "{message}");
}
#[test]
fn vault_without_an_environment_refuses_rather_than_passing_through() {
let OrionError::Config { message } = unconfigured("VAULT_ADDR") else {
unreachable!("expected Config error");
};
assert!(message.contains("vault://"), "{message}");
assert!(message.contains("VAULT_ADDR"), "{message}");
}
#[test]
fn the_default_registry_is_built_once() {
assert!(
std::ptr::eq(default_resolvers(), default_resolvers()),
"default_resolvers() must hand back one process-wide registry"
);
}
#[tokio::test]
async fn every_reserved_scheme_is_rejected() {
for scheme in RESERVED_SCHEMES {
let mut v = json!({ "token": format!("{scheme}://some/path") });
assert!(
resolve_in_place(&mut v, default_resolvers(), "test")
.await
.is_err(),
"scheme '{scheme}' must be rejected"
);
}
}
#[tokio::test]
async fn default_resolvers_leave_connection_urls_untouched() {
let mut v = json!({
"connection_string": "postgres://user:pass@db.internal:5432/app",
"url": "redis://cache.internal:6379",
"brokers": ["kafka.internal:9092"]
});
resolve_in_place(&mut v, default_resolvers(), "test")
.await
.expect("test");
assert_eq!(
v["connection_string"],
"postgres://user:pass@db.internal:5432/app"
);
assert_eq!(v["url"], "redis://cache.internal:6379");
assert_eq!(v["brokers"][0], "kafka.internal:9092");
}
#[tokio::test]
async fn resolve_in_place_recurses_into_objects() {
let mut v = json!({
"auth": { "type": "bearer", "token": "env://TOK" },
"max_retries": 3
});
resolve_in_place(&mut v, &stub(&[("TOK", "abc")]), "test")
.await
.expect("test");
assert_eq!(v["auth"]["token"], "abc");
assert_eq!(v["max_retries"], 3);
}
#[tokio::test]
async fn resolve_in_place_recurses_into_arrays() {
let mut v = json!({ "brokers": ["env://B1", "literal:9092"] });
resolve_in_place(&mut v, &stub(&[("B1", "broker.local:9092")]), "test")
.await
.expect("test");
assert_eq!(v["brokers"][0], "broker.local:9092");
assert_eq!(v["brokers"][1], "literal:9092");
}
#[tokio::test]
async fn missing_env_var_errors_with_source_label() {
let mut v = json!({ "token": "env://NOPE" });
let err = resolve_in_place(&mut v, &stub(&[]), "connector 'foo'")
.await
.expect_err("test");
let OrionError::Config { message } = err else {
unreachable!("expected Config error");
};
assert!(message.contains("NOPE"));
assert!(message.contains("connector 'foo'"));
}
#[tokio::test]
async fn env_resolver_rejects_invalid_var_name() {
let r = EnvSecretResolver;
assert!(r.resolve("").await.is_err());
assert!(r.resolve("has-hyphen").await.is_err());
assert!(r.resolve("with space").await.is_err());
}
}
#[cfg(test)]
mod vault_tests {
use super::*;
use axum::Json;
use axum::http::{HeaderMap, StatusCode};
use serde_json::json;
async fn fake_vault() -> String {
fn authed(headers: &HeaderMap) -> bool {
headers.get("X-Vault-Token").is_some_and(|v| v == "t0ken")
}
let app = axum::Router::new()
.route(
"/v1/secret/data/db",
axum::routing::get(|headers: HeaderMap| async move {
if !authed(&headers) {
return (StatusCode::FORBIDDEN, Json(json!({"errors": ["denied"]})));
}
(
StatusCode::OK,
Json(json!({"data": {"data": {"password": "hunter2"}}})),
)
}),
)
.route(
"/v1/legacy/db",
axum::routing::get(|headers: HeaderMap| async move {
if !authed(&headers) {
return (StatusCode::FORBIDDEN, Json(json!({"errors": ["denied"]})));
}
(
StatusCode::OK,
Json(json!({"data": {"password": "legacy2"}})),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
tokio::spawn(async move {
axum::serve(listener, app).await.expect("serve");
});
format!("http://{addr}")
}
#[tokio::test]
async fn resolves_kv2_and_kv1_shapes() {
let addr = fake_vault().await;
let r = VaultSecretResolver::new(&addr, "t0ken");
assert_eq!(
r.resolve("secret/data/db#password").await.expect("kv2"),
"hunter2"
);
assert_eq!(
r.resolve("legacy/db#password").await.expect("kv1"),
"legacy2"
);
}
#[tokio::test]
async fn failures_are_loud_and_tokenless() {
let addr = fake_vault().await;
let r = VaultSecretResolver::new(&addr, "wrong");
let err = r
.resolve("secret/data/db#password")
.await
.expect_err("must refuse");
let msg = err.to_string();
assert!(msg.contains("403"), "{msg}");
assert!(!msg.contains("wrong"), "token must not leak: {msg}");
let r = VaultSecretResolver::new(&addr, "t0ken");
let err = r
.resolve("secret/data/db#missing_field")
.await
.expect_err("must refuse");
assert!(err.to_string().contains("missing_field"));
let err = r.resolve("secret/data/db").await.expect_err("must refuse");
assert!(err.to_string().contains("must name a field"));
}
#[tokio::test]
async fn resolve_in_place_uses_the_vault_resolver() {
let addr = fake_vault().await;
let resolvers: Vec<Box<dyn SecretResolver>> = vec![
Box::new(EnvSecretResolver),
Box::new(VaultSecretResolver::new(&addr, "t0ken")),
];
let mut v = serde_json::json!({
"auth": {"password": "vault://secret/data/db#password"},
"url": "https://db.example.com"
});
resolve_in_place(&mut v, &resolvers, "connector 'db'")
.await
.expect("resolves");
assert_eq!(v["auth"]["password"], "hunter2");
assert_eq!(v["url"], "https://db.example.com");
}
#[tokio::test]
async fn an_oversized_vault_response_is_refused_while_streaming() {
const CHUNK: usize = 64 * 1024;
const CHUNKS: usize = 128; let (url, server) = crate::http_body::flood_server(CHUNK, CHUNKS).await;
let r = VaultSecretResolver::new(url.trim_end_matches('/'), "t0ken");
let err = r
.resolve("secret/data/db#password")
.await
.expect_err("an unbounded body must be refused");
assert!(
err.to_string().contains("byte limit"),
"the refusal must be the size cap: {err}"
);
crate::http_body::assert_stopped_early(
server.await.expect("test server"),
CHUNK * CHUNKS,
"vault resolver",
);
}
}
#[cfg(test)]
mod reference_parse_tests {
use super::*;
use crate::connector::{ConnectorConfig, ConnectorType};
use serde_json::json;
fn env_resolvers() -> Vec<Box<dyn SecretResolver>> {
vec![Box::new(EnvSecretResolver)]
}
fn load(stored: Value, resolved: Value) -> Result<ConnectorConfig, ResolvedParseError> {
let sites = reference_sites(&stored, &env_resolvers());
ConnectorConfig::parse_variant(
ConnectorType::Http,
&resolved,
&ResolvedReferences { sites: &sites },
)
}
#[test]
fn parse_resolved_coerces_true_and_false_at_sites_only() {
let stored = json!({"url": "env://URL", "allow_private_urls": "env://PRIVATE"});
for (text, expected) in [("true", true), (" FALSE\n", false), ("True", true)] {
let resolved = json!({"url": "http://peer:8080", "allow_private_urls": text});
match load(stored.clone(), resolved).expect("coerced") {
ConnectorConfig::Http(http) => {
assert_eq!(http.allow_private_urls, expected, "{text:?}");
assert_eq!(http.url, "http://peer:8080");
}
other => unreachable!("{other:?}"),
}
}
let literal = json!({"url": "http://peer", "allow_private_urls": "true"});
let err = load(literal.clone(), literal).expect_err("not a site");
assert!(matches!(err, ResolvedParseError::Shape(_)), "{err:?}");
}
#[test]
fn a_non_boolean_resolution_names_the_field_and_not_the_value() {
let stored = json!({"url": "http://peer", "allow_private_urls": "env://PRIVATE"});
let resolved = json!({"url": "http://peer", "allow_private_urls": "s3cr3t-token"});
let err = load(stored, resolved).expect_err("garbage");
assert_eq!(
err,
ResolvedParseError::NotABoolean {
field: "allow_private_urls".to_string()
}
);
let message = err.to_string();
assert!(message.contains("allow_private_urls"), "{message}");
assert!(!message.contains("s3cr3t"), "{message}");
}
#[test]
fn embedded_references_are_found_and_whole_ones_are_not() {
for (text, expected) in [
("Bearer env://API_KEY", Some("env")),
("x vault://secret/a#b", Some("vault")),
("token=(env://K)", Some("env")),
("env://API_KEY", None),
("someenv://k", None),
("${X:-env://Y}", None),
("https://h/p", None),
("see env:// ", None),
] {
assert_eq!(embedded_reference(text), expected, "{text:?}");
}
let found = embedded_references(&json!({
"url": "https://api.example.com",
"headers": {"Authorization": "Bearer env://API_KEY", "X-Other": "k env://K"},
"auth": {"type": "bearer", "token": "env://API_KEY"},
}));
assert_eq!(found.len(), 2, "{found:?}");
assert_eq!(found[0].path, "headers.Authorization");
assert!(found[0].authorization_header);
assert!(found[0].remedy().contains("\"auth\""));
assert_eq!(found[1].path, "headers.X-Other");
assert!(!found[1].authorization_header);
}
}