holoconf_core/
resolver.rs

1//! Resolver architecture per ADR-002
2//!
3//! Resolvers are functions or objects that resolve interpolation expressions
4//! like `${env:VAR}` to actual values.
5
6use std::collections::HashMap;
7use std::sync::{Arc, OnceLock, RwLock};
8
9use crate::error::{Error, Result};
10use crate::value::Value;
11
12// Global resolver registry for extension packages
13static GLOBAL_REGISTRY: OnceLock<RwLock<ResolverRegistry>> = OnceLock::new();
14
15/// Get the global resolver registry.
16///
17/// This registry is lazily initialized with built-in resolvers.
18/// Extension packages can register additional resolvers here.
19pub fn global_registry() -> &'static RwLock<ResolverRegistry> {
20    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(ResolverRegistry::with_builtins()))
21}
22
23/// Register a resolver in the global registry.
24///
25/// # Arguments
26/// * `resolver` - The resolver to register
27/// * `force` - If true, overwrite any existing resolver with the same name.
28///   If false, return an error if the name is already registered.
29///
30/// # Returns
31/// * `Ok(())` on success
32/// * `Err(Error)` if force=false and a resolver with the same name exists
33pub fn register_global(resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
34    let mut registry = global_registry()
35        .write()
36        .expect("Global registry lock poisoned");
37    registry.register_with_force(resolver, force)
38}
39
40/// A resolved value with optional sensitivity metadata
41#[derive(Debug, Clone)]
42pub struct ResolvedValue {
43    /// The actual resolved value
44    pub value: Value,
45    /// Whether this value is sensitive (should be redacted in logs/exports)
46    pub sensitive: bool,
47}
48
49impl ResolvedValue {
50    /// Create a non-sensitive resolved value
51    pub fn new(value: impl Into<Value>) -> Self {
52        Self {
53            value: value.into(),
54            sensitive: false,
55        }
56    }
57
58    /// Create a sensitive resolved value
59    pub fn sensitive(value: impl Into<Value>) -> Self {
60        Self {
61            value: value.into(),
62            sensitive: true,
63        }
64    }
65}
66
67impl From<Value> for ResolvedValue {
68    fn from(value: Value) -> Self {
69        ResolvedValue::new(value)
70    }
71}
72
73impl From<String> for ResolvedValue {
74    fn from(s: String) -> Self {
75        ResolvedValue::new(Value::String(s))
76    }
77}
78
79impl From<&str> for ResolvedValue {
80    fn from(s: &str) -> Self {
81        ResolvedValue::new(Value::String(s.to_string()))
82    }
83}
84
85/// Context provided to resolvers during resolution
86#[derive(Debug, Clone)]
87pub struct ResolverContext {
88    /// The path in the config where this resolution is happening
89    pub config_path: String,
90    /// The config root (for self-references)
91    pub config_root: Option<Arc<Value>>,
92    /// The base path for relative file paths
93    pub base_path: Option<std::path::PathBuf>,
94    /// Resolution stack for circular reference detection
95    pub resolution_stack: Vec<String>,
96    /// Whether HTTP resolver is enabled
97    pub allow_http: bool,
98    /// HTTP URL allowlist (glob patterns)
99    pub http_allowlist: Vec<String>,
100    /// HTTP proxy URL (e.g., "http://proxy:8080" or "socks5://proxy:1080")
101    pub http_proxy: Option<String>,
102    /// Whether to auto-detect proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
103    pub http_proxy_from_env: bool,
104    /// Path to CA bundle PEM file (replaces default webpki-roots)
105    pub http_ca_bundle: Option<std::path::PathBuf>,
106    /// Path to extra CA bundle PEM file (appends to webpki-roots)
107    pub http_extra_ca_bundle: Option<std::path::PathBuf>,
108    /// Path to client certificate PEM or P12/PFX file (for mTLS)
109    pub http_client_cert: Option<std::path::PathBuf>,
110    /// Path to client private key PEM file (for mTLS, not needed for P12/PFX)
111    pub http_client_key: Option<std::path::PathBuf>,
112    /// Password for encrypted private key or P12/PFX file
113    pub http_client_key_password: Option<String>,
114    /// DANGEROUS: Skip TLS certificate verification (dev only)
115    pub http_insecure: bool,
116}
117
118impl ResolverContext {
119    /// Create a new resolver context
120    pub fn new(config_path: impl Into<String>) -> Self {
121        Self {
122            config_path: config_path.into(),
123            config_root: None,
124            base_path: None,
125            resolution_stack: Vec::new(),
126            allow_http: false,
127            http_allowlist: Vec::new(),
128            http_proxy: None,
129            http_proxy_from_env: false,
130            http_ca_bundle: None,
131            http_extra_ca_bundle: None,
132            http_client_cert: None,
133            http_client_key: None,
134            http_client_key_password: None,
135            http_insecure: false,
136        }
137    }
138
139    /// Set whether HTTP resolver is enabled
140    pub fn with_allow_http(mut self, allow: bool) -> Self {
141        self.allow_http = allow;
142        self
143    }
144
145    /// Set HTTP URL allowlist
146    pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
147        self.http_allowlist = allowlist;
148        self
149    }
150
151    /// Set HTTP proxy URL
152    pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
153        self.http_proxy = Some(proxy.into());
154        self
155    }
156
157    /// Set whether to auto-detect proxy from environment variables
158    pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
159        self.http_proxy_from_env = enabled;
160        self
161    }
162
163    /// Set CA bundle path (replaces webpki-roots)
164    pub fn with_http_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
165        self.http_ca_bundle = Some(path.into());
166        self
167    }
168
169    /// Set extra CA bundle path (appends to webpki-roots)
170    pub fn with_http_extra_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
171        self.http_extra_ca_bundle = Some(path.into());
172        self
173    }
174
175    /// Set client certificate path for mTLS
176    pub fn with_http_client_cert(mut self, path: impl Into<std::path::PathBuf>) -> Self {
177        self.http_client_cert = Some(path.into());
178        self
179    }
180
181    /// Set client private key path for mTLS
182    pub fn with_http_client_key(mut self, path: impl Into<std::path::PathBuf>) -> Self {
183        self.http_client_key = Some(path.into());
184        self
185    }
186
187    /// Set password for encrypted private key or P12/PFX file
188    pub fn with_http_client_key_password(mut self, password: impl Into<String>) -> Self {
189        self.http_client_key_password = Some(password.into());
190        self
191    }
192
193    /// DANGEROUS: Skip TLS certificate verification
194    pub fn with_http_insecure(mut self, insecure: bool) -> Self {
195        self.http_insecure = insecure;
196        self
197    }
198
199    /// Set the config root for self-references
200    pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
201        self.config_root = Some(root);
202        self
203    }
204
205    /// Set the base path for file resolution
206    pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
207        self.base_path = Some(path);
208        self
209    }
210
211    /// Check if resolving a path would cause a circular reference
212    pub fn would_cause_cycle(&self, path: &str) -> bool {
213        self.resolution_stack.contains(&path.to_string())
214    }
215
216    /// Push a path onto the resolution stack
217    pub fn push_resolution(&mut self, path: &str) {
218        self.resolution_stack.push(path.to_string());
219    }
220
221    /// Pop a path from the resolution stack
222    pub fn pop_resolution(&mut self) {
223        self.resolution_stack.pop();
224    }
225
226    /// Get the resolution chain for error reporting
227    pub fn get_resolution_chain(&self) -> Vec<String> {
228        self.resolution_stack.clone()
229    }
230}
231
232/// Trait for resolver implementations
233pub trait Resolver: Send + Sync {
234    /// Resolve an interpolation expression
235    ///
236    /// # Arguments
237    /// * `args` - Positional arguments from the interpolation
238    /// * `kwargs` - Keyword arguments from the interpolation
239    /// * `ctx` - Resolution context
240    fn resolve(
241        &self,
242        args: &[String],
243        kwargs: &HashMap<String, String>,
244        ctx: &ResolverContext,
245    ) -> Result<ResolvedValue>;
246
247    /// Get the name of this resolver
248    fn name(&self) -> &str;
249}
250
251/// A simple function-based resolver
252pub struct FnResolver<F>
253where
254    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
255        + Send
256        + Sync,
257{
258    name: String,
259    func: F,
260}
261
262impl<F> FnResolver<F>
263where
264    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
265        + Send
266        + Sync,
267{
268    /// Create a new function-based resolver
269    pub fn new(name: impl Into<String>, func: F) -> Self {
270        Self {
271            name: name.into(),
272            func,
273        }
274    }
275}
276
277impl<F> Resolver for FnResolver<F>
278where
279    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
280        + Send
281        + Sync,
282{
283    fn resolve(
284        &self,
285        args: &[String],
286        kwargs: &HashMap<String, String>,
287        ctx: &ResolverContext,
288    ) -> Result<ResolvedValue> {
289        (self.func)(args, kwargs, ctx)
290    }
291
292    fn name(&self) -> &str {
293        &self.name
294    }
295}
296
297/// Registry of available resolvers
298#[derive(Clone)]
299pub struct ResolverRegistry {
300    resolvers: HashMap<String, Arc<dyn Resolver>>,
301}
302
303impl Default for ResolverRegistry {
304    fn default() -> Self {
305        Self::new()
306    }
307}
308
309impl ResolverRegistry {
310    /// Create a new empty registry
311    pub fn new() -> Self {
312        Self {
313            resolvers: HashMap::new(),
314        }
315    }
316
317    /// Create a registry with the standard built-in resolvers
318    pub fn with_builtins() -> Self {
319        let mut registry = Self::new();
320        registry.register_builtin_resolvers();
321        registry
322    }
323
324    /// Register the built-in resolvers (env, file, http)
325    fn register_builtin_resolvers(&mut self) {
326        // Environment variable resolver
327        self.register(Arc::new(FnResolver::new("env", env_resolver)));
328        // File resolver
329        self.register(Arc::new(FnResolver::new("file", file_resolver)));
330        // HTTP resolver (disabled by default for security)
331        self.register(Arc::new(FnResolver::new("http", http_resolver)));
332    }
333
334    /// Register a resolver
335    pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
336        self.resolvers.insert(resolver.name().to_string(), resolver);
337    }
338
339    /// Register a resolver with optional force overwrite.
340    ///
341    /// # Arguments
342    /// * `resolver` - The resolver to register
343    /// * `force` - If true, overwrite any existing resolver with the same name.
344    ///   If false, return an error if the name is already registered.
345    ///
346    /// # Returns
347    /// * `Ok(())` on success
348    /// * `Err(Error)` if force=false and a resolver with the same name exists
349    pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
350        let name = resolver.name().to_string();
351        if !force && self.resolvers.contains_key(&name) {
352            return Err(Error::resolver_already_registered(&name));
353        }
354        self.resolvers.insert(name, resolver);
355        Ok(())
356    }
357
358    /// Register a function as a resolver
359    pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
360    where
361        F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
362            + Send
363            + Sync
364            + 'static,
365    {
366        let name = name.into();
367        self.register(Arc::new(FnResolver::new(name, func)));
368    }
369
370    /// Get a resolver by name
371    pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
372        self.resolvers.get(name)
373    }
374
375    /// Check if a resolver is registered
376    pub fn contains(&self, name: &str) -> bool {
377        self.resolvers.contains_key(name)
378    }
379
380    /// Resolve an interpolation using the appropriate resolver
381    ///
382    /// This method implements framework-level handling of the `sensitive` kwarg per ADR-011.
383    /// The `sensitive` kwarg overrides the resolver's sensitivity hint.
384    ///
385    /// Note: `default` handling with lazy resolution is done at the Config level,
386    /// not here, to support nested interpolations in default values.
387    pub fn resolve(
388        &self,
389        resolver_name: &str,
390        args: &[String],
391        kwargs: &HashMap<String, String>,
392        ctx: &ResolverContext,
393    ) -> Result<ResolvedValue> {
394        let resolver = self
395            .resolvers
396            .get(resolver_name)
397            .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
398
399        // Extract framework-level `sensitive` kwarg per ADR-011
400        let sensitive_override = kwargs
401            .get("sensitive")
402            .map(|v| v.eq_ignore_ascii_case("true"));
403
404        // Pass remaining kwargs to the resolver (filter out framework keyword)
405        let resolver_kwargs: HashMap<String, String> = kwargs
406            .iter()
407            .filter(|(k, _)| *k != "sensitive")
408            .map(|(k, v)| (k.clone(), v.clone()))
409            .collect();
410
411        // Call the resolver
412        let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
413
414        // Apply sensitivity override if specified
415        if let Some(is_sensitive) = sensitive_override {
416            resolved.sensitive = is_sensitive;
417        }
418
419        Ok(resolved)
420    }
421}
422
423/// Built-in environment variable resolver
424///
425/// Usage:
426///   ${env:VAR_NAME}                      - Get env var (error if not set)
427///   ${env:VAR_NAME,default=value}        - Get env var with default (framework-handled)
428///   ${env:VAR_NAME,sensitive=true}       - Mark as sensitive for redaction (framework-handled)
429///
430/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
431/// This resolver just returns the env var value or an error if not found.
432fn env_resolver(
433    args: &[String],
434    _kwargs: &HashMap<String, String>,
435    ctx: &ResolverContext,
436) -> Result<ResolvedValue> {
437    if args.is_empty() {
438        return Err(Error::parse("env resolver requires a variable name")
439            .with_path(ctx.config_path.clone()));
440    }
441
442    let var_name = &args[0];
443
444    match std::env::var(var_name) {
445        Ok(value) => {
446            // Return non-sensitive by default; sensitivity can be overridden via kwarg
447            Ok(ResolvedValue::new(Value::String(value)))
448        }
449        Err(_) => {
450            // Return EnvNotFound error - framework will handle default if provided
451            Err(Error::env_not_found(
452                var_name,
453                Some(ctx.config_path.clone()),
454            ))
455        }
456    }
457}
458
459/// Built-in file resolver
460///
461/// Usage:
462///   ${file:path/to/file}                    - Read file as text (UTF-8)
463///   ${file:path/to/file,parse=yaml}         - Parse as YAML
464///   ${file:path/to/file,parse=json}         - Parse as JSON
465///   ${file:path/to/file,parse=text}         - Read as text (explicit)
466///   ${file:path/to/file,parse=auto}         - Auto-detect from extension (default)
467///   ${file:path/to/file,encoding=utf-8}     - UTF-8 encoding (default)
468///   ${file:path/to/file,encoding=ascii}     - ASCII encoding (strips non-ASCII)
469///   ${file:path/to/file,encoding=base64}    - Base64 encode the file contents as string
470///   ${file:path/to/file,encoding=binary}    - Return raw bytes as Value::Bytes
471///   ${file:path/to/file,default={}}         - Default if file not found (framework-handled)
472///   ${file:path/to/file,sensitive=true}     - Mark as sensitive (framework-handled)
473///
474/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
475fn file_resolver(
476    args: &[String],
477    kwargs: &HashMap<String, String>,
478    ctx: &ResolverContext,
479) -> Result<ResolvedValue> {
480    use std::path::Path;
481
482    if args.is_empty() {
483        return Err(
484            Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
485        );
486    }
487
488    let file_path_str = &args[0];
489    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
490    let encoding = kwargs
491        .get("encoding")
492        .map(|s| s.as_str())
493        .unwrap_or("utf-8");
494
495    // Resolve relative paths based on context base path
496    let file_path = if Path::new(file_path_str).is_relative() {
497        if let Some(base) = &ctx.base_path {
498            base.join(file_path_str)
499        } else {
500            std::path::PathBuf::from(file_path_str)
501        }
502    } else {
503        std::path::PathBuf::from(file_path_str)
504    };
505
506    // Handle binary encoding separately - returns Value::Bytes directly
507    if encoding == "binary" {
508        let bytes = std::fs::read(&file_path)
509            .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
510        return Ok(ResolvedValue::new(Value::Bytes(bytes)));
511    }
512
513    // Read the file based on encoding
514    let content = match encoding {
515        "base64" => {
516            // Read as binary and base64 encode
517            use base64::{engine::general_purpose::STANDARD, Engine as _};
518            let bytes = std::fs::read(&file_path)
519                .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
520            STANDARD.encode(bytes)
521        }
522        "ascii" => {
523            // Read as UTF-8 but strip non-ASCII characters
524            let raw = std::fs::read_to_string(&file_path)
525                .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
526            raw.chars().filter(|c| c.is_ascii()).collect()
527        }
528        _ => {
529            // Default to UTF-8 (including explicit "utf-8")
530            std::fs::read_to_string(&file_path)
531                .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?
532        }
533    };
534
535    // For base64 encoding, always return as text (don't try to parse)
536    if encoding == "base64" {
537        return Ok(ResolvedValue::new(Value::String(content)));
538    }
539
540    // Determine parse mode
541    let actual_parse_mode = if parse_mode == "auto" {
542        // Detect from extension
543        match file_path.extension().and_then(|e| e.to_str()) {
544            Some("yaml") | Some("yml") => "yaml",
545            Some("json") => "json",
546            _ => "text",
547        }
548    } else {
549        parse_mode
550    };
551
552    // Parse content based on mode
553    match actual_parse_mode {
554        "yaml" => {
555            let value: Value = serde_yaml::from_str(&content).map_err(|e| {
556                Error::parse(format!("Failed to parse YAML: {}", e))
557                    .with_path(ctx.config_path.clone())
558            })?;
559            Ok(ResolvedValue::new(value))
560        }
561        "json" => {
562            let value: Value = serde_json::from_str(&content).map_err(|e| {
563                Error::parse(format!("Failed to parse JSON: {}", e))
564                    .with_path(ctx.config_path.clone())
565            })?;
566            Ok(ResolvedValue::new(value))
567        }
568        _ => {
569            // Default to text mode (including explicit "text")
570            Ok(ResolvedValue::new(Value::String(content)))
571        }
572    }
573}
574
575/// Built-in HTTP resolver
576///
577/// Fetches content from remote URLs.
578///
579/// Usage:
580///   ${http:https://example.com/config.yaml}           - Auto-detect parse mode
581///   ${http:https://example.com/config,parse=yaml}     - Parse as YAML
582///   ${http:https://example.com/config,parse=json}     - Parse as JSON
583///   ${http:https://example.com/config,parse=text}     - Read as text
584///   ${http:https://example.com/config,parse=binary}   - Read as binary
585///   ${http:https://example.com/config,timeout=60}     - Timeout in seconds
586///   ${http:https://example.com/config,header=Auth:Bearer token} - Add header
587///   ${http:https://example.com/config,default={}}     - Default if request fails
588///   ${http:https://example.com/config,sensitive=true} - Mark as sensitive
589///
590/// Security:
591/// - Disabled by default (requires allow_http=true in ConfigOptions)
592/// - URL allowlist can restrict which URLs are accessible
593fn http_resolver(
594    args: &[String],
595    kwargs: &HashMap<String, String>,
596    ctx: &ResolverContext,
597) -> Result<ResolvedValue> {
598    if args.is_empty() {
599        return Err(Error::parse("http resolver requires a URL").with_path(ctx.config_path.clone()));
600    }
601
602    let url = &args[0];
603
604    // Check if HTTP is enabled
605    if !ctx.allow_http {
606        return Err(Error {
607            kind: crate::error::ErrorKind::Resolver(crate::error::ResolverErrorKind::HttpDisabled),
608            path: Some(ctx.config_path.clone()),
609            source_location: None,
610            help: Some(format!(
611                "Enable HTTP resolver with Config.load(..., allow_http=True)\nURL: {}",
612                url
613            )),
614            cause: None,
615        });
616    }
617
618    // Check URL against allowlist if configured
619    if !ctx.http_allowlist.is_empty() {
620        let url_allowed = ctx
621            .http_allowlist
622            .iter()
623            .any(|pattern| url_matches_pattern(url, pattern));
624        if !url_allowed {
625            return Err(Error::http_not_in_allowlist(
626                url,
627                &ctx.http_allowlist,
628                Some(ctx.config_path.clone()),
629            ));
630        }
631    }
632
633    // Perform the actual HTTP request
634    #[cfg(feature = "http")]
635    {
636        http_fetch(url, kwargs, ctx)
637    }
638
639    #[cfg(not(feature = "http"))]
640    {
641        // If compiled without HTTP feature, return an error
642        let _ = kwargs; // Suppress unused warning
643        Err(Error::resolver_custom(
644            "http",
645            "HTTP support not compiled in. Rebuild with --features http",
646        ))
647    }
648}
649
650/// Check if a URL matches an allowlist pattern
651///
652/// Supports glob-style patterns:
653/// - `https://example.com/*` matches any path on example.com
654/// - `https://*.example.com/*` matches any subdomain
655fn url_matches_pattern(url: &str, pattern: &str) -> bool {
656    // Convert glob pattern to regex
657    let regex_pattern = pattern
658        .replace('.', r"\.")
659        .replace('*', ".*")
660        .replace('?', ".");
661
662    if let Ok(re) = regex::Regex::new(&format!("^{}$", regex_pattern)) {
663        re.is_match(url)
664    } else {
665        // If pattern is invalid, do exact match
666        url == pattern
667    }
668}
669
670// =============================================================================
671// TLS/Proxy Configuration Helpers (HTTP feature)
672// =============================================================================
673
674/// Load certificates from a PEM file (returns owned static-lifetime certs)
675#[cfg(feature = "http")]
676fn load_certs_from_pem(path: &std::path::Path) -> Result<Vec<ureq::tls::Certificate<'static>>> {
677    use ureq::tls::PemItem;
678
679    let pem_content = std::fs::read(path).map_err(|e| {
680        Error::pem_load_error(
681            path.display().to_string(),
682            format!("Failed to open file: {}", e),
683        )
684    })?;
685
686    let certs: Vec<_> = ureq::tls::parse_pem(&pem_content)
687        .filter_map(|item| item.ok())
688        .filter_map(|item| match item {
689            PemItem::Certificate(cert) => Some(cert.to_owned()),
690            _ => None,
691        })
692        .collect();
693
694    if certs.is_empty() {
695        return Err(Error::pem_load_error(
696            path.display().to_string(),
697            "No valid certificates found in PEM file",
698        ));
699    }
700
701    Ok(certs)
702}
703
704/// Load a private key from a PEM file (handles unencrypted keys)
705#[cfg(feature = "http")]
706fn load_private_key_from_pem(path: &std::path::Path) -> Result<ureq::tls::PrivateKey<'static>> {
707    let pem_content = std::fs::read(path).map_err(|e| {
708        Error::pem_load_error(
709            path.display().to_string(),
710            format!("Failed to open file: {}", e),
711        )
712    })?;
713
714    let key = ureq::tls::PrivateKey::from_pem(&pem_content).map_err(|e| {
715        Error::pem_load_error(
716            path.display().to_string(),
717            format!("Failed to parse key: {}", e),
718        )
719    })?;
720
721    Ok(key.to_owned())
722}
723
724/// Load an encrypted private key from a PEM file
725#[cfg(feature = "http")]
726fn load_encrypted_private_key_from_pem(
727    path: &std::path::Path,
728    password: &str,
729) -> Result<ureq::tls::PrivateKey<'static>> {
730    use pkcs8::der::Decode;
731
732    let pem_content = std::fs::read_to_string(path).map_err(|e| {
733        Error::pem_load_error(
734            path.display().to_string(),
735            format!("Failed to read file: {}", e),
736        )
737    })?;
738
739    // Check if this is an encrypted PKCS#8 key
740    if pem_content.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") {
741        // Extract the base64 content from PEM
742        let der_bytes = pem_to_der(&pem_content, "ENCRYPTED PRIVATE KEY")
743            .map_err(|e| Error::pem_load_error(path.display().to_string(), e))?;
744
745        let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
746            .map_err(|e| Error::pem_load_error(path.display().to_string(), e.to_string()))?;
747
748        let decrypted = encrypted
749            .decrypt(password)
750            .map_err(|e| Error::key_decryption_error(e.to_string()))?;
751
752        // The decrypted key is in PKCS#8 DER format
753        // Wrap it in PEM format so ureq can parse it
754        let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
755
756        ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
757            .map(|k| k.to_owned())
758            .map_err(|e| {
759                Error::pem_load_error(
760                    path.display().to_string(),
761                    format!("Failed to parse decrypted key: {}", e),
762                )
763            })
764    } else {
765        // Not encrypted, try loading as regular key
766        load_private_key_from_pem(path)
767    }
768}
769
770/// Extract DER bytes from PEM format
771#[cfg(feature = "http")]
772fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
773    let begin_marker = format!("-----BEGIN {}-----", label);
774    let end_marker = format!("-----END {}-----", label);
775
776    let start = pem
777        .find(&begin_marker)
778        .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
779    let end = pem
780        .find(&end_marker)
781        .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
782
783    let base64_content: String = pem[start + begin_marker.len()..end]
784        .chars()
785        .filter(|c| !c.is_whitespace())
786        .collect();
787
788    use base64::Engine;
789    base64::engine::general_purpose::STANDARD
790        .decode(&base64_content)
791        .map_err(|e| format!("Failed to decode base64: {}", e))
792}
793
794/// Convert DER bytes to PEM format
795#[cfg(feature = "http")]
796fn der_to_pem(der: &[u8], label: &str) -> String {
797    use base64::Engine;
798    let base64 = base64::engine::general_purpose::STANDARD.encode(der);
799    // Split into 64-char lines
800    let lines: Vec<&str> = base64
801        .as_bytes()
802        .chunks(64)
803        .map(|chunk| std::str::from_utf8(chunk).unwrap())
804        .collect();
805    format!(
806        "-----BEGIN {}-----\n{}\n-----END {}-----\n",
807        label,
808        lines.join("\n"),
809        label
810    )
811}
812
813/// Detect if a file is P12/PFX format by extension
814#[cfg(feature = "http")]
815fn is_p12_file(path: &std::path::Path) -> bool {
816    path.extension()
817        .and_then(|ext| ext.to_str())
818        .map(|ext| ext.eq_ignore_ascii_case("p12") || ext.eq_ignore_ascii_case("pfx"))
819        .unwrap_or(false)
820}
821
822/// Load client certificate and key from P12/PFX file
823#[cfg(feature = "http")]
824fn load_identity_from_p12(
825    path: &std::path::Path,
826    password: &str,
827) -> Result<(
828    Vec<ureq::tls::Certificate<'static>>,
829    ureq::tls::PrivateKey<'static>,
830)> {
831    let p12_data = std::fs::read(path).map_err(|e| {
832        Error::p12_load_error(
833            path.display().to_string(),
834            format!("Failed to read file: {}", e),
835        )
836    })?;
837
838    let keystore = p12_keystore::KeyStore::from_pkcs12(&p12_data, password)
839        .map_err(|e| Error::p12_load_error(path.display().to_string(), e.to_string()))?;
840
841    // Get the first key entry (most P12 files have one key)
842    // private_key_chain() returns Option<(&str, &PrivateKeyChain)>
843    let (_alias, key_chain) = keystore.private_key_chain().ok_or_else(|| {
844        Error::p12_load_error(
845            path.display().to_string(),
846            "No private key found in P12 file",
847        )
848    })?;
849
850    // Get the private key DER bytes - wrap in PEM for ureq
851    let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
852    let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
853        .map(|k| k.to_owned())
854        .map_err(|e| {
855            Error::p12_load_error(
856                path.display().to_string(),
857                format!("Failed to parse private key: {}", e),
858            )
859        })?;
860
861    // Get certificates from the chain
862    let certs: Vec<_> = key_chain
863        .chain()
864        .iter()
865        .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
866        .collect();
867
868    if certs.is_empty() {
869        return Err(Error::p12_load_error(
870            path.display().to_string(),
871            "No certificates found in P12 file",
872        ));
873    }
874
875    Ok((certs, private_key))
876}
877
878/// Load client identity (cert + key) for mTLS
879#[cfg(feature = "http")]
880fn load_client_identity(
881    cert_path: &std::path::Path,
882    key_path: Option<&std::path::Path>,
883    password: Option<&str>,
884) -> Result<(
885    Vec<ureq::tls::Certificate<'static>>,
886    ureq::tls::PrivateKey<'static>,
887)> {
888    // If cert is P12/PFX, load both cert and key from it
889    if is_p12_file(cert_path) {
890        let pwd = password.unwrap_or("");
891        return load_identity_from_p12(cert_path, pwd);
892    }
893
894    // Otherwise, load PEM cert and key separately
895    let cert_chain = load_certs_from_pem(cert_path)?;
896
897    let key_path = key_path.ok_or_else(|| {
898        Error::tls_config_error("Client key path required when using PEM certificate (not P12)")
899    })?;
900
901    let private_key = if let Some(pwd) = password {
902        load_encrypted_private_key_from_pem(key_path, pwd)?
903    } else {
904        load_private_key_from_pem(key_path)?
905    };
906
907    Ok((cert_chain, private_key))
908}
909
910/// Build TLS configuration from context and per-request kwargs
911#[cfg(feature = "http")]
912fn build_tls_config(
913    ctx: &ResolverContext,
914    kwargs: &HashMap<String, String>,
915) -> Result<ureq::tls::TlsConfig> {
916    use std::sync::Arc;
917    use ureq::tls::{ClientCert, RootCerts, TlsConfig};
918
919    let mut builder = TlsConfig::builder();
920
921    // Check for insecure mode (per-request kwargs override context)
922    let insecure = kwargs
923        .get("insecure")
924        .map(|v| v == "true")
925        .unwrap_or(ctx.http_insecure);
926
927    if insecure {
928        // DANGEROUS: Skip TLS verification
929        // Log a warning (in production, use proper logging)
930        eprintln!("WARNING: TLS certificate verification is disabled (http_insecure=true)");
931        builder = builder.disable_verification(true);
932    }
933
934    // Load CA bundle if specified (per-request overrides context)
935    let ca_bundle_path = kwargs
936        .get("ca_bundle")
937        .map(std::path::PathBuf::from)
938        .or_else(|| ctx.http_ca_bundle.clone());
939
940    let extra_ca_bundle_path = kwargs
941        .get("extra_ca_bundle")
942        .map(std::path::PathBuf::from)
943        .or_else(|| ctx.http_extra_ca_bundle.clone());
944
945    if let Some(ca_path) = ca_bundle_path {
946        // Replace root certs with custom CA bundle
947        let certs = load_certs_from_pem(&ca_path)?;
948        builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
949    } else if let Some(extra_ca_path) = extra_ca_bundle_path {
950        // Add extra certs to webpki roots using new_with_certs
951        let extra_certs = load_certs_from_pem(&extra_ca_path)?;
952        builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
953    }
954
955    // Load client certificate for mTLS (per-request overrides context)
956    let client_cert_path = kwargs
957        .get("client_cert")
958        .map(std::path::PathBuf::from)
959        .or_else(|| ctx.http_client_cert.clone());
960
961    if let Some(cert_path) = client_cert_path {
962        let client_key_path = kwargs
963            .get("client_key")
964            .map(std::path::PathBuf::from)
965            .or_else(|| ctx.http_client_key.clone());
966
967        let password = kwargs
968            .get("key_password")
969            .map(|s| s.as_str())
970            .or(ctx.http_client_key_password.as_deref());
971
972        let (certs, key) = load_client_identity(&cert_path, client_key_path.as_deref(), password)?;
973
974        let client_cert = ClientCert::new_with_certs(&certs, key);
975        builder = builder.client_cert(Some(client_cert));
976    }
977
978    Ok(builder.build())
979}
980
981/// Build proxy configuration from context and per-request kwargs
982#[cfg(feature = "http")]
983fn build_proxy_config(
984    ctx: &ResolverContext,
985    kwargs: &HashMap<String, String>,
986) -> Result<Option<ureq::Proxy>> {
987    // Per-request proxy overrides context
988    let proxy_url = kwargs
989        .get("proxy")
990        .cloned()
991        .or_else(|| ctx.http_proxy.clone());
992
993    // If no explicit proxy, check environment if enabled
994    let proxy_url = proxy_url.or_else(|| {
995        if ctx.http_proxy_from_env {
996            // Check standard proxy environment variables
997            std::env::var("HTTPS_PROXY")
998                .or_else(|_| std::env::var("https_proxy"))
999                .or_else(|_| std::env::var("HTTP_PROXY"))
1000                .or_else(|_| std::env::var("http_proxy"))
1001                .ok()
1002        } else {
1003            None
1004        }
1005    });
1006
1007    if let Some(url) = proxy_url {
1008        let proxy = ureq::Proxy::new(&url).map_err(|e| {
1009            Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1010        })?;
1011        Ok(Some(proxy))
1012    } else {
1013        Ok(None)
1014    }
1015}
1016
1017/// Perform HTTP request and parse response
1018#[cfg(feature = "http")]
1019fn http_fetch(
1020    url: &str,
1021    kwargs: &HashMap<String, String>,
1022    ctx: &ResolverContext,
1023) -> Result<ResolvedValue> {
1024    use std::time::Duration;
1025
1026    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
1027    let timeout_secs: u64 = kwargs
1028        .get("timeout")
1029        .and_then(|s| s.parse().ok())
1030        .unwrap_or(30);
1031
1032    // Build TLS configuration
1033    let tls_config = build_tls_config(ctx, kwargs)?;
1034
1035    // Build proxy configuration
1036    let proxy = build_proxy_config(ctx, kwargs)?;
1037
1038    // Build agent with timeout, TLS, and proxy configuration
1039    let mut config_builder = ureq::Agent::config_builder()
1040        .timeout_global(Some(Duration::from_secs(timeout_secs)))
1041        .tls_config(tls_config);
1042
1043    if proxy.is_some() {
1044        config_builder = config_builder.proxy(proxy);
1045    }
1046
1047    let config = config_builder.build();
1048    let agent: ureq::Agent = config.into();
1049
1050    // Build the request
1051    let mut request = agent.get(url);
1052
1053    // Add custom headers
1054    for (key, value) in kwargs {
1055        if key == "header" {
1056            // Parse header in format "Name:Value"
1057            if let Some((name, val)) = value.split_once(':') {
1058                request = request.header(name.trim(), val.trim());
1059            }
1060        }
1061    }
1062
1063    // Send request
1064    let response = request.call().map_err(|e| {
1065        let error_msg = match &e {
1066            ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1067            ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1068            ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1069            _ => format!("HTTP request failed: {}", e),
1070        };
1071        Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1072    })?;
1073
1074    // Get content type from response
1075    let content_type = response
1076        .headers()
1077        .get("content-type")
1078        .and_then(|v| v.to_str().ok())
1079        .map(|s| s.to_string())
1080        .unwrap_or_default();
1081
1082    // Handle binary mode separately
1083    if parse_mode == "binary" {
1084        let bytes = response.into_body().read_to_vec().map_err(|e| {
1085            Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1086        })?;
1087        return Ok(ResolvedValue::new(Value::Bytes(bytes)));
1088    }
1089
1090    // Read response body as text
1091    let body = response.into_body().read_to_string().map_err(|e| {
1092        Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1093    })?;
1094
1095    // Determine actual parse mode
1096    let actual_parse_mode = if parse_mode == "auto" {
1097        detect_parse_mode(url, &content_type)
1098    } else {
1099        parse_mode
1100    };
1101
1102    // Parse content based on mode
1103    match actual_parse_mode {
1104        "yaml" => {
1105            let value: Value = serde_yaml::from_str(&body).map_err(|e| {
1106                Error::parse(format!("Failed to parse YAML from {}: {}", url, e))
1107                    .with_path(ctx.config_path.clone())
1108            })?;
1109            Ok(ResolvedValue::new(value))
1110        }
1111        "json" => {
1112            let value: Value = serde_json::from_str(&body).map_err(|e| {
1113                Error::parse(format!("Failed to parse JSON from {}: {}", url, e))
1114                    .with_path(ctx.config_path.clone())
1115            })?;
1116            Ok(ResolvedValue::new(value))
1117        }
1118        _ => {
1119            // Default to text mode
1120            Ok(ResolvedValue::new(Value::String(body)))
1121        }
1122    }
1123}
1124
1125/// Detect parse mode from URL extension or content type
1126#[cfg(feature = "http")]
1127fn detect_parse_mode<'a>(url: &str, content_type: &str) -> &'a str {
1128    // Check content type first
1129    let ct_lower = content_type.to_lowercase();
1130    if ct_lower.contains("application/json") || ct_lower.contains("text/json") {
1131        return "json";
1132    }
1133    if ct_lower.contains("application/yaml")
1134        || ct_lower.contains("application/x-yaml")
1135        || ct_lower.contains("text/yaml")
1136    {
1137        return "yaml";
1138    }
1139
1140    // Check URL extension
1141    if let Some(path) = url.split('?').next() {
1142        if path.ends_with(".json") {
1143            return "json";
1144        }
1145        if path.ends_with(".yaml") || path.ends_with(".yml") {
1146            return "yaml";
1147        }
1148    }
1149
1150    // Default to text
1151    "text"
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156    use super::*;
1157
1158    #[test]
1159    fn test_env_resolver_with_value() {
1160        std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
1161
1162        let ctx = ResolverContext::new("test.path");
1163        let args = vec!["HOLOCONF_TEST_VAR".to_string()];
1164        let kwargs = HashMap::new();
1165
1166        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
1167        assert_eq!(result.value.as_str(), Some("test_value"));
1168        assert!(!result.sensitive);
1169
1170        std::env::remove_var("HOLOCONF_TEST_VAR");
1171    }
1172
1173    #[test]
1174    fn test_env_resolver_missing_returns_error() {
1175        // Make sure the var doesn't exist
1176        std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
1177
1178        let registry = ResolverRegistry::with_builtins();
1179        let ctx = ResolverContext::new("test.path");
1180        let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
1181        let kwargs = HashMap::new();
1182
1183        // Registry doesn't handle defaults - that's done at Config level for lazy resolution
1184        // So this should return an error
1185        let result = registry.resolve("env", &args, &kwargs, &ctx);
1186        assert!(result.is_err());
1187    }
1188
1189    #[test]
1190    fn test_env_resolver_missing_no_default() {
1191        std::env::remove_var("HOLOCONF_MISSING_VAR");
1192
1193        let ctx = ResolverContext::new("test.path");
1194        let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
1195        let kwargs = HashMap::new();
1196
1197        let result = env_resolver(&args, &kwargs, &ctx);
1198        assert!(result.is_err());
1199    }
1200
1201    #[test]
1202    fn test_env_resolver_sensitive_kwarg() {
1203        std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
1204
1205        let registry = ResolverRegistry::with_builtins();
1206        let ctx = ResolverContext::new("test.path");
1207        let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
1208        let mut kwargs = HashMap::new();
1209        kwargs.insert("sensitive".to_string(), "true".to_string());
1210
1211        // Framework-level sensitive handling via registry
1212        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1213        assert_eq!(result.value.as_str(), Some("secret_value"));
1214        assert!(result.sensitive);
1215
1216        std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
1217    }
1218
1219    #[test]
1220    fn test_env_resolver_sensitive_false() {
1221        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
1222
1223        let registry = ResolverRegistry::with_builtins();
1224        let ctx = ResolverContext::new("test.path");
1225        let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
1226        let mut kwargs = HashMap::new();
1227        kwargs.insert("sensitive".to_string(), "false".to_string());
1228
1229        // Framework-level sensitive handling via registry
1230        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1231        assert_eq!(result.value.as_str(), Some("public_value"));
1232        assert!(!result.sensitive);
1233
1234        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1235    }
1236
1237    // Note: test_env_resolver_sensitive_with_default has moved to config.rs tests
1238    // since default handling with lazy resolution is done at the Config level
1239
1240    #[test]
1241    fn test_resolver_registry() {
1242        let registry = ResolverRegistry::with_builtins();
1243
1244        assert!(registry.contains("env"));
1245        assert!(!registry.contains("nonexistent"));
1246    }
1247
1248    #[test]
1249    fn test_custom_resolver() {
1250        let mut registry = ResolverRegistry::new();
1251
1252        registry.register_fn("custom", |args, _kwargs, _ctx| {
1253            let value = args.first().cloned().unwrap_or_default();
1254            Ok(ResolvedValue::new(Value::String(format!(
1255                "custom:{}",
1256                value
1257            ))))
1258        });
1259
1260        let ctx = ResolverContext::new("test");
1261        let result = registry
1262            .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
1263            .unwrap();
1264
1265        assert_eq!(result.value.as_str(), Some("custom:arg"));
1266    }
1267
1268    #[test]
1269    fn test_resolved_value_sensitivity() {
1270        let non_sensitive = ResolvedValue::new("public");
1271        assert!(!non_sensitive.sensitive);
1272
1273        let sensitive = ResolvedValue::sensitive("secret");
1274        assert!(sensitive.sensitive);
1275    }
1276
1277    #[test]
1278    fn test_resolver_context_cycle_detection() {
1279        let mut ctx = ResolverContext::new("root");
1280        ctx.push_resolution("a");
1281        ctx.push_resolution("b");
1282
1283        assert!(ctx.would_cause_cycle("a"));
1284        assert!(ctx.would_cause_cycle("b"));
1285        assert!(!ctx.would_cause_cycle("c"));
1286
1287        ctx.pop_resolution();
1288        assert!(!ctx.would_cause_cycle("b"));
1289    }
1290
1291    #[test]
1292    fn test_file_resolver() {
1293        use std::io::Write;
1294
1295        // Create a temporary file
1296        let temp_dir = std::env::temp_dir();
1297        let test_file = temp_dir.join("holoconf_test_file.txt");
1298        {
1299            let mut file = std::fs::File::create(&test_file).unwrap();
1300            writeln!(file, "test content").unwrap();
1301        }
1302
1303        let mut ctx = ResolverContext::new("test.path");
1304        ctx.base_path = Some(temp_dir.clone());
1305
1306        let args = vec!["holoconf_test_file.txt".to_string()];
1307        let mut kwargs = HashMap::new();
1308        kwargs.insert("parse".to_string(), "text".to_string());
1309
1310        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1311        assert!(result.value.as_str().unwrap().contains("test content"));
1312        assert!(!result.sensitive);
1313
1314        // Cleanup
1315        std::fs::remove_file(test_file).ok();
1316    }
1317
1318    #[test]
1319    fn test_file_resolver_yaml() {
1320        use std::io::Write;
1321
1322        // Create a temporary YAML file
1323        let temp_dir = std::env::temp_dir();
1324        let test_file = temp_dir.join("holoconf_test.yaml");
1325        {
1326            let mut file = std::fs::File::create(&test_file).unwrap();
1327            writeln!(file, "key: value").unwrap();
1328            writeln!(file, "number: 42").unwrap();
1329        }
1330
1331        let mut ctx = ResolverContext::new("test.path");
1332        ctx.base_path = Some(temp_dir.clone());
1333
1334        let args = vec!["holoconf_test.yaml".to_string()];
1335        let kwargs = HashMap::new();
1336
1337        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1338        assert!(result.value.is_mapping());
1339
1340        // Cleanup
1341        std::fs::remove_file(test_file).ok();
1342    }
1343
1344    #[test]
1345    fn test_file_resolver_not_found() {
1346        let ctx = ResolverContext::new("test.path");
1347        let args = vec!["nonexistent_file.txt".to_string()];
1348        let kwargs = HashMap::new();
1349
1350        let result = file_resolver(&args, &kwargs, &ctx);
1351        assert!(result.is_err());
1352    }
1353
1354    #[test]
1355    fn test_registry_with_file() {
1356        let registry = ResolverRegistry::with_builtins();
1357        assert!(registry.contains("file"));
1358    }
1359
1360    #[test]
1361    fn test_http_resolver_disabled() {
1362        let ctx = ResolverContext::new("test.path");
1363        let args = vec!["https://example.com/config.yaml".to_string()];
1364        let kwargs = HashMap::new();
1365
1366        let result = http_resolver(&args, &kwargs, &ctx);
1367        assert!(result.is_err());
1368
1369        let err = result.unwrap_err();
1370        let display = format!("{}", err);
1371        assert!(display.contains("HTTP resolver is disabled"));
1372    }
1373
1374    #[test]
1375    fn test_registry_with_http() {
1376        let registry = ResolverRegistry::with_builtins();
1377        assert!(registry.contains("http"));
1378    }
1379
1380    // Additional edge case tests for improved coverage
1381
1382    #[test]
1383    fn test_env_resolver_no_args() {
1384        let ctx = ResolverContext::new("test.path");
1385        let args = vec![];
1386        let kwargs = HashMap::new();
1387
1388        let result = env_resolver(&args, &kwargs, &ctx);
1389        assert!(result.is_err());
1390        let err = result.unwrap_err();
1391        assert!(err.to_string().contains("requires"));
1392    }
1393
1394    #[test]
1395    fn test_file_resolver_no_args() {
1396        let ctx = ResolverContext::new("test.path");
1397        let args = vec![];
1398        let kwargs = HashMap::new();
1399
1400        let result = file_resolver(&args, &kwargs, &ctx);
1401        assert!(result.is_err());
1402        let err = result.unwrap_err();
1403        assert!(err.to_string().contains("requires"));
1404    }
1405
1406    #[test]
1407    fn test_http_resolver_no_args() {
1408        let ctx = ResolverContext::new("test.path");
1409        let args = vec![];
1410        let kwargs = HashMap::new();
1411
1412        let result = http_resolver(&args, &kwargs, &ctx);
1413        assert!(result.is_err());
1414        let err = result.unwrap_err();
1415        assert!(err.to_string().contains("requires"));
1416    }
1417
1418    #[test]
1419    fn test_unknown_resolver() {
1420        let registry = ResolverRegistry::with_builtins();
1421        let ctx = ResolverContext::new("test.path");
1422
1423        let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
1424        assert!(result.is_err());
1425        let err = result.unwrap_err();
1426        assert!(err.to_string().contains("unknown_resolver"));
1427    }
1428
1429    #[test]
1430    fn test_resolved_value_from_traits() {
1431        let from_value: ResolvedValue = Value::String("test".to_string()).into();
1432        assert_eq!(from_value.value.as_str(), Some("test"));
1433        assert!(!from_value.sensitive);
1434
1435        let from_string: ResolvedValue = "hello".to_string().into();
1436        assert_eq!(from_string.value.as_str(), Some("hello"));
1437
1438        let from_str: ResolvedValue = "world".into();
1439        assert_eq!(from_str.value.as_str(), Some("world"));
1440    }
1441
1442    #[test]
1443    fn test_resolver_context_with_base_path() {
1444        let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
1445        assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
1446    }
1447
1448    #[test]
1449    fn test_resolver_context_with_config_root() {
1450        use std::sync::Arc;
1451        let root = Arc::new(Value::String("root".to_string()));
1452        let ctx = ResolverContext::new("test").with_config_root(root.clone());
1453        assert!(ctx.config_root.is_some());
1454    }
1455
1456    #[test]
1457    fn test_resolver_context_resolution_chain() {
1458        let mut ctx = ResolverContext::new("root");
1459        ctx.push_resolution("a");
1460        ctx.push_resolution("b");
1461        ctx.push_resolution("c");
1462
1463        let chain = ctx.get_resolution_chain();
1464        assert_eq!(chain, vec!["a", "b", "c"]);
1465    }
1466
1467    #[test]
1468    fn test_registry_get_resolver() {
1469        let registry = ResolverRegistry::with_builtins();
1470
1471        let env_resolver = registry.get("env");
1472        assert!(env_resolver.is_some());
1473        assert_eq!(env_resolver.unwrap().name(), "env");
1474
1475        let missing = registry.get("nonexistent");
1476        assert!(missing.is_none());
1477    }
1478
1479    #[test]
1480    fn test_registry_default() {
1481        let registry = ResolverRegistry::default();
1482        // Default registry is empty
1483        assert!(!registry.contains("env"));
1484    }
1485
1486    #[test]
1487    fn test_fn_resolver_name() {
1488        let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
1489        assert_eq!(resolver.name(), "my_resolver");
1490    }
1491
1492    #[test]
1493    fn test_file_resolver_json() {
1494        use std::io::Write;
1495
1496        // Create a temporary JSON file
1497        let temp_dir = std::env::temp_dir();
1498        let test_file = temp_dir.join("holoconf_test.json");
1499        {
1500            let mut file = std::fs::File::create(&test_file).unwrap();
1501            writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
1502        }
1503
1504        let mut ctx = ResolverContext::new("test.path");
1505        ctx.base_path = Some(temp_dir.clone());
1506
1507        let args = vec!["holoconf_test.json".to_string()];
1508        let kwargs = HashMap::new();
1509
1510        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1511        assert!(result.value.is_mapping());
1512
1513        // Cleanup
1514        std::fs::remove_file(test_file).ok();
1515    }
1516
1517    #[test]
1518    fn test_file_resolver_absolute_path() {
1519        use std::io::Write;
1520
1521        // Create a temporary file
1522        let temp_dir = std::env::temp_dir();
1523        let test_file = temp_dir.join("holoconf_abs_test.txt");
1524        {
1525            let mut file = std::fs::File::create(&test_file).unwrap();
1526            writeln!(file, "absolute path content").unwrap();
1527        }
1528
1529        let ctx = ResolverContext::new("test.path");
1530        // No base path - using absolute path directly
1531        let args = vec![test_file.to_string_lossy().to_string()];
1532        let mut kwargs = HashMap::new();
1533        kwargs.insert("parse".to_string(), "text".to_string());
1534
1535        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1536        assert!(result
1537            .value
1538            .as_str()
1539            .unwrap()
1540            .contains("absolute path content"));
1541
1542        // Cleanup
1543        std::fs::remove_file(test_file).ok();
1544    }
1545
1546    #[test]
1547    fn test_file_resolver_invalid_yaml() {
1548        use std::io::Write;
1549
1550        // Create a temporary file with invalid YAML
1551        let temp_dir = std::env::temp_dir();
1552        let test_file = temp_dir.join("holoconf_invalid.yaml");
1553        {
1554            let mut file = std::fs::File::create(&test_file).unwrap();
1555            writeln!(file, "key: [invalid").unwrap();
1556        }
1557
1558        let mut ctx = ResolverContext::new("test.path");
1559        ctx.base_path = Some(temp_dir.clone());
1560
1561        let args = vec!["holoconf_invalid.yaml".to_string()];
1562        let kwargs = HashMap::new();
1563
1564        let result = file_resolver(&args, &kwargs, &ctx);
1565        assert!(result.is_err());
1566        let err = result.unwrap_err();
1567        assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
1568
1569        // Cleanup
1570        std::fs::remove_file(test_file).ok();
1571    }
1572
1573    #[test]
1574    fn test_file_resolver_invalid_json() {
1575        use std::io::Write;
1576
1577        // Create a temporary file with invalid JSON
1578        let temp_dir = std::env::temp_dir();
1579        let test_file = temp_dir.join("holoconf_invalid.json");
1580        {
1581            let mut file = std::fs::File::create(&test_file).unwrap();
1582            writeln!(file, "{{invalid json}}").unwrap();
1583        }
1584
1585        let mut ctx = ResolverContext::new("test.path");
1586        ctx.base_path = Some(temp_dir.clone());
1587
1588        let args = vec!["holoconf_invalid.json".to_string()];
1589        let kwargs = HashMap::new();
1590
1591        let result = file_resolver(&args, &kwargs, &ctx);
1592        assert!(result.is_err());
1593        let err = result.unwrap_err();
1594        assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
1595
1596        // Cleanup
1597        std::fs::remove_file(test_file).ok();
1598    }
1599
1600    #[test]
1601    fn test_file_resolver_unknown_extension() {
1602        use std::io::Write;
1603
1604        // Create a temporary file with unknown extension (treated as text)
1605        let temp_dir = std::env::temp_dir();
1606        let test_file = temp_dir.join("holoconf_test.xyz");
1607        {
1608            let mut file = std::fs::File::create(&test_file).unwrap();
1609            writeln!(file, "plain text content").unwrap();
1610        }
1611
1612        let mut ctx = ResolverContext::new("test.path");
1613        ctx.base_path = Some(temp_dir.clone());
1614
1615        let args = vec!["holoconf_test.xyz".to_string()];
1616        let kwargs = HashMap::new();
1617
1618        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1619        // Unknown extension defaults to text mode
1620        assert!(result
1621            .value
1622            .as_str()
1623            .unwrap()
1624            .contains("plain text content"));
1625
1626        // Cleanup
1627        std::fs::remove_file(test_file).ok();
1628    }
1629
1630    #[test]
1631    fn test_file_resolver_encoding_utf8() {
1632        use std::io::Write;
1633
1634        // Create a temporary file with UTF-8 content including non-ASCII
1635        let temp_dir = std::env::temp_dir();
1636        let test_file = temp_dir.join("holoconf_utf8.txt");
1637        {
1638            let mut file = std::fs::File::create(&test_file).unwrap();
1639            writeln!(file, "Hello, 世界! 🌍").unwrap();
1640        }
1641
1642        let mut ctx = ResolverContext::new("test.path");
1643        ctx.base_path = Some(temp_dir.clone());
1644
1645        let args = vec!["holoconf_utf8.txt".to_string()];
1646        let mut kwargs = HashMap::new();
1647        kwargs.insert("encoding".to_string(), "utf-8".to_string());
1648
1649        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1650        let content = result.value.as_str().unwrap();
1651        assert!(content.contains("世界"));
1652        assert!(content.contains("🌍"));
1653
1654        // Cleanup
1655        std::fs::remove_file(test_file).ok();
1656    }
1657
1658    #[test]
1659    fn test_file_resolver_encoding_ascii() {
1660        use std::io::Write;
1661
1662        // Create a temporary file with mixed ASCII and non-ASCII content
1663        let temp_dir = std::env::temp_dir();
1664        let test_file = temp_dir.join("holoconf_ascii.txt");
1665        {
1666            let mut file = std::fs::File::create(&test_file).unwrap();
1667            writeln!(file, "Hello, 世界! Welcome").unwrap();
1668        }
1669
1670        let mut ctx = ResolverContext::new("test.path");
1671        ctx.base_path = Some(temp_dir.clone());
1672
1673        let args = vec!["holoconf_ascii.txt".to_string()];
1674        let mut kwargs = HashMap::new();
1675        kwargs.insert("encoding".to_string(), "ascii".to_string());
1676
1677        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1678        let content = result.value.as_str().unwrap();
1679        // ASCII mode should strip non-ASCII characters
1680        assert!(content.contains("Hello"));
1681        assert!(content.contains("Welcome"));
1682        assert!(!content.contains("世界"));
1683
1684        // Cleanup
1685        std::fs::remove_file(test_file).ok();
1686    }
1687
1688    #[test]
1689    fn test_file_resolver_encoding_base64() {
1690        use std::io::Write;
1691
1692        // Create a temporary file with binary content
1693        let temp_dir = std::env::temp_dir();
1694        let test_file = temp_dir.join("holoconf_binary.bin");
1695        {
1696            let mut file = std::fs::File::create(&test_file).unwrap();
1697            // Write some bytes that include non-UTF8 sequences
1698            file.write_all(b"Hello\x00\x01\x02World").unwrap();
1699        }
1700
1701        let mut ctx = ResolverContext::new("test.path");
1702        ctx.base_path = Some(temp_dir.clone());
1703
1704        let args = vec!["holoconf_binary.bin".to_string()];
1705        let mut kwargs = HashMap::new();
1706        kwargs.insert("encoding".to_string(), "base64".to_string());
1707
1708        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1709        let content = result.value.as_str().unwrap();
1710
1711        // Verify the base64 encoding is correct
1712        use base64::{engine::general_purpose::STANDARD, Engine as _};
1713        let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
1714        assert_eq!(content, expected);
1715
1716        // Cleanup
1717        std::fs::remove_file(test_file).ok();
1718    }
1719
1720    #[test]
1721    fn test_file_resolver_encoding_default_is_utf8() {
1722        use std::io::Write;
1723
1724        // Create a temporary file with UTF-8 content
1725        let temp_dir = std::env::temp_dir();
1726        let test_file = temp_dir.join("holoconf_default_enc.txt");
1727        {
1728            let mut file = std::fs::File::create(&test_file).unwrap();
1729            writeln!(file, "café résumé").unwrap();
1730        }
1731
1732        let mut ctx = ResolverContext::new("test.path");
1733        ctx.base_path = Some(temp_dir.clone());
1734
1735        let args = vec!["holoconf_default_enc.txt".to_string()];
1736        let kwargs = HashMap::new(); // No encoding specified
1737
1738        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1739        let content = result.value.as_str().unwrap();
1740        // Default encoding should be UTF-8, preserving accents
1741        assert!(content.contains("café"));
1742        assert!(content.contains("résumé"));
1743
1744        // Cleanup
1745        std::fs::remove_file(test_file).ok();
1746    }
1747
1748    #[test]
1749    fn test_file_resolver_encoding_binary() {
1750        use std::io::Write;
1751
1752        // Create a temporary file with binary content
1753        let temp_dir = std::env::temp_dir();
1754        let test_file = temp_dir.join("holoconf_binary_bytes.bin");
1755        let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
1756        {
1757            let mut file = std::fs::File::create(&test_file).unwrap();
1758            file.write_all(&binary_data).unwrap();
1759        }
1760
1761        let mut ctx = ResolverContext::new("test.path");
1762        ctx.base_path = Some(temp_dir.clone());
1763
1764        let args = vec!["holoconf_binary_bytes.bin".to_string()];
1765        let mut kwargs = HashMap::new();
1766        kwargs.insert("encoding".to_string(), "binary".to_string());
1767
1768        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1769
1770        // Verify we get Value::Bytes back
1771        assert!(result.value.is_bytes());
1772        assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
1773
1774        // Cleanup
1775        std::fs::remove_file(test_file).ok();
1776    }
1777
1778    #[test]
1779    fn test_file_resolver_encoding_binary_empty() {
1780        // Create an empty file
1781        let temp_dir = std::env::temp_dir();
1782        let test_file = temp_dir.join("holoconf_binary_empty.bin");
1783        {
1784            std::fs::File::create(&test_file).unwrap();
1785        }
1786
1787        let mut ctx = ResolverContext::new("test.path");
1788        ctx.base_path = Some(temp_dir.clone());
1789
1790        let args = vec!["holoconf_binary_empty.bin".to_string()];
1791        let mut kwargs = HashMap::new();
1792        kwargs.insert("encoding".to_string(), "binary".to_string());
1793
1794        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1795
1796        // Verify we get empty Value::Bytes
1797        assert!(result.value.is_bytes());
1798        let empty: &[u8] = &[];
1799        assert_eq!(result.value.as_bytes().unwrap(), empty);
1800
1801        // Cleanup
1802        std::fs::remove_file(test_file).ok();
1803    }
1804
1805    // Framework-level sensitive test (default handling moved to config tests)
1806
1807    #[test]
1808    fn test_file_resolver_with_sensitive() {
1809        use std::io::Write;
1810
1811        // Create a temporary file
1812        let temp_dir = std::env::temp_dir();
1813        let test_file = temp_dir.join("holoconf_sensitive_test.txt");
1814        {
1815            let mut file = std::fs::File::create(&test_file).unwrap();
1816            writeln!(file, "secret content").unwrap();
1817        }
1818
1819        let registry = ResolverRegistry::with_builtins();
1820        let mut ctx = ResolverContext::new("test.path");
1821        ctx.base_path = Some(temp_dir.clone());
1822
1823        let args = vec!["holoconf_sensitive_test.txt".to_string()];
1824        let mut kwargs = HashMap::new();
1825        kwargs.insert("sensitive".to_string(), "true".to_string());
1826
1827        // Framework-level sensitive handling via registry
1828        let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
1829        assert!(result.value.as_str().unwrap().contains("secret content"));
1830        assert!(result.sensitive);
1831
1832        // Cleanup
1833        std::fs::remove_file(test_file).ok();
1834    }
1835
1836    #[test]
1837    fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
1838        // Ensure that 'sensitive' kwarg is NOT passed to the resolver
1839        // (Note: 'default' is handled at Config level, not registry level)
1840        let mut registry = ResolverRegistry::new();
1841
1842        // Register a test resolver that checks it doesn't receive sensitive kwarg
1843        registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
1844            // Sensitive kwarg should be filtered out
1845            assert!(
1846                !kwargs.contains_key("sensitive"),
1847                "sensitive kwarg should not be passed to resolver"
1848            );
1849            // But custom kwargs should be passed through
1850            if let Some(custom) = kwargs.get("custom") {
1851                Ok(ResolvedValue::new(Value::String(format!(
1852                    "custom={}",
1853                    custom
1854                ))))
1855            } else {
1856                Ok(ResolvedValue::new(Value::String("no custom".to_string())))
1857            }
1858        });
1859
1860        let ctx = ResolverContext::new("test.path");
1861        let args = vec![];
1862        let mut kwargs = HashMap::new();
1863        kwargs.insert("sensitive".to_string(), "true".to_string());
1864        kwargs.insert("custom".to_string(), "myvalue".to_string());
1865
1866        let result = registry
1867            .resolve("test_kwargs", &args, &kwargs, &ctx)
1868            .unwrap();
1869        assert_eq!(result.value.as_str(), Some("custom=myvalue"));
1870        // Sensitive override should still be applied by framework
1871        assert!(result.sensitive);
1872    }
1873}
1874
1875// Tests for global registry (TDD - written before implementation)
1876#[cfg(test)]
1877mod global_registry_tests {
1878    use super::*;
1879
1880    /// Test helper: create a mock resolver with a given name
1881    fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
1882        Arc::new(FnResolver::new(name, |_, _, _| {
1883            Ok(ResolvedValue::new("mock"))
1884        }))
1885    }
1886
1887    #[test]
1888    fn test_register_new_resolver_succeeds() {
1889        let mut registry = ResolverRegistry::new();
1890        let resolver = mock_resolver("test_new");
1891
1892        // Registering a new resolver should succeed with force=false
1893        let result = registry.register_with_force(resolver, false);
1894        assert!(result.is_ok());
1895        assert!(registry.contains("test_new"));
1896    }
1897
1898    #[test]
1899    fn test_register_duplicate_errors_without_force() {
1900        let mut registry = ResolverRegistry::new();
1901        let resolver1 = mock_resolver("test_dup");
1902        let resolver2 = mock_resolver("test_dup");
1903
1904        // First registration succeeds
1905        registry.register_with_force(resolver1, false).unwrap();
1906
1907        // Second registration with same name should fail without force
1908        let result = registry.register_with_force(resolver2, false);
1909        assert!(result.is_err());
1910        let err = result.unwrap_err();
1911        assert!(err.to_string().contains("already registered"));
1912    }
1913
1914    #[test]
1915    fn test_register_duplicate_succeeds_with_force() {
1916        let mut registry = ResolverRegistry::new();
1917        let resolver1 = mock_resolver("test_force");
1918        let resolver2 = mock_resolver("test_force");
1919
1920        // First registration succeeds
1921        registry.register_with_force(resolver1, false).unwrap();
1922
1923        // Second registration with force=true should succeed
1924        let result = registry.register_with_force(resolver2, true);
1925        assert!(result.is_ok());
1926    }
1927
1928    #[test]
1929    fn test_global_registry_is_singleton() {
1930        // The global registry should return the same instance
1931        let registry1 = global_registry();
1932        let registry2 = global_registry();
1933
1934        // They should point to the same instance (same address)
1935        assert!(std::ptr::eq(registry1, registry2));
1936    }
1937
1938    #[test]
1939    fn test_register_global_new_resolver() {
1940        // Clean slate - register a unique resolver name
1941        let resolver = mock_resolver("global_test_unique_42");
1942        let result = register_global(resolver, false);
1943        // May fail if already registered from previous test runs
1944        // That's expected behavior - the test verifies the API works
1945        assert!(result.is_ok() || result.is_err());
1946    }
1947}
1948
1949// Integration tests for lazy default resolution (requires Config)
1950#[cfg(test)]
1951mod lazy_resolution_tests {
1952    use super::*;
1953    use crate::Config;
1954    use std::sync::atomic::{AtomicBool, Ordering};
1955    use std::sync::Arc;
1956
1957    #[test]
1958    fn test_default_not_resolved_when_main_value_exists() {
1959        // Track whether the "fail" resolver was called
1960        let fail_called = Arc::new(AtomicBool::new(false));
1961        let fail_called_clone = fail_called.clone();
1962
1963        // Create a config with a custom resolver that would fail if called
1964        let yaml = r#"
1965value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
1966"#;
1967        // Set the env var so the default should NOT be needed
1968        std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
1969
1970        let mut config = Config::from_yaml(yaml).unwrap();
1971
1972        // Register a "fail" resolver that sets a flag and panics
1973        config.register_resolver(Arc::new(FnResolver::new(
1974            "fail",
1975            move |_args, _kwargs, _ctx| {
1976                fail_called_clone.store(true, Ordering::SeqCst);
1977                panic!("fail resolver should not have been called - lazy resolution failed!");
1978            },
1979        )));
1980
1981        // Access the value - should get main value, not call fail resolver
1982        let result = config.get("value").unwrap();
1983        assert_eq!(result.as_str(), Some("main_value"));
1984
1985        // Verify the fail resolver was never called
1986        assert!(
1987            !fail_called.load(Ordering::SeqCst),
1988            "The default resolver should not have been called when main value exists"
1989        );
1990
1991        std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
1992    }
1993
1994    #[test]
1995    fn test_default_is_resolved_when_main_value_missing() {
1996        // Track whether the default resolver was called
1997        let default_called = Arc::new(AtomicBool::new(false));
1998        let default_called_clone = default_called.clone();
1999
2000        // Create a config where env var doesn't exist
2001        let yaml = r#"
2002value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
2003"#;
2004        std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
2005
2006        let mut config = Config::from_yaml(yaml).unwrap();
2007
2008        // Register a custom default resolver
2009        config.register_resolver(Arc::new(FnResolver::new(
2010            "custom_default",
2011            move |args: &[String], _kwargs, _ctx| {
2012                default_called_clone.store(true, Ordering::SeqCst);
2013                let arg = args.first().cloned().unwrap_or_default();
2014                Ok(ResolvedValue::new(Value::String(format!(
2015                    "default_was_{}",
2016                    arg
2017                ))))
2018            },
2019        )));
2020
2021        // Access the value - should call default resolver since main value missing
2022        let result = config.get("value").unwrap();
2023        assert_eq!(result.as_str(), Some("default_was_fallback"));
2024
2025        // Verify the default resolver WAS called
2026        assert!(
2027            default_called.load(Ordering::SeqCst),
2028            "The default resolver should have been called when main value is missing"
2029        );
2030    }
2031}
2032
2033// HTTP resolver tests (require http feature and mockito)
2034#[cfg(all(test, feature = "http"))]
2035mod http_resolver_tests {
2036    use super::*;
2037    use mockito::Server;
2038
2039    #[test]
2040    fn test_http_fetch_json() {
2041        let mut server = Server::new();
2042        let mock = server
2043            .mock("GET", "/config.json")
2044            .with_status(200)
2045            .with_header("content-type", "application/json")
2046            .with_body(r#"{"key": "value", "number": 42}"#)
2047            .create();
2048
2049        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2050        let args = vec![format!("{}/config.json", server.url())];
2051        let kwargs = HashMap::new();
2052
2053        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2054        assert!(result.value.is_mapping());
2055
2056        mock.assert();
2057    }
2058
2059    #[test]
2060    fn test_http_fetch_yaml() {
2061        let mut server = Server::new();
2062        let mock = server
2063            .mock("GET", "/config.yaml")
2064            .with_status(200)
2065            .with_header("content-type", "application/yaml")
2066            .with_body("key: value\nnumber: 42")
2067            .create();
2068
2069        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2070        let args = vec![format!("{}/config.yaml", server.url())];
2071        let kwargs = HashMap::new();
2072
2073        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2074        assert!(result.value.is_mapping());
2075
2076        mock.assert();
2077    }
2078
2079    #[test]
2080    fn test_http_fetch_text() {
2081        let mut server = Server::new();
2082        let mock = server
2083            .mock("GET", "/data.txt")
2084            .with_status(200)
2085            .with_header("content-type", "text/plain")
2086            .with_body("Hello, World!")
2087            .create();
2088
2089        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2090        let args = vec![format!("{}/data.txt", server.url())];
2091        let kwargs = HashMap::new();
2092
2093        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2094        assert_eq!(result.value.as_str(), Some("Hello, World!"));
2095
2096        mock.assert();
2097    }
2098
2099    #[test]
2100    fn test_http_fetch_binary() {
2101        let mut server = Server::new();
2102        let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
2103        let mock = server
2104            .mock("GET", "/data.bin")
2105            .with_status(200)
2106            .with_header("content-type", "application/octet-stream")
2107            .with_body(binary_data.clone())
2108            .create();
2109
2110        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2111        let args = vec![format!("{}/data.bin", server.url())];
2112        let mut kwargs = HashMap::new();
2113        kwargs.insert("parse".to_string(), "binary".to_string());
2114
2115        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2116        assert!(result.value.is_bytes());
2117        assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2118
2119        mock.assert();
2120    }
2121
2122    #[test]
2123    fn test_http_fetch_explicit_parse_mode() {
2124        let mut server = Server::new();
2125        // Return JSON but with text/plain content-type
2126        let mock = server
2127            .mock("GET", "/data")
2128            .with_status(200)
2129            .with_header("content-type", "text/plain")
2130            .with_body(r#"{"key": "value"}"#)
2131            .create();
2132
2133        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2134        let args = vec![format!("{}/data", server.url())];
2135        let mut kwargs = HashMap::new();
2136        kwargs.insert("parse".to_string(), "json".to_string());
2137
2138        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2139        assert!(result.value.is_mapping());
2140
2141        mock.assert();
2142    }
2143
2144    #[test]
2145    fn test_http_fetch_with_custom_header() {
2146        let mut server = Server::new();
2147        let mock = server
2148            .mock("GET", "/protected")
2149            .match_header("Authorization", "Bearer my-token")
2150            .with_status(200)
2151            .with_body("authorized content")
2152            .create();
2153
2154        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2155        let args = vec![format!("{}/protected", server.url())];
2156        let mut kwargs = HashMap::new();
2157        kwargs.insert(
2158            "header".to_string(),
2159            "Authorization:Bearer my-token".to_string(),
2160        );
2161
2162        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2163        assert_eq!(result.value.as_str(), Some("authorized content"));
2164
2165        mock.assert();
2166    }
2167
2168    #[test]
2169    fn test_http_fetch_404_error() {
2170        let mut server = Server::new();
2171        let mock = server.mock("GET", "/notfound").with_status(404).create();
2172
2173        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2174        let args = vec![format!("{}/notfound", server.url())];
2175        let kwargs = HashMap::new();
2176
2177        let result = http_resolver(&args, &kwargs, &ctx);
2178        assert!(result.is_err());
2179        let err = result.unwrap_err();
2180        assert!(err.to_string().contains("HTTP"));
2181
2182        mock.assert();
2183    }
2184
2185    #[test]
2186    fn test_http_disabled_by_default() {
2187        let ctx = ResolverContext::new("test.path");
2188        // allow_http defaults to false
2189        let args = vec!["https://example.com/config.yaml".to_string()];
2190        let kwargs = HashMap::new();
2191
2192        let result = http_resolver(&args, &kwargs, &ctx);
2193        assert!(result.is_err());
2194        let err = result.unwrap_err();
2195        assert!(err.to_string().contains("disabled"));
2196    }
2197
2198    #[test]
2199    fn test_http_allowlist_blocks_url() {
2200        let ctx = ResolverContext::new("test.path")
2201            .with_allow_http(true)
2202            .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
2203
2204        let args = vec!["https://blocked.example.com/config.yaml".to_string()];
2205        let kwargs = HashMap::new();
2206
2207        let result = http_resolver(&args, &kwargs, &ctx);
2208        assert!(result.is_err());
2209        let err = result.unwrap_err();
2210        assert!(
2211            err.to_string().contains("not in allowlist")
2212                || err.to_string().contains("HttpNotAllowed")
2213        );
2214    }
2215
2216    #[test]
2217    fn test_http_allowlist_allows_matching_url() {
2218        let mut server = Server::new();
2219        let mock = server
2220            .mock("GET", "/config.yaml")
2221            .with_status(200)
2222            .with_body("key: value")
2223            .create();
2224
2225        // The allowlist pattern needs to match the server URL
2226        let server_url = server.url();
2227        let ctx = ResolverContext::new("test.path")
2228            .with_allow_http(true)
2229            .with_http_allowlist(vec![format!("{}/*", server_url)]);
2230
2231        let args = vec![format!("{}/config.yaml", server_url)];
2232        let kwargs = HashMap::new();
2233
2234        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2235        assert!(result.value.is_mapping());
2236
2237        mock.assert();
2238    }
2239
2240    #[test]
2241    fn test_url_matches_pattern_exact() {
2242        assert!(url_matches_pattern(
2243            "https://example.com/config.yaml",
2244            "https://example.com/config.yaml"
2245        ));
2246        assert!(!url_matches_pattern(
2247            "https://example.com/other.yaml",
2248            "https://example.com/config.yaml"
2249        ));
2250    }
2251
2252    #[test]
2253    fn test_url_matches_pattern_wildcard() {
2254        assert!(url_matches_pattern(
2255            "https://example.com/config.yaml",
2256            "https://example.com/*"
2257        ));
2258        assert!(url_matches_pattern(
2259            "https://example.com/path/to/config.yaml",
2260            "https://example.com/*"
2261        ));
2262        assert!(!url_matches_pattern(
2263            "https://other.com/config.yaml",
2264            "https://example.com/*"
2265        ));
2266    }
2267
2268    #[test]
2269    fn test_url_matches_pattern_subdomain() {
2270        assert!(url_matches_pattern(
2271            "https://api.example.com/config",
2272            "https://*.example.com/*"
2273        ));
2274        assert!(url_matches_pattern(
2275            "https://staging.example.com/config",
2276            "https://*.example.com/*"
2277        ));
2278        assert!(!url_matches_pattern(
2279            "https://example.com/config",
2280            "https://*.example.com/*"
2281        ));
2282    }
2283
2284    #[test]
2285    fn test_detect_parse_mode_from_content_type() {
2286        assert_eq!(
2287            detect_parse_mode("http://example.com/data", "application/json"),
2288            "json"
2289        );
2290        assert_eq!(
2291            detect_parse_mode("http://example.com/data", "text/json"),
2292            "json"
2293        );
2294        assert_eq!(
2295            detect_parse_mode("http://example.com/data", "application/yaml"),
2296            "yaml"
2297        );
2298        assert_eq!(
2299            detect_parse_mode("http://example.com/data", "application/x-yaml"),
2300            "yaml"
2301        );
2302        assert_eq!(
2303            detect_parse_mode("http://example.com/data", "text/yaml"),
2304            "yaml"
2305        );
2306        assert_eq!(
2307            detect_parse_mode("http://example.com/data", "text/plain"),
2308            "text"
2309        );
2310    }
2311
2312    #[test]
2313    fn test_detect_parse_mode_from_url_extension() {
2314        assert_eq!(
2315            detect_parse_mode("http://example.com/config.json", ""),
2316            "json"
2317        );
2318        assert_eq!(
2319            detect_parse_mode("http://example.com/config.yaml", ""),
2320            "yaml"
2321        );
2322        assert_eq!(
2323            detect_parse_mode("http://example.com/config.yml", ""),
2324            "yaml"
2325        );
2326        assert_eq!(
2327            detect_parse_mode("http://example.com/config.txt", ""),
2328            "text"
2329        );
2330        assert_eq!(detect_parse_mode("http://example.com/config", ""), "text");
2331    }
2332
2333    #[test]
2334    fn test_detect_parse_mode_content_type_takes_precedence() {
2335        // Content-Type should take precedence over URL extension
2336        assert_eq!(
2337            detect_parse_mode("http://example.com/config.yaml", "application/json"),
2338            "json"
2339        );
2340    }
2341}