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