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// PEM format constants
13const PEM_BEGIN_MARKER: &str = "-----BEGIN";
14const PEM_BEGIN_ENCRYPTED_KEY: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
15
16/// Certificate or key input - can be text (PEM content or file path) or binary (P12/PFX bytes)
17#[derive(Clone, Debug)]
18pub enum CertInput {
19    /// PEM text content or file path to PEM/P12 file
20    Text(String),
21    /// Binary P12/PFX content
22    Binary(Vec<u8>),
23}
24
25impl CertInput {
26    /// Check if this looks like PEM content (has -----BEGIN marker)
27    pub fn is_pem_content(&self) -> bool {
28        matches!(self, CertInput::Text(s) if s.contains(PEM_BEGIN_MARKER))
29    }
30
31    /// Check if this looks like a P12/PFX file path (by extension)
32    pub fn is_p12_path(&self) -> bool {
33        matches!(self, CertInput::Text(s) if {
34            let lower = s.to_lowercase();
35            lower.ends_with(".p12") || lower.ends_with(".pfx")
36        })
37    }
38
39    /// Get the text content if this is a Text variant
40    pub fn as_text(&self) -> Option<&str> {
41        match self {
42            CertInput::Text(s) => Some(s),
43            CertInput::Binary(_) => None,
44        }
45    }
46
47    /// Get the binary content if this is a Binary variant
48    pub fn as_bytes(&self) -> Option<&[u8]> {
49        match self {
50            CertInput::Text(_) => None,
51            CertInput::Binary(b) => Some(b),
52        }
53    }
54}
55
56impl From<String> for CertInput {
57    fn from(s: String) -> Self {
58        CertInput::Text(s)
59    }
60}
61
62impl From<&str> for CertInput {
63    fn from(s: &str) -> Self {
64        CertInput::Text(s.to_string())
65    }
66}
67
68impl From<Vec<u8>> for CertInput {
69    fn from(b: Vec<u8>) -> Self {
70        CertInput::Binary(b)
71    }
72}
73
74// Global resolver registry for extension packages
75static GLOBAL_REGISTRY: OnceLock<RwLock<ResolverRegistry>> = OnceLock::new();
76
77/// Get the global resolver registry.
78///
79/// This registry is lazily initialized with built-in resolvers.
80/// Extension packages can register additional resolvers here.
81pub fn global_registry() -> &'static RwLock<ResolverRegistry> {
82    GLOBAL_REGISTRY.get_or_init(|| RwLock::new(ResolverRegistry::with_builtins()))
83}
84
85/// Register a resolver in the global registry.
86///
87/// # Arguments
88/// * `resolver` - The resolver to register
89/// * `force` - If true, overwrite any existing resolver with the same name.
90///   If false, return an error if the name is already registered.
91///
92/// # Returns
93/// * `Ok(())` on success
94/// * `Err(Error)` if force=false and a resolver with the same name exists
95pub fn register_global(resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
96    let mut registry = global_registry()
97        .write()
98        .expect("Global registry lock poisoned");
99    registry.register_with_force(resolver, force)
100}
101
102/// A resolved value with optional sensitivity metadata
103#[derive(Clone)]
104pub struct ResolvedValue {
105    /// The actual resolved value
106    pub value: Value,
107    /// Whether this value is sensitive (should be redacted in logs/exports)
108    pub sensitive: bool,
109}
110
111impl std::fmt::Debug for ResolvedValue {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("ResolvedValue")
114            .field(
115                "value",
116                if self.sensitive {
117                    &"[REDACTED]"
118                } else {
119                    &self.value
120                },
121            )
122            .field("sensitive", &self.sensitive)
123            .finish()
124    }
125}
126
127impl ResolvedValue {
128    /// Create a non-sensitive resolved value
129    pub fn new(value: impl Into<Value>) -> Self {
130        Self {
131            value: value.into(),
132            sensitive: false,
133        }
134    }
135
136    /// Create a sensitive resolved value
137    pub fn sensitive(value: impl Into<Value>) -> Self {
138        Self {
139            value: value.into(),
140            sensitive: true,
141        }
142    }
143}
144
145impl From<Value> for ResolvedValue {
146    fn from(value: Value) -> Self {
147        ResolvedValue::new(value)
148    }
149}
150
151impl From<String> for ResolvedValue {
152    fn from(s: String) -> Self {
153        ResolvedValue::new(Value::String(s))
154    }
155}
156
157impl From<&str> for ResolvedValue {
158    fn from(s: &str) -> Self {
159        ResolvedValue::new(Value::String(s.to_string()))
160    }
161}
162
163/// Context provided to resolvers during resolution
164#[derive(Debug, Clone)]
165pub struct ResolverContext {
166    /// The path in the config where this resolution is happening
167    pub config_path: String,
168    /// The config root (for self-references)
169    pub config_root: Option<Arc<Value>>,
170    /// The base path for relative file paths
171    pub base_path: Option<std::path::PathBuf>,
172    /// Allowed root directories for file resolver (path traversal protection)
173    pub file_roots: std::collections::HashSet<std::path::PathBuf>,
174    /// Resolution stack for circular reference detection
175    pub resolution_stack: Vec<String>,
176    /// Whether HTTP resolver is enabled
177    pub allow_http: bool,
178    /// HTTP URL allowlist (glob patterns)
179    pub http_allowlist: Vec<String>,
180    /// HTTP proxy URL (e.g., "http://proxy:8080" or "socks5://proxy:1080")
181    pub http_proxy: Option<String>,
182    /// Whether to auto-detect proxy from environment variables (HTTP_PROXY, HTTPS_PROXY, NO_PROXY)
183    pub http_proxy_from_env: bool,
184    /// CA bundle (file path or PEM content) - replaces default webpki-roots
185    pub http_ca_bundle: Option<CertInput>,
186    /// Extra CA bundle (file path or PEM content) - appends to webpki-roots
187    pub http_extra_ca_bundle: Option<CertInput>,
188    /// Client certificate for mTLS (file path, PEM content, or P12/PFX binary)
189    pub http_client_cert: Option<CertInput>,
190    /// Client private key for mTLS (file path or PEM content, not needed for P12/PFX)
191    pub http_client_key: Option<CertInput>,
192    /// Password for encrypted private key or P12/PFX file
193    pub http_client_key_password: Option<String>,
194    // NOTE: http_insecure removed - use insecure=true kwarg on each resolver call
195}
196
197impl ResolverContext {
198    /// Create a new resolver context
199    pub fn new(config_path: impl Into<String>) -> Self {
200        Self {
201            config_path: config_path.into(),
202            config_root: None,
203            base_path: None,
204            file_roots: std::collections::HashSet::new(),
205            resolution_stack: Vec::new(),
206            allow_http: false,
207            http_allowlist: Vec::new(),
208            http_proxy: None,
209            http_proxy_from_env: false,
210            http_ca_bundle: None,
211            http_extra_ca_bundle: None,
212            http_client_cert: None,
213            http_client_key: None,
214            http_client_key_password: None,
215        }
216    }
217
218    /// Set whether HTTP resolver is enabled
219    pub fn with_allow_http(mut self, allow: bool) -> Self {
220        self.allow_http = allow;
221        self
222    }
223
224    /// Set HTTP URL allowlist
225    pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
226        self.http_allowlist = allowlist;
227        self
228    }
229
230    /// Set HTTP proxy URL
231    pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
232        self.http_proxy = Some(proxy.into());
233        self
234    }
235
236    /// Set whether to auto-detect proxy from environment variables
237    pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
238        self.http_proxy_from_env = enabled;
239        self
240    }
241
242    /// Set CA bundle (file path or PEM content)
243    pub fn with_http_ca_bundle(mut self, input: impl Into<CertInput>) -> Self {
244        self.http_ca_bundle = Some(input.into());
245        self
246    }
247
248    /// Set extra CA bundle (file path or PEM content)
249    pub fn with_http_extra_ca_bundle(mut self, input: impl Into<CertInput>) -> Self {
250        self.http_extra_ca_bundle = Some(input.into());
251        self
252    }
253
254    /// Set client certificate for mTLS (file path, PEM content, or P12/PFX binary)
255    pub fn with_http_client_cert(mut self, input: impl Into<CertInput>) -> Self {
256        self.http_client_cert = Some(input.into());
257        self
258    }
259
260    /// Set client private key for mTLS (file path or PEM content, not needed for P12/PFX)
261    pub fn with_http_client_key(mut self, input: impl Into<CertInput>) -> Self {
262        self.http_client_key = Some(input.into());
263        self
264    }
265
266    /// Set password for encrypted private key or P12/PFX file
267    pub fn with_http_client_key_password(mut self, password: impl Into<String>) -> Self {
268        self.http_client_key_password = Some(password.into());
269        self
270    }
271
272    // DANGEROUS: with_http_insecure removed - use insecure=true kwarg instead
273
274    /// Set the config root for self-references
275    pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
276        self.config_root = Some(root);
277        self
278    }
279
280    /// Set the base path for file resolution
281    pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
282        self.base_path = Some(path);
283        self
284    }
285
286    /// Check if resolving a path would cause a circular reference
287    pub fn would_cause_cycle(&self, path: &str) -> bool {
288        self.resolution_stack.contains(&path.to_string())
289    }
290
291    /// Push a path onto the resolution stack
292    pub fn push_resolution(&mut self, path: &str) {
293        self.resolution_stack.push(path.to_string());
294    }
295
296    /// Pop a path from the resolution stack
297    pub fn pop_resolution(&mut self) {
298        self.resolution_stack.pop();
299    }
300
301    /// Get the resolution chain for error reporting
302    pub fn get_resolution_chain(&self) -> Vec<String> {
303        self.resolution_stack.clone()
304    }
305}
306
307/// Trait for resolver implementations
308pub trait Resolver: Send + Sync {
309    /// Resolve an interpolation expression
310    ///
311    /// # Arguments
312    /// * `args` - Positional arguments from the interpolation
313    /// * `kwargs` - Keyword arguments from the interpolation
314    /// * `ctx` - Resolution context
315    fn resolve(
316        &self,
317        args: &[String],
318        kwargs: &HashMap<String, String>,
319        ctx: &ResolverContext,
320    ) -> Result<ResolvedValue>;
321
322    /// Get the name of this resolver
323    fn name(&self) -> &str;
324}
325
326/// A simple function-based resolver
327pub struct FnResolver<F>
328where
329    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
330        + Send
331        + Sync,
332{
333    name: String,
334    func: F,
335}
336
337impl<F> FnResolver<F>
338where
339    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
340        + Send
341        + Sync,
342{
343    /// Create a new function-based resolver
344    pub fn new(name: impl Into<String>, func: F) -> Self {
345        Self {
346            name: name.into(),
347            func,
348        }
349    }
350}
351
352impl<F> Resolver for FnResolver<F>
353where
354    F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
355        + Send
356        + Sync,
357{
358    fn resolve(
359        &self,
360        args: &[String],
361        kwargs: &HashMap<String, String>,
362        ctx: &ResolverContext,
363    ) -> Result<ResolvedValue> {
364        (self.func)(args, kwargs, ctx)
365    }
366
367    fn name(&self) -> &str {
368        &self.name
369    }
370}
371
372/// Registry of available resolvers
373#[derive(Clone)]
374pub struct ResolverRegistry {
375    resolvers: HashMap<String, Arc<dyn Resolver>>,
376}
377
378impl Default for ResolverRegistry {
379    fn default() -> Self {
380        Self::new()
381    }
382}
383
384impl ResolverRegistry {
385    /// Create a new empty registry
386    pub fn new() -> Self {
387        Self {
388            resolvers: HashMap::new(),
389        }
390    }
391
392    /// Create a registry with the standard built-in resolvers
393    pub fn with_builtins() -> Self {
394        let mut registry = Self::new();
395        registry.register_builtin_resolvers();
396        registry
397    }
398
399    /// Register the built-in resolvers (env, file, http, https)
400    fn register_builtin_resolvers(&mut self) {
401        // Environment variable resolver
402        self.register(Arc::new(FnResolver::new("env", env_resolver)));
403        // File resolver
404        self.register(Arc::new(FnResolver::new("file", file_resolver)));
405
406        // HTTP/HTTPS resolvers (only available with http feature)
407        #[cfg(feature = "http")]
408        {
409            // HTTP resolver (disabled by default for security)
410            self.register(Arc::new(FnResolver::new("http", http_resolver)));
411            // HTTPS resolver (disabled by default for security)
412            self.register(Arc::new(FnResolver::new("https", https_resolver)));
413        }
414    }
415
416    /// Register a resolver
417    pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
418        self.resolvers.insert(resolver.name().to_string(), resolver);
419    }
420
421    /// Register a resolver with optional force overwrite.
422    ///
423    /// # Arguments
424    /// * `resolver` - The resolver to register
425    /// * `force` - If true, overwrite any existing resolver with the same name.
426    ///   If false, return an error if the name is already registered.
427    ///
428    /// # Returns
429    /// * `Ok(())` on success
430    /// * `Err(Error)` if force=false and a resolver with the same name exists
431    pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
432        let name = resolver.name().to_string();
433        if !force && self.resolvers.contains_key(&name) {
434            return Err(Error::resolver_already_registered(&name));
435        }
436        self.resolvers.insert(name, resolver);
437        Ok(())
438    }
439
440    /// Register a function as a resolver
441    pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
442    where
443        F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
444            + Send
445            + Sync
446            + 'static,
447    {
448        let name = name.into();
449        self.register(Arc::new(FnResolver::new(name, func)));
450    }
451
452    /// Get a resolver by name
453    pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
454        self.resolvers.get(name)
455    }
456
457    /// Check if a resolver is registered
458    pub fn contains(&self, name: &str) -> bool {
459        self.resolvers.contains_key(name)
460    }
461
462    /// Resolve an interpolation using the appropriate resolver
463    ///
464    /// This method implements framework-level handling of the `sensitive` kwarg per ADR-011.
465    /// The `sensitive` kwarg overrides the resolver's sensitivity hint.
466    ///
467    /// Note: `default` handling with lazy resolution is done at the Config level,
468    /// not here, to support nested interpolations in default values.
469    pub fn resolve(
470        &self,
471        resolver_name: &str,
472        args: &[String],
473        kwargs: &HashMap<String, String>,
474        ctx: &ResolverContext,
475    ) -> Result<ResolvedValue> {
476        let resolver = self
477            .resolvers
478            .get(resolver_name)
479            .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
480
481        // Extract framework-level `sensitive` kwarg per ADR-011
482        let sensitive_override = kwargs
483            .get("sensitive")
484            .map(|v| v.eq_ignore_ascii_case("true"));
485
486        // Pass remaining kwargs to the resolver (filter out framework keyword)
487        let resolver_kwargs: HashMap<String, String> = kwargs
488            .iter()
489            .filter(|(k, _)| *k != "sensitive")
490            .map(|(k, v)| (k.clone(), v.clone()))
491            .collect();
492
493        // Call the resolver
494        let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
495
496        // Apply sensitivity override if specified
497        if let Some(is_sensitive) = sensitive_override {
498            resolved.sensitive = is_sensitive;
499        }
500
501        Ok(resolved)
502    }
503}
504
505/// Built-in environment variable resolver
506///
507/// Usage:
508///   ${env:VAR_NAME}                      - Get env var (error if not set)
509///   ${env:VAR_NAME,default=value}        - Get env var with default (framework-handled)
510///   ${env:VAR_NAME,sensitive=true}       - Mark as sensitive for redaction (framework-handled)
511///
512/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
513/// This resolver just returns the env var value or an error if not found.
514fn env_resolver(
515    args: &[String],
516    _kwargs: &HashMap<String, String>,
517    ctx: &ResolverContext,
518) -> Result<ResolvedValue> {
519    if args.is_empty() {
520        return Err(Error::parse("env resolver requires a variable name")
521            .with_path(ctx.config_path.clone()));
522    }
523
524    let var_name = &args[0];
525
526    match std::env::var(var_name) {
527        Ok(value) => {
528            // Return non-sensitive by default; sensitivity can be overridden via kwarg
529            Ok(ResolvedValue::new(Value::String(value)))
530        }
531        Err(_) => {
532            // Return EnvNotFound error - framework will handle default if provided
533            Err(Error::env_not_found(
534                var_name,
535                Some(ctx.config_path.clone()),
536            ))
537        }
538    }
539}
540
541/// Check if a hostname represents localhost
542///
543/// Recognizes various forms of localhost:
544/// - "localhost" (case-insensitive)
545/// - "127.0.0.1" (IPv4 loopback)
546/// - Any 127.x.x.x address (IPv4 loopback range)
547/// - "::1" (IPv6 loopback)
548/// - "[::1]" (IPv6 loopback with brackets)
549///
550/// Note: We intentionally reject internationalized domain names (IDN)
551/// and punycode for security and simplicity.
552fn is_localhost(hostname: &str) -> bool {
553    // ASCII localhost (case-insensitive)
554    if hostname.eq_ignore_ascii_case("localhost") {
555        return true;
556    }
557
558    // IPv4 loopback: 127.0.0.1 or any 127.x.x.x
559    if hostname.starts_with("127.") {
560        return true;
561    }
562
563    // IPv6 loopback: ::1 or [::1]
564    if hostname == "::1" || hostname == "[::1]" {
565        return true;
566    }
567
568    false
569}
570
571/// Normalize file path according to RFC 8089 file: URI scheme
572///
573/// RFC 8089 defines these valid formats:
574///   file:///path      - Local file (empty authority)
575///   file://localhost/path - Local file (explicit localhost)
576///   file:/path        - Local file (minimal form)
577///   file://host/path  - Remote file (not supported, returns error)
578///   path              - HoloConf relative path (not RFC, but supported)
579///
580/// Returns (normalized_path, is_relative)
581fn normalize_file_path(arg: &str) -> Result<(String, bool)> {
582    // Security: Reject null bytes
583    if arg.contains('\0') {
584        return Err(Error::resolver_custom(
585            "file",
586            "File paths cannot contain null bytes",
587        ));
588    }
589
590    if let Some(after_slashes) = arg.strip_prefix("//") {
591        // file://... format - Parse as RFC 8089 file: URL
592        // Remove exactly two leading slashes to get the authority+path
593
594        // Check if this is file:/// (third slash means empty authority)
595        if after_slashes.starts_with('/') {
596            // file:/// - empty authority means localhost
597            // The rest is the absolute path (already starts with /)
598            Ok((after_slashes.to_string(), false))
599        } else {
600            // file://hostname/path or file://hostname format
601            // Extract hostname (before first slash, or entire string if no slash)
602            let parts: Vec<&str> = after_slashes.splitn(2, '/').collect();
603            let hostname = parts[0];
604
605            // Check if empty hostname (file:// with no authority)
606            if hostname.is_empty() {
607                return Ok(("/".to_string(), false));
608            }
609
610            if is_localhost(hostname) {
611                // file://localhost/path - explicit localhost
612                let path = parts
613                    .get(1)
614                    .map(|s| format!("/{}", s))
615                    .unwrap_or_else(|| "/".to_string());
616                Ok((path, false))
617            } else {
618                // file://hostname/path - remote file (not supported)
619                Err(Error::resolver_custom(
620                    "file",
621                    format!(
622                        "Remote file URIs not supported: hostname '{}' is not localhost\n\
623                         \n\
624                         HoloConf only supports local files:\n\
625                         - file:///path/to/file (absolute, empty authority)\n\
626                         - file://localhost/path/to/file (absolute, explicit localhost)\n\
627                         - file:/path/to/file (absolute, minimal)\n\
628                         - relative/path/to/file (relative to config directory)",
629                        hostname
630                    ),
631                ))
632            }
633        }
634    } else if arg.starts_with('/') {
635        // file:/path - RFC 8089 local absolute (no authority)
636        Ok((arg.to_string(), false))
637    } else {
638        // No leading slashes: relative path (HoloConf convention)
639        Ok((arg.to_string(), true))
640    }
641}
642
643/// Built-in file resolver
644///
645/// Usage:
646///   ${file:path/to/file}                    - Read file as text (UTF-8), relative to config
647///   ${file:///absolute/path}                - Absolute path (RFC 8089)
648///   ${file://localhost/absolute/path}       - Absolute path (RFC 8089, explicit localhost)
649///   ${file:/absolute/path}                  - Absolute path (RFC 8089 minimal form)
650///   ${file:path/to/file,parse=yaml}         - Parse as YAML
651///   ${file:path/to/file,parse=json}         - Parse as JSON
652///   ${file:path/to/file,parse=text}         - Read as text (explicit)
653///   ${file:path/to/file,parse=auto}         - Auto-detect from extension (default)
654///   ${file:path/to/file,encoding=utf-8}     - UTF-8 encoding (default)
655///   ${file:path/to/file,encoding=ascii}     - ASCII encoding (strips non-ASCII)
656///   ${file:path/to/file,encoding=base64}    - Base64 encode the file contents as string
657///   ${file:path/to/file,encoding=binary}    - Return raw bytes as Value::Bytes
658///   ${file:path/to/file,default={}}         - Default if file not found (framework-handled)
659///   ${file:path/to/file,sensitive=true}     - Mark as sensitive (framework-handled)
660///
661/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
662fn file_resolver(
663    args: &[String],
664    kwargs: &HashMap<String, String>,
665    ctx: &ResolverContext,
666) -> Result<ResolvedValue> {
667    if args.is_empty() {
668        return Err(
669            Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
670        );
671    }
672
673    let file_path_arg = &args[0];
674    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
675    let encoding = kwargs
676        .get("encoding")
677        .map(|s| s.as_str())
678        .unwrap_or("utf-8");
679
680    // Normalize file path according to RFC 8089
681    let (normalized_path, is_relative) = normalize_file_path(file_path_arg)?;
682
683    // Resolve relative paths based on context base path
684    let file_path = if is_relative {
685        if let Some(base) = &ctx.base_path {
686            base.join(&normalized_path)
687        } else {
688            std::path::PathBuf::from(&normalized_path)
689        }
690    } else {
691        // Absolute path from RFC 8089 file: URI
692        std::path::PathBuf::from(&normalized_path)
693    };
694
695    // Validate path is within allowed roots (path traversal protection)
696    // Security: Empty file_roots would bypass all validation - deny by default
697    if ctx.file_roots.is_empty() {
698        return Err(Error::resolver_custom(
699            "file",
700            "File resolver requires allowed directories to be configured. \
701             Use Config.load() which auto-configures the parent directory, or \
702             specify file_roots explicitly for Config.loads()."
703                .to_string(),
704        )
705        .with_path(ctx.config_path.clone()));
706    }
707
708    // Canonicalize file path to resolve symlinks and get absolute path
709    // This also checks if file exists (canonicalize fails if file doesn't exist)
710    let canonical_path = file_path.canonicalize().map_err(|e| {
711        // Check if this is a "not found" error for better error message
712        if e.kind() == std::io::ErrorKind::NotFound {
713            return Error::file_not_found(file_path_arg, Some(ctx.config_path.clone()));
714        }
715        Error::resolver_custom("file", format!("Failed to resolve file path: {}", e))
716            .with_path(ctx.config_path.clone())
717    })?;
718
719    // Validate against allowed roots
720    let mut canonicalization_errors = Vec::new();
721    let is_allowed = ctx.file_roots.iter().any(|root| {
722        match root.canonicalize() {
723            Ok(canonical_root) => canonical_path.starts_with(&canonical_root),
724            Err(e) => {
725                // Log but don't fail - root might not exist yet
726                canonicalization_errors.push((root.clone(), e));
727                false
728            }
729        }
730    });
731
732    if !is_allowed {
733        // Sanitize error message to avoid information disclosure
734        let display_path = if let Some(base) = &ctx.base_path {
735            file_path
736                .strip_prefix(base)
737                .map(|p| p.display().to_string())
738                .unwrap_or_else(|_| "<outside allowed directories>".to_string())
739        } else {
740            "<outside allowed directories>".to_string()
741        };
742
743        let mut msg = format!(
744            "Access denied: file '{}' is outside allowed directories.",
745            display_path
746        );
747
748        if !canonicalization_errors.is_empty() {
749            msg.push_str(&format!(
750                " Note: {} configured root(s) could not be validated.",
751                canonicalization_errors.len()
752            ));
753        }
754
755        msg.push_str(" Use file_roots parameter to extend allowed directories.");
756
757        return Err(Error::resolver_custom("file", msg).with_path(ctx.config_path.clone()));
758    }
759
760    // Handle binary encoding separately - returns Value::Bytes directly
761    if encoding == "binary" {
762        let bytes = std::fs::read(&file_path)
763            .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
764        return Ok(ResolvedValue::new(Value::Bytes(bytes)));
765    }
766
767    // Read the file based on encoding
768    let content = match encoding {
769        "base64" => {
770            // Read as binary and base64 encode
771            use base64::{engine::general_purpose::STANDARD, Engine as _};
772            let bytes = std::fs::read(&file_path)
773                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
774            STANDARD.encode(bytes)
775        }
776        "ascii" => {
777            // Read as UTF-8 but strip non-ASCII characters
778            let raw = std::fs::read_to_string(&file_path)
779                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
780            raw.chars().filter(|c| c.is_ascii()).collect()
781        }
782        _ => {
783            // Default to UTF-8 (including explicit "utf-8")
784            std::fs::read_to_string(&file_path)
785                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?
786        }
787    };
788
789    // For base64 encoding, always return as text (don't try to parse)
790    if encoding == "base64" {
791        return Ok(ResolvedValue::new(Value::String(content)));
792    }
793
794    // Determine parse mode
795    let actual_parse_mode = if parse_mode == "auto" {
796        // Detect from extension
797        match file_path.extension().and_then(|e| e.to_str()) {
798            Some("yaml") | Some("yml") => "yaml",
799            Some("json") => "json",
800            _ => "text",
801        }
802    } else {
803        parse_mode
804    };
805
806    // Parse content based on mode
807    match actual_parse_mode {
808        "yaml" => {
809            let value: Value = serde_yaml::from_str(&content).map_err(|e| {
810                Error::parse(format!("Failed to parse YAML: {}", e))
811                    .with_path(ctx.config_path.clone())
812            })?;
813            Ok(ResolvedValue::new(value))
814        }
815        "json" => {
816            let value: Value = serde_json::from_str(&content).map_err(|e| {
817                Error::parse(format!("Failed to parse JSON: {}", e))
818                    .with_path(ctx.config_path.clone())
819            })?;
820            Ok(ResolvedValue::new(value))
821        }
822        _ => {
823            // Default to text mode (including explicit "text")
824            Ok(ResolvedValue::new(Value::String(content)))
825        }
826    }
827}
828
829/// Normalize HTTP/HTTPS URL by stripping existing scheme and prepending the correct one
830///
831/// This allows flexible syntax like:
832///   ${https://example.com}    → https://example.com
833///   ${https:example.com}      → https://example.com
834///   ${https:https://example}  → https://example.com (backwards compatible)
835///
836/// Returns an error if the URL is invalid (empty, or has invalid syntax like ///)
837#[cfg(feature = "http")]
838fn normalize_http_url(scheme: &str, arg: &str) -> Result<String> {
839    // Strip any existing http:// or https:// prefix
840    let clean = arg
841        .strip_prefix("http://")
842        .or_else(|| arg.strip_prefix("https://"))
843        .unwrap_or(arg);
844
845    // Strip leading // if present (handles ${https://example.com} syntax)
846    let clean = clean.strip_prefix("//").unwrap_or(clean);
847
848    // Validate: Reject empty URLs
849    if clean.trim().is_empty() {
850        return Err(Error::resolver_custom(
851            scheme,
852            format!(
853                "{} resolver requires a non-empty URL",
854                scheme.to_uppercase()
855            ),
856        ));
857    }
858
859    // Validate: Reject URLs starting with / (like /// which would create scheme:///)
860    if clean.starts_with('/') {
861        return Err(Error::resolver_custom(
862            scheme,
863            format!(
864                "Invalid URL syntax: '{}'. URLs must have a hostname after the ://\n\
865                 Valid formats:\n\
866                 - ${{{}:example.com/path}} (clean syntax)\n\
867                 - ${{{}:{}://example.com/path}} (backwards compatible)",
868                arg, scheme, scheme, scheme
869            ),
870        ));
871    }
872
873    // Prepend the correct scheme
874    Ok(format!("{}://{}", scheme, clean))
875}
876
877/// Common HTTP/HTTPS resolver implementation
878///
879/// This shared function reduces duplication between http_resolver and https_resolver.
880/// The only difference between the two resolvers is the scheme they prepend.
881fn http_or_https_resolver(
882    scheme: &str,
883    args: &[String],
884    kwargs: &HashMap<String, String>,
885    ctx: &ResolverContext,
886) -> Result<ResolvedValue> {
887    if args.is_empty() {
888        return Err(
889            Error::parse(format!("{} resolver requires a URL", scheme.to_uppercase()))
890                .with_path(ctx.config_path.clone()),
891        );
892    }
893
894    #[cfg(feature = "http")]
895    {
896        // Normalize URL to prepend the appropriate scheme
897        let url = normalize_http_url(scheme, &args[0])?;
898
899        // Check if HTTP is enabled
900        if !ctx.allow_http {
901            return Err(Error {
902                kind: crate::error::ErrorKind::Resolver(
903                    crate::error::ResolverErrorKind::HttpDisabled,
904                ),
905                path: Some(ctx.config_path.clone()),
906                source_location: None,
907                help: Some(format!(
908                    "{} resolver is disabled. The URL specified by this config path cannot be fetched.\n\
909                     Enable with Config.load(..., allow_http=True)",
910                    scheme.to_uppercase()
911                )),
912                cause: None,
913            });
914        }
915
916        // Check URL against allowlist if configured
917        if !ctx.http_allowlist.is_empty() {
918            let url_allowed = ctx
919                .http_allowlist
920                .iter()
921                .any(|pattern| url_matches_pattern(&url, pattern));
922            if !url_allowed {
923                return Err(Error::http_not_in_allowlist(
924                    &url,
925                    &ctx.http_allowlist,
926                    Some(ctx.config_path.clone()),
927                ));
928            }
929        }
930
931        http_fetch(&url, kwargs, ctx)
932    }
933
934    #[cfg(not(feature = "http"))]
935    {
936        // If compiled without HTTP feature, return an error
937        let _ = (kwargs, ctx); // Suppress unused warnings
938        Err(Error::resolver_custom(
939            scheme,
940            format!(
941                "{} support not compiled in. Rebuild with --features http",
942                scheme.to_uppercase()
943            ),
944        ))
945    }
946}
947
948/// Built-in HTTP resolver
949///
950/// Fetches content from remote URLs.
951///
952/// Usage:
953///   ${http:example.com/config.yaml}                   - Clean syntax (auto-prepends http://)
954///   ${http:example.com/config,parse=yaml}             - Parse as YAML
955///   ${http:example.com/config,parse=json}             - Parse as JSON
956///   ${http:example.com/config,parse=text}             - Read as text
957///   ${http:example.com/config,parse=binary}           - Read as binary
958///   ${http:example.com/config,timeout=60}             - Timeout in seconds
959///   ${http:example.com/config,header=Auth:Bearer token} - Add header
960///   ${http:example.com/config,default={}}             - Default if request fails
961///   ${http:example.com/config,sensitive=true}         - Mark as sensitive
962///
963/// Backwards compatible:
964///   ${http:http://example.com}                        - Still works (protocol stripped and re-prepended)
965///
966/// Security:
967/// - Disabled by default (requires allow_http=true in ConfigOptions)
968/// - URL allowlist can restrict which URLs are accessible
969fn http_resolver(
970    args: &[String],
971    kwargs: &HashMap<String, String>,
972    ctx: &ResolverContext,
973) -> Result<ResolvedValue> {
974    http_or_https_resolver("http", args, kwargs, ctx)
975}
976
977/// Built-in HTTPS resolver
978///
979/// Fetches content from remote HTTPS URLs. Same as http_resolver but prepends https:// scheme.
980///
981/// Usage:
982///   ${https:example.com/config.yaml}                  - Clean syntax (auto-prepends https://)
983///   ${https:example.com/config,parse=yaml}            - Parse as YAML
984///   ${https:example.com/config,parse=json}            - Parse as JSON
985///   ${https:example.com/config,parse=text}            - Read as text
986///   ${https:example.com/config,parse=binary}          - Read as binary
987///   ${https:example.com/config,timeout=60}            - Timeout in seconds
988///   ${https:example.com/config,header=Auth:Bearer token} - Add header
989///   ${https:example.com/config,default={}}            - Default if request fails
990///   ${https:example.com/config,sensitive=true}        - Mark as sensitive
991///
992/// Backwards compatible:
993///   ${https:https://example.com}                      - Still works (protocol stripped and re-prepended)
994///
995/// Security:
996/// - Disabled by default (requires allow_http=true in ConfigOptions)
997/// - URL allowlist can restrict which URLs are accessible
998fn https_resolver(
999    args: &[String],
1000    kwargs: &HashMap<String, String>,
1001    ctx: &ResolverContext,
1002) -> Result<ResolvedValue> {
1003    http_or_https_resolver("https", args, kwargs, ctx)
1004}
1005
1006/// Check if a URL matches an allowlist pattern
1007///
1008/// Supports glob-style patterns:
1009/// - `https://example.com/*` matches any path on example.com
1010/// - `https://*.example.com/*` matches any subdomain
1011#[cfg(feature = "http")]
1012fn url_matches_pattern(url: &str, pattern: &str) -> bool {
1013    // Security: Parse URL first to prevent bypass via malformed URLs
1014    let parsed_url = match url::Url::parse(url) {
1015        Ok(u) => u,
1016        Err(_) => {
1017            // Invalid URL - no match
1018            log::warn!("Invalid URL '{}' rejected by allowlist", url);
1019            return false;
1020        }
1021    };
1022
1023    // Validate pattern doesn't contain dangerous sequences
1024    if pattern.contains("**") || pattern.contains(".*.*") {
1025        log::warn!(
1026            "Invalid allowlist pattern '{}' - contains dangerous sequence",
1027            pattern
1028        );
1029        return false;
1030    }
1031
1032    // Use glob crate for proper glob matching
1033    let glob_pattern = match glob::Pattern::new(pattern) {
1034        Ok(p) => p,
1035        Err(_) => {
1036            // Invalid pattern - fall back to exact match
1037            log::warn!(
1038                "Invalid glob pattern '{}' - falling back to exact match",
1039                pattern
1040            );
1041            return url == pattern;
1042        }
1043    };
1044
1045    // Match against the full URL string
1046    // This allows patterns like:
1047    // - "https://api.example.com/*" (all paths on this host)
1048    // - "https://*.example.com/api/*" (all subdomains)
1049    // - "https://api.example.com/v1/users" (exact match)
1050    glob_pattern.matches(parsed_url.as_str())
1051}
1052
1053// =============================================================================
1054// TLS/Proxy Configuration Helpers (HTTP feature)
1055// =============================================================================
1056
1057/// Parse PEM certificates from bytes
1058#[cfg(feature = "http")]
1059fn parse_pem_certs(pem_bytes: &[u8], source: &str) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1060    use ureq::tls::PemItem;
1061
1062    let certs: Vec<_> = ureq::tls::parse_pem(pem_bytes)
1063        .filter_map(|item| item.ok())
1064        .filter_map(|item| match item {
1065            PemItem::Certificate(cert) => Some(cert.to_owned()),
1066            _ => None,
1067        })
1068        .collect();
1069
1070    if certs.is_empty() {
1071        return Err(Error::pem_load_error(
1072            source,
1073            "No valid certificates found in PEM data",
1074        ));
1075    }
1076
1077    Ok(certs)
1078}
1079
1080/// Load certificates from CertInput (PEM content or file path)
1081#[cfg(feature = "http")]
1082fn load_certs(input: &CertInput) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1083    match input {
1084        CertInput::Binary(_) => {
1085            Err(Error::tls_config_error(
1086                "CA bundle must be PEM format, not binary. For P12 client certificates, use client_cert parameter."
1087            ))
1088        }
1089        CertInput::Text(text) => {
1090            // Try as file path first
1091            let path = std::path::Path::new(text);
1092            if path.exists() {
1093                log::trace!("Loading certificates from file: {}", text);
1094                let bytes = std::fs::read(path).map_err(|e| {
1095                    // Sanitize path for error message (could contain PEM content if detection fails)
1096                    let display_path = if text.len() < 256 && !text.contains('\n') {
1097                        text
1098                    } else {
1099                        "[PEM content or long path]"
1100                    };
1101                    Error::pem_load_error(
1102                        display_path,
1103                        format!("Failed to read certificate file: {}", e),
1104                    )
1105                })?;
1106                parse_pem_certs(&bytes, text)
1107            } else {
1108                // Fallback to parsing as PEM content
1109                log::trace!("Path does not exist, attempting to parse as PEM content");
1110                parse_pem_certs(text.as_bytes(), "PEM content")
1111            }
1112        }
1113    }
1114}
1115
1116/// Parse a private key from PEM bytes (handles encrypted and unencrypted)
1117#[cfg(feature = "http")]
1118fn parse_pem_private_key(
1119    pem_content: &str,
1120    password: Option<&str>,
1121    source: &str,
1122) -> Result<ureq::tls::PrivateKey<'static>> {
1123    use pkcs8::der::Decode;
1124
1125    // Check if this is an encrypted PKCS#8 key
1126    if pem_content.contains(PEM_BEGIN_ENCRYPTED_KEY) {
1127        let pwd = password.ok_or_else(|| {
1128            Error::tls_config_error(format!(
1129                "Password required for encrypted private key from: {}",
1130                source
1131            ))
1132        })?;
1133
1134        // Extract the base64 content from PEM
1135        let der_bytes = pem_to_der(pem_content, "ENCRYPTED PRIVATE KEY")
1136            .map_err(|e| Error::pem_load_error(source, e))?;
1137
1138        let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
1139            .map_err(|e| Error::pem_load_error(source, e.to_string()))?;
1140
1141        let decrypted = encrypted
1142            .decrypt(pwd)
1143            .map_err(|e| Error::key_decryption_error(e.to_string()))?;
1144
1145        // The decrypted key is in PKCS#8 DER format
1146        // Wrap it in PEM format so ureq can parse it
1147        let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
1148
1149        ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1150            .map(|k| k.to_owned())
1151            .map_err(|e| {
1152                Error::pem_load_error(source, format!("Failed to parse decrypted key: {}", e))
1153            })
1154    } else {
1155        // Try loading as regular key
1156        ureq::tls::PrivateKey::from_pem(pem_content.as_bytes())
1157            .map(|k| k.to_owned())
1158            .map_err(|e| {
1159                Error::pem_load_error(source, format!("Failed to parse private key: {}", e))
1160            })
1161    }
1162}
1163
1164/// Load a private key from CertInput (PEM content or file path)
1165#[cfg(feature = "http")]
1166fn load_private_key(
1167    input: &CertInput,
1168    password: Option<&str>,
1169) -> Result<ureq::tls::PrivateKey<'static>> {
1170    match input {
1171        CertInput::Binary(_) => {
1172            Err(Error::tls_config_error(
1173                "Private key must be PEM text format, not binary. For P12, use client_cert only (no client_key needed)."
1174            ))
1175        }
1176        CertInput::Text(text) => {
1177            // Try as file path first
1178            let path = std::path::Path::new(text);
1179            if path.exists() {
1180                log::trace!("Loading private key from file: {}", text);
1181                let pem_content = std::fs::read_to_string(path).map_err(|e| {
1182                    // Sanitize path for error message
1183                    let display_path = if text.len() < 256 && !text.contains('\n') {
1184                        text
1185                    } else {
1186                        "[PEM content or long path]"
1187                    };
1188                    Error::pem_load_error(
1189                        display_path,
1190                        format!("Failed to read key file: {}", e),
1191                    )
1192                })?;
1193                parse_pem_private_key(&pem_content, password, text)
1194            } else {
1195                // Fallback to parsing as PEM content
1196                log::trace!("Path does not exist, attempting to parse as PEM content");
1197                parse_pem_private_key(text, password, "PEM content")
1198            }
1199        }
1200    }
1201}
1202
1203/// Extract DER bytes from PEM format
1204#[cfg(feature = "http")]
1205fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
1206    let begin_marker = format!("-----BEGIN {}-----", label);
1207    let end_marker = format!("-----END {}-----", label);
1208
1209    let start = pem
1210        .find(&begin_marker)
1211        .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
1212    let end = pem
1213        .find(&end_marker)
1214        .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
1215
1216    let base64_content: String = pem[start + begin_marker.len()..end]
1217        .chars()
1218        .filter(|c| !c.is_whitespace())
1219        .collect();
1220
1221    use base64::Engine;
1222    base64::engine::general_purpose::STANDARD
1223        .decode(&base64_content)
1224        .map_err(|e| format!("Failed to decode base64: {}", e))
1225}
1226
1227/// Convert DER bytes to PEM format
1228#[cfg(feature = "http")]
1229fn der_to_pem(der: &[u8], label: &str) -> String {
1230    use base64::Engine;
1231    let base64 = base64::engine::general_purpose::STANDARD.encode(der);
1232    // Split into 64-char lines
1233    let lines: Vec<&str> = base64
1234        .as_bytes()
1235        .chunks(64)
1236        .map(|chunk| std::str::from_utf8(chunk).unwrap())
1237        .collect();
1238    format!(
1239        "-----BEGIN {}-----\n{}\n-----END {}-----\n",
1240        label,
1241        lines.join("\n"),
1242        label
1243    )
1244}
1245
1246/// Parse P12/PFX bytes into certificate chain and private key
1247#[cfg(feature = "http")]
1248fn parse_p12_identity(
1249    p12_data: &[u8],
1250    password: &str,
1251    source: &str,
1252) -> Result<(
1253    Vec<ureq::tls::Certificate<'static>>,
1254    ureq::tls::PrivateKey<'static>,
1255)> {
1256    // Warn if using empty password (valid but insecure)
1257    if password.is_empty() {
1258        log::warn!(
1259            "Loading P12 file without password from: {} - ensure file is properly protected",
1260            source
1261        );
1262    }
1263
1264    let keystore = p12_keystore::KeyStore::from_pkcs12(p12_data, password)
1265        .map_err(|e| Error::p12_load_error(source, e.to_string()))?;
1266
1267    // Get the first key entry (most P12 files have one key)
1268    // private_key_chain() returns Option<(&str, &PrivateKeyChain)>
1269    let (_alias, key_chain) = keystore
1270        .private_key_chain()
1271        .ok_or_else(|| Error::p12_load_error(source, "No private key found in P12 data"))?;
1272
1273    // Get the private key DER bytes - wrap in PEM for ureq
1274    let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
1275    let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1276        .map(|k| k.to_owned())
1277        .map_err(|e| {
1278            Error::p12_load_error(source, format!("Failed to parse private key: {}", e))
1279        })?;
1280
1281    // Get certificates from the chain
1282    let certs: Vec<_> = key_chain
1283        .chain()
1284        .iter()
1285        .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
1286        .collect();
1287
1288    if certs.is_empty() {
1289        return Err(Error::p12_load_error(
1290            source,
1291            "No certificates found in P12 data",
1292        ));
1293    }
1294
1295    Ok((certs, private_key))
1296}
1297
1298/// Load client identity (cert + key) for mTLS from CertInput
1299#[cfg(feature = "http")]
1300fn load_client_identity(
1301    cert_input: &CertInput,
1302    key_input: Option<&CertInput>,
1303    password: Option<&str>,
1304) -> Result<(
1305    Vec<ureq::tls::Certificate<'static>>,
1306    ureq::tls::PrivateKey<'static>,
1307)> {
1308    match cert_input {
1309        // P12 binary content
1310        CertInput::Binary(bytes) => {
1311            log::trace!("Loading client identity from P12 binary content");
1312            // P12 files may have empty passwords - this is valid (warning logged in parse_p12_identity)
1313            let pwd = password.unwrap_or("");
1314            parse_p12_identity(bytes, pwd, "P12 binary content")
1315        }
1316
1317        // Text - could be PEM content, P12 path, or PEM path
1318        CertInput::Text(text) => {
1319            // Check if it's a P12 file path
1320            if cert_input.is_p12_path() {
1321                log::trace!("Loading client identity from P12 file: {}", text);
1322                let bytes = std::fs::read(text).map_err(|e| {
1323                    Error::p12_load_error(text, format!("Failed to read P12 file: {}", e))
1324                })?;
1325                // P12 files may have empty passwords - this is valid (warning logged in parse_p12_identity)
1326                let pwd = password.unwrap_or("");
1327                return parse_p12_identity(&bytes, pwd, text);
1328            }
1329
1330            // PEM content or path
1331            log::trace!("Loading client identity from PEM (cert + key)");
1332            let certs = load_certs(cert_input)?;
1333
1334            let key_input = key_input.ok_or_else(|| {
1335                Error::tls_config_error(
1336                    "client_key required when using PEM certificate (not needed for P12)",
1337                )
1338            })?;
1339
1340            let key = load_private_key(key_input, password)?;
1341            Ok((certs, key))
1342        }
1343    }
1344}
1345
1346/// Build TLS configuration from context and per-request kwargs
1347#[cfg(feature = "http")]
1348fn build_tls_config(
1349    ctx: &ResolverContext,
1350    kwargs: &HashMap<String, String>,
1351) -> Result<ureq::tls::TlsConfig> {
1352    use std::sync::Arc;
1353    use ureq::tls::{ClientCert, RootCerts, TlsConfig};
1354
1355    let mut builder = TlsConfig::builder();
1356
1357    // Check for insecure mode (only from per-request kwargs)
1358    let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
1359
1360    if insecure {
1361        // OBNOXIOUS WARNING: This is a SECURITY RISK
1362        eprintln!("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
1363        eprintln!("┃ ⚠️  WARNING: TLS CERTIFICATE VERIFICATION DISABLED ┃");
1364        eprintln!("┃                                                    ┃");
1365        eprintln!("┃ You are using insecure=true which disables ALL    ┃");
1366        eprintln!("┃ TLS certificate validation. This is DANGEROUS     ┃");
1367        eprintln!("┃ and should ONLY be used in development.           ┃");
1368        eprintln!("┃                                                    ┃");
1369        eprintln!("┃ In production, use proper certificate             ┃");
1370        eprintln!("┃ configuration with ca_bundle or extra_ca_bundle.  ┃");
1371        eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n");
1372        log::warn!("TLS certificate verification is disabled (insecure=true)");
1373        builder = builder.disable_verification(true);
1374    }
1375
1376    // Load CA bundle if specified (per-request overrides context)
1377    let ca_bundle_input = kwargs
1378        .get("ca_bundle")
1379        .map(|s| CertInput::Text(s.clone()))
1380        .or_else(|| ctx.http_ca_bundle.clone());
1381
1382    let extra_ca_bundle_input = kwargs
1383        .get("extra_ca_bundle")
1384        .map(|s| CertInput::Text(s.clone()))
1385        .or_else(|| ctx.http_extra_ca_bundle.clone());
1386
1387    if let Some(ca_input) = ca_bundle_input.as_ref() {
1388        // Replace root certs with custom CA bundle
1389        let certs = load_certs(ca_input)?;
1390        builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
1391    } else if let Some(extra_ca_input) = extra_ca_bundle_input.as_ref() {
1392        // Add extra certs to webpki roots using new_with_certs
1393        let extra_certs = load_certs(extra_ca_input)?;
1394        builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
1395    }
1396
1397    // Load client certificate for mTLS (per-request overrides context)
1398    let client_cert_input = kwargs
1399        .get("client_cert")
1400        .map(|s| CertInput::Text(s.clone()))
1401        .or_else(|| ctx.http_client_cert.clone());
1402
1403    if let Some(cert_input) = client_cert_input.as_ref() {
1404        let client_key_input = kwargs
1405            .get("client_key")
1406            .map(|s| CertInput::Text(s.clone()))
1407            .or_else(|| ctx.http_client_key.clone());
1408
1409        let password = kwargs
1410            .get("key_password")
1411            .map(|s| s.as_str())
1412            .or(ctx.http_client_key_password.as_deref());
1413
1414        let (certs, key) = load_client_identity(cert_input, client_key_input.as_ref(), password)?;
1415
1416        let client_cert = ClientCert::new_with_certs(&certs, key);
1417        builder = builder.client_cert(Some(client_cert));
1418    }
1419
1420    Ok(builder.build())
1421}
1422
1423/// Build proxy configuration from context and per-request kwargs
1424#[cfg(feature = "http")]
1425fn build_proxy_config(
1426    ctx: &ResolverContext,
1427    kwargs: &HashMap<String, String>,
1428) -> Result<Option<ureq::Proxy>> {
1429    // Per-request proxy overrides context
1430    let proxy_url = kwargs
1431        .get("proxy")
1432        .cloned()
1433        .or_else(|| ctx.http_proxy.clone());
1434
1435    // If no explicit proxy, check environment if enabled
1436    let proxy_url = proxy_url.or_else(|| {
1437        if ctx.http_proxy_from_env {
1438            // Check standard proxy environment variables
1439            std::env::var("HTTPS_PROXY")
1440                .or_else(|_| std::env::var("https_proxy"))
1441                .or_else(|_| std::env::var("HTTP_PROXY"))
1442                .or_else(|_| std::env::var("http_proxy"))
1443                .ok()
1444        } else {
1445            None
1446        }
1447    });
1448
1449    if let Some(url) = proxy_url {
1450        let proxy = ureq::Proxy::new(&url).map_err(|e| {
1451            Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1452        })?;
1453        Ok(Some(proxy))
1454    } else {
1455        Ok(None)
1456    }
1457}
1458
1459/// Perform HTTP request and parse response
1460#[cfg(feature = "http")]
1461fn http_fetch(
1462    url: &str,
1463    kwargs: &HashMap<String, String>,
1464    ctx: &ResolverContext,
1465) -> Result<ResolvedValue> {
1466    use std::time::Duration;
1467
1468    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
1469    let timeout_secs: u64 = kwargs
1470        .get("timeout")
1471        .and_then(|s| s.parse().ok())
1472        .unwrap_or(30);
1473
1474    // Build TLS configuration
1475    let tls_config = build_tls_config(ctx, kwargs)?;
1476
1477    // Build proxy configuration
1478    let proxy = build_proxy_config(ctx, kwargs)?;
1479
1480    // Build agent with timeout, TLS, and proxy configuration
1481    let mut config_builder = ureq::Agent::config_builder()
1482        .timeout_global(Some(Duration::from_secs(timeout_secs)))
1483        .tls_config(tls_config);
1484
1485    if proxy.is_some() {
1486        config_builder = config_builder.proxy(proxy);
1487    }
1488
1489    let config = config_builder.build();
1490    let agent: ureq::Agent = config.into();
1491
1492    // Build the request
1493    let mut request = agent.get(url);
1494
1495    // Add custom headers
1496    for (key, value) in kwargs {
1497        if key == "header" {
1498            // Parse header in format "Name:Value"
1499            if let Some((name, val)) = value.split_once(':') {
1500                request = request.header(name.trim(), val.trim());
1501            }
1502        }
1503    }
1504
1505    // Send request
1506    let response = request.call().map_err(|e| {
1507        let error_msg = match &e {
1508            ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1509            ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1510            ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1511            _ => format!("HTTP request failed: {}", e),
1512        };
1513        Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1514    })?;
1515
1516    // Get content type from response
1517    let content_type = response
1518        .headers()
1519        .get("content-type")
1520        .and_then(|v| v.to_str().ok())
1521        .map(|s| s.to_string())
1522        .unwrap_or_default();
1523
1524    // Handle binary mode separately
1525    if parse_mode == "binary" {
1526        let bytes = response.into_body().read_to_vec().map_err(|e| {
1527            Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1528        })?;
1529        return Ok(ResolvedValue::new(Value::Bytes(bytes)));
1530    }
1531
1532    // Read response body as text
1533    let body = response.into_body().read_to_string().map_err(|e| {
1534        Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1535    })?;
1536
1537    // Determine actual parse mode
1538    let actual_parse_mode = if parse_mode == "auto" {
1539        detect_parse_mode(url, &content_type)
1540    } else {
1541        parse_mode
1542    };
1543
1544    // Parse content based on mode
1545    match actual_parse_mode {
1546        "yaml" => {
1547            let value: Value = serde_yaml::from_str(&body).map_err(|e| {
1548                Error::parse(format!("Failed to parse YAML from {}: {}", url, e))
1549                    .with_path(ctx.config_path.clone())
1550            })?;
1551            Ok(ResolvedValue::new(value))
1552        }
1553        "json" => {
1554            let value: Value = serde_json::from_str(&body).map_err(|e| {
1555                Error::parse(format!("Failed to parse JSON from {}: {}", url, e))
1556                    .with_path(ctx.config_path.clone())
1557            })?;
1558            Ok(ResolvedValue::new(value))
1559        }
1560        _ => {
1561            // Default to text mode
1562            Ok(ResolvedValue::new(Value::String(body)))
1563        }
1564    }
1565}
1566
1567/// Detect parse mode from URL extension or content type
1568#[cfg(feature = "http")]
1569fn detect_parse_mode<'a>(url: &str, content_type: &str) -> &'a str {
1570    // Check content type first
1571    let ct_lower = content_type.to_lowercase();
1572    if ct_lower.contains("application/json") || ct_lower.contains("text/json") {
1573        return "json";
1574    }
1575    if ct_lower.contains("application/yaml")
1576        || ct_lower.contains("application/x-yaml")
1577        || ct_lower.contains("text/yaml")
1578    {
1579        return "yaml";
1580    }
1581
1582    // Check URL extension
1583    if let Some(path) = url.split('?').next() {
1584        if path.ends_with(".json") {
1585            return "json";
1586        }
1587        if path.ends_with(".yaml") || path.ends_with(".yml") {
1588            return "yaml";
1589        }
1590    }
1591
1592    // Default to text
1593    "text"
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598    use super::*;
1599
1600    #[test]
1601    fn test_env_resolver_with_value() {
1602        std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
1603
1604        let ctx = ResolverContext::new("test.path");
1605        let args = vec!["HOLOCONF_TEST_VAR".to_string()];
1606        let kwargs = HashMap::new();
1607
1608        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
1609        assert_eq!(result.value.as_str(), Some("test_value"));
1610        assert!(!result.sensitive);
1611
1612        std::env::remove_var("HOLOCONF_TEST_VAR");
1613    }
1614
1615    #[test]
1616    fn test_env_resolver_missing_returns_error() {
1617        // Make sure the var doesn't exist
1618        std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
1619
1620        let registry = ResolverRegistry::with_builtins();
1621        let ctx = ResolverContext::new("test.path");
1622        let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
1623        let kwargs = HashMap::new();
1624
1625        // Registry doesn't handle defaults - that's done at Config level for lazy resolution
1626        // So this should return an error
1627        let result = registry.resolve("env", &args, &kwargs, &ctx);
1628        assert!(result.is_err());
1629    }
1630
1631    #[test]
1632    fn test_env_resolver_missing_no_default() {
1633        std::env::remove_var("HOLOCONF_MISSING_VAR");
1634
1635        let ctx = ResolverContext::new("test.path");
1636        let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
1637        let kwargs = HashMap::new();
1638
1639        let result = env_resolver(&args, &kwargs, &ctx);
1640        assert!(result.is_err());
1641    }
1642
1643    #[test]
1644    fn test_env_resolver_sensitive_kwarg() {
1645        std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
1646
1647        let registry = ResolverRegistry::with_builtins();
1648        let ctx = ResolverContext::new("test.path");
1649        let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
1650        let mut kwargs = HashMap::new();
1651        kwargs.insert("sensitive".to_string(), "true".to_string());
1652
1653        // Framework-level sensitive handling via registry
1654        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1655        assert_eq!(result.value.as_str(), Some("secret_value"));
1656        assert!(result.sensitive);
1657
1658        std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
1659    }
1660
1661    #[test]
1662    fn test_env_resolver_sensitive_false() {
1663        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
1664
1665        let registry = ResolverRegistry::with_builtins();
1666        let ctx = ResolverContext::new("test.path");
1667        let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
1668        let mut kwargs = HashMap::new();
1669        kwargs.insert("sensitive".to_string(), "false".to_string());
1670
1671        // Framework-level sensitive handling via registry
1672        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1673        assert_eq!(result.value.as_str(), Some("public_value"));
1674        assert!(!result.sensitive);
1675
1676        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1677    }
1678
1679    // Note: test_env_resolver_sensitive_with_default has moved to config.rs tests
1680    // since default handling with lazy resolution is done at the Config level
1681
1682    #[test]
1683    fn test_resolver_registry() {
1684        let registry = ResolverRegistry::with_builtins();
1685
1686        assert!(registry.contains("env"));
1687        assert!(!registry.contains("nonexistent"));
1688    }
1689
1690    #[test]
1691    fn test_custom_resolver() {
1692        let mut registry = ResolverRegistry::new();
1693
1694        registry.register_fn("custom", |args, _kwargs, _ctx| {
1695            let value = args.first().cloned().unwrap_or_default();
1696            Ok(ResolvedValue::new(Value::String(format!(
1697                "custom:{}",
1698                value
1699            ))))
1700        });
1701
1702        let ctx = ResolverContext::new("test");
1703        let result = registry
1704            .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
1705            .unwrap();
1706
1707        assert_eq!(result.value.as_str(), Some("custom:arg"));
1708    }
1709
1710    #[test]
1711    fn test_resolved_value_sensitivity() {
1712        let non_sensitive = ResolvedValue::new("public");
1713        assert!(!non_sensitive.sensitive);
1714
1715        let sensitive = ResolvedValue::sensitive("secret");
1716        assert!(sensitive.sensitive);
1717    }
1718
1719    #[test]
1720    fn test_resolver_context_cycle_detection() {
1721        let mut ctx = ResolverContext::new("root");
1722        ctx.push_resolution("a");
1723        ctx.push_resolution("b");
1724
1725        assert!(ctx.would_cause_cycle("a"));
1726        assert!(ctx.would_cause_cycle("b"));
1727        assert!(!ctx.would_cause_cycle("c"));
1728
1729        ctx.pop_resolution();
1730        assert!(!ctx.would_cause_cycle("b"));
1731    }
1732
1733    #[test]
1734    fn test_file_resolver() {
1735        use std::io::Write;
1736
1737        // Create a temporary file
1738        let temp_dir = std::env::temp_dir();
1739        let test_file = temp_dir.join("holoconf_test_file.txt");
1740        {
1741            let mut file = std::fs::File::create(&test_file).unwrap();
1742            writeln!(file, "test content").unwrap();
1743        }
1744
1745        let mut ctx = ResolverContext::new("test.path");
1746        ctx.base_path = Some(temp_dir.clone());
1747        ctx.file_roots.insert(temp_dir.clone());
1748
1749        let args = vec!["holoconf_test_file.txt".to_string()];
1750        let mut kwargs = HashMap::new();
1751        kwargs.insert("parse".to_string(), "text".to_string());
1752
1753        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1754        assert!(result.value.as_str().unwrap().contains("test content"));
1755        assert!(!result.sensitive);
1756
1757        // Cleanup
1758        std::fs::remove_file(test_file).ok();
1759    }
1760
1761    #[test]
1762    fn test_file_resolver_yaml() {
1763        use std::io::Write;
1764
1765        // Create a temporary YAML file
1766        let temp_dir = std::env::temp_dir();
1767        let test_file = temp_dir.join("holoconf_test.yaml");
1768        {
1769            let mut file = std::fs::File::create(&test_file).unwrap();
1770            writeln!(file, "key: value").unwrap();
1771            writeln!(file, "number: 42").unwrap();
1772        }
1773
1774        let mut ctx = ResolverContext::new("test.path");
1775        ctx.base_path = Some(temp_dir.clone());
1776        ctx.file_roots.insert(temp_dir.clone());
1777
1778        let args = vec!["holoconf_test.yaml".to_string()];
1779        let kwargs = HashMap::new();
1780
1781        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1782        assert!(result.value.is_mapping());
1783
1784        // Cleanup
1785        std::fs::remove_file(test_file).ok();
1786    }
1787
1788    #[test]
1789    fn test_file_resolver_not_found() {
1790        let ctx = ResolverContext::new("test.path");
1791        let args = vec!["nonexistent_file.txt".to_string()];
1792        let kwargs = HashMap::new();
1793
1794        let result = file_resolver(&args, &kwargs, &ctx);
1795        assert!(result.is_err());
1796    }
1797
1798    #[test]
1799    fn test_registry_with_file() {
1800        let registry = ResolverRegistry::with_builtins();
1801        assert!(registry.contains("file"));
1802    }
1803
1804    #[test]
1805    fn test_http_resolver_disabled() {
1806        let ctx = ResolverContext::new("test.path");
1807        let args = vec!["example.com/config.yaml".to_string()];
1808        let kwargs = HashMap::new();
1809
1810        let result = http_resolver(&args, &kwargs, &ctx);
1811        assert!(result.is_err());
1812
1813        let err = result.unwrap_err();
1814        let display = format!("{}", err);
1815        // Error message uses uppercase HTTP
1816        assert!(display.contains("HTTP resolver is disabled"));
1817    }
1818
1819    #[test]
1820    fn test_registry_with_http() {
1821        let registry = ResolverRegistry::with_builtins();
1822        assert!(registry.contains("http"));
1823    }
1824
1825    #[test]
1826    #[cfg(feature = "http")]
1827    fn test_registry_with_https() {
1828        let registry = ResolverRegistry::with_builtins();
1829        assert!(
1830            registry.contains("https"),
1831            "https resolver should be registered when http feature is enabled"
1832        );
1833    }
1834
1835    // Additional edge case tests for improved coverage
1836
1837    #[test]
1838    fn test_env_resolver_no_args() {
1839        let ctx = ResolverContext::new("test.path");
1840        let args = vec![];
1841        let kwargs = HashMap::new();
1842
1843        let result = env_resolver(&args, &kwargs, &ctx);
1844        assert!(result.is_err());
1845        let err = result.unwrap_err();
1846        assert!(err.to_string().contains("requires"));
1847    }
1848
1849    #[test]
1850    fn test_file_resolver_no_args() {
1851        let ctx = ResolverContext::new("test.path");
1852        let args = vec![];
1853        let kwargs = HashMap::new();
1854
1855        let result = file_resolver(&args, &kwargs, &ctx);
1856        assert!(result.is_err());
1857        let err = result.unwrap_err();
1858        assert!(err.to_string().contains("requires"));
1859    }
1860
1861    #[test]
1862    fn test_http_resolver_no_args() {
1863        let ctx = ResolverContext::new("test.path");
1864        let args = vec![];
1865        let kwargs = HashMap::new();
1866
1867        let result = http_resolver(&args, &kwargs, &ctx);
1868        assert!(result.is_err());
1869        let err = result.unwrap_err();
1870        assert!(err.to_string().contains("requires"));
1871    }
1872
1873    #[test]
1874    fn test_unknown_resolver() {
1875        let registry = ResolverRegistry::with_builtins();
1876        let ctx = ResolverContext::new("test.path");
1877
1878        let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
1879        assert!(result.is_err());
1880        let err = result.unwrap_err();
1881        assert!(err.to_string().contains("unknown_resolver"));
1882    }
1883
1884    #[test]
1885    fn test_resolved_value_from_traits() {
1886        let from_value: ResolvedValue = Value::String("test".to_string()).into();
1887        assert_eq!(from_value.value.as_str(), Some("test"));
1888        assert!(!from_value.sensitive);
1889
1890        let from_string: ResolvedValue = "hello".to_string().into();
1891        assert_eq!(from_string.value.as_str(), Some("hello"));
1892
1893        let from_str: ResolvedValue = "world".into();
1894        assert_eq!(from_str.value.as_str(), Some("world"));
1895    }
1896
1897    #[test]
1898    fn test_resolver_context_with_base_path() {
1899        let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
1900        assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
1901    }
1902
1903    #[test]
1904    fn test_resolver_context_with_config_root() {
1905        use std::sync::Arc;
1906        let root = Arc::new(Value::String("root".to_string()));
1907        let ctx = ResolverContext::new("test").with_config_root(root.clone());
1908        assert!(ctx.config_root.is_some());
1909    }
1910
1911    #[test]
1912    fn test_resolver_context_resolution_chain() {
1913        let mut ctx = ResolverContext::new("root");
1914        ctx.push_resolution("a");
1915        ctx.push_resolution("b");
1916        ctx.push_resolution("c");
1917
1918        let chain = ctx.get_resolution_chain();
1919        assert_eq!(chain, vec!["a", "b", "c"]);
1920    }
1921
1922    #[test]
1923    fn test_registry_get_resolver() {
1924        let registry = ResolverRegistry::with_builtins();
1925
1926        let env_resolver = registry.get("env");
1927        assert!(env_resolver.is_some());
1928        assert_eq!(env_resolver.unwrap().name(), "env");
1929
1930        let missing = registry.get("nonexistent");
1931        assert!(missing.is_none());
1932    }
1933
1934    #[test]
1935    fn test_registry_default() {
1936        let registry = ResolverRegistry::default();
1937        // Default registry is empty
1938        assert!(!registry.contains("env"));
1939    }
1940
1941    #[test]
1942    fn test_fn_resolver_name() {
1943        let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
1944        assert_eq!(resolver.name(), "my_resolver");
1945    }
1946
1947    #[test]
1948    fn test_file_resolver_json() {
1949        use std::io::Write;
1950
1951        // Create a temporary JSON file
1952        let temp_dir = std::env::temp_dir();
1953        let test_file = temp_dir.join("holoconf_test.json");
1954        {
1955            let mut file = std::fs::File::create(&test_file).unwrap();
1956            writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
1957        }
1958
1959        let mut ctx = ResolverContext::new("test.path");
1960        ctx.base_path = Some(temp_dir.clone());
1961        ctx.file_roots.insert(temp_dir.clone());
1962
1963        let args = vec!["holoconf_test.json".to_string()];
1964        let kwargs = HashMap::new();
1965
1966        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1967        assert!(result.value.is_mapping());
1968
1969        // Cleanup
1970        std::fs::remove_file(test_file).ok();
1971    }
1972
1973    #[test]
1974    fn test_file_resolver_absolute_path() {
1975        use std::io::Write;
1976
1977        // Create a temporary file
1978        let temp_dir = std::env::temp_dir();
1979        let test_file = temp_dir.join("holoconf_abs_test.txt");
1980        {
1981            let mut file = std::fs::File::create(&test_file).unwrap();
1982            writeln!(file, "absolute path content").unwrap();
1983        }
1984
1985        let mut ctx = ResolverContext::new("test.path");
1986        ctx.file_roots.insert(temp_dir.clone());
1987        // No base path - using absolute path directly
1988        let args = vec![test_file.to_string_lossy().to_string()];
1989        let mut kwargs = HashMap::new();
1990        kwargs.insert("parse".to_string(), "text".to_string());
1991
1992        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1993        assert!(result
1994            .value
1995            .as_str()
1996            .unwrap()
1997            .contains("absolute path content"));
1998
1999        // Cleanup
2000        std::fs::remove_file(test_file).ok();
2001    }
2002
2003    #[test]
2004    fn test_file_resolver_invalid_yaml() {
2005        use std::io::Write;
2006
2007        // Create a temporary file with invalid YAML
2008        let temp_dir = std::env::temp_dir();
2009        let test_file = temp_dir.join("holoconf_invalid.yaml");
2010        {
2011            let mut file = std::fs::File::create(&test_file).unwrap();
2012            writeln!(file, "key: [invalid").unwrap();
2013        }
2014
2015        let mut ctx = ResolverContext::new("test.path");
2016        ctx.base_path = Some(temp_dir.clone());
2017        ctx.file_roots.insert(temp_dir.clone());
2018
2019        let args = vec!["holoconf_invalid.yaml".to_string()];
2020        let kwargs = HashMap::new();
2021
2022        let result = file_resolver(&args, &kwargs, &ctx);
2023        assert!(result.is_err());
2024        let err = result.unwrap_err();
2025        assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
2026
2027        // Cleanup
2028        std::fs::remove_file(test_file).ok();
2029    }
2030
2031    #[test]
2032    fn test_file_resolver_invalid_json() {
2033        use std::io::Write;
2034
2035        // Create a temporary file with invalid JSON
2036        let temp_dir = std::env::temp_dir();
2037        let test_file = temp_dir.join("holoconf_invalid.json");
2038        {
2039            let mut file = std::fs::File::create(&test_file).unwrap();
2040            writeln!(file, "{{invalid json}}").unwrap();
2041        }
2042
2043        let mut ctx = ResolverContext::new("test.path");
2044        ctx.base_path = Some(temp_dir.clone());
2045        ctx.file_roots.insert(temp_dir.clone());
2046
2047        let args = vec!["holoconf_invalid.json".to_string()];
2048        let kwargs = HashMap::new();
2049
2050        let result = file_resolver(&args, &kwargs, &ctx);
2051        assert!(result.is_err());
2052        let err = result.unwrap_err();
2053        assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
2054
2055        // Cleanup
2056        std::fs::remove_file(test_file).ok();
2057    }
2058
2059    #[test]
2060    fn test_file_resolver_unknown_extension() {
2061        use std::io::Write;
2062
2063        // Create a temporary file with unknown extension (treated as text)
2064        let temp_dir = std::env::temp_dir();
2065        let test_file = temp_dir.join("holoconf_test.xyz");
2066        {
2067            let mut file = std::fs::File::create(&test_file).unwrap();
2068            writeln!(file, "plain text content").unwrap();
2069        }
2070
2071        let mut ctx = ResolverContext::new("test.path");
2072        ctx.base_path = Some(temp_dir.clone());
2073        ctx.file_roots.insert(temp_dir.clone());
2074
2075        let args = vec!["holoconf_test.xyz".to_string()];
2076        let kwargs = HashMap::new();
2077
2078        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2079        // Unknown extension defaults to text mode
2080        assert!(result
2081            .value
2082            .as_str()
2083            .unwrap()
2084            .contains("plain text content"));
2085
2086        // Cleanup
2087        std::fs::remove_file(test_file).ok();
2088    }
2089
2090    #[test]
2091    fn test_file_resolver_encoding_utf8() {
2092        use std::io::Write;
2093
2094        // Create a temporary file with UTF-8 content including non-ASCII
2095        let temp_dir = std::env::temp_dir();
2096        let test_file = temp_dir.join("holoconf_utf8.txt");
2097        {
2098            let mut file = std::fs::File::create(&test_file).unwrap();
2099            writeln!(file, "Hello, 世界! 🌍").unwrap();
2100        }
2101
2102        let mut ctx = ResolverContext::new("test.path");
2103        ctx.base_path = Some(temp_dir.clone());
2104        ctx.file_roots.insert(temp_dir.clone());
2105
2106        let args = vec!["holoconf_utf8.txt".to_string()];
2107        let mut kwargs = HashMap::new();
2108        kwargs.insert("encoding".to_string(), "utf-8".to_string());
2109
2110        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2111        let content = result.value.as_str().unwrap();
2112        assert!(content.contains("世界"));
2113        assert!(content.contains("🌍"));
2114
2115        // Cleanup
2116        std::fs::remove_file(test_file).ok();
2117    }
2118
2119    #[test]
2120    fn test_file_resolver_encoding_ascii() {
2121        use std::io::Write;
2122
2123        // Create a temporary file with mixed ASCII and non-ASCII content
2124        let temp_dir = std::env::temp_dir();
2125        let test_file = temp_dir.join("holoconf_ascii.txt");
2126        {
2127            let mut file = std::fs::File::create(&test_file).unwrap();
2128            writeln!(file, "Hello, 世界! Welcome").unwrap();
2129        }
2130
2131        let mut ctx = ResolverContext::new("test.path");
2132        ctx.base_path = Some(temp_dir.clone());
2133        ctx.file_roots.insert(temp_dir.clone());
2134
2135        let args = vec!["holoconf_ascii.txt".to_string()];
2136        let mut kwargs = HashMap::new();
2137        kwargs.insert("encoding".to_string(), "ascii".to_string());
2138
2139        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2140        let content = result.value.as_str().unwrap();
2141        // ASCII mode should strip non-ASCII characters
2142        assert!(content.contains("Hello"));
2143        assert!(content.contains("Welcome"));
2144        assert!(!content.contains("世界"));
2145
2146        // Cleanup
2147        std::fs::remove_file(test_file).ok();
2148    }
2149
2150    #[test]
2151    fn test_file_resolver_encoding_base64() {
2152        use std::io::Write;
2153
2154        // Create a temporary file with binary content
2155        let temp_dir = std::env::temp_dir();
2156        let test_file = temp_dir.join("holoconf_binary.bin");
2157        {
2158            let mut file = std::fs::File::create(&test_file).unwrap();
2159            // Write some bytes that include non-UTF8 sequences
2160            file.write_all(b"Hello\x00\x01\x02World").unwrap();
2161        }
2162
2163        let mut ctx = ResolverContext::new("test.path");
2164        ctx.base_path = Some(temp_dir.clone());
2165        ctx.file_roots.insert(temp_dir.clone());
2166
2167        let args = vec!["holoconf_binary.bin".to_string()];
2168        let mut kwargs = HashMap::new();
2169        kwargs.insert("encoding".to_string(), "base64".to_string());
2170
2171        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2172        let content = result.value.as_str().unwrap();
2173
2174        // Verify the base64 encoding is correct
2175        use base64::{engine::general_purpose::STANDARD, Engine as _};
2176        let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
2177        assert_eq!(content, expected);
2178
2179        // Cleanup
2180        std::fs::remove_file(test_file).ok();
2181    }
2182
2183    #[test]
2184    fn test_file_resolver_encoding_default_is_utf8() {
2185        use std::io::Write;
2186
2187        // Create a temporary file with UTF-8 content
2188        let temp_dir = std::env::temp_dir();
2189        let test_file = temp_dir.join("holoconf_default_enc.txt");
2190        {
2191            let mut file = std::fs::File::create(&test_file).unwrap();
2192            writeln!(file, "café résumé").unwrap();
2193        }
2194
2195        let mut ctx = ResolverContext::new("test.path");
2196        ctx.base_path = Some(temp_dir.clone());
2197        ctx.file_roots.insert(temp_dir.clone());
2198
2199        let args = vec!["holoconf_default_enc.txt".to_string()];
2200        let kwargs = HashMap::new(); // No encoding specified
2201
2202        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2203        let content = result.value.as_str().unwrap();
2204        // Default encoding should be UTF-8, preserving accents
2205        assert!(content.contains("café"));
2206        assert!(content.contains("résumé"));
2207
2208        // Cleanup
2209        std::fs::remove_file(test_file).ok();
2210    }
2211
2212    #[test]
2213    fn test_file_resolver_encoding_binary() {
2214        use std::io::Write;
2215
2216        // Create a temporary file with binary content
2217        let temp_dir = std::env::temp_dir();
2218        let test_file = temp_dir.join("holoconf_binary_bytes.bin");
2219        let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
2220        {
2221            let mut file = std::fs::File::create(&test_file).unwrap();
2222            file.write_all(&binary_data).unwrap();
2223        }
2224
2225        let mut ctx = ResolverContext::new("test.path");
2226        ctx.base_path = Some(temp_dir.clone());
2227        ctx.file_roots.insert(temp_dir.clone());
2228
2229        let args = vec!["holoconf_binary_bytes.bin".to_string()];
2230        let mut kwargs = HashMap::new();
2231        kwargs.insert("encoding".to_string(), "binary".to_string());
2232
2233        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2234
2235        // Verify we get Value::Bytes back
2236        assert!(result.value.is_bytes());
2237        assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2238
2239        // Cleanup
2240        std::fs::remove_file(test_file).ok();
2241    }
2242
2243    #[test]
2244    fn test_file_resolver_encoding_binary_empty() {
2245        // Create an empty file
2246        let temp_dir = std::env::temp_dir();
2247        let test_file = temp_dir.join("holoconf_binary_empty.bin");
2248        {
2249            std::fs::File::create(&test_file).unwrap();
2250        }
2251
2252        let mut ctx = ResolverContext::new("test.path");
2253        ctx.base_path = Some(temp_dir.clone());
2254        ctx.file_roots.insert(temp_dir.clone());
2255
2256        let args = vec!["holoconf_binary_empty.bin".to_string()];
2257        let mut kwargs = HashMap::new();
2258        kwargs.insert("encoding".to_string(), "binary".to_string());
2259
2260        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2261
2262        // Verify we get empty Value::Bytes
2263        assert!(result.value.is_bytes());
2264        let empty: &[u8] = &[];
2265        assert_eq!(result.value.as_bytes().unwrap(), empty);
2266
2267        // Cleanup
2268        std::fs::remove_file(test_file).ok();
2269    }
2270
2271    // Framework-level sensitive test (default handling moved to config tests)
2272
2273    #[test]
2274    fn test_file_resolver_with_sensitive() {
2275        use std::io::Write;
2276
2277        // Create a temporary file
2278        let temp_dir = std::env::temp_dir();
2279        let test_file = temp_dir.join("holoconf_sensitive_test.txt");
2280        {
2281            let mut file = std::fs::File::create(&test_file).unwrap();
2282            writeln!(file, "secret content").unwrap();
2283        }
2284
2285        let registry = ResolverRegistry::with_builtins();
2286        let mut ctx = ResolverContext::new("test.path");
2287        ctx.base_path = Some(temp_dir.clone());
2288        ctx.file_roots.insert(temp_dir.clone());
2289
2290        let args = vec!["holoconf_sensitive_test.txt".to_string()];
2291        let mut kwargs = HashMap::new();
2292        kwargs.insert("sensitive".to_string(), "true".to_string());
2293
2294        // Framework-level sensitive handling via registry
2295        let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
2296        assert!(result.value.as_str().unwrap().contains("secret content"));
2297        assert!(result.sensitive);
2298
2299        // Cleanup
2300        std::fs::remove_file(test_file).ok();
2301    }
2302
2303    #[test]
2304    fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
2305        // Ensure that 'sensitive' kwarg is NOT passed to the resolver
2306        // (Note: 'default' is handled at Config level, not registry level)
2307        let mut registry = ResolverRegistry::new();
2308
2309        // Register a test resolver that checks it doesn't receive sensitive kwarg
2310        registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
2311            // Sensitive kwarg should be filtered out
2312            assert!(
2313                !kwargs.contains_key("sensitive"),
2314                "sensitive kwarg should not be passed to resolver"
2315            );
2316            // But custom kwargs should be passed through
2317            if let Some(custom) = kwargs.get("custom") {
2318                Ok(ResolvedValue::new(Value::String(format!(
2319                    "custom={}",
2320                    custom
2321                ))))
2322            } else {
2323                Ok(ResolvedValue::new(Value::String("no custom".to_string())))
2324            }
2325        });
2326
2327        let ctx = ResolverContext::new("test.path");
2328        let args = vec![];
2329        let mut kwargs = HashMap::new();
2330        kwargs.insert("sensitive".to_string(), "true".to_string());
2331        kwargs.insert("custom".to_string(), "myvalue".to_string());
2332
2333        let result = registry
2334            .resolve("test_kwargs", &args, &kwargs, &ctx)
2335            .unwrap();
2336        assert_eq!(result.value.as_str(), Some("custom=myvalue"));
2337        // Sensitive override should still be applied by framework
2338        assert!(result.sensitive);
2339    }
2340
2341    // Tests for normalize_http_url function
2342    #[test]
2343    #[cfg(feature = "http")]
2344    fn test_normalize_http_url_clean_syntax() {
2345        assert_eq!(
2346            normalize_http_url("https", "example.com/path").unwrap(),
2347            "https://example.com/path"
2348        );
2349        assert_eq!(
2350            normalize_http_url("http", "example.com").unwrap(),
2351            "http://example.com"
2352        );
2353    }
2354
2355    #[test]
2356    #[cfg(feature = "http")]
2357    fn test_normalize_http_url_double_slash() {
2358        assert_eq!(
2359            normalize_http_url("https", "//example.com/path").unwrap(),
2360            "https://example.com/path"
2361        );
2362    }
2363
2364    #[test]
2365    #[cfg(feature = "http")]
2366    fn test_normalize_http_url_existing_https() {
2367        assert_eq!(
2368            normalize_http_url("https", "https://example.com/path").unwrap(),
2369            "https://example.com/path"
2370        );
2371    }
2372
2373    #[test]
2374    #[cfg(feature = "http")]
2375    fn test_normalize_http_url_wrong_scheme() {
2376        // Should strip http:// and add https://
2377        assert_eq!(
2378            normalize_http_url("https", "http://example.com").unwrap(),
2379            "https://example.com"
2380        );
2381    }
2382
2383    #[test]
2384    #[cfg(feature = "http")]
2385    fn test_normalize_http_url_with_query() {
2386        assert_eq!(
2387            normalize_http_url("https", "example.com/path?query=val&other=val2").unwrap(),
2388            "https://example.com/path?query=val&other=val2"
2389        );
2390    }
2391
2392    #[test]
2393    #[cfg(feature = "http")]
2394    fn test_normalize_http_url_empty() {
2395        let result = normalize_http_url("https", "");
2396        assert!(result.is_err());
2397        assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2398    }
2399
2400    #[test]
2401    #[cfg(feature = "http")]
2402    fn test_normalize_http_url_triple_slash() {
2403        // ${https:///example.com} should error (invalid syntax)
2404        let result = normalize_http_url("https", "///example.com");
2405        assert!(result.is_err());
2406        assert!(result
2407            .unwrap_err()
2408            .to_string()
2409            .contains("Invalid URL syntax"));
2410    }
2411
2412    #[test]
2413    #[cfg(feature = "http")]
2414    fn test_normalize_http_url_whitespace_only() {
2415        let result = normalize_http_url("https", "   ");
2416        assert!(result.is_err());
2417        assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2418    }
2419
2420    // Tests for is_localhost function
2421    #[test]
2422    fn test_is_localhost_ascii() {
2423        assert!(is_localhost("localhost"));
2424        assert!(is_localhost("LOCALHOST"));
2425        assert!(is_localhost("LocalHost"));
2426    }
2427
2428    #[test]
2429    fn test_is_localhost_ipv4() {
2430        assert!(is_localhost("127.0.0.1"));
2431        assert!(is_localhost("127.0.0.100"));
2432        assert!(is_localhost("127.1.2.3"));
2433        assert!(!is_localhost("128.0.0.1"));
2434    }
2435
2436    #[test]
2437    fn test_is_localhost_ipv6() {
2438        assert!(is_localhost("::1"));
2439        assert!(is_localhost("[::1]"));
2440        assert!(!is_localhost("::2"));
2441    }
2442
2443    #[test]
2444    fn test_is_localhost_not() {
2445        assert!(!is_localhost("example.com"));
2446        assert!(!is_localhost("remote.host"));
2447        assert!(!is_localhost("192.168.1.1"));
2448    }
2449
2450    // Tests for normalize_file_path function
2451    #[test]
2452    fn test_normalize_file_path_relative() {
2453        let (path, is_rel) = normalize_file_path("data.txt").unwrap();
2454        assert_eq!(path, "data.txt");
2455        assert!(is_rel);
2456
2457        let (path, is_rel) = normalize_file_path("./data.txt").unwrap();
2458        assert_eq!(path, "./data.txt");
2459        assert!(is_rel);
2460    }
2461
2462    #[test]
2463    fn test_normalize_file_path_absolute() {
2464        let (path, is_rel) = normalize_file_path("/etc/config.yaml").unwrap();
2465        assert_eq!(path, "/etc/config.yaml");
2466        assert!(!is_rel);
2467    }
2468
2469    #[test]
2470    fn test_normalize_file_path_rfc8089_empty_authority() {
2471        // file:/// (empty authority = localhost)
2472        let (path, is_rel) = normalize_file_path("///etc/config.yaml").unwrap();
2473        assert_eq!(path, "/etc/config.yaml");
2474        assert!(!is_rel);
2475    }
2476
2477    #[test]
2478    fn test_normalize_file_path_rfc8089_localhost() {
2479        // file://localhost/
2480        let (path, is_rel) = normalize_file_path("//localhost/var/data").unwrap();
2481        assert_eq!(path, "/var/data");
2482        assert!(!is_rel);
2483
2484        // file://localhost (no path)
2485        let (path, is_rel) = normalize_file_path("//localhost").unwrap();
2486        assert_eq!(path, "/");
2487        assert!(!is_rel);
2488    }
2489
2490    #[test]
2491    fn test_normalize_file_path_rfc8089_localhost_ipv4() {
2492        // file://127.0.0.1/
2493        let (path, is_rel) = normalize_file_path("//127.0.0.1/tmp/file.txt").unwrap();
2494        assert_eq!(path, "/tmp/file.txt");
2495        assert!(!is_rel);
2496    }
2497
2498    #[test]
2499    fn test_normalize_file_path_rfc8089_localhost_ipv6() {
2500        // file://::1/
2501        let (path, is_rel) = normalize_file_path("//::1/tmp/file.txt").unwrap();
2502        assert_eq!(path, "/tmp/file.txt");
2503        assert!(!is_rel);
2504    }
2505
2506    #[test]
2507    fn test_normalize_file_path_rfc8089_remote_rejected() {
2508        let result = normalize_file_path("//remote.host/path");
2509        assert!(result.is_err());
2510        let err_msg = result.unwrap_err().to_string();
2511        assert!(err_msg.contains("Remote file URIs not supported"));
2512        assert!(err_msg.contains("remote.host"));
2513
2514        let result = normalize_file_path("//server.example.com/share");
2515        assert!(result.is_err());
2516    }
2517
2518    #[test]
2519    fn test_normalize_file_path_rfc8089_empty_hostname() {
2520        // file:// with no authority
2521        let (path, is_rel) = normalize_file_path("//").unwrap();
2522        assert_eq!(path, "/");
2523        assert!(!is_rel);
2524    }
2525
2526    #[test]
2527    fn test_normalize_file_path_null_byte() {
2528        let result = normalize_file_path("/etc/passwd\0.txt");
2529        assert!(result.is_err());
2530        assert!(result.unwrap_err().to_string().contains("null byte"));
2531    }
2532
2533    #[test]
2534    fn test_normalize_file_path_null_byte_relative() {
2535        let result = normalize_file_path("data\0.txt");
2536        assert!(result.is_err());
2537        assert!(result.unwrap_err().to_string().contains("null byte"));
2538    }
2539
2540    // Tests for CertInput type
2541    #[test]
2542    fn test_cert_input_is_pem_content() {
2543        let pem_content = CertInput::Text("-----BEGIN CERTIFICATE-----\nMIIC...".to_string());
2544        assert!(pem_content.is_pem_content());
2545
2546        let file_path = CertInput::Text("/path/to/cert.pem".to_string());
2547        assert!(!file_path.is_pem_content());
2548
2549        let binary = CertInput::Binary(vec![0, 1, 2, 3]);
2550        assert!(!binary.is_pem_content());
2551    }
2552
2553    #[test]
2554    fn test_cert_input_is_p12_path() {
2555        assert!(CertInput::Text("/path/to/identity.p12".to_string()).is_p12_path());
2556        assert!(CertInput::Text("/path/to/identity.pfx".to_string()).is_p12_path());
2557        assert!(CertInput::Text("/path/to/identity.P12".to_string()).is_p12_path());
2558        assert!(CertInput::Text("/path/to/identity.PFX".to_string()).is_p12_path());
2559
2560        assert!(!CertInput::Text("/path/to/cert.pem".to_string()).is_p12_path());
2561        assert!(!CertInput::Text("-----BEGIN CERTIFICATE-----".to_string()).is_p12_path());
2562        assert!(!CertInput::Binary(vec![0, 1, 2, 3]).is_p12_path());
2563    }
2564
2565    #[test]
2566    fn test_cert_input_as_text() {
2567        let text_input = CertInput::Text("some text".to_string());
2568        assert_eq!(text_input.as_text(), Some("some text"));
2569
2570        let binary_input = CertInput::Binary(vec![0, 1, 2]);
2571        assert_eq!(binary_input.as_text(), None);
2572    }
2573
2574    #[test]
2575    fn test_cert_input_as_bytes() {
2576        let binary_input = CertInput::Binary(vec![0, 1, 2]);
2577        assert_eq!(binary_input.as_bytes(), Some(&[0, 1, 2][..]));
2578
2579        let text_input = CertInput::Text("some text".to_string());
2580        assert_eq!(text_input.as_bytes(), None);
2581    }
2582
2583    #[test]
2584    fn test_cert_input_from_string() {
2585        let input1 = CertInput::from("test".to_string());
2586        assert!(matches!(input1, CertInput::Text(_)));
2587        assert_eq!(input1.as_text(), Some("test"));
2588
2589        let input2 = CertInput::from("test");
2590        assert!(matches!(input2, CertInput::Text(_)));
2591        assert_eq!(input2.as_text(), Some("test"));
2592    }
2593
2594    #[test]
2595    fn test_cert_input_from_vec_u8() {
2596        let input = CertInput::from(vec![1, 2, 3]);
2597        assert!(matches!(input, CertInput::Binary(_)));
2598        assert_eq!(input.as_bytes(), Some(&[1, 2, 3][..]));
2599    }
2600}
2601
2602// Tests for global registry (TDD - written before implementation)
2603#[cfg(test)]
2604mod global_registry_tests {
2605    use super::*;
2606
2607    /// Test helper: create a mock resolver with a given name
2608    fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
2609        Arc::new(FnResolver::new(name, |_, _, _| {
2610            Ok(ResolvedValue::new("mock"))
2611        }))
2612    }
2613
2614    #[test]
2615    fn test_register_new_resolver_succeeds() {
2616        let mut registry = ResolverRegistry::new();
2617        let resolver = mock_resolver("test_new");
2618
2619        // Registering a new resolver should succeed with force=false
2620        let result = registry.register_with_force(resolver, false);
2621        assert!(result.is_ok());
2622        assert!(registry.contains("test_new"));
2623    }
2624
2625    #[test]
2626    fn test_register_duplicate_errors_without_force() {
2627        let mut registry = ResolverRegistry::new();
2628        let resolver1 = mock_resolver("test_dup");
2629        let resolver2 = mock_resolver("test_dup");
2630
2631        // First registration succeeds
2632        registry.register_with_force(resolver1, false).unwrap();
2633
2634        // Second registration with same name should fail without force
2635        let result = registry.register_with_force(resolver2, false);
2636        assert!(result.is_err());
2637        let err = result.unwrap_err();
2638        assert!(err.to_string().contains("already registered"));
2639    }
2640
2641    #[test]
2642    fn test_register_duplicate_succeeds_with_force() {
2643        let mut registry = ResolverRegistry::new();
2644        let resolver1 = mock_resolver("test_force");
2645        let resolver2 = mock_resolver("test_force");
2646
2647        // First registration succeeds
2648        registry.register_with_force(resolver1, false).unwrap();
2649
2650        // Second registration with force=true should succeed
2651        let result = registry.register_with_force(resolver2, true);
2652        assert!(result.is_ok());
2653    }
2654
2655    #[test]
2656    fn test_global_registry_is_singleton() {
2657        // The global registry should return the same instance
2658        let registry1 = global_registry();
2659        let registry2 = global_registry();
2660
2661        // They should point to the same instance (same address)
2662        assert!(std::ptr::eq(registry1, registry2));
2663    }
2664
2665    #[test]
2666    fn test_register_global_new_resolver() {
2667        // Clean slate - register a unique resolver name
2668        let resolver = mock_resolver("global_test_unique_42");
2669        let result = register_global(resolver, false);
2670        // May fail if already registered from previous test runs
2671        // That's expected behavior - the test verifies the API works
2672        assert!(result.is_ok() || result.is_err());
2673    }
2674}
2675
2676// Integration tests for lazy default resolution (requires Config)
2677#[cfg(test)]
2678mod lazy_resolution_tests {
2679    use super::*;
2680    use crate::Config;
2681    use std::sync::atomic::{AtomicBool, Ordering};
2682    use std::sync::Arc;
2683
2684    #[test]
2685    fn test_default_not_resolved_when_main_value_exists() {
2686        // Track whether the "fail" resolver was called
2687        let fail_called = Arc::new(AtomicBool::new(false));
2688        let fail_called_clone = fail_called.clone();
2689
2690        // Create a config with a custom resolver that would fail if called
2691        let yaml = r#"
2692value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
2693"#;
2694        // Set the env var so the default should NOT be needed
2695        std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
2696
2697        let mut config = Config::from_yaml(yaml).unwrap();
2698
2699        // Register a "fail" resolver that sets a flag and panics
2700        config.register_resolver(Arc::new(FnResolver::new(
2701            "fail",
2702            move |_args, _kwargs, _ctx| {
2703                fail_called_clone.store(true, Ordering::SeqCst);
2704                panic!("fail resolver should not have been called - lazy resolution failed!");
2705            },
2706        )));
2707
2708        // Access the value - should get main value, not call fail resolver
2709        let result = config.get("value").unwrap();
2710        assert_eq!(result.as_str(), Some("main_value"));
2711
2712        // Verify the fail resolver was never called
2713        assert!(
2714            !fail_called.load(Ordering::SeqCst),
2715            "The default resolver should not have been called when main value exists"
2716        );
2717
2718        std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
2719    }
2720
2721    #[test]
2722    fn test_default_is_resolved_when_main_value_missing() {
2723        // Track whether the default resolver was called
2724        let default_called = Arc::new(AtomicBool::new(false));
2725        let default_called_clone = default_called.clone();
2726
2727        // Create a config where env var doesn't exist
2728        let yaml = r#"
2729value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
2730"#;
2731        std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
2732
2733        let mut config = Config::from_yaml(yaml).unwrap();
2734
2735        // Register a custom default resolver
2736        config.register_resolver(Arc::new(FnResolver::new(
2737            "custom_default",
2738            move |args: &[String], _kwargs, _ctx| {
2739                default_called_clone.store(true, Ordering::SeqCst);
2740                let arg = args.first().cloned().unwrap_or_default();
2741                Ok(ResolvedValue::new(Value::String(format!(
2742                    "default_was_{}",
2743                    arg
2744                ))))
2745            },
2746        )));
2747
2748        // Access the value - should call default resolver since main value missing
2749        let result = config.get("value").unwrap();
2750        assert_eq!(result.as_str(), Some("default_was_fallback"));
2751
2752        // Verify the default resolver WAS called
2753        assert!(
2754            default_called.load(Ordering::SeqCst),
2755            "The default resolver should have been called when main value is missing"
2756        );
2757    }
2758}
2759
2760// HTTP resolver tests (require http feature and mockito)
2761#[cfg(all(test, feature = "http"))]
2762mod http_resolver_tests {
2763    use super::*;
2764    use mockito::Server;
2765
2766    #[test]
2767    fn test_http_fetch_json() {
2768        let mut server = Server::new();
2769        let mock = server
2770            .mock("GET", "/config.json")
2771            .with_status(200)
2772            .with_header("content-type", "application/json")
2773            .with_body(r#"{"key": "value", "number": 42}"#)
2774            .create();
2775
2776        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2777        let args = vec![format!("{}/config.json", server.url())];
2778        let kwargs = HashMap::new();
2779
2780        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2781        assert!(result.value.is_mapping());
2782
2783        mock.assert();
2784    }
2785
2786    #[test]
2787    fn test_http_fetch_yaml() {
2788        let mut server = Server::new();
2789        let mock = server
2790            .mock("GET", "/config.yaml")
2791            .with_status(200)
2792            .with_header("content-type", "application/yaml")
2793            .with_body("key: value\nnumber: 42")
2794            .create();
2795
2796        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2797        let args = vec![format!("{}/config.yaml", server.url())];
2798        let kwargs = HashMap::new();
2799
2800        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2801        assert!(result.value.is_mapping());
2802
2803        mock.assert();
2804    }
2805
2806    #[test]
2807    fn test_http_fetch_text() {
2808        let mut server = Server::new();
2809        let mock = server
2810            .mock("GET", "/data.txt")
2811            .with_status(200)
2812            .with_header("content-type", "text/plain")
2813            .with_body("Hello, World!")
2814            .create();
2815
2816        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2817        let args = vec![format!("{}/data.txt", server.url())];
2818        let kwargs = HashMap::new();
2819
2820        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2821        assert_eq!(result.value.as_str(), Some("Hello, World!"));
2822
2823        mock.assert();
2824    }
2825
2826    #[test]
2827    fn test_http_fetch_binary() {
2828        let mut server = Server::new();
2829        let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
2830        let mock = server
2831            .mock("GET", "/data.bin")
2832            .with_status(200)
2833            .with_header("content-type", "application/octet-stream")
2834            .with_body(binary_data.clone())
2835            .create();
2836
2837        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2838        let args = vec![format!("{}/data.bin", server.url())];
2839        let mut kwargs = HashMap::new();
2840        kwargs.insert("parse".to_string(), "binary".to_string());
2841
2842        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2843        assert!(result.value.is_bytes());
2844        assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2845
2846        mock.assert();
2847    }
2848
2849    #[test]
2850    fn test_http_fetch_explicit_parse_mode() {
2851        let mut server = Server::new();
2852        // Return JSON but with text/plain content-type
2853        let mock = server
2854            .mock("GET", "/data")
2855            .with_status(200)
2856            .with_header("content-type", "text/plain")
2857            .with_body(r#"{"key": "value"}"#)
2858            .create();
2859
2860        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2861        let args = vec![format!("{}/data", server.url())];
2862        let mut kwargs = HashMap::new();
2863        kwargs.insert("parse".to_string(), "json".to_string());
2864
2865        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2866        assert!(result.value.is_mapping());
2867
2868        mock.assert();
2869    }
2870
2871    #[test]
2872    fn test_http_fetch_with_custom_header() {
2873        let mut server = Server::new();
2874        let mock = server
2875            .mock("GET", "/protected")
2876            .match_header("Authorization", "Bearer my-token")
2877            .with_status(200)
2878            .with_body("authorized content")
2879            .create();
2880
2881        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2882        let args = vec![format!("{}/protected", server.url())];
2883        let mut kwargs = HashMap::new();
2884        kwargs.insert(
2885            "header".to_string(),
2886            "Authorization:Bearer my-token".to_string(),
2887        );
2888
2889        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2890        assert_eq!(result.value.as_str(), Some("authorized content"));
2891
2892        mock.assert();
2893    }
2894
2895    #[test]
2896    fn test_http_fetch_404_error() {
2897        let mut server = Server::new();
2898        let mock = server.mock("GET", "/notfound").with_status(404).create();
2899
2900        let ctx = ResolverContext::new("test.path").with_allow_http(true);
2901        let args = vec![format!("{}/notfound", server.url())];
2902        let kwargs = HashMap::new();
2903
2904        let result = http_resolver(&args, &kwargs, &ctx);
2905        assert!(result.is_err());
2906        let err = result.unwrap_err();
2907        assert!(err.to_string().contains("HTTP"));
2908
2909        mock.assert();
2910    }
2911
2912    #[test]
2913    fn test_http_disabled_by_default() {
2914        let ctx = ResolverContext::new("test.path");
2915        // allow_http defaults to false
2916        let args = vec!["https://example.com/config.yaml".to_string()];
2917        let kwargs = HashMap::new();
2918
2919        let result = http_resolver(&args, &kwargs, &ctx);
2920        assert!(result.is_err());
2921        let err = result.unwrap_err();
2922        assert!(err.to_string().contains("disabled"));
2923    }
2924
2925    #[test]
2926    fn test_http_allowlist_blocks_url() {
2927        let ctx = ResolverContext::new("test.path")
2928            .with_allow_http(true)
2929            .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
2930
2931        let args = vec!["https://blocked.example.com/config.yaml".to_string()];
2932        let kwargs = HashMap::new();
2933
2934        let result = http_resolver(&args, &kwargs, &ctx);
2935        assert!(result.is_err());
2936        let err = result.unwrap_err();
2937        assert!(
2938            err.to_string().contains("not in allowlist")
2939                || err.to_string().contains("HttpNotAllowed")
2940        );
2941    }
2942
2943    #[test]
2944    fn test_http_allowlist_allows_matching_url() {
2945        let mut server = Server::new();
2946        let mock = server
2947            .mock("GET", "/config.yaml")
2948            .with_status(200)
2949            .with_body("key: value")
2950            .create();
2951
2952        // The allowlist pattern needs to match the server URL
2953        let server_url = server.url();
2954        let ctx = ResolverContext::new("test.path")
2955            .with_allow_http(true)
2956            .with_http_allowlist(vec![format!("{}/*", server_url)]);
2957
2958        let args = vec![format!("{}/config.yaml", server_url)];
2959        let kwargs = HashMap::new();
2960
2961        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2962        assert!(result.value.is_mapping());
2963
2964        mock.assert();
2965    }
2966
2967    #[test]
2968    fn test_url_matches_pattern_exact() {
2969        assert!(url_matches_pattern(
2970            "https://example.com/config.yaml",
2971            "https://example.com/config.yaml"
2972        ));
2973        assert!(!url_matches_pattern(
2974            "https://example.com/other.yaml",
2975            "https://example.com/config.yaml"
2976        ));
2977    }
2978
2979    #[test]
2980    fn test_url_matches_pattern_wildcard() {
2981        assert!(url_matches_pattern(
2982            "https://example.com/config.yaml",
2983            "https://example.com/*"
2984        ));
2985        assert!(url_matches_pattern(
2986            "https://example.com/path/to/config.yaml",
2987            "https://example.com/*"
2988        ));
2989        assert!(!url_matches_pattern(
2990            "https://other.com/config.yaml",
2991            "https://example.com/*"
2992        ));
2993    }
2994
2995    #[test]
2996    fn test_url_matches_pattern_subdomain() {
2997        assert!(url_matches_pattern(
2998            "https://api.example.com/config",
2999            "https://*.example.com/*"
3000        ));
3001        assert!(url_matches_pattern(
3002            "https://staging.example.com/config",
3003            "https://*.example.com/*"
3004        ));
3005        assert!(!url_matches_pattern(
3006            "https://example.com/config",
3007            "https://*.example.com/*"
3008        ));
3009    }
3010
3011    #[test]
3012    fn test_detect_parse_mode_from_content_type() {
3013        assert_eq!(
3014            detect_parse_mode("http://example.com/data", "application/json"),
3015            "json"
3016        );
3017        assert_eq!(
3018            detect_parse_mode("http://example.com/data", "text/json"),
3019            "json"
3020        );
3021        assert_eq!(
3022            detect_parse_mode("http://example.com/data", "application/yaml"),
3023            "yaml"
3024        );
3025        assert_eq!(
3026            detect_parse_mode("http://example.com/data", "application/x-yaml"),
3027            "yaml"
3028        );
3029        assert_eq!(
3030            detect_parse_mode("http://example.com/data", "text/yaml"),
3031            "yaml"
3032        );
3033        assert_eq!(
3034            detect_parse_mode("http://example.com/data", "text/plain"),
3035            "text"
3036        );
3037    }
3038
3039    #[test]
3040    fn test_detect_parse_mode_from_url_extension() {
3041        assert_eq!(
3042            detect_parse_mode("http://example.com/config.json", ""),
3043            "json"
3044        );
3045        assert_eq!(
3046            detect_parse_mode("http://example.com/config.yaml", ""),
3047            "yaml"
3048        );
3049        assert_eq!(
3050            detect_parse_mode("http://example.com/config.yml", ""),
3051            "yaml"
3052        );
3053        assert_eq!(
3054            detect_parse_mode("http://example.com/config.txt", ""),
3055            "text"
3056        );
3057        assert_eq!(detect_parse_mode("http://example.com/config", ""), "text");
3058    }
3059
3060    #[test]
3061    fn test_detect_parse_mode_content_type_takes_precedence() {
3062        // Content-Type should take precedence over URL extension
3063        assert_eq!(
3064            detect_parse_mode("http://example.com/config.yaml", "application/json"),
3065            "json"
3066        );
3067    }
3068}