use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
use crate::error::{Error, Result};
use crate::value::Value;
const PEM_BEGIN_MARKER: &str = "-----BEGIN";
const PEM_BEGIN_ENCRYPTED_KEY: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
#[derive(Clone, Debug)]
pub enum CertInput {
Text(String),
Binary(Vec<u8>),
}
impl CertInput {
pub fn is_pem_content(&self) -> bool {
matches!(self, CertInput::Text(s) if s.contains(PEM_BEGIN_MARKER))
}
pub fn is_p12_path(&self) -> bool {
matches!(self, CertInput::Text(s) if {
let lower = s.to_lowercase();
lower.ends_with(".p12") || lower.ends_with(".pfx")
})
}
pub fn as_text(&self) -> Option<&str> {
match self {
CertInput::Text(s) => Some(s),
CertInput::Binary(_) => None,
}
}
pub fn as_bytes(&self) -> Option<&[u8]> {
match self {
CertInput::Text(_) => None,
CertInput::Binary(b) => Some(b),
}
}
}
impl From<String> for CertInput {
fn from(s: String) -> Self {
CertInput::Text(s)
}
}
impl From<&str> for CertInput {
fn from(s: &str) -> Self {
CertInput::Text(s.to_string())
}
}
impl From<Vec<u8>> for CertInput {
fn from(b: Vec<u8>) -> Self {
CertInput::Binary(b)
}
}
static GLOBAL_REGISTRY: OnceLock<RwLock<ResolverRegistry>> = OnceLock::new();
pub fn global_registry() -> &'static RwLock<ResolverRegistry> {
GLOBAL_REGISTRY.get_or_init(|| RwLock::new(ResolverRegistry::with_builtins()))
}
pub fn register_global(resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
let mut registry = global_registry()
.write()
.expect("Global registry lock poisoned");
registry.register_with_force(resolver, force)
}
#[derive(Clone)]
pub struct ResolvedValue {
pub value: Value,
pub sensitive: bool,
}
impl std::fmt::Debug for ResolvedValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedValue")
.field(
"value",
if self.sensitive {
&"[REDACTED]"
} else {
&self.value
},
)
.field("sensitive", &self.sensitive)
.finish()
}
}
impl ResolvedValue {
pub fn new(value: impl Into<Value>) -> Self {
Self {
value: value.into(),
sensitive: false,
}
}
pub fn sensitive(value: impl Into<Value>) -> Self {
Self {
value: value.into(),
sensitive: true,
}
}
}
impl From<Value> for ResolvedValue {
fn from(value: Value) -> Self {
ResolvedValue::new(value)
}
}
impl From<String> for ResolvedValue {
fn from(s: String) -> Self {
ResolvedValue::new(Value::String(s))
}
}
impl From<&str> for ResolvedValue {
fn from(s: &str) -> Self {
ResolvedValue::new(Value::String(s.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct ResolverContext {
pub config_path: String,
pub config_root: Option<Arc<Value>>,
pub base_path: Option<std::path::PathBuf>,
pub file_roots: std::collections::HashSet<std::path::PathBuf>,
pub resolution_stack: Vec<String>,
pub allow_http: bool,
pub http_allowlist: Vec<String>,
pub http_proxy: Option<String>,
pub http_proxy_from_env: bool,
pub http_ca_bundle: Option<CertInput>,
pub http_extra_ca_bundle: Option<CertInput>,
pub http_client_cert: Option<CertInput>,
pub http_client_key: Option<CertInput>,
pub http_client_key_password: Option<String>,
}
impl ResolverContext {
pub fn new(config_path: impl Into<String>) -> Self {
Self {
config_path: config_path.into(),
config_root: None,
base_path: None,
file_roots: std::collections::HashSet::new(),
resolution_stack: Vec::new(),
allow_http: false,
http_allowlist: Vec::new(),
http_proxy: None,
http_proxy_from_env: false,
http_ca_bundle: None,
http_extra_ca_bundle: None,
http_client_cert: None,
http_client_key: None,
http_client_key_password: None,
}
}
pub fn with_allow_http(mut self, allow: bool) -> Self {
self.allow_http = allow;
self
}
pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
self.http_allowlist = allowlist;
self
}
pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
self.http_proxy = Some(proxy.into());
self
}
pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
self.http_proxy_from_env = enabled;
self
}
pub fn with_http_ca_bundle(mut self, input: impl Into<CertInput>) -> Self {
self.http_ca_bundle = Some(input.into());
self
}
pub fn with_http_extra_ca_bundle(mut self, input: impl Into<CertInput>) -> Self {
self.http_extra_ca_bundle = Some(input.into());
self
}
pub fn with_http_client_cert(mut self, input: impl Into<CertInput>) -> Self {
self.http_client_cert = Some(input.into());
self
}
pub fn with_http_client_key(mut self, input: impl Into<CertInput>) -> Self {
self.http_client_key = Some(input.into());
self
}
pub fn with_http_client_key_password(mut self, password: impl Into<String>) -> Self {
self.http_client_key_password = Some(password.into());
self
}
pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
self.config_root = Some(root);
self
}
pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
self.base_path = Some(path);
self
}
pub fn would_cause_cycle(&self, path: &str) -> bool {
self.resolution_stack.contains(&path.to_string())
}
pub fn push_resolution(&mut self, path: &str) {
self.resolution_stack.push(path.to_string());
}
pub fn pop_resolution(&mut self) {
self.resolution_stack.pop();
}
pub fn get_resolution_chain(&self) -> Vec<String> {
self.resolution_stack.clone()
}
}
pub trait Resolver: Send + Sync {
fn resolve(
&self,
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue>;
fn name(&self) -> &str;
}
pub struct FnResolver<F>
where
F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
+ Send
+ Sync,
{
name: String,
func: F,
}
impl<F> FnResolver<F>
where
F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
+ Send
+ Sync,
{
pub fn new(name: impl Into<String>, func: F) -> Self {
Self {
name: name.into(),
func,
}
}
}
impl<F> Resolver for FnResolver<F>
where
F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
+ Send
+ Sync,
{
fn resolve(
&self,
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
(self.func)(args, kwargs, ctx)
}
fn name(&self) -> &str {
&self.name
}
}
#[derive(Clone)]
pub struct ResolverRegistry {
resolvers: HashMap<String, Arc<dyn Resolver>>,
}
impl Default for ResolverRegistry {
fn default() -> Self {
Self::new()
}
}
impl ResolverRegistry {
pub fn new() -> Self {
Self {
resolvers: HashMap::new(),
}
}
pub fn with_builtins() -> Self {
let mut registry = Self::new();
registry.register_builtin_resolvers();
registry
}
fn register_builtin_resolvers(&mut self) {
self.register(Arc::new(FnResolver::new("env", env_resolver)));
self.register(Arc::new(FnResolver::new("file", file_resolver)));
#[cfg(feature = "http")]
{
self.register(Arc::new(FnResolver::new("http", http_resolver)));
self.register(Arc::new(FnResolver::new("https", https_resolver)));
}
self.register(Arc::new(FnResolver::new("json", json_resolver)));
self.register(Arc::new(FnResolver::new("yaml", yaml_resolver)));
self.register(Arc::new(FnResolver::new("split", split_resolver)));
self.register(Arc::new(FnResolver::new("csv", csv_resolver)));
self.register(Arc::new(FnResolver::new("base64", base64_resolver)));
#[cfg(feature = "archive")]
{
self.register(Arc::new(FnResolver::new("extract", extract_resolver)));
}
}
pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
self.resolvers.insert(resolver.name().to_string(), resolver);
}
pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
let name = resolver.name().to_string();
if !force && self.resolvers.contains_key(&name) {
return Err(Error::resolver_already_registered(&name));
}
self.resolvers.insert(name, resolver);
Ok(())
}
pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
where
F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
+ Send
+ Sync
+ 'static,
{
let name = name.into();
self.register(Arc::new(FnResolver::new(name, func)));
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
self.resolvers.get(name)
}
pub fn contains(&self, name: &str) -> bool {
self.resolvers.contains_key(name)
}
pub fn resolve(
&self,
resolver_name: &str,
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
let resolver = self
.resolvers
.get(resolver_name)
.ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
let sensitive_override = kwargs
.get("sensitive")
.map(|v| v.eq_ignore_ascii_case("true"));
let resolver_kwargs: HashMap<String, String> = kwargs
.iter()
.filter(|(k, _)| *k != "sensitive")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
if let Some(is_sensitive) = sensitive_override {
resolved.sensitive = is_sensitive;
}
Ok(resolved)
}
}
fn env_resolver(
args: &[String],
_kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("env resolver requires a variable name")
.with_path(ctx.config_path.clone()));
}
let var_name = &args[0];
match std::env::var(var_name) {
Ok(value) => {
Ok(ResolvedValue::new(Value::String(value)))
}
Err(_) => {
Err(Error::env_not_found(
var_name,
Some(ctx.config_path.clone()),
))
}
}
}
fn is_localhost(hostname: &str) -> bool {
if hostname.eq_ignore_ascii_case("localhost") {
return true;
}
if hostname.starts_with("127.") {
return true;
}
if hostname == "::1" || hostname == "[::1]" {
return true;
}
false
}
fn normalize_file_path(arg: &str) -> Result<(String, bool)> {
if arg.contains('\0') {
return Err(Error::resolver_custom(
"file",
"File paths cannot contain null bytes",
));
}
if let Some(after_slashes) = arg.strip_prefix("//") {
if after_slashes.starts_with('/') {
Ok((after_slashes.to_string(), false))
} else {
let parts: Vec<&str> = after_slashes.splitn(2, '/').collect();
let hostname = parts[0];
if hostname.is_empty() {
return Ok(("/".to_string(), false));
}
if is_localhost(hostname) {
let path = parts
.get(1)
.map(|s| format!("/{}", s))
.unwrap_or_else(|| "/".to_string());
Ok((path, false))
} else {
Err(Error::resolver_custom(
"file",
format!(
"Remote file URIs not supported: hostname '{}' is not localhost\n\
\n\
HoloConf only supports local files:\n\
- file:///path/to/file (absolute, empty authority)\n\
- file://localhost/path/to/file (absolute, explicit localhost)\n\
- file:/path/to/file (absolute, minimal)\n\
- relative/path/to/file (relative to config directory)",
hostname
),
))
}
}
} else if arg.starts_with('/') {
Ok((arg.to_string(), false))
} else {
Ok((arg.to_string(), true))
}
}
fn file_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(
Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
);
}
let file_path_arg = &args[0];
let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("text");
let encoding = kwargs
.get("encoding")
.map(|s| s.as_str())
.unwrap_or("utf-8");
let (normalized_path, is_relative) = normalize_file_path(file_path_arg)?;
let file_path = if is_relative {
if let Some(base) = &ctx.base_path {
base.join(&normalized_path)
} else {
std::path::PathBuf::from(&normalized_path)
}
} else {
std::path::PathBuf::from(&normalized_path)
};
if ctx.file_roots.is_empty() {
return Err(Error::resolver_custom(
"file",
"File resolver requires allowed directories to be configured. \
Use Config.load() which auto-configures the parent directory, or \
specify file_roots explicitly for Config.loads()."
.to_string(),
)
.with_path(ctx.config_path.clone()));
}
let canonical_path = file_path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
return Error::file_not_found(file_path_arg, Some(ctx.config_path.clone()));
}
Error::resolver_custom("file", format!("Failed to resolve file path: {}", e))
.with_path(ctx.config_path.clone())
})?;
let mut canonicalization_errors = Vec::new();
let is_allowed = ctx.file_roots.iter().any(|root| {
match root.canonicalize() {
Ok(canonical_root) => canonical_path.starts_with(&canonical_root),
Err(e) => {
canonicalization_errors.push((root.clone(), e));
false
}
}
});
if !is_allowed {
let display_path = if let Some(base) = &ctx.base_path {
file_path
.strip_prefix(base)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "<outside allowed directories>".to_string())
} else {
"<outside allowed directories>".to_string()
};
let mut msg = format!(
"Access denied: file '{}' is outside allowed directories.",
display_path
);
if !canonicalization_errors.is_empty() {
msg.push_str(&format!(
" Note: {} configured root(s) could not be validated.",
canonicalization_errors.len()
));
}
msg.push_str(" Use file_roots parameter to extend allowed directories.");
return Err(Error::resolver_custom("file", msg).with_path(ctx.config_path.clone()));
}
if encoding == "binary" {
let file = std::fs::File::open(&file_path)
.map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
return Ok(ResolvedValue::new(Value::Stream(Box::new(file))));
}
let content = match encoding {
"base64" => {
use base64::{engine::general_purpose::STANDARD, Engine as _};
let bytes = std::fs::read(&file_path)
.map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
STANDARD.encode(bytes)
}
"ascii" => {
let raw = std::fs::read_to_string(&file_path)
.map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
raw.chars().filter(|c| c.is_ascii()).collect()
}
_ => {
std::fs::read_to_string(&file_path)
.map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?
}
};
if encoding == "base64" {
return Ok(ResolvedValue::new(Value::String(content)));
}
match parse_mode {
"none" => {
let file = std::fs::File::open(&file_path)
.map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
Ok(ResolvedValue::new(Value::Stream(Box::new(file))))
}
_ => {
Ok(ResolvedValue::new(Value::String(content)))
}
}
}
#[cfg(feature = "http")]
fn normalize_http_url(scheme: &str, arg: &str) -> Result<String> {
let clean = arg
.strip_prefix("http://")
.or_else(|| arg.strip_prefix("https://"))
.unwrap_or(arg);
let clean = clean.strip_prefix("//").unwrap_or(clean);
if clean.trim().is_empty() {
return Err(Error::resolver_custom(
scheme,
format!(
"{} resolver requires a non-empty URL",
scheme.to_uppercase()
),
));
}
if clean.starts_with('/') {
return Err(Error::resolver_custom(
scheme,
format!(
"Invalid URL syntax: '{}'. URLs must have a hostname after the ://\n\
Valid formats:\n\
- ${{{}:example.com/path}} (clean syntax)\n\
- ${{{}:{}://example.com/path}} (backwards compatible)",
arg, scheme, scheme, scheme
),
));
}
Ok(format!("{}://{}", scheme, clean))
}
fn http_or_https_resolver(
scheme: &str,
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(
Error::parse(format!("{} resolver requires a URL", scheme.to_uppercase()))
.with_path(ctx.config_path.clone()),
);
}
#[cfg(feature = "http")]
{
let url = normalize_http_url(scheme, &args[0])?;
if !ctx.allow_http {
return Err(Error {
kind: crate::error::ErrorKind::Resolver(
crate::error::ResolverErrorKind::HttpDisabled,
),
path: Some(ctx.config_path.clone()),
source_location: None,
help: Some(format!(
"{} resolver is disabled. The URL specified by this config path cannot be fetched.\n\
Enable with Config.load(..., allow_http=True)",
scheme.to_uppercase()
)),
cause: None,
});
}
if !ctx.http_allowlist.is_empty() {
let url_allowed = ctx
.http_allowlist
.iter()
.any(|pattern| url_matches_pattern(&url, pattern));
if !url_allowed {
return Err(Error::http_not_in_allowlist(
&url,
&ctx.http_allowlist,
Some(ctx.config_path.clone()),
));
}
}
http_fetch(&url, kwargs, ctx)
}
#[cfg(not(feature = "http"))]
{
let _ = (kwargs, ctx); Err(Error::resolver_custom(
scheme,
format!(
"{} support not compiled in. Rebuild with --features http",
scheme.to_uppercase()
),
))
}
}
fn http_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
http_or_https_resolver("http", args, kwargs, ctx)
}
fn https_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
http_or_https_resolver("https", args, kwargs, ctx)
}
#[cfg(feature = "http")]
fn url_matches_pattern(url: &str, pattern: &str) -> bool {
let parsed_url = match url::Url::parse(url) {
Ok(u) => u,
Err(_) => {
log::warn!("Invalid URL '{}' rejected by allowlist", url);
return false;
}
};
if pattern.contains("**") || pattern.contains(".*.*") {
log::warn!(
"Invalid allowlist pattern '{}' - contains dangerous sequence",
pattern
);
return false;
}
let glob_pattern = match glob::Pattern::new(pattern) {
Ok(p) => p,
Err(_) => {
log::warn!(
"Invalid glob pattern '{}' - falling back to exact match",
pattern
);
return url == pattern;
}
};
glob_pattern.matches(parsed_url.as_str())
}
#[cfg(feature = "http")]
fn parse_pem_certs(pem_bytes: &[u8], source: &str) -> Result<Vec<ureq::tls::Certificate<'static>>> {
use ureq::tls::PemItem;
let certs: Vec<_> = ureq::tls::parse_pem(pem_bytes)
.filter_map(|item| item.ok())
.filter_map(|item| match item {
PemItem::Certificate(cert) => Some(cert.to_owned()),
_ => None,
})
.collect();
if certs.is_empty() {
return Err(Error::pem_load_error(
source,
"No valid certificates found in PEM data",
));
}
Ok(certs)
}
#[cfg(feature = "http")]
fn load_certs(input: &CertInput) -> Result<Vec<ureq::tls::Certificate<'static>>> {
match input {
CertInput::Binary(_) => {
Err(Error::tls_config_error(
"CA bundle must be PEM format, not binary. For P12 client certificates, use client_cert parameter."
))
}
CertInput::Text(text) => {
let path = std::path::Path::new(text);
if path.exists() {
log::trace!("Loading certificates from file: {}", text);
let bytes = std::fs::read(path).map_err(|e| {
let display_path = if text.len() < 256 && !text.contains('\n') {
text
} else {
"[PEM content or long path]"
};
Error::pem_load_error(
display_path,
format!("Failed to read certificate file: {}", e),
)
})?;
parse_pem_certs(&bytes, text)
} else {
log::trace!("Path does not exist, attempting to parse as PEM content");
parse_pem_certs(text.as_bytes(), "PEM content")
}
}
}
}
#[cfg(feature = "http")]
fn parse_pem_private_key(
pem_content: &str,
password: Option<&str>,
source: &str,
) -> Result<ureq::tls::PrivateKey<'static>> {
use pkcs8::der::Decode;
if pem_content.contains(PEM_BEGIN_ENCRYPTED_KEY) {
let pwd = password.ok_or_else(|| {
Error::tls_config_error(format!(
"Password required for encrypted private key from: {}",
source
))
})?;
let der_bytes = pem_to_der(pem_content, "ENCRYPTED PRIVATE KEY")
.map_err(|e| Error::pem_load_error(source, e))?;
let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
.map_err(|e| Error::pem_load_error(source, e.to_string()))?;
let decrypted = encrypted
.decrypt(pwd)
.map_err(|e| Error::key_decryption_error(e.to_string()))?;
let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
.map(|k| k.to_owned())
.map_err(|e| {
Error::pem_load_error(source, format!("Failed to parse decrypted key: {}", e))
})
} else {
ureq::tls::PrivateKey::from_pem(pem_content.as_bytes())
.map(|k| k.to_owned())
.map_err(|e| {
Error::pem_load_error(source, format!("Failed to parse private key: {}", e))
})
}
}
#[cfg(feature = "http")]
fn load_private_key(
input: &CertInput,
password: Option<&str>,
) -> Result<ureq::tls::PrivateKey<'static>> {
match input {
CertInput::Binary(_) => {
Err(Error::tls_config_error(
"Private key must be PEM text format, not binary. For P12, use client_cert only (no client_key needed)."
))
}
CertInput::Text(text) => {
let path = std::path::Path::new(text);
if path.exists() {
log::trace!("Loading private key from file: {}", text);
let pem_content = std::fs::read_to_string(path).map_err(|e| {
let display_path = if text.len() < 256 && !text.contains('\n') {
text
} else {
"[PEM content or long path]"
};
Error::pem_load_error(
display_path,
format!("Failed to read key file: {}", e),
)
})?;
parse_pem_private_key(&pem_content, password, text)
} else {
log::trace!("Path does not exist, attempting to parse as PEM content");
parse_pem_private_key(text, password, "PEM content")
}
}
}
}
#[cfg(feature = "http")]
fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
let begin_marker = format!("-----BEGIN {}-----", label);
let end_marker = format!("-----END {}-----", label);
let start = pem
.find(&begin_marker)
.ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
let end = pem
.find(&end_marker)
.ok_or_else(|| format!("PEM end marker not found for {}", label))?;
let base64_content: String = pem[start + begin_marker.len()..end]
.chars()
.filter(|c| !c.is_whitespace())
.collect();
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(&base64_content)
.map_err(|e| format!("Failed to decode base64: {}", e))
}
#[cfg(feature = "http")]
fn der_to_pem(der: &[u8], label: &str) -> String {
use base64::Engine;
let base64 = base64::engine::general_purpose::STANDARD.encode(der);
let lines: Vec<&str> = base64
.as_bytes()
.chunks(64)
.map(|chunk| std::str::from_utf8(chunk).unwrap())
.collect();
format!(
"-----BEGIN {}-----\n{}\n-----END {}-----\n",
label,
lines.join("\n"),
label
)
}
#[cfg(feature = "http")]
fn parse_p12_identity(
p12_data: &[u8],
password: &str,
source: &str,
) -> Result<(
Vec<ureq::tls::Certificate<'static>>,
ureq::tls::PrivateKey<'static>,
)> {
if password.is_empty() {
log::warn!(
"Loading P12 file without password from: {} - ensure file is properly protected",
source
);
}
let keystore = p12_keystore::KeyStore::from_pkcs12(p12_data, password)
.map_err(|e| Error::p12_load_error(source, e.to_string()))?;
let (_alias, key_chain) = keystore
.private_key_chain()
.ok_or_else(|| Error::p12_load_error(source, "No private key found in P12 data"))?;
let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
.map(|k| k.to_owned())
.map_err(|e| {
Error::p12_load_error(source, format!("Failed to parse private key: {}", e))
})?;
let certs: Vec<_> = key_chain
.chain()
.iter()
.map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
.collect();
if certs.is_empty() {
return Err(Error::p12_load_error(
source,
"No certificates found in P12 data",
));
}
Ok((certs, private_key))
}
#[cfg(feature = "http")]
fn load_client_identity(
cert_input: &CertInput,
key_input: Option<&CertInput>,
password: Option<&str>,
) -> Result<(
Vec<ureq::tls::Certificate<'static>>,
ureq::tls::PrivateKey<'static>,
)> {
match cert_input {
CertInput::Binary(bytes) => {
log::trace!("Loading client identity from P12 binary content");
let pwd = password.unwrap_or("");
parse_p12_identity(bytes, pwd, "P12 binary content")
}
CertInput::Text(text) => {
if cert_input.is_p12_path() {
log::trace!("Loading client identity from P12 file: {}", text);
let bytes = std::fs::read(text).map_err(|e| {
Error::p12_load_error(text, format!("Failed to read P12 file: {}", e))
})?;
let pwd = password.unwrap_or("");
return parse_p12_identity(&bytes, pwd, text);
}
log::trace!("Loading client identity from PEM (cert + key)");
let certs = load_certs(cert_input)?;
let key_input = key_input.ok_or_else(|| {
Error::tls_config_error(
"client_key required when using PEM certificate (not needed for P12)",
)
})?;
let key = load_private_key(key_input, password)?;
Ok((certs, key))
}
}
}
#[cfg(feature = "http")]
fn build_tls_config(
ctx: &ResolverContext,
kwargs: &HashMap<String, String>,
) -> Result<ureq::tls::TlsConfig> {
use std::sync::Arc;
use ureq::tls::{ClientCert, RootCerts, TlsConfig};
let mut builder = TlsConfig::builder();
let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
if insecure {
eprintln!("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
eprintln!("┃ ⚠️ WARNING: TLS CERTIFICATE VERIFICATION DISABLED ┃");
eprintln!("┃ ┃");
eprintln!("┃ You are using insecure=true which disables ALL ┃");
eprintln!("┃ TLS certificate validation. This is DANGEROUS ┃");
eprintln!("┃ and should ONLY be used in development. ┃");
eprintln!("┃ ┃");
eprintln!("┃ In production, use proper certificate ┃");
eprintln!("┃ configuration with ca_bundle or extra_ca_bundle. ┃");
eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n");
log::warn!("TLS certificate verification is disabled (insecure=true)");
builder = builder.disable_verification(true);
}
let ca_bundle_input = kwargs
.get("ca_bundle")
.map(|s| CertInput::Text(s.clone()))
.or_else(|| ctx.http_ca_bundle.clone());
let extra_ca_bundle_input = kwargs
.get("extra_ca_bundle")
.map(|s| CertInput::Text(s.clone()))
.or_else(|| ctx.http_extra_ca_bundle.clone());
if let Some(ca_input) = ca_bundle_input.as_ref() {
let certs = load_certs(ca_input)?;
builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
} else if let Some(extra_ca_input) = extra_ca_bundle_input.as_ref() {
let extra_certs = load_certs(extra_ca_input)?;
builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
}
let client_cert_input = kwargs
.get("client_cert")
.map(|s| CertInput::Text(s.clone()))
.or_else(|| ctx.http_client_cert.clone());
if let Some(cert_input) = client_cert_input.as_ref() {
let client_key_input = kwargs
.get("client_key")
.map(|s| CertInput::Text(s.clone()))
.or_else(|| ctx.http_client_key.clone());
let password = kwargs
.get("key_password")
.map(|s| s.as_str())
.or(ctx.http_client_key_password.as_deref());
let (certs, key) = load_client_identity(cert_input, client_key_input.as_ref(), password)?;
let client_cert = ClientCert::new_with_certs(&certs, key);
builder = builder.client_cert(Some(client_cert));
}
Ok(builder.build())
}
#[cfg(feature = "http")]
fn build_proxy_config(
ctx: &ResolverContext,
kwargs: &HashMap<String, String>,
) -> Result<Option<ureq::Proxy>> {
let proxy_url = kwargs
.get("proxy")
.cloned()
.or_else(|| ctx.http_proxy.clone());
let proxy_url = proxy_url.or_else(|| {
if ctx.http_proxy_from_env {
std::env::var("HTTPS_PROXY")
.or_else(|_| std::env::var("https_proxy"))
.or_else(|_| std::env::var("HTTP_PROXY"))
.or_else(|_| std::env::var("http_proxy"))
.ok()
} else {
None
}
});
if let Some(url) = proxy_url {
let proxy = ureq::Proxy::new(&url).map_err(|e| {
Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
})?;
Ok(Some(proxy))
} else {
Ok(None)
}
}
#[cfg(feature = "http")]
fn http_fetch(
url: &str,
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
use std::time::Duration;
let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("text");
let timeout_secs: u64 = kwargs
.get("timeout")
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let tls_config = build_tls_config(ctx, kwargs)?;
let proxy = build_proxy_config(ctx, kwargs)?;
let mut config_builder = ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(timeout_secs)))
.tls_config(tls_config);
if proxy.is_some() {
config_builder = config_builder.proxy(proxy);
}
let config = config_builder.build();
let agent: ureq::Agent = config.into();
let mut request = agent.get(url);
for (key, value) in kwargs {
if key == "header" {
if let Some((name, val)) = value.split_once(':') {
request = request.header(name.trim(), val.trim());
}
}
}
let response = request.call().map_err(|e| {
let error_msg = match &e {
ureq::Error::StatusCode(code) => format!("HTTP {}", code),
ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
_ => format!("HTTP request failed: {}", e),
};
Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
})?;
match parse_mode {
"binary" => {
Ok(ResolvedValue::new(Value::Stream(Box::new(
response.into_body().into_reader(),
))))
}
_ => {
let body = response.into_body().read_to_string().map_err(|e| {
Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
})?;
Ok(ResolvedValue::new(Value::String(body)))
}
}
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len])
}
}
fn json_resolver(
args: &[String],
_kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("json resolver requires a string argument")
.with_path(ctx.config_path.clone()));
}
let json_str = &args[0];
let parsed: Value = serde_json::from_str(json_str).map_err(|e| {
Error::parse(format!(
"Invalid JSON at line {}, column {}: {}\nInput preview: {}",
e.line(),
e.column(),
e,
truncate_str(json_str, 50)
))
.with_path(ctx.config_path.clone())
})?;
Ok(ResolvedValue::new(parsed))
}
fn yaml_resolver(
args: &[String],
_kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("yaml resolver requires a string argument")
.with_path(ctx.config_path.clone()));
}
let yaml_str = &args[0];
let parsed: Value = serde_yaml::from_str(yaml_str).map_err(|e| {
let location_info = if let Some(loc) = e.location() {
format!(" at line {}, column {}", loc.line(), loc.column())
} else {
String::new()
};
Error::parse(format!(
"Invalid YAML{}: {}\nInput preview: {}",
location_info,
e,
truncate_str(yaml_str, 50)
))
.with_path(ctx.config_path.clone())
})?;
Ok(ResolvedValue::new(parsed))
}
fn split_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("split resolver requires a string argument")
.with_path(ctx.config_path.clone()));
}
let input_str = &args[0];
let delim = kwargs.get("delim").map(|s| s.as_str()).unwrap_or(",");
let trim = kwargs
.get("trim")
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(true); let skip_empty = kwargs
.get("skip_empty")
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let limit = kwargs.get("limit").and_then(|s| s.parse::<usize>().ok());
let parts: Vec<&str> = if let Some(limit) = limit {
input_str.splitn(limit + 1, delim).collect()
} else {
input_str.split(delim).collect()
};
let result: Vec<Value> = parts
.iter()
.map(|s| if trim { s.trim() } else { *s })
.filter(|s| !skip_empty || !s.is_empty())
.map(|s| Value::String(s.to_string()))
.collect();
Ok(ResolvedValue::new(Value::Sequence(result)))
}
fn csv_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("csv resolver requires a string argument")
.with_path(ctx.config_path.clone()));
}
let csv_str = &args[0];
let header = kwargs
.get("header")
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(true); let trim = kwargs
.get("trim")
.map(|s| s.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let delim_str = kwargs.get("delim").map(|s| s.as_str()).unwrap_or(",");
let delim_char = delim_str.chars().next().ok_or_else(|| {
Error::parse("CSV delimiter cannot be empty").with_path(ctx.config_path.clone())
})?;
let mut reader = csv::ReaderBuilder::new()
.has_headers(header)
.delimiter(delim_char as u8)
.trim(if trim {
csv::Trim::All
} else {
csv::Trim::None
})
.from_reader(csv_str.as_bytes());
let headers = if header {
Some(
reader
.headers()
.map_err(|e| {
Error::parse(format!("CSV parse error: {}", e))
.with_path(ctx.config_path.clone())
})?
.clone(),
)
} else {
None
};
let mut rows = Vec::new();
for result in reader.records() {
let record = result.map_err(|e| {
let location_info = e
.position()
.map(|p| format!(" at line {}", p.line()))
.unwrap_or_default();
Error::parse(format!("CSV parse error{}: {}", location_info, e))
.with_path(ctx.config_path.clone())
})?;
let row = if let Some(ref headers) = headers {
let mut obj = indexmap::IndexMap::new();
for (i, field) in record.iter().enumerate() {
let key = headers.get(i).unwrap_or(&format!("col{}", i)).to_string();
obj.insert(key, Value::String(field.to_string()));
}
Value::Mapping(obj)
} else {
Value::Sequence(
record
.iter()
.map(|s| Value::String(s.to_string()))
.collect(),
)
};
rows.push(row);
}
Ok(ResolvedValue::new(Value::Sequence(rows)))
}
fn base64_resolver(
args: &[String],
_kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
if args.is_empty() {
return Err(Error::parse("base64 resolver requires a string argument")
.with_path(ctx.config_path.clone()));
}
let b64_str = args[0].trim();
use base64::{engine::general_purpose, Engine as _};
let decoded = general_purpose::STANDARD.decode(b64_str).map_err(|e| {
Error::parse(format!(
"Invalid base64: {}\nInput preview: {}",
e,
truncate_str(b64_str, 50)
))
.with_path(ctx.config_path.clone())
})?;
match String::from_utf8(decoded) {
Ok(s) => Ok(ResolvedValue::new(Value::String(s))),
Err(e) => Ok(ResolvedValue::new(Value::Bytes(e.into_bytes()))),
}
}
#[cfg(feature = "archive")]
mod archive_limits {
pub const MAX_EXTRACTED_FILE_SIZE: u64 = 10 * 1024 * 1024;
pub const MAX_COMPRESSION_RATIO: u64 = 100;
}
#[cfg(feature = "archive")]
use archive_limits::*;
#[cfg(feature = "archive")]
fn read_with_limits<R: std::io::Read>(
mut reader: R,
max_size: u64,
compressed_size: Option<u64>,
) -> Result<Vec<u8>> {
let mut contents = Vec::new();
let mut total_read = 0u64;
let mut buffer = [0u8; 8192];
loop {
match reader.read(&mut buffer) {
Ok(0) => break, Ok(n) => {
total_read += n as u64;
if total_read > max_size {
return Err(Error::resolver_custom(
"extract",
format!(
"Extracted file exceeds size limit ({} bytes > {} bytes limit). \
This may be a zip bomb or the file is too large for a config.",
total_read, max_size
),
));
}
if let Some(compressed) = compressed_size {
if compressed > 0 {
let ratio = total_read / compressed;
if ratio > MAX_COMPRESSION_RATIO {
return Err(Error::resolver_custom(
"extract",
format!(
"Compression ratio too high ({}:1, max {}:1). \
This may be a zip bomb attack.",
ratio, MAX_COMPRESSION_RATIO
),
));
}
}
}
contents.extend_from_slice(&buffer[..n]);
}
Err(e) => {
return Err(Error::resolver_custom(
"extract",
format!("Failed to read from archive: {}", e),
));
}
}
}
Ok(contents)
}
#[cfg(feature = "archive")]
fn extract_resolver(
args: &[String],
kwargs: &HashMap<String, String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
use std::io::Cursor;
if args.is_empty() {
return Err(
Error::parse("extract resolver requires archive data as first argument")
.with_path(ctx.config_path.clone()),
);
}
let file_path = kwargs.get("path").ok_or_else(|| {
Error::parse("extract resolver requires 'path' kwarg specifying file to extract")
.with_path(ctx.config_path.clone())
})?;
let password = kwargs.get("password");
use base64::Engine;
let archive_bytes =
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&args[0]) {
decoded
} else {
args[0].as_bytes().to_vec()
};
let format = detect_archive_format(&archive_bytes)?;
match format {
ArchiveFormat::Zip => {
extract_from_zip(Cursor::new(archive_bytes), file_path, password, ctx)
}
ArchiveFormat::Tar => extract_from_tar(Cursor::new(archive_bytes), file_path, ctx),
ArchiveFormat::TarGz => extract_from_tar_gz(Cursor::new(archive_bytes), file_path, ctx),
}
}
#[cfg(feature = "archive")]
enum ArchiveFormat {
Zip,
Tar,
TarGz,
}
#[cfg(feature = "archive")]
fn detect_archive_format(data: &[u8]) -> Result<ArchiveFormat> {
if let Some(kind) = infer::get(data) {
match kind.mime_type() {
"application/zip" => return Ok(ArchiveFormat::Zip),
"application/gzip" => return Ok(ArchiveFormat::TarGz),
_ => {}
}
}
if data.len() > 262 && &data[257..262] == b"ustar" {
return Ok(ArchiveFormat::Tar);
}
Err(Error::resolver_custom(
"extract",
"Unsupported archive format. Supported formats: ZIP, TAR, TAR.GZ",
))
}
#[cfg(feature = "archive")]
fn extract_from_zip<R: std::io::Read + std::io::Seek>(
reader: R,
file_path: &str,
password: Option<&String>,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
let mut archive = zip::ZipArchive::new(reader).map_err(|e| {
Error::resolver_custom("extract", format!("Failed to open ZIP archive: {}", e))
.with_path(ctx.config_path.clone())
})?;
let file = if let Some(pwd) = password {
archive
.by_name_decrypt(file_path, pwd.as_bytes())
.map_err(|e| {
Error::resolver_custom(
"extract",
format!(
"Failed to access encrypted file '{}' in ZIP (check password): {}",
file_path, e
),
)
.with_path(ctx.config_path.clone())
})?
} else {
archive.by_name(file_path).map_err(|e| {
match e {
zip::result::ZipError::FileNotFound => {
Error::not_found(format!("File '{}' in ZIP archive", file_path), Some(ctx.config_path.clone()))
}
zip::result::ZipError::UnsupportedArchive(msg) => {
if msg.contains("encrypted") || msg.contains("password") {
Error::resolver_custom(
"extract",
format!("ZIP file '{}' is password-protected but no password provided. Use password=... kwarg", file_path),
)
.with_path(ctx.config_path.clone())
} else {
Error::resolver_custom("extract", format!("Unsupported ZIP feature: {}", msg))
.with_path(ctx.config_path.clone())
}
}
_ => Error::resolver_custom("extract", format!("Failed to access '{}' in ZIP: {}", file_path, e))
.with_path(ctx.config_path.clone()),
}
})?
};
let compressed_size = file.compressed_size();
let contents = read_with_limits(file, MAX_EXTRACTED_FILE_SIZE, Some(compressed_size))
.map_err(|e| e.with_path(ctx.config_path.clone()))?;
Ok(ResolvedValue::new(Value::Bytes(contents)))
}
#[cfg(feature = "archive")]
fn extract_from_tar<R: std::io::Read>(
reader: R,
file_path: &str,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
let mut archive = tar::Archive::new(reader);
for entry_result in archive.entries().map_err(|e| {
Error::resolver_custom("extract", format!("Failed to read TAR archive: {}", e))
.with_path(ctx.config_path.clone())
})? {
let entry = entry_result.map_err(|e| {
Error::resolver_custom("extract", format!("Failed to read TAR entry: {}", e))
.with_path(ctx.config_path.clone())
})?;
let path = entry.path().map_err(|e| {
Error::resolver_custom("extract", format!("Invalid TAR entry path: {}", e))
.with_path(ctx.config_path.clone())
})?;
if path.to_string_lossy() == file_path {
let contents = read_with_limits(entry, MAX_EXTRACTED_FILE_SIZE, None)
.map_err(|e| e.with_path(ctx.config_path.clone()))?;
return Ok(ResolvedValue::new(Value::Bytes(contents)));
}
}
Err(Error::not_found(
format!("File '{}' in TAR archive", file_path),
Some(ctx.config_path.clone()),
))
}
#[cfg(feature = "archive")]
fn extract_from_tar_gz<R: std::io::Read>(
reader: R,
file_path: &str,
ctx: &ResolverContext,
) -> Result<ResolvedValue> {
use flate2::read::GzDecoder;
let gz_decoder = GzDecoder::new(reader);
extract_from_tar(gz_decoder, file_path, ctx)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_env_resolver_with_value() {
std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
let ctx = ResolverContext::new("test.path");
let args = vec!["HOLOCONF_TEST_VAR".to_string()];
let kwargs = HashMap::new();
let result = env_resolver(&args, &kwargs, &ctx).unwrap();
assert_eq!(result.value.as_str(), Some("test_value"));
assert!(!result.sensitive);
std::env::remove_var("HOLOCONF_TEST_VAR");
}
#[test]
fn test_env_resolver_missing_returns_error() {
std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
let registry = ResolverRegistry::with_builtins();
let ctx = ResolverContext::new("test.path");
let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
let kwargs = HashMap::new();
let result = registry.resolve("env", &args, &kwargs, &ctx);
assert!(result.is_err());
}
#[test]
fn test_env_resolver_missing_no_default() {
std::env::remove_var("HOLOCONF_MISSING_VAR");
let ctx = ResolverContext::new("test.path");
let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
let kwargs = HashMap::new();
let result = env_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
}
#[test]
fn test_env_resolver_sensitive_kwarg() {
std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
let registry = ResolverRegistry::with_builtins();
let ctx = ResolverContext::new("test.path");
let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("sensitive".to_string(), "true".to_string());
let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
assert_eq!(result.value.as_str(), Some("secret_value"));
assert!(result.sensitive);
std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
}
#[test]
fn test_env_resolver_sensitive_false() {
std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
let registry = ResolverRegistry::with_builtins();
let ctx = ResolverContext::new("test.path");
let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("sensitive".to_string(), "false".to_string());
let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
assert_eq!(result.value.as_str(), Some("public_value"));
assert!(!result.sensitive);
std::env::remove_var("HOLOCONF_NON_SENSITIVE");
}
#[test]
fn test_resolver_registry() {
let registry = ResolverRegistry::with_builtins();
assert!(registry.contains("env"));
assert!(!registry.contains("nonexistent"));
}
#[test]
fn test_custom_resolver() {
let mut registry = ResolverRegistry::new();
registry.register_fn("custom", |args, _kwargs, _ctx| {
let value = args.first().cloned().unwrap_or_default();
Ok(ResolvedValue::new(Value::String(format!(
"custom:{}",
value
))))
});
let ctx = ResolverContext::new("test");
let result = registry
.resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
.unwrap();
assert_eq!(result.value.as_str(), Some("custom:arg"));
}
#[test]
fn test_resolved_value_sensitivity() {
let non_sensitive = ResolvedValue::new("public");
assert!(!non_sensitive.sensitive);
let sensitive = ResolvedValue::sensitive("secret");
assert!(sensitive.sensitive);
}
#[test]
fn test_resolver_context_cycle_detection() {
let mut ctx = ResolverContext::new("root");
ctx.push_resolution("a");
ctx.push_resolution("b");
assert!(ctx.would_cause_cycle("a"));
assert!(ctx.would_cause_cycle("b"));
assert!(!ctx.would_cause_cycle("c"));
ctx.pop_resolution();
assert!(!ctx.would_cause_cycle("b"));
}
#[test]
fn test_file_resolver() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_test_file.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "test content").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_test_file.txt".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("parse".to_string(), "text".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.as_str().unwrap().contains("test content"));
assert!(!result.sensitive);
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_yaml() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_test.yaml");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "key: value").unwrap();
writeln!(file, "number: 42").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_test.yaml".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert!(result.value.as_str().unwrap().contains("key: value"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_not_found() {
let ctx = ResolverContext::new("test.path");
let args = vec!["nonexistent_file.txt".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
}
#[test]
fn test_registry_with_file() {
let registry = ResolverRegistry::with_builtins();
assert!(registry.contains("file"));
}
#[test]
#[cfg(feature = "http")]
fn test_http_resolver_disabled() {
let ctx = ResolverContext::new("test.path");
let args = vec!["example.com/config.yaml".to_string()];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
let display = format!("{}", err);
assert!(display.contains("HTTP resolver is disabled"));
}
#[test]
#[cfg(feature = "http")]
fn test_registry_with_http() {
let registry = ResolverRegistry::with_builtins();
assert!(registry.contains("http"));
}
#[test]
#[cfg(feature = "http")]
fn test_registry_with_https() {
let registry = ResolverRegistry::with_builtins();
assert!(
registry.contains("https"),
"https resolver should be registered when http feature is enabled"
);
}
#[test]
fn test_env_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = env_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires"));
}
#[test]
fn test_file_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires"));
}
#[test]
fn test_http_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires"));
}
#[test]
fn test_unknown_resolver() {
let registry = ResolverRegistry::with_builtins();
let ctx = ResolverContext::new("test.path");
let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("unknown_resolver"));
}
#[test]
fn test_resolved_value_from_traits() {
let from_value: ResolvedValue = Value::String("test".to_string()).into();
assert_eq!(from_value.value.as_str(), Some("test"));
assert!(!from_value.sensitive);
let from_string: ResolvedValue = "hello".to_string().into();
assert_eq!(from_string.value.as_str(), Some("hello"));
let from_str: ResolvedValue = "world".into();
assert_eq!(from_str.value.as_str(), Some("world"));
}
#[test]
fn test_resolver_context_with_base_path() {
let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
}
#[test]
fn test_resolver_context_with_config_root() {
use std::sync::Arc;
let root = Arc::new(Value::String("root".to_string()));
let ctx = ResolverContext::new("test").with_config_root(root.clone());
assert!(ctx.config_root.is_some());
}
#[test]
fn test_resolver_context_resolution_chain() {
let mut ctx = ResolverContext::new("root");
ctx.push_resolution("a");
ctx.push_resolution("b");
ctx.push_resolution("c");
let chain = ctx.get_resolution_chain();
assert_eq!(chain, vec!["a", "b", "c"]);
}
#[test]
fn test_registry_get_resolver() {
let registry = ResolverRegistry::with_builtins();
let env_resolver = registry.get("env");
assert!(env_resolver.is_some());
assert_eq!(env_resolver.unwrap().name(), "env");
let missing = registry.get("nonexistent");
assert!(missing.is_none());
}
#[test]
fn test_registry_default() {
let registry = ResolverRegistry::default();
assert!(!registry.contains("env"));
}
#[test]
fn test_fn_resolver_name() {
let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
assert_eq!(resolver.name(), "my_resolver");
}
#[test]
fn test_file_resolver_json() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_test.json");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_test.json".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert!(result.value.as_str().unwrap().contains(r#""key": "value""#));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_absolute_path() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_abs_test.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "absolute path content").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.file_roots.insert(temp_dir.clone());
let args = vec![test_file.to_string_lossy().to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("parse".to_string(), "text".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result
.value
.as_str()
.unwrap()
.contains("absolute path content"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_invalid_yaml() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_invalid.yaml");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "key: [invalid").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_invalid.yaml".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_invalid_json() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_invalid.json");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "{{invalid json}}").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_invalid.json".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_unknown_extension() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_test.xyz");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "plain text content").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_test.xyz".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result
.value
.as_str()
.unwrap()
.contains("plain text content"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_utf8() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_utf8.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "Hello, 世界! 🌍").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_utf8.txt".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("encoding".to_string(), "utf-8".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
let content = result.value.as_str().unwrap();
assert!(content.contains("世界"));
assert!(content.contains("🌍"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_ascii() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_ascii.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "Hello, 世界! Welcome").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_ascii.txt".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("encoding".to_string(), "ascii".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
let content = result.value.as_str().unwrap();
assert!(content.contains("Hello"));
assert!(content.contains("Welcome"));
assert!(!content.contains("世界"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_base64() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_binary.bin");
{
let mut file = std::fs::File::create(&test_file).unwrap();
file.write_all(b"Hello\x00\x01\x02World").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_binary.bin".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("encoding".to_string(), "base64".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
let content = result.value.as_str().unwrap();
use base64::{engine::general_purpose::STANDARD, Engine as _};
let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
assert_eq!(content, expected);
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_default_is_utf8() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_default_enc.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "café résumé").unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_default_enc.txt".to_string()];
let kwargs = HashMap::new();
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
let content = result.value.as_str().unwrap();
assert!(content.contains("café"));
assert!(content.contains("résumé"));
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_binary() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_binary_bytes.bin");
let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
{
let mut file = std::fs::File::create(&test_file).unwrap();
file.write_all(&binary_data).unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_binary_bytes.bin".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("encoding".to_string(), "binary".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_stream());
let materialized = result.value.materialize().unwrap();
assert!(materialized.is_bytes());
assert_eq!(materialized.as_bytes().unwrap(), &binary_data);
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_encoding_binary_empty() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_binary_empty.bin");
{
std::fs::File::create(&test_file).unwrap();
}
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_binary_empty.bin".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("encoding".to_string(), "binary".to_string());
let result = file_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_stream());
let materialized = result.value.materialize().unwrap();
assert!(materialized.is_bytes());
let empty: &[u8] = &[];
assert_eq!(materialized.as_bytes().unwrap(), empty);
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_file_resolver_with_sensitive() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("holoconf_sensitive_test.txt");
{
let mut file = std::fs::File::create(&test_file).unwrap();
writeln!(file, "secret content").unwrap();
}
let registry = ResolverRegistry::with_builtins();
let mut ctx = ResolverContext::new("test.path");
ctx.base_path = Some(temp_dir.clone());
ctx.file_roots.insert(temp_dir.clone());
let args = vec!["holoconf_sensitive_test.txt".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("sensitive".to_string(), "true".to_string());
let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
assert!(result.value.as_str().unwrap().contains("secret content"));
assert!(result.sensitive);
std::fs::remove_file(test_file).ok();
}
#[test]
fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
let mut registry = ResolverRegistry::new();
registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
assert!(
!kwargs.contains_key("sensitive"),
"sensitive kwarg should not be passed to resolver"
);
if let Some(custom) = kwargs.get("custom") {
Ok(ResolvedValue::new(Value::String(format!(
"custom={}",
custom
))))
} else {
Ok(ResolvedValue::new(Value::String("no custom".to_string())))
}
});
let ctx = ResolverContext::new("test.path");
let args = vec![];
let mut kwargs = HashMap::new();
kwargs.insert("sensitive".to_string(), "true".to_string());
kwargs.insert("custom".to_string(), "myvalue".to_string());
let result = registry
.resolve("test_kwargs", &args, &kwargs, &ctx)
.unwrap();
assert_eq!(result.value.as_str(), Some("custom=myvalue"));
assert!(result.sensitive);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_clean_syntax() {
assert_eq!(
normalize_http_url("https", "example.com/path").unwrap(),
"https://example.com/path"
);
assert_eq!(
normalize_http_url("http", "example.com").unwrap(),
"http://example.com"
);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_double_slash() {
assert_eq!(
normalize_http_url("https", "//example.com/path").unwrap(),
"https://example.com/path"
);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_existing_https() {
assert_eq!(
normalize_http_url("https", "https://example.com/path").unwrap(),
"https://example.com/path"
);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_wrong_scheme() {
assert_eq!(
normalize_http_url("https", "http://example.com").unwrap(),
"https://example.com"
);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_with_query() {
assert_eq!(
normalize_http_url("https", "example.com/path?query=val&other=val2").unwrap(),
"https://example.com/path?query=val&other=val2"
);
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_empty() {
let result = normalize_http_url("https", "");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-empty URL"));
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_triple_slash() {
let result = normalize_http_url("https", "///example.com");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid URL syntax"));
}
#[test]
#[cfg(feature = "http")]
fn test_normalize_http_url_whitespace_only() {
let result = normalize_http_url("https", " ");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("non-empty URL"));
}
#[test]
fn test_is_localhost_ascii() {
assert!(is_localhost("localhost"));
assert!(is_localhost("LOCALHOST"));
assert!(is_localhost("LocalHost"));
}
#[test]
fn test_is_localhost_ipv4() {
assert!(is_localhost("127.0.0.1"));
assert!(is_localhost("127.0.0.100"));
assert!(is_localhost("127.1.2.3"));
assert!(!is_localhost("128.0.0.1"));
}
#[test]
fn test_is_localhost_ipv6() {
assert!(is_localhost("::1"));
assert!(is_localhost("[::1]"));
assert!(!is_localhost("::2"));
}
#[test]
fn test_is_localhost_not() {
assert!(!is_localhost("example.com"));
assert!(!is_localhost("remote.host"));
assert!(!is_localhost("192.168.1.1"));
}
#[test]
fn test_normalize_file_path_relative() {
let (path, is_rel) = normalize_file_path("data.txt").unwrap();
assert_eq!(path, "data.txt");
assert!(is_rel);
let (path, is_rel) = normalize_file_path("./data.txt").unwrap();
assert_eq!(path, "./data.txt");
assert!(is_rel);
}
#[test]
fn test_normalize_file_path_absolute() {
let (path, is_rel) = normalize_file_path("/etc/config.yaml").unwrap();
assert_eq!(path, "/etc/config.yaml");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_rfc8089_empty_authority() {
let (path, is_rel) = normalize_file_path("///etc/config.yaml").unwrap();
assert_eq!(path, "/etc/config.yaml");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_rfc8089_localhost() {
let (path, is_rel) = normalize_file_path("//localhost/var/data").unwrap();
assert_eq!(path, "/var/data");
assert!(!is_rel);
let (path, is_rel) = normalize_file_path("//localhost").unwrap();
assert_eq!(path, "/");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_rfc8089_localhost_ipv4() {
let (path, is_rel) = normalize_file_path("//127.0.0.1/tmp/file.txt").unwrap();
assert_eq!(path, "/tmp/file.txt");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_rfc8089_localhost_ipv6() {
let (path, is_rel) = normalize_file_path("//::1/tmp/file.txt").unwrap();
assert_eq!(path, "/tmp/file.txt");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_rfc8089_remote_rejected() {
let result = normalize_file_path("//remote.host/path");
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Remote file URIs not supported"));
assert!(err_msg.contains("remote.host"));
let result = normalize_file_path("//server.example.com/share");
assert!(result.is_err());
}
#[test]
fn test_normalize_file_path_rfc8089_empty_hostname() {
let (path, is_rel) = normalize_file_path("//").unwrap();
assert_eq!(path, "/");
assert!(!is_rel);
}
#[test]
fn test_normalize_file_path_null_byte() {
let result = normalize_file_path("/etc/passwd\0.txt");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("null byte"));
}
#[test]
fn test_normalize_file_path_null_byte_relative() {
let result = normalize_file_path("data\0.txt");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("null byte"));
}
#[test]
fn test_cert_input_is_pem_content() {
let pem_content = CertInput::Text("-----BEGIN CERTIFICATE-----\nMIIC...".to_string());
assert!(pem_content.is_pem_content());
let file_path = CertInput::Text("/path/to/cert.pem".to_string());
assert!(!file_path.is_pem_content());
let binary = CertInput::Binary(vec![0, 1, 2, 3]);
assert!(!binary.is_pem_content());
}
#[test]
fn test_cert_input_is_p12_path() {
assert!(CertInput::Text("/path/to/identity.p12".to_string()).is_p12_path());
assert!(CertInput::Text("/path/to/identity.pfx".to_string()).is_p12_path());
assert!(CertInput::Text("/path/to/identity.P12".to_string()).is_p12_path());
assert!(CertInput::Text("/path/to/identity.PFX".to_string()).is_p12_path());
assert!(!CertInput::Text("/path/to/cert.pem".to_string()).is_p12_path());
assert!(!CertInput::Text("-----BEGIN CERTIFICATE-----".to_string()).is_p12_path());
assert!(!CertInput::Binary(vec![0, 1, 2, 3]).is_p12_path());
}
#[test]
fn test_cert_input_as_text() {
let text_input = CertInput::Text("some text".to_string());
assert_eq!(text_input.as_text(), Some("some text"));
let binary_input = CertInput::Binary(vec![0, 1, 2]);
assert_eq!(binary_input.as_text(), None);
}
#[test]
fn test_cert_input_as_bytes() {
let binary_input = CertInput::Binary(vec![0, 1, 2]);
assert_eq!(binary_input.as_bytes(), Some(&[0, 1, 2][..]));
let text_input = CertInput::Text("some text".to_string());
assert_eq!(text_input.as_bytes(), None);
}
#[test]
fn test_cert_input_from_string() {
let input1 = CertInput::from("test".to_string());
assert!(matches!(input1, CertInput::Text(_)));
assert_eq!(input1.as_text(), Some("test"));
let input2 = CertInput::from("test");
assert!(matches!(input2, CertInput::Text(_)));
assert_eq!(input2.as_text(), Some("test"));
}
#[test]
fn test_cert_input_from_vec_u8() {
let input = CertInput::from(vec![1, 2, 3]);
assert!(matches!(input, CertInput::Binary(_)));
assert_eq!(input.as_bytes(), Some(&[1, 2, 3][..]));
}
}
#[cfg(test)]
mod global_registry_tests {
use super::*;
fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
Arc::new(FnResolver::new(name, |_, _, _| {
Ok(ResolvedValue::new("mock"))
}))
}
#[test]
fn test_register_new_resolver_succeeds() {
let mut registry = ResolverRegistry::new();
let resolver = mock_resolver("test_new");
let result = registry.register_with_force(resolver, false);
assert!(result.is_ok());
assert!(registry.contains("test_new"));
}
#[test]
fn test_register_duplicate_errors_without_force() {
let mut registry = ResolverRegistry::new();
let resolver1 = mock_resolver("test_dup");
let resolver2 = mock_resolver("test_dup");
registry.register_with_force(resolver1, false).unwrap();
let result = registry.register_with_force(resolver2, false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("already registered"));
}
#[test]
fn test_register_duplicate_succeeds_with_force() {
let mut registry = ResolverRegistry::new();
let resolver1 = mock_resolver("test_force");
let resolver2 = mock_resolver("test_force");
registry.register_with_force(resolver1, false).unwrap();
let result = registry.register_with_force(resolver2, true);
assert!(result.is_ok());
}
#[test]
fn test_global_registry_is_singleton() {
let registry1 = global_registry();
let registry2 = global_registry();
assert!(std::ptr::eq(registry1, registry2));
}
#[test]
fn test_register_global_new_resolver() {
let resolver = mock_resolver("global_test_unique_42");
let result = register_global(resolver, false);
assert!(result.is_ok() || result.is_err());
}
}
#[cfg(test)]
mod lazy_resolution_tests {
use super::*;
use crate::Config;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_default_not_resolved_when_main_value_exists() {
let fail_called = Arc::new(AtomicBool::new(false));
let fail_called_clone = fail_called.clone();
let yaml = r#"
value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
"#;
std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
let mut config = Config::from_yaml(yaml).unwrap();
config.register_resolver(Arc::new(FnResolver::new(
"fail",
move |_args, _kwargs, _ctx| {
fail_called_clone.store(true, Ordering::SeqCst);
panic!("fail resolver should not have been called - lazy resolution failed!");
},
)));
let result = config.get("value").unwrap();
assert_eq!(result.as_str(), Some("main_value"));
assert!(
!fail_called.load(Ordering::SeqCst),
"The default resolver should not have been called when main value exists"
);
std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
}
#[test]
fn test_default_is_resolved_when_main_value_missing() {
let default_called = Arc::new(AtomicBool::new(false));
let default_called_clone = default_called.clone();
let yaml = r#"
value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
"#;
std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
let mut config = Config::from_yaml(yaml).unwrap();
config.register_resolver(Arc::new(FnResolver::new(
"custom_default",
move |args: &[String], _kwargs, _ctx| {
default_called_clone.store(true, Ordering::SeqCst);
let arg = args.first().cloned().unwrap_or_default();
Ok(ResolvedValue::new(Value::String(format!(
"default_was_{}",
arg
))))
},
)));
let result = config.get("value").unwrap();
assert_eq!(result.as_str(), Some("default_was_fallback"));
assert!(
default_called.load(Ordering::SeqCst),
"The default resolver should have been called when main value is missing"
);
}
}
#[cfg(all(test, feature = "http"))]
mod http_resolver_tests {
use super::*;
use mockito::Server;
#[test]
fn test_http_fetch_json() {
let mut server = Server::new();
let mock = server
.mock("GET", "/config.json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"key": "value", "number": 42}"#)
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/config.json", server.url())];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert!(result.value.as_str().unwrap().contains(r#""key": "value""#));
mock.assert();
}
#[test]
fn test_http_fetch_yaml() {
let mut server = Server::new();
let mock = server
.mock("GET", "/config.yaml")
.with_status(200)
.with_header("content-type", "application/yaml")
.with_body("key: value\nnumber: 42")
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/config.yaml", server.url())];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert!(result.value.as_str().unwrap().contains("key: value"));
mock.assert();
}
#[test]
fn test_http_fetch_text() {
let mut server = Server::new();
let mock = server
.mock("GET", "/data.txt")
.with_status(200)
.with_header("content-type", "text/plain")
.with_body("Hello, World!")
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/data.txt", server.url())];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert_eq!(result.value.as_str(), Some("Hello, World!"));
mock.assert();
}
#[test]
fn test_http_fetch_binary() {
let mut server = Server::new();
let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
let mock = server
.mock("GET", "/data.bin")
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(binary_data.clone())
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/data.bin", server.url())];
let mut kwargs = HashMap::new();
kwargs.insert("parse".to_string(), "binary".to_string());
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_stream());
let materialized = result.value.materialize().unwrap();
assert!(materialized.is_bytes());
assert_eq!(materialized.as_bytes().unwrap(), &binary_data);
mock.assert();
}
#[test]
fn test_http_fetch_explicit_parse_text() {
let mut server = Server::new();
let mock = server
.mock("GET", "/data")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"key": "value"}"#)
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/data", server.url())];
let mut kwargs = HashMap::new();
kwargs.insert("parse".to_string(), "text".to_string());
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert_eq!(result.value.as_str(), Some(r#"{"key": "value"}"#));
mock.assert();
}
#[test]
fn test_http_fetch_with_custom_header() {
let mut server = Server::new();
let mock = server
.mock("GET", "/protected")
.match_header("Authorization", "Bearer my-token")
.with_status(200)
.with_body("authorized content")
.create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/protected", server.url())];
let mut kwargs = HashMap::new();
kwargs.insert(
"header".to_string(),
"Authorization:Bearer my-token".to_string(),
);
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert_eq!(result.value.as_str(), Some("authorized content"));
mock.assert();
}
#[test]
fn test_http_fetch_404_error() {
let mut server = Server::new();
let mock = server.mock("GET", "/notfound").with_status(404).create();
let ctx = ResolverContext::new("test.path").with_allow_http(true);
let args = vec![format!("{}/notfound", server.url())];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("HTTP"));
mock.assert();
}
#[test]
fn test_http_disabled_by_default() {
let ctx = ResolverContext::new("test.path");
let args = vec!["https://example.com/config.yaml".to_string()];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("disabled"));
}
#[test]
fn test_http_allowlist_blocks_url() {
let ctx = ResolverContext::new("test.path")
.with_allow_http(true)
.with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
let args = vec!["https://blocked.example.com/config.yaml".to_string()];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("not in allowlist")
|| err.to_string().contains("HttpNotAllowed")
);
}
#[test]
fn test_http_allowlist_allows_matching_url() {
let mut server = Server::new();
let mock = server
.mock("GET", "/config.yaml")
.with_status(200)
.with_body("key: value")
.create();
let server_url = server.url();
let ctx = ResolverContext::new("test.path")
.with_allow_http(true)
.with_http_allowlist(vec![format!("{}/*", server_url)]);
let args = vec![format!("{}/config.yaml", server_url)];
let kwargs = HashMap::new();
let result = http_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
assert!(result.value.as_str().unwrap().contains("key: value"));
mock.assert();
}
#[test]
fn test_url_matches_pattern_exact() {
assert!(url_matches_pattern(
"https://example.com/config.yaml",
"https://example.com/config.yaml"
));
assert!(!url_matches_pattern(
"https://example.com/other.yaml",
"https://example.com/config.yaml"
));
}
#[test]
fn test_url_matches_pattern_wildcard() {
assert!(url_matches_pattern(
"https://example.com/config.yaml",
"https://example.com/*"
));
assert!(url_matches_pattern(
"https://example.com/path/to/config.yaml",
"https://example.com/*"
));
assert!(!url_matches_pattern(
"https://other.com/config.yaml",
"https://example.com/*"
));
}
#[test]
fn test_url_matches_pattern_subdomain() {
assert!(url_matches_pattern(
"https://api.example.com/config",
"https://*.example.com/*"
));
assert!(url_matches_pattern(
"https://staging.example.com/config",
"https://*.example.com/*"
));
assert!(!url_matches_pattern(
"https://example.com/config",
"https://*.example.com/*"
));
}
#[test]
fn test_json_resolver_valid() {
let ctx = ResolverContext::new("test.path");
let args = vec![r#"{"key": "value", "num": 42}"#.to_string()];
let kwargs = HashMap::new();
let result = json_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_mapping());
let map = result.value.as_mapping().unwrap();
assert_eq!(map.get("key"), Some(&Value::String("value".to_string())));
assert_eq!(map.get("num"), Some(&Value::Integer(42)));
assert!(!result.sensitive);
}
#[test]
fn test_json_resolver_array() {
let ctx = ResolverContext::new("test.path");
let args = vec![r#"[1, 2, 3, "four"]"#.to_string()];
let kwargs = HashMap::new();
let result = json_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_sequence());
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 4);
assert_eq!(seq[0], Value::Integer(1));
assert_eq!(seq[3], Value::String("four".to_string()));
}
#[test]
fn test_json_resolver_invalid() {
let ctx = ResolverContext::new("test.path");
let args = vec![r#"{"key": invalid}"#.to_string()];
let kwargs = HashMap::new();
let result = json_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid JSON"));
}
#[test]
fn test_json_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = json_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires a string argument"));
}
#[test]
fn test_yaml_resolver_valid() {
let ctx = ResolverContext::new("test.path");
let args = vec!["key: value\nnum: 42\nlist:\n - a\n - b".to_string()];
let kwargs = HashMap::new();
let result = yaml_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_mapping());
let map = result.value.as_mapping().unwrap();
assert_eq!(map.get("key"), Some(&Value::String("value".to_string())));
assert_eq!(map.get("num"), Some(&Value::Integer(42)));
assert!(!result.sensitive);
}
#[test]
fn test_yaml_resolver_array() {
let ctx = ResolverContext::new("test.path");
let args = vec!["- one\n- two\n- three".to_string()];
let kwargs = HashMap::new();
let result = yaml_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_sequence());
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 3);
assert_eq!(seq[0], Value::String("one".to_string()));
}
#[test]
fn test_yaml_resolver_invalid() {
let ctx = ResolverContext::new("test.path");
let args = vec!["key: value\n bad_indent: oops".to_string()];
let kwargs = HashMap::new();
let result = yaml_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid YAML"));
}
#[test]
fn test_yaml_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = yaml_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires a string argument"));
}
#[test]
fn test_split_resolver_basic() {
let ctx = ResolverContext::new("test.path");
let args = vec!["a,b,c".to_string()];
let kwargs = HashMap::new();
let result = split_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_sequence());
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 3);
assert_eq!(seq[0], Value::String("a".to_string()));
assert_eq!(seq[1], Value::String("b".to_string()));
assert_eq!(seq[2], Value::String("c".to_string()));
}
#[test]
fn test_split_resolver_custom_delim() {
let ctx = ResolverContext::new("test.path");
let args = vec!["one|two|three".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("delim".to_string(), "|".to_string());
let result = split_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 3);
assert_eq!(seq[0], Value::String("one".to_string()));
}
#[test]
fn test_split_resolver_with_trim() {
let ctx = ResolverContext::new("test.path");
let args = vec![" a , b , c ".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("trim".to_string(), "true".to_string());
let result = split_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq[0], Value::String("a".to_string()));
assert_eq!(seq[1], Value::String("b".to_string()));
}
#[test]
fn test_split_resolver_no_trim() {
let ctx = ResolverContext::new("test.path");
let args = vec![" a , b ".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("trim".to_string(), "false".to_string());
let result = split_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq[0], Value::String(" a ".to_string()));
assert_eq!(seq[1], Value::String(" b ".to_string()));
}
#[test]
fn test_split_resolver_with_limit() {
let ctx = ResolverContext::new("test.path");
let args = vec!["a,b,c,d,e".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("limit".to_string(), "2".to_string());
let result = split_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 3); assert_eq!(seq[0], Value::String("a".to_string()));
assert_eq!(seq[1], Value::String("b".to_string()));
assert_eq!(seq[2], Value::String("c,d,e".to_string()));
}
#[test]
fn test_csv_resolver_with_headers() {
let ctx = ResolverContext::new("test.path");
let args = vec!["name,age\nAlice,30\nBob,25".to_string()];
let kwargs = HashMap::new();
let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_sequence());
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 2);
let first = seq[0].as_mapping().unwrap();
assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
assert_eq!(first.get("age"), Some(&Value::String("30".to_string())));
}
#[test]
fn test_csv_resolver_without_headers() {
let ctx = ResolverContext::new("test.path");
let args = vec!["Alice,30\nBob,25".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("header".to_string(), "false".to_string());
let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_sequence());
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 2);
let first = seq[0].as_sequence().unwrap();
assert_eq!(first.len(), 2);
assert_eq!(first[0], Value::String("Alice".to_string()));
assert_eq!(first[1], Value::String("30".to_string()));
}
#[test]
fn test_csv_resolver_custom_delim() {
let ctx = ResolverContext::new("test.path");
let args = vec!["name|age\nAlice|30\nBob|25".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("delim".to_string(), "|".to_string());
let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
assert_eq!(seq.len(), 2);
let first = seq[0].as_mapping().unwrap();
assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
}
#[test]
fn test_csv_resolver_with_trim() {
let ctx = ResolverContext::new("test.path");
let args = vec!["name , age\n Alice , 30 ".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("trim".to_string(), "true".to_string());
let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
let seq = result.value.as_sequence().unwrap();
let first = seq[0].as_mapping().unwrap();
assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
assert_eq!(first.get("age"), Some(&Value::String("30".to_string())));
}
#[test]
fn test_csv_resolver_empty_delimiter() {
let ctx = ResolverContext::new("test.path");
let args = vec!["name,age".to_string()];
let mut kwargs = HashMap::new();
kwargs.insert("delim".to_string(), "".to_string());
let result = csv_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("delimiter cannot be empty"));
}
#[test]
fn test_csv_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = csv_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires a string argument"));
}
#[test]
fn test_base64_resolver_valid() {
let ctx = ResolverContext::new("test.path");
let args = vec!["SGVsbG8sIFdvcmxkIQ==".to_string()];
let kwargs = HashMap::new();
let result = base64_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_string());
let text = result.value.as_str().unwrap();
assert_eq!(text, "Hello, World!");
assert!(!result.sensitive);
}
#[test]
fn test_base64_resolver_with_whitespace() {
let ctx = ResolverContext::new("test.path");
let args = vec![" SGVsbG8= ".to_string()];
let kwargs = HashMap::new();
let result = base64_resolver(&args, &kwargs, &ctx).unwrap();
let text = result.value.as_str().unwrap();
assert_eq!(text, "Hello");
}
#[test]
fn test_base64_resolver_invalid() {
let ctx = ResolverContext::new("test.path");
let args = vec!["not-valid-base64!!!".to_string()];
let kwargs = HashMap::new();
let result = base64_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Invalid base64"));
}
#[test]
fn test_base64_resolver_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let kwargs = HashMap::new();
let result = base64_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("requires a string argument"));
}
#[test]
fn test_transformation_resolvers_registered() {
let registry = ResolverRegistry::with_builtins();
assert!(registry.contains("json"));
assert!(registry.contains("yaml"));
assert!(registry.contains("split"));
assert!(registry.contains("csv"));
assert!(registry.contains("base64"));
}
}
#[cfg(all(test, feature = "archive"))]
mod extract_resolver_tests {
use super::*;
use std::io::Write;
fn create_test_zip_with_file(content: &[u8]) -> Vec<u8> {
use zip::write::FileOptions;
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
zip.start_file::<&str, ()>("test.txt", FileOptions::default())
.unwrap();
zip.write_all(content).unwrap();
zip.finish().unwrap();
}
buffer.into_inner()
}
fn create_test_zip_with_password(content: &[u8], password: &str) -> Vec<u8> {
use zip::unstable::write::FileOptionsExt;
use zip::write::{ExtendedFileOptions, FileOptions};
let mut buffer = std::io::Cursor::new(Vec::new());
{
let mut zip = zip::ZipWriter::new(&mut buffer);
let options: FileOptions<ExtendedFileOptions> =
FileOptions::default().with_deprecated_encryption(password.as_bytes());
zip.start_file("secret.txt", options).unwrap();
zip.write_all(content).unwrap();
zip.finish().unwrap();
}
buffer.into_inner()
}
fn create_test_tar_with_file(content: &[u8]) -> Vec<u8> {
let mut buffer = Vec::new();
{
let mut tar = tar::Builder::new(&mut buffer);
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append_data(&mut header, "test.txt", content).unwrap();
tar.finish().unwrap();
}
buffer
}
fn create_test_tar_gz_with_file(content: &[u8]) -> Vec<u8> {
use flate2::write::GzEncoder;
use flate2::Compression;
let tar_data = create_test_tar_with_file(content);
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&tar_data).unwrap();
encoder.finish().unwrap()
}
#[test]
fn test_extract_from_zip() {
let content = b"Hello from ZIP!";
let zip_data = create_test_zip_with_file(content);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "test.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_bytes());
assert_eq!(result.value.as_bytes().unwrap(), content);
}
#[test]
fn test_extract_from_zip_with_password() {
let content = b"Secret data!";
let password = "mysecret123";
let zip_data = create_test_zip_with_password(content, password);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "secret.txt".to_string());
kwargs.insert("password".to_string(), password.to_string());
let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_bytes());
assert_eq!(result.value.as_bytes().unwrap(), content);
}
#[test]
fn test_extract_from_zip_wrong_password() {
let content = b"Secret data!";
let zip_data = create_test_zip_with_password(content, "correct");
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "secret.txt".to_string());
kwargs.insert("password".to_string(), "wrong".to_string());
let result = extract_resolver(&args, &kwargs, &ctx);
if let Err(err) = result {
let msg = err.to_string();
assert!(msg.contains("password") || msg.contains("decrypt"));
}
}
#[test]
fn test_extract_from_zip_file_not_found() {
let content = b"Hello";
let zip_data = create_test_zip_with_file(content);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "nonexistent.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn test_extract_from_tar() {
let content = b"Hello from TAR!";
let tar_data = create_test_tar_with_file(content);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "test.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_bytes());
assert_eq!(result.value.as_bytes().unwrap(), content);
}
#[test]
fn test_extract_from_tar_gz() {
let content = b"Hello from TAR.GZ!";
let tar_gz_data = create_test_tar_gz_with_file(content);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_gz_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "test.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
assert!(result.value.is_bytes());
assert_eq!(result.value.as_bytes().unwrap(), content);
}
#[test]
fn test_extract_from_tar_file_not_found() {
let content = b"Hello";
let tar_data = create_test_tar_with_file(content);
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "missing.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn test_extract_no_args() {
let ctx = ResolverContext::new("test.path");
let args = vec![];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "test.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("requires archive data"));
}
#[test]
fn test_extract_no_path_kwarg() {
let zip_data = create_test_zip_with_file(b"test");
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let kwargs = HashMap::new();
let result = extract_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("requires 'path'"));
}
#[test]
fn test_extract_unsupported_format() {
let invalid_data = b"Not an archive";
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(invalid_data);
let ctx = ResolverContext::new("test.path");
let args = vec![encoded];
let mut kwargs = HashMap::new();
kwargs.insert("path".to_string(), "test.txt".to_string());
let result = extract_resolver(&args, &kwargs, &ctx);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Unsupported archive format"));
}
#[test]
fn test_extract_resolver_registered() {
let registry = ResolverRegistry::with_builtins();
assert!(registry.contains("extract"));
}
}