Skip to main content

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, json, yaml, split, csv, base64)
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        // Transformation resolvers
416        self.register(Arc::new(FnResolver::new("json", json_resolver)));
417        self.register(Arc::new(FnResolver::new("yaml", yaml_resolver)));
418        self.register(Arc::new(FnResolver::new("split", split_resolver)));
419        self.register(Arc::new(FnResolver::new("csv", csv_resolver)));
420        self.register(Arc::new(FnResolver::new("base64", base64_resolver)));
421
422        // Archive extraction resolver (only available with archive feature)
423        #[cfg(feature = "archive")]
424        {
425            self.register(Arc::new(FnResolver::new("extract", extract_resolver)));
426        }
427    }
428
429    /// Register a resolver
430    pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
431        self.resolvers.insert(resolver.name().to_string(), resolver);
432    }
433
434    /// Register a resolver with optional force overwrite.
435    ///
436    /// # Arguments
437    /// * `resolver` - The resolver to register
438    /// * `force` - If true, overwrite any existing resolver with the same name.
439    ///   If false, return an error if the name is already registered.
440    ///
441    /// # Returns
442    /// * `Ok(())` on success
443    /// * `Err(Error)` if force=false and a resolver with the same name exists
444    pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
445        let name = resolver.name().to_string();
446        if !force && self.resolvers.contains_key(&name) {
447            return Err(Error::resolver_already_registered(&name));
448        }
449        self.resolvers.insert(name, resolver);
450        Ok(())
451    }
452
453    /// Register a function as a resolver
454    pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
455    where
456        F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
457            + Send
458            + Sync
459            + 'static,
460    {
461        let name = name.into();
462        self.register(Arc::new(FnResolver::new(name, func)));
463    }
464
465    /// Get a resolver by name
466    pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
467        self.resolvers.get(name)
468    }
469
470    /// Check if a resolver is registered
471    pub fn contains(&self, name: &str) -> bool {
472        self.resolvers.contains_key(name)
473    }
474
475    /// Resolve an interpolation using the appropriate resolver
476    ///
477    /// This method implements framework-level handling of the `sensitive` kwarg per ADR-011.
478    /// The `sensitive` kwarg overrides the resolver's sensitivity hint.
479    ///
480    /// Note: `default` handling with lazy resolution is done at the Config level,
481    /// not here, to support nested interpolations in default values.
482    pub fn resolve(
483        &self,
484        resolver_name: &str,
485        args: &[String],
486        kwargs: &HashMap<String, String>,
487        ctx: &ResolverContext,
488    ) -> Result<ResolvedValue> {
489        let resolver = self
490            .resolvers
491            .get(resolver_name)
492            .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
493
494        // Extract framework-level `sensitive` kwarg per ADR-011
495        let sensitive_override = kwargs
496            .get("sensitive")
497            .map(|v| v.eq_ignore_ascii_case("true"));
498
499        // Pass remaining kwargs to the resolver (filter out framework keyword)
500        let resolver_kwargs: HashMap<String, String> = kwargs
501            .iter()
502            .filter(|(k, _)| *k != "sensitive")
503            .map(|(k, v)| (k.clone(), v.clone()))
504            .collect();
505
506        // Call the resolver
507        let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
508
509        // Apply sensitivity override if specified
510        if let Some(is_sensitive) = sensitive_override {
511            resolved.sensitive = is_sensitive;
512        }
513
514        Ok(resolved)
515    }
516}
517
518/// Built-in environment variable resolver
519///
520/// Usage:
521///   ${env:VAR_NAME}                      - Get env var (error if not set)
522///   ${env:VAR_NAME,default=value}        - Get env var with default (framework-handled)
523///   ${env:VAR_NAME,sensitive=true}       - Mark as sensitive for redaction (framework-handled)
524///
525/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
526/// This resolver just returns the env var value or an error if not found.
527fn env_resolver(
528    args: &[String],
529    _kwargs: &HashMap<String, String>,
530    ctx: &ResolverContext,
531) -> Result<ResolvedValue> {
532    if args.is_empty() {
533        return Err(Error::parse("env resolver requires a variable name")
534            .with_path(ctx.config_path.clone()));
535    }
536
537    let var_name = &args[0];
538
539    match std::env::var(var_name) {
540        Ok(value) => {
541            // Return non-sensitive by default; sensitivity can be overridden via kwarg
542            Ok(ResolvedValue::new(Value::String(value)))
543        }
544        Err(_) => {
545            // Return EnvNotFound error - framework will handle default if provided
546            Err(Error::env_not_found(
547                var_name,
548                Some(ctx.config_path.clone()),
549            ))
550        }
551    }
552}
553
554/// Check if a hostname represents localhost
555///
556/// Recognizes various forms of localhost:
557/// - "localhost" (case-insensitive)
558/// - "127.0.0.1" (IPv4 loopback)
559/// - Any 127.x.x.x address (IPv4 loopback range)
560/// - "::1" (IPv6 loopback)
561/// - "[::1]" (IPv6 loopback with brackets)
562///
563/// Note: We intentionally reject internationalized domain names (IDN)
564/// and punycode for security and simplicity.
565fn is_localhost(hostname: &str) -> bool {
566    // ASCII localhost (case-insensitive)
567    if hostname.eq_ignore_ascii_case("localhost") {
568        return true;
569    }
570
571    // IPv4 loopback: 127.0.0.1 or any 127.x.x.x
572    if hostname.starts_with("127.") {
573        return true;
574    }
575
576    // IPv6 loopback: ::1 or [::1]
577    if hostname == "::1" || hostname == "[::1]" {
578        return true;
579    }
580
581    false
582}
583
584/// Normalize file path according to RFC 8089 file: URI scheme
585///
586/// RFC 8089 defines these valid formats:
587///   file:///path      - Local file (empty authority)
588///   file://localhost/path - Local file (explicit localhost)
589///   file:/path        - Local file (minimal form)
590///   file://host/path  - Remote file (not supported, returns error)
591///   path              - HoloConf relative path (not RFC, but supported)
592///
593/// Returns (normalized_path, is_relative)
594fn normalize_file_path(arg: &str) -> Result<(String, bool)> {
595    // Security: Reject null bytes
596    if arg.contains('\0') {
597        return Err(Error::resolver_custom(
598            "file",
599            "File paths cannot contain null bytes",
600        ));
601    }
602
603    if let Some(after_slashes) = arg.strip_prefix("//") {
604        // file://... format - Parse as RFC 8089 file: URL
605        // Remove exactly two leading slashes to get the authority+path
606
607        // Check if this is file:/// (third slash means empty authority)
608        if after_slashes.starts_with('/') {
609            // file:/// - empty authority means localhost
610            // The rest is the absolute path (already starts with /)
611            Ok((after_slashes.to_string(), false))
612        } else {
613            // file://hostname/path or file://hostname format
614            // Extract hostname (before first slash, or entire string if no slash)
615            let parts: Vec<&str> = after_slashes.splitn(2, '/').collect();
616            let hostname = parts[0];
617
618            // Check if empty hostname (file:// with no authority)
619            if hostname.is_empty() {
620                return Ok(("/".to_string(), false));
621            }
622
623            if is_localhost(hostname) {
624                // file://localhost/path - explicit localhost
625                let path = parts
626                    .get(1)
627                    .map(|s| format!("/{}", s))
628                    .unwrap_or_else(|| "/".to_string());
629                Ok((path, false))
630            } else {
631                // file://hostname/path - remote file (not supported)
632                Err(Error::resolver_custom(
633                    "file",
634                    format!(
635                        "Remote file URIs not supported: hostname '{}' is not localhost\n\
636                         \n\
637                         HoloConf only supports local files:\n\
638                         - file:///path/to/file (absolute, empty authority)\n\
639                         - file://localhost/path/to/file (absolute, explicit localhost)\n\
640                         - file:/path/to/file (absolute, minimal)\n\
641                         - relative/path/to/file (relative to config directory)",
642                        hostname
643                    ),
644                ))
645            }
646        }
647    } else if arg.starts_with('/') {
648        // file:/path - RFC 8089 local absolute (no authority)
649        Ok((arg.to_string(), false))
650    } else {
651        // No leading slashes: relative path (HoloConf convention)
652        Ok((arg.to_string(), true))
653    }
654}
655
656/// Built-in file resolver
657///
658/// Usage:
659///   ${file:path/to/file}                    - Read file as text (UTF-8), relative to config
660///   ${file:///absolute/path}                - Absolute path (RFC 8089)
661///   ${file://localhost/absolute/path}       - Absolute path (RFC 8089, explicit localhost)
662///   ${file:/absolute/path}                  - Absolute path (RFC 8089 minimal form)
663///   ${file:path/to/file,parse=text}         - Read as text (explicit, no parsing)
664///   ${file:path/to/file,parse=none}         - Return raw bytes (alias for encoding=binary)
665///   ${file:path/to/file,encoding=utf-8}     - UTF-8 encoding (default)
666///   ${file:path/to/file,encoding=ascii}     - ASCII encoding (strips non-ASCII)
667///   ${file:path/to/file,encoding=base64}    - Base64 encode the file contents as string
668///   ${file:path/to/file,encoding=binary}    - Return raw bytes as Value::Bytes
669///   ${file:path/to/file,default={}}         - Default if file not found (framework-handled)
670///   ${file:path/to/file,sensitive=true}     - Mark as sensitive (framework-handled)
671///
672/// For structured data parsing, use transformation resolvers:
673///   ${json:${file:config.json}}             - Parse JSON file
674///   ${yaml:${file:config.yaml}}             - Parse YAML file
675///   ${csv:${file:data.csv}}                 - Parse CSV file
676///
677/// Note: `default` and `sensitive` are framework-level kwargs handled by ResolverRegistry.
678fn file_resolver(
679    args: &[String],
680    kwargs: &HashMap<String, String>,
681    ctx: &ResolverContext,
682) -> Result<ResolvedValue> {
683    if args.is_empty() {
684        return Err(
685            Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
686        );
687    }
688
689    let file_path_arg = &args[0];
690    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("text");
691    let encoding = kwargs
692        .get("encoding")
693        .map(|s| s.as_str())
694        .unwrap_or("utf-8");
695
696    // Normalize file path according to RFC 8089
697    let (normalized_path, is_relative) = normalize_file_path(file_path_arg)?;
698
699    // Resolve relative paths based on context base path
700    let file_path = if is_relative {
701        if let Some(base) = &ctx.base_path {
702            base.join(&normalized_path)
703        } else {
704            std::path::PathBuf::from(&normalized_path)
705        }
706    } else {
707        // Absolute path from RFC 8089 file: URI
708        std::path::PathBuf::from(&normalized_path)
709    };
710
711    // Validate path is within allowed roots (path traversal protection)
712    // Security: Empty file_roots would bypass all validation - deny by default
713    if ctx.file_roots.is_empty() {
714        return Err(Error::resolver_custom(
715            "file",
716            "File resolver requires allowed directories to be configured. \
717             Use Config.load() which auto-configures the parent directory, or \
718             specify file_roots explicitly for Config.loads()."
719                .to_string(),
720        )
721        .with_path(ctx.config_path.clone()));
722    }
723
724    // Canonicalize file path to resolve symlinks and get absolute path
725    // This also checks if file exists (canonicalize fails if file doesn't exist)
726    let canonical_path = file_path.canonicalize().map_err(|e| {
727        // Check if this is a "not found" error for better error message
728        if e.kind() == std::io::ErrorKind::NotFound {
729            return Error::file_not_found(file_path_arg, Some(ctx.config_path.clone()));
730        }
731        Error::resolver_custom("file", format!("Failed to resolve file path: {}", e))
732            .with_path(ctx.config_path.clone())
733    })?;
734
735    // Validate against allowed roots
736    let mut canonicalization_errors = Vec::new();
737    let is_allowed = ctx.file_roots.iter().any(|root| {
738        match root.canonicalize() {
739            Ok(canonical_root) => canonical_path.starts_with(&canonical_root),
740            Err(e) => {
741                // Log but don't fail - root might not exist yet
742                canonicalization_errors.push((root.clone(), e));
743                false
744            }
745        }
746    });
747
748    if !is_allowed {
749        // Sanitize error message to avoid information disclosure
750        let display_path = if let Some(base) = &ctx.base_path {
751            file_path
752                .strip_prefix(base)
753                .map(|p| p.display().to_string())
754                .unwrap_or_else(|_| "<outside allowed directories>".to_string())
755        } else {
756            "<outside allowed directories>".to_string()
757        };
758
759        let mut msg = format!(
760            "Access denied: file '{}' is outside allowed directories.",
761            display_path
762        );
763
764        if !canonicalization_errors.is_empty() {
765            msg.push_str(&format!(
766                " Note: {} configured root(s) could not be validated.",
767                canonicalization_errors.len()
768            ));
769        }
770
771        msg.push_str(" Use file_roots parameter to extend allowed directories.");
772
773        return Err(Error::resolver_custom("file", msg).with_path(ctx.config_path.clone()));
774    }
775
776    // Handle binary encoding separately - returns Value::Stream for efficient streaming
777    if encoding == "binary" {
778        let file = std::fs::File::open(&file_path)
779            .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
780        return Ok(ResolvedValue::new(Value::Stream(Box::new(file))));
781    }
782
783    // Read the file based on encoding
784    let content = match encoding {
785        "base64" => {
786            // Read as binary and base64 encode
787            use base64::{engine::general_purpose::STANDARD, Engine as _};
788            let bytes = std::fs::read(&file_path)
789                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
790            STANDARD.encode(bytes)
791        }
792        "ascii" => {
793            // Read as UTF-8 but strip non-ASCII characters
794            let raw = std::fs::read_to_string(&file_path)
795                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
796            raw.chars().filter(|c| c.is_ascii()).collect()
797        }
798        _ => {
799            // Default to UTF-8 (including explicit "utf-8")
800            std::fs::read_to_string(&file_path)
801                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?
802        }
803    };
804
805    // For base64 encoding, always return as text (don't try to parse)
806    if encoding == "base64" {
807        return Ok(ResolvedValue::new(Value::String(content)));
808    }
809
810    // Parse mode: only "text" (default) or "none" (same as encoding=binary)
811    // For structured data parsing, use transformation resolvers:
812    //   ${json:${file:config.json}}, ${yaml:${file:config.yaml}}, etc.
813    match parse_mode {
814        "none" => {
815            // parse=none is an alias for encoding=binary - return stream for efficiency
816            let file = std::fs::File::open(&file_path)
817                .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
818            Ok(ResolvedValue::new(Value::Stream(Box::new(file))))
819        }
820        _ => {
821            // Default to text mode (including explicit "text") - return content as string
822            Ok(ResolvedValue::new(Value::String(content)))
823        }
824    }
825}
826
827/// Normalize HTTP/HTTPS URL by stripping existing scheme and prepending the correct one
828///
829/// This allows flexible syntax like:
830///   ${https://example.com}    → https://example.com
831///   ${https:example.com}      → https://example.com
832///   ${https:https://example}  → https://example.com (backwards compatible)
833///
834/// Returns an error if the URL is invalid (empty, or has invalid syntax like ///)
835#[cfg(feature = "http")]
836fn normalize_http_url(scheme: &str, arg: &str) -> Result<String> {
837    // Strip any existing http:// or https:// prefix
838    let clean = arg
839        .strip_prefix("http://")
840        .or_else(|| arg.strip_prefix("https://"))
841        .unwrap_or(arg);
842
843    // Strip leading // if present (handles ${https://example.com} syntax)
844    let clean = clean.strip_prefix("//").unwrap_or(clean);
845
846    // Validate: Reject empty URLs
847    if clean.trim().is_empty() {
848        return Err(Error::resolver_custom(
849            scheme,
850            format!(
851                "{} resolver requires a non-empty URL",
852                scheme.to_uppercase()
853            ),
854        ));
855    }
856
857    // Validate: Reject URLs starting with / (like /// which would create scheme:///)
858    if clean.starts_with('/') {
859        return Err(Error::resolver_custom(
860            scheme,
861            format!(
862                "Invalid URL syntax: '{}'. URLs must have a hostname after the ://\n\
863                 Valid formats:\n\
864                 - ${{{}:example.com/path}} (clean syntax)\n\
865                 - ${{{}:{}://example.com/path}} (backwards compatible)",
866                arg, scheme, scheme, scheme
867            ),
868        ));
869    }
870
871    // Prepend the correct scheme
872    Ok(format!("{}://{}", scheme, clean))
873}
874
875/// Common HTTP/HTTPS resolver implementation
876///
877/// This shared function reduces duplication between http_resolver and https_resolver.
878/// The only difference between the two resolvers is the scheme they prepend.
879fn http_or_https_resolver(
880    scheme: &str,
881    args: &[String],
882    kwargs: &HashMap<String, String>,
883    ctx: &ResolverContext,
884) -> Result<ResolvedValue> {
885    if args.is_empty() {
886        return Err(
887            Error::parse(format!("{} resolver requires a URL", scheme.to_uppercase()))
888                .with_path(ctx.config_path.clone()),
889        );
890    }
891
892    #[cfg(feature = "http")]
893    {
894        // Normalize URL to prepend the appropriate scheme
895        let url = normalize_http_url(scheme, &args[0])?;
896
897        // Check if HTTP is enabled
898        if !ctx.allow_http {
899            return Err(Error {
900                kind: crate::error::ErrorKind::Resolver(
901                    crate::error::ResolverErrorKind::HttpDisabled,
902                ),
903                path: Some(ctx.config_path.clone()),
904                source_location: None,
905                help: Some(format!(
906                    "{} resolver is disabled. The URL specified by this config path cannot be fetched.\n\
907                     Enable with Config.load(..., allow_http=True)",
908                    scheme.to_uppercase()
909                )),
910                cause: None,
911            });
912        }
913
914        // Check URL against allowlist if configured
915        if !ctx.http_allowlist.is_empty() {
916            let url_allowed = ctx
917                .http_allowlist
918                .iter()
919                .any(|pattern| url_matches_pattern(&url, pattern));
920            if !url_allowed {
921                return Err(Error::http_not_in_allowlist(
922                    &url,
923                    &ctx.http_allowlist,
924                    Some(ctx.config_path.clone()),
925                ));
926            }
927        }
928
929        http_fetch(&url, kwargs, ctx)
930    }
931
932    #[cfg(not(feature = "http"))]
933    {
934        // If compiled without HTTP feature, return an error
935        let _ = (kwargs, ctx); // Suppress unused warnings
936        Err(Error::resolver_custom(
937            scheme,
938            format!(
939                "{} support not compiled in. Rebuild with --features http",
940                scheme.to_uppercase()
941            ),
942        ))
943    }
944}
945
946/// Built-in HTTP resolver
947///
948/// Fetches content from remote URLs.
949///
950/// Usage:
951///   ${http:example.com/config.yaml}                   - Clean syntax (auto-prepends http://)
952///   ${http:example.com/config,parse=text}             - Read as text (explicit, no parsing)
953///   ${http:example.com/config,parse=binary}           - Read as binary (Value::Bytes)
954///   ${http:example.com/config,timeout=60}             - Timeout in seconds
955///   ${http:example.com/config,header=Auth:Bearer token} - Add header
956///   ${http:example.com/config,default={}}             - Default if request fails
957///   ${http:example.com/config,sensitive=true}         - Mark as sensitive
958///
959/// For structured data parsing, use transformation resolvers:
960///   ${json:${http:api.example.com/config}}            - Parse JSON response
961///   ${yaml:${http:example.com/config.yaml}}           - Parse YAML response
962///   ${csv:${http:data.example.com/export}}            - Parse CSV response
963///
964/// Backwards compatible:
965///   ${http:http://example.com}                        - Still works (protocol stripped and re-prepended)
966///
967/// Security:
968/// - Disabled by default (requires allow_http=true in ConfigOptions)
969/// - URL allowlist can restrict which URLs are accessible
970fn http_resolver(
971    args: &[String],
972    kwargs: &HashMap<String, String>,
973    ctx: &ResolverContext,
974) -> Result<ResolvedValue> {
975    http_or_https_resolver("http", args, kwargs, ctx)
976}
977
978/// Built-in HTTPS resolver
979///
980/// Fetches content from remote HTTPS URLs. Same as http_resolver but prepends https:// scheme.
981///
982/// Usage:
983///   ${https:example.com/config.yaml}                  - Clean syntax (auto-prepends https://)
984///   ${https:example.com/config,parse=text}            - Read as text (explicit, no parsing)
985///   ${https:example.com/config,parse=binary}          - Read as binary (Value::Bytes)
986///   ${https:example.com/config,timeout=60}            - Timeout in seconds
987///   ${https:example.com/config,header=Auth:Bearer token} - Add header
988///   ${https:example.com/config,default={}}            - Default if request fails
989///   ${https:example.com/config,sensitive=true}        - Mark as sensitive
990///
991/// For structured data parsing, use transformation resolvers:
992///   ${json:${https:api.example.com/config}}           - Parse JSON response
993///   ${yaml:${https:example.com/config.yaml}}          - Parse YAML response
994///   ${csv:${https:data.example.com/export}}           - Parse CSV response
995///
996/// Backwards compatible:
997///   ${https:https://example.com}                      - Still works (protocol stripped and re-prepended)
998///
999/// Security:
1000/// - Disabled by default (requires allow_http=true in ConfigOptions)
1001/// - URL allowlist can restrict which URLs are accessible
1002fn https_resolver(
1003    args: &[String],
1004    kwargs: &HashMap<String, String>,
1005    ctx: &ResolverContext,
1006) -> Result<ResolvedValue> {
1007    http_or_https_resolver("https", args, kwargs, ctx)
1008}
1009
1010/// Check if a URL matches an allowlist pattern
1011///
1012/// Supports glob-style patterns:
1013/// - `https://example.com/*` matches any path on example.com
1014/// - `https://*.example.com/*` matches any subdomain
1015#[cfg(feature = "http")]
1016fn url_matches_pattern(url: &str, pattern: &str) -> bool {
1017    // Security: Parse URL first to prevent bypass via malformed URLs
1018    let parsed_url = match url::Url::parse(url) {
1019        Ok(u) => u,
1020        Err(_) => {
1021            // Invalid URL - no match
1022            log::warn!("Invalid URL '{}' rejected by allowlist", url);
1023            return false;
1024        }
1025    };
1026
1027    // Validate pattern doesn't contain dangerous sequences
1028    if pattern.contains("**") || pattern.contains(".*.*") {
1029        log::warn!(
1030            "Invalid allowlist pattern '{}' - contains dangerous sequence",
1031            pattern
1032        );
1033        return false;
1034    }
1035
1036    // Use glob crate for proper glob matching
1037    let glob_pattern = match glob::Pattern::new(pattern) {
1038        Ok(p) => p,
1039        Err(_) => {
1040            // Invalid pattern - fall back to exact match
1041            log::warn!(
1042                "Invalid glob pattern '{}' - falling back to exact match",
1043                pattern
1044            );
1045            return url == pattern;
1046        }
1047    };
1048
1049    // Match against the full URL string
1050    // This allows patterns like:
1051    // - "https://api.example.com/*" (all paths on this host)
1052    // - "https://*.example.com/api/*" (all subdomains)
1053    // - "https://api.example.com/v1/users" (exact match)
1054    glob_pattern.matches(parsed_url.as_str())
1055}
1056
1057// =============================================================================
1058// TLS/Proxy Configuration Helpers (HTTP feature)
1059// =============================================================================
1060
1061/// Parse PEM certificates from bytes
1062#[cfg(feature = "http")]
1063fn parse_pem_certs(pem_bytes: &[u8], source: &str) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1064    use ureq::tls::PemItem;
1065
1066    let certs: Vec<_> = ureq::tls::parse_pem(pem_bytes)
1067        .filter_map(|item| item.ok())
1068        .filter_map(|item| match item {
1069            PemItem::Certificate(cert) => Some(cert.to_owned()),
1070            _ => None,
1071        })
1072        .collect();
1073
1074    if certs.is_empty() {
1075        return Err(Error::pem_load_error(
1076            source,
1077            "No valid certificates found in PEM data",
1078        ));
1079    }
1080
1081    Ok(certs)
1082}
1083
1084/// Load certificates from CertInput (PEM content or file path)
1085#[cfg(feature = "http")]
1086fn load_certs(input: &CertInput) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1087    match input {
1088        CertInput::Binary(_) => {
1089            Err(Error::tls_config_error(
1090                "CA bundle must be PEM format, not binary. For P12 client certificates, use client_cert parameter."
1091            ))
1092        }
1093        CertInput::Text(text) => {
1094            // Try as file path first
1095            let path = std::path::Path::new(text);
1096            if path.exists() {
1097                log::trace!("Loading certificates from file: {}", text);
1098                let bytes = std::fs::read(path).map_err(|e| {
1099                    // Sanitize path for error message (could contain PEM content if detection fails)
1100                    let display_path = if text.len() < 256 && !text.contains('\n') {
1101                        text
1102                    } else {
1103                        "[PEM content or long path]"
1104                    };
1105                    Error::pem_load_error(
1106                        display_path,
1107                        format!("Failed to read certificate file: {}", e),
1108                    )
1109                })?;
1110                parse_pem_certs(&bytes, text)
1111            } else {
1112                // Fallback to parsing as PEM content
1113                log::trace!("Path does not exist, attempting to parse as PEM content");
1114                parse_pem_certs(text.as_bytes(), "PEM content")
1115            }
1116        }
1117    }
1118}
1119
1120/// Parse a private key from PEM bytes (handles encrypted and unencrypted)
1121#[cfg(feature = "http")]
1122fn parse_pem_private_key(
1123    pem_content: &str,
1124    password: Option<&str>,
1125    source: &str,
1126) -> Result<ureq::tls::PrivateKey<'static>> {
1127    use pkcs8::der::Decode;
1128
1129    // Check if this is an encrypted PKCS#8 key
1130    if pem_content.contains(PEM_BEGIN_ENCRYPTED_KEY) {
1131        let pwd = password.ok_or_else(|| {
1132            Error::tls_config_error(format!(
1133                "Password required for encrypted private key from: {}",
1134                source
1135            ))
1136        })?;
1137
1138        // Extract the base64 content from PEM
1139        let der_bytes = pem_to_der(pem_content, "ENCRYPTED PRIVATE KEY")
1140            .map_err(|e| Error::pem_load_error(source, e))?;
1141
1142        let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
1143            .map_err(|e| Error::pem_load_error(source, e.to_string()))?;
1144
1145        let decrypted = encrypted
1146            .decrypt(pwd)
1147            .map_err(|e| Error::key_decryption_error(e.to_string()))?;
1148
1149        // The decrypted key is in PKCS#8 DER format
1150        // Wrap it in PEM format so ureq can parse it
1151        let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
1152
1153        ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1154            .map(|k| k.to_owned())
1155            .map_err(|e| {
1156                Error::pem_load_error(source, format!("Failed to parse decrypted key: {}", e))
1157            })
1158    } else {
1159        // Try loading as regular key
1160        ureq::tls::PrivateKey::from_pem(pem_content.as_bytes())
1161            .map(|k| k.to_owned())
1162            .map_err(|e| {
1163                Error::pem_load_error(source, format!("Failed to parse private key: {}", e))
1164            })
1165    }
1166}
1167
1168/// Load a private key from CertInput (PEM content or file path)
1169#[cfg(feature = "http")]
1170fn load_private_key(
1171    input: &CertInput,
1172    password: Option<&str>,
1173) -> Result<ureq::tls::PrivateKey<'static>> {
1174    match input {
1175        CertInput::Binary(_) => {
1176            Err(Error::tls_config_error(
1177                "Private key must be PEM text format, not binary. For P12, use client_cert only (no client_key needed)."
1178            ))
1179        }
1180        CertInput::Text(text) => {
1181            // Try as file path first
1182            let path = std::path::Path::new(text);
1183            if path.exists() {
1184                log::trace!("Loading private key from file: {}", text);
1185                let pem_content = std::fs::read_to_string(path).map_err(|e| {
1186                    // Sanitize path for error message
1187                    let display_path = if text.len() < 256 && !text.contains('\n') {
1188                        text
1189                    } else {
1190                        "[PEM content or long path]"
1191                    };
1192                    Error::pem_load_error(
1193                        display_path,
1194                        format!("Failed to read key file: {}", e),
1195                    )
1196                })?;
1197                parse_pem_private_key(&pem_content, password, text)
1198            } else {
1199                // Fallback to parsing as PEM content
1200                log::trace!("Path does not exist, attempting to parse as PEM content");
1201                parse_pem_private_key(text, password, "PEM content")
1202            }
1203        }
1204    }
1205}
1206
1207/// Extract DER bytes from PEM format
1208#[cfg(feature = "http")]
1209fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
1210    let begin_marker = format!("-----BEGIN {}-----", label);
1211    let end_marker = format!("-----END {}-----", label);
1212
1213    let start = pem
1214        .find(&begin_marker)
1215        .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
1216    let end = pem
1217        .find(&end_marker)
1218        .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
1219
1220    let base64_content: String = pem[start + begin_marker.len()..end]
1221        .chars()
1222        .filter(|c| !c.is_whitespace())
1223        .collect();
1224
1225    use base64::Engine;
1226    base64::engine::general_purpose::STANDARD
1227        .decode(&base64_content)
1228        .map_err(|e| format!("Failed to decode base64: {}", e))
1229}
1230
1231/// Convert DER bytes to PEM format
1232#[cfg(feature = "http")]
1233fn der_to_pem(der: &[u8], label: &str) -> String {
1234    use base64::Engine;
1235    let base64 = base64::engine::general_purpose::STANDARD.encode(der);
1236    // Split into 64-char lines
1237    let lines: Vec<&str> = base64
1238        .as_bytes()
1239        .chunks(64)
1240        .map(|chunk| std::str::from_utf8(chunk).unwrap())
1241        .collect();
1242    format!(
1243        "-----BEGIN {}-----\n{}\n-----END {}-----\n",
1244        label,
1245        lines.join("\n"),
1246        label
1247    )
1248}
1249
1250/// Parse P12/PFX bytes into certificate chain and private key
1251#[cfg(feature = "http")]
1252fn parse_p12_identity(
1253    p12_data: &[u8],
1254    password: &str,
1255    source: &str,
1256) -> Result<(
1257    Vec<ureq::tls::Certificate<'static>>,
1258    ureq::tls::PrivateKey<'static>,
1259)> {
1260    // Warn if using empty password (valid but insecure)
1261    if password.is_empty() {
1262        log::warn!(
1263            "Loading P12 file without password from: {} - ensure file is properly protected",
1264            source
1265        );
1266    }
1267
1268    let keystore = p12_keystore::KeyStore::from_pkcs12(p12_data, password)
1269        .map_err(|e| Error::p12_load_error(source, e.to_string()))?;
1270
1271    // Get the first key entry (most P12 files have one key)
1272    // private_key_chain() returns Option<(&str, &PrivateKeyChain)>
1273    let (_alias, key_chain) = keystore
1274        .private_key_chain()
1275        .ok_or_else(|| Error::p12_load_error(source, "No private key found in P12 data"))?;
1276
1277    // Get the private key DER bytes - wrap in PEM for ureq
1278    let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
1279    let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1280        .map(|k| k.to_owned())
1281        .map_err(|e| {
1282            Error::p12_load_error(source, format!("Failed to parse private key: {}", e))
1283        })?;
1284
1285    // Get certificates from the chain
1286    let certs: Vec<_> = key_chain
1287        .chain()
1288        .iter()
1289        .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
1290        .collect();
1291
1292    if certs.is_empty() {
1293        return Err(Error::p12_load_error(
1294            source,
1295            "No certificates found in P12 data",
1296        ));
1297    }
1298
1299    Ok((certs, private_key))
1300}
1301
1302/// Load client identity (cert + key) for mTLS from CertInput
1303#[cfg(feature = "http")]
1304fn load_client_identity(
1305    cert_input: &CertInput,
1306    key_input: Option<&CertInput>,
1307    password: Option<&str>,
1308) -> Result<(
1309    Vec<ureq::tls::Certificate<'static>>,
1310    ureq::tls::PrivateKey<'static>,
1311)> {
1312    match cert_input {
1313        // P12 binary content
1314        CertInput::Binary(bytes) => {
1315            log::trace!("Loading client identity from P12 binary content");
1316            // P12 files may have empty passwords - this is valid (warning logged in parse_p12_identity)
1317            let pwd = password.unwrap_or("");
1318            parse_p12_identity(bytes, pwd, "P12 binary content")
1319        }
1320
1321        // Text - could be PEM content, P12 path, or PEM path
1322        CertInput::Text(text) => {
1323            // Check if it's a P12 file path
1324            if cert_input.is_p12_path() {
1325                log::trace!("Loading client identity from P12 file: {}", text);
1326                let bytes = std::fs::read(text).map_err(|e| {
1327                    Error::p12_load_error(text, format!("Failed to read P12 file: {}", e))
1328                })?;
1329                // P12 files may have empty passwords - this is valid (warning logged in parse_p12_identity)
1330                let pwd = password.unwrap_or("");
1331                return parse_p12_identity(&bytes, pwd, text);
1332            }
1333
1334            // PEM content or path
1335            log::trace!("Loading client identity from PEM (cert + key)");
1336            let certs = load_certs(cert_input)?;
1337
1338            let key_input = key_input.ok_or_else(|| {
1339                Error::tls_config_error(
1340                    "client_key required when using PEM certificate (not needed for P12)",
1341                )
1342            })?;
1343
1344            let key = load_private_key(key_input, password)?;
1345            Ok((certs, key))
1346        }
1347    }
1348}
1349
1350/// Build TLS configuration from context and per-request kwargs
1351#[cfg(feature = "http")]
1352fn build_tls_config(
1353    ctx: &ResolverContext,
1354    kwargs: &HashMap<String, String>,
1355) -> Result<ureq::tls::TlsConfig> {
1356    use std::sync::Arc;
1357    use ureq::tls::{ClientCert, RootCerts, TlsConfig};
1358
1359    let mut builder = TlsConfig::builder();
1360
1361    // Check for insecure mode (only from per-request kwargs)
1362    let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
1363
1364    if insecure {
1365        // OBNOXIOUS WARNING: This is a SECURITY RISK
1366        eprintln!("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
1367        eprintln!("┃ ⚠️  WARNING: TLS CERTIFICATE VERIFICATION DISABLED ┃");
1368        eprintln!("┃                                                    ┃");
1369        eprintln!("┃ You are using insecure=true which disables ALL    ┃");
1370        eprintln!("┃ TLS certificate validation. This is DANGEROUS     ┃");
1371        eprintln!("┃ and should ONLY be used in development.           ┃");
1372        eprintln!("┃                                                    ┃");
1373        eprintln!("┃ In production, use proper certificate             ┃");
1374        eprintln!("┃ configuration with ca_bundle or extra_ca_bundle.  ┃");
1375        eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n");
1376        log::warn!("TLS certificate verification is disabled (insecure=true)");
1377        builder = builder.disable_verification(true);
1378    }
1379
1380    // Load CA bundle if specified (per-request overrides context)
1381    let ca_bundle_input = kwargs
1382        .get("ca_bundle")
1383        .map(|s| CertInput::Text(s.clone()))
1384        .or_else(|| ctx.http_ca_bundle.clone());
1385
1386    let extra_ca_bundle_input = kwargs
1387        .get("extra_ca_bundle")
1388        .map(|s| CertInput::Text(s.clone()))
1389        .or_else(|| ctx.http_extra_ca_bundle.clone());
1390
1391    if let Some(ca_input) = ca_bundle_input.as_ref() {
1392        // Replace root certs with custom CA bundle
1393        let certs = load_certs(ca_input)?;
1394        builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
1395    } else if let Some(extra_ca_input) = extra_ca_bundle_input.as_ref() {
1396        // Add extra certs to webpki roots using new_with_certs
1397        let extra_certs = load_certs(extra_ca_input)?;
1398        builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
1399    }
1400
1401    // Load client certificate for mTLS (per-request overrides context)
1402    let client_cert_input = kwargs
1403        .get("client_cert")
1404        .map(|s| CertInput::Text(s.clone()))
1405        .or_else(|| ctx.http_client_cert.clone());
1406
1407    if let Some(cert_input) = client_cert_input.as_ref() {
1408        let client_key_input = kwargs
1409            .get("client_key")
1410            .map(|s| CertInput::Text(s.clone()))
1411            .or_else(|| ctx.http_client_key.clone());
1412
1413        let password = kwargs
1414            .get("key_password")
1415            .map(|s| s.as_str())
1416            .or(ctx.http_client_key_password.as_deref());
1417
1418        let (certs, key) = load_client_identity(cert_input, client_key_input.as_ref(), password)?;
1419
1420        let client_cert = ClientCert::new_with_certs(&certs, key);
1421        builder = builder.client_cert(Some(client_cert));
1422    }
1423
1424    Ok(builder.build())
1425}
1426
1427/// Build proxy configuration from context and per-request kwargs
1428#[cfg(feature = "http")]
1429fn build_proxy_config(
1430    ctx: &ResolverContext,
1431    kwargs: &HashMap<String, String>,
1432) -> Result<Option<ureq::Proxy>> {
1433    // Per-request proxy overrides context
1434    let proxy_url = kwargs
1435        .get("proxy")
1436        .cloned()
1437        .or_else(|| ctx.http_proxy.clone());
1438
1439    // If no explicit proxy, check environment if enabled
1440    let proxy_url = proxy_url.or_else(|| {
1441        if ctx.http_proxy_from_env {
1442            // Check standard proxy environment variables
1443            std::env::var("HTTPS_PROXY")
1444                .or_else(|_| std::env::var("https_proxy"))
1445                .or_else(|_| std::env::var("HTTP_PROXY"))
1446                .or_else(|_| std::env::var("http_proxy"))
1447                .ok()
1448        } else {
1449            None
1450        }
1451    });
1452
1453    if let Some(url) = proxy_url {
1454        let proxy = ureq::Proxy::new(&url).map_err(|e| {
1455            Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1456        })?;
1457        Ok(Some(proxy))
1458    } else {
1459        Ok(None)
1460    }
1461}
1462
1463/// Perform HTTP request and parse response
1464#[cfg(feature = "http")]
1465fn http_fetch(
1466    url: &str,
1467    kwargs: &HashMap<String, String>,
1468    ctx: &ResolverContext,
1469) -> Result<ResolvedValue> {
1470    use std::time::Duration;
1471
1472    let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("text");
1473    let timeout_secs: u64 = kwargs
1474        .get("timeout")
1475        .and_then(|s| s.parse().ok())
1476        .unwrap_or(30);
1477
1478    // Build TLS configuration
1479    let tls_config = build_tls_config(ctx, kwargs)?;
1480
1481    // Build proxy configuration
1482    let proxy = build_proxy_config(ctx, kwargs)?;
1483
1484    // Build agent with timeout, TLS, and proxy configuration
1485    let mut config_builder = ureq::Agent::config_builder()
1486        .timeout_global(Some(Duration::from_secs(timeout_secs)))
1487        .tls_config(tls_config);
1488
1489    if proxy.is_some() {
1490        config_builder = config_builder.proxy(proxy);
1491    }
1492
1493    let config = config_builder.build();
1494    let agent: ureq::Agent = config.into();
1495
1496    // Build the request
1497    let mut request = agent.get(url);
1498
1499    // Add custom headers
1500    for (key, value) in kwargs {
1501        if key == "header" {
1502            // Parse header in format "Name:Value"
1503            if let Some((name, val)) = value.split_once(':') {
1504                request = request.header(name.trim(), val.trim());
1505            }
1506        }
1507    }
1508
1509    // Send request
1510    let response = request.call().map_err(|e| {
1511        let error_msg = match &e {
1512            ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1513            ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1514            ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1515            _ => format!("HTTP request failed: {}", e),
1516        };
1517        Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1518    })?;
1519
1520    // Parse mode: only "text" (default) or "binary"
1521    // For structured data parsing, use transformation resolvers:
1522    //   ${json:${http:...}}, ${yaml:${http:...}}, etc.
1523    match parse_mode {
1524        "binary" => {
1525            // Return stream for efficient data transfer (no materialization until consumed)
1526            // Body::into_reader() converts Body to an owned impl Read
1527            Ok(ResolvedValue::new(Value::Stream(Box::new(
1528                response.into_body().into_reader(),
1529            ))))
1530        }
1531        _ => {
1532            // Default to text mode (including explicit "text") - return response body as string
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            Ok(ResolvedValue::new(Value::String(body)))
1537        }
1538    }
1539}
1540
1541// =============================================================================
1542// Transformation Resolvers
1543// =============================================================================
1544
1545/// Helper: Truncate string for error messages
1546fn truncate_str(s: &str, max_len: usize) -> String {
1547    if s.len() <= max_len {
1548        s.to_string()
1549    } else {
1550        format!("{}...", &s[..max_len])
1551    }
1552}
1553
1554/// JSON resolver - Parse JSON strings into structured data
1555fn json_resolver(
1556    args: &[String],
1557    _kwargs: &HashMap<String, String>,
1558    ctx: &ResolverContext,
1559) -> Result<ResolvedValue> {
1560    if args.is_empty() {
1561        return Err(Error::parse("json resolver requires a string argument")
1562            .with_path(ctx.config_path.clone()));
1563    }
1564
1565    let json_str = &args[0];
1566
1567    // Parse JSON
1568    let parsed: Value = serde_json::from_str(json_str).map_err(|e| {
1569        Error::parse(format!(
1570            "Invalid JSON at line {}, column {}: {}\nInput preview: {}",
1571            e.line(),
1572            e.column(),
1573            e,
1574            truncate_str(json_str, 50)
1575        ))
1576        .with_path(ctx.config_path.clone())
1577    })?;
1578
1579    Ok(ResolvedValue::new(parsed))
1580}
1581
1582/// YAML resolver - Parse YAML strings (first document only)
1583fn yaml_resolver(
1584    args: &[String],
1585    _kwargs: &HashMap<String, String>,
1586    ctx: &ResolverContext,
1587) -> Result<ResolvedValue> {
1588    if args.is_empty() {
1589        return Err(Error::parse("yaml resolver requires a string argument")
1590            .with_path(ctx.config_path.clone()));
1591    }
1592
1593    let yaml_str = &args[0];
1594
1595    // Parse YAML (first document only)
1596    let parsed: Value = serde_yaml::from_str(yaml_str).map_err(|e| {
1597        let location_info = if let Some(loc) = e.location() {
1598            format!(" at line {}, column {}", loc.line(), loc.column())
1599        } else {
1600            String::new()
1601        };
1602
1603        Error::parse(format!(
1604            "Invalid YAML{}: {}\nInput preview: {}",
1605            location_info,
1606            e,
1607            truncate_str(yaml_str, 50)
1608        ))
1609        .with_path(ctx.config_path.clone())
1610    })?;
1611
1612    Ok(ResolvedValue::new(parsed))
1613}
1614
1615/// Split resolver - Split strings into arrays
1616fn split_resolver(
1617    args: &[String],
1618    kwargs: &HashMap<String, String>,
1619    ctx: &ResolverContext,
1620) -> Result<ResolvedValue> {
1621    if args.is_empty() {
1622        return Err(Error::parse("split resolver requires a string argument")
1623            .with_path(ctx.config_path.clone()));
1624    }
1625
1626    let input_str = &args[0];
1627    let delim = kwargs.get("delim").map(|s| s.as_str()).unwrap_or(",");
1628    let trim = kwargs
1629        .get("trim")
1630        .map(|s| s.eq_ignore_ascii_case("true"))
1631        .unwrap_or(true); // Default: trim
1632    let skip_empty = kwargs
1633        .get("skip_empty")
1634        .map(|s| s.eq_ignore_ascii_case("true"))
1635        .unwrap_or(false);
1636    let limit = kwargs.get("limit").and_then(|s| s.parse::<usize>().ok());
1637
1638    // Split
1639    let parts: Vec<&str> = if let Some(limit) = limit {
1640        input_str.splitn(limit + 1, delim).collect()
1641    } else {
1642        input_str.split(delim).collect()
1643    };
1644
1645    // Process: trim and filter
1646    let result: Vec<Value> = parts
1647        .iter()
1648        .map(|s| if trim { s.trim() } else { *s })
1649        .filter(|s| !skip_empty || !s.is_empty())
1650        .map(|s| Value::String(s.to_string()))
1651        .collect();
1652
1653    Ok(ResolvedValue::new(Value::Sequence(result)))
1654}
1655
1656/// CSV resolver - Parse CSV data into arrays
1657fn csv_resolver(
1658    args: &[String],
1659    kwargs: &HashMap<String, String>,
1660    ctx: &ResolverContext,
1661) -> Result<ResolvedValue> {
1662    if args.is_empty() {
1663        return Err(Error::parse("csv resolver requires a string argument")
1664            .with_path(ctx.config_path.clone()));
1665    }
1666
1667    let csv_str = &args[0];
1668    let header = kwargs
1669        .get("header")
1670        .map(|s| s.eq_ignore_ascii_case("true"))
1671        .unwrap_or(true); // Default: true
1672    let trim = kwargs
1673        .get("trim")
1674        .map(|s| s.eq_ignore_ascii_case("true"))
1675        .unwrap_or(false);
1676    let delim_str = kwargs.get("delim").map(|s| s.as_str()).unwrap_or(",");
1677
1678    // Parse delimiter
1679    let delim_char = delim_str.chars().next().ok_or_else(|| {
1680        Error::parse("CSV delimiter cannot be empty").with_path(ctx.config_path.clone())
1681    })?;
1682
1683    // Build CSV reader
1684    let mut reader = csv::ReaderBuilder::new()
1685        .has_headers(header)
1686        .delimiter(delim_char as u8)
1687        .trim(if trim {
1688            csv::Trim::All
1689        } else {
1690            csv::Trim::None
1691        })
1692        .from_reader(csv_str.as_bytes());
1693
1694    // Get headers if present
1695    let headers = if header {
1696        Some(
1697            reader
1698                .headers()
1699                .map_err(|e| {
1700                    Error::parse(format!("CSV parse error: {}", e))
1701                        .with_path(ctx.config_path.clone())
1702                })?
1703                .clone(),
1704        )
1705    } else {
1706        None
1707    };
1708
1709    // Parse rows
1710    let mut rows = Vec::new();
1711    for result in reader.records() {
1712        let record = result.map_err(|e| {
1713            let location_info = e
1714                .position()
1715                .map(|p| format!(" at line {}", p.line()))
1716                .unwrap_or_default();
1717            Error::parse(format!("CSV parse error{}: {}", location_info, e))
1718                .with_path(ctx.config_path.clone())
1719        })?;
1720
1721        let row = if let Some(ref headers) = headers {
1722            // Array of objects: [{"name": "Alice", ...}]
1723            let mut obj = indexmap::IndexMap::new();
1724            for (i, field) in record.iter().enumerate() {
1725                let key = headers.get(i).unwrap_or(&format!("col{}", i)).to_string();
1726                obj.insert(key, Value::String(field.to_string()));
1727            }
1728            Value::Mapping(obj)
1729        } else {
1730            // Array of arrays: [["Alice", "admin"]]
1731            Value::Sequence(
1732                record
1733                    .iter()
1734                    .map(|s| Value::String(s.to_string()))
1735                    .collect(),
1736            )
1737        };
1738
1739        rows.push(row);
1740    }
1741
1742    Ok(ResolvedValue::new(Value::Sequence(rows)))
1743}
1744
1745/// Base64 resolver - Decode base64 strings to bytes
1746fn base64_resolver(
1747    args: &[String],
1748    _kwargs: &HashMap<String, String>,
1749    ctx: &ResolverContext,
1750) -> Result<ResolvedValue> {
1751    if args.is_empty() {
1752        return Err(Error::parse("base64 resolver requires a string argument")
1753            .with_path(ctx.config_path.clone()));
1754    }
1755
1756    let b64_str = args[0].trim();
1757
1758    use base64::{engine::general_purpose, Engine as _};
1759
1760    let decoded = general_purpose::STANDARD.decode(b64_str).map_err(|e| {
1761        Error::parse(format!(
1762            "Invalid base64: {}\nInput preview: {}",
1763            e,
1764            truncate_str(b64_str, 50)
1765        ))
1766        .with_path(ctx.config_path.clone())
1767    })?;
1768
1769    // Try to decode as UTF-8 string (common for secrets, tokens, configs)
1770    // Fall back to bytes for binary data (images, certificates, etc.)
1771    match String::from_utf8(decoded) {
1772        Ok(s) => Ok(ResolvedValue::new(Value::String(s))),
1773        Err(e) => Ok(ResolvedValue::new(Value::Bytes(e.into_bytes()))),
1774    }
1775}
1776
1777// =============================================================================
1778// Archive Extraction Resolver
1779// =============================================================================
1780
1781#[cfg(feature = "archive")]
1782mod archive_limits {
1783    /// Maximum size for a single extracted file (10MB)
1784    /// Rationale: Config files are typically < 1MB; 10MB is generous
1785    pub const MAX_EXTRACTED_FILE_SIZE: u64 = 10 * 1024 * 1024;
1786
1787    /// Maximum compression ratio allowed (100:1)
1788    /// Rationale: Legitimate compressed configs rarely exceed 10:1;
1789    /// 100:1 allows for highly compressible data while blocking zip bombs
1790    pub const MAX_COMPRESSION_RATIO: u64 = 100;
1791}
1792
1793#[cfg(feature = "archive")]
1794use archive_limits::*;
1795
1796/// Read from a stream with size limits to prevent zip bomb attacks
1797///
1798/// This function reads data in chunks and enforces a maximum size limit,
1799/// preventing memory exhaustion from maliciously crafted archives.
1800#[cfg(feature = "archive")]
1801fn read_with_limits<R: std::io::Read>(
1802    mut reader: R,
1803    max_size: u64,
1804    compressed_size: Option<u64>,
1805) -> Result<Vec<u8>> {
1806    let mut contents = Vec::new();
1807    let mut total_read = 0u64;
1808    let mut buffer = [0u8; 8192];
1809
1810    loop {
1811        match reader.read(&mut buffer) {
1812            Ok(0) => break, // EOF
1813            Ok(n) => {
1814                total_read += n as u64;
1815
1816                // Check absolute size limit
1817                if total_read > max_size {
1818                    return Err(Error::resolver_custom(
1819                        "extract",
1820                        format!(
1821                            "Extracted file exceeds size limit ({} bytes > {} bytes limit). \
1822                             This may be a zip bomb or the file is too large for a config.",
1823                            total_read, max_size
1824                        ),
1825                    ));
1826                }
1827
1828                // Check compression ratio if known (zip bomb detection)
1829                if let Some(compressed) = compressed_size {
1830                    if compressed > 0 {
1831                        let ratio = total_read / compressed;
1832                        if ratio > MAX_COMPRESSION_RATIO {
1833                            return Err(Error::resolver_custom(
1834                                "extract",
1835                                format!(
1836                                    "Compression ratio too high ({}:1, max {}:1). \
1837                                     This may be a zip bomb attack.",
1838                                    ratio, MAX_COMPRESSION_RATIO
1839                                ),
1840                            ));
1841                        }
1842                    }
1843                }
1844
1845                contents.extend_from_slice(&buffer[..n]);
1846            }
1847            Err(e) => {
1848                return Err(Error::resolver_custom(
1849                    "extract",
1850                    format!("Failed to read from archive: {}", e),
1851                ));
1852            }
1853        }
1854    }
1855
1856    Ok(contents)
1857}
1858
1859/// Extract a file from an archive (zip, tar, tar.gz)
1860///
1861/// Automatically detects archive format using magic bytes.
1862/// Includes zip bomb protection via size limits.
1863///
1864/// Security limits:
1865/// - Max file size: 10MB per extracted file
1866/// - Max compression ratio: 100:1
1867///
1868/// Usage:
1869///   ${extract:${file:archive.tar.gz,encoding=binary},path=config.json}
1870///   ${extract:${https:releases.example.com/app.zip},path=README.md}
1871///   ${extract:${file:backup.zip,encoding=binary},path=secrets/key.pem,password=${env:ZIP_PASS}}
1872#[cfg(feature = "archive")]
1873fn extract_resolver(
1874    args: &[String],
1875    kwargs: &HashMap<String, String>,
1876    ctx: &ResolverContext,
1877) -> Result<ResolvedValue> {
1878    use std::io::Cursor;
1879
1880    if args.is_empty() {
1881        return Err(
1882            Error::parse("extract resolver requires archive data as first argument")
1883                .with_path(ctx.config_path.clone()),
1884        );
1885    }
1886
1887    // Get required 'path' kwarg
1888    let file_path = kwargs.get("path").ok_or_else(|| {
1889        Error::parse("extract resolver requires 'path' kwarg specifying file to extract")
1890            .with_path(ctx.config_path.clone())
1891    })?;
1892
1893    // Optional password for encrypted archives
1894    let password = kwargs.get("password");
1895
1896    // Get archive data as bytes (first arg should be binary from file/http resolver)
1897    // The arg comes as a string (base64 if it was bytes), so we need to decode it
1898    use base64::Engine;
1899    let archive_bytes =
1900        if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&args[0]) {
1901            decoded
1902        } else {
1903            // If not base64, treat as raw string bytes (shouldn't happen with proper chaining)
1904            args[0].as_bytes().to_vec()
1905        };
1906
1907    // Detect archive format using magic bytes
1908    let format = detect_archive_format(&archive_bytes)?;
1909
1910    // Extract file based on format
1911    match format {
1912        ArchiveFormat::Zip => {
1913            extract_from_zip(Cursor::new(archive_bytes), file_path, password, ctx)
1914        }
1915        ArchiveFormat::Tar => extract_from_tar(Cursor::new(archive_bytes), file_path, ctx),
1916        ArchiveFormat::TarGz => extract_from_tar_gz(Cursor::new(archive_bytes), file_path, ctx),
1917    }
1918}
1919
1920#[cfg(feature = "archive")]
1921enum ArchiveFormat {
1922    Zip,
1923    Tar,
1924    TarGz,
1925}
1926
1927#[cfg(feature = "archive")]
1928fn detect_archive_format(data: &[u8]) -> Result<ArchiveFormat> {
1929    // Use infer crate for magic byte detection
1930    if let Some(kind) = infer::get(data) {
1931        match kind.mime_type() {
1932            "application/zip" => return Ok(ArchiveFormat::Zip),
1933            "application/gzip" => return Ok(ArchiveFormat::TarGz),
1934            _ => {}
1935        }
1936    }
1937
1938    // Fallback: Check for TAR (ustar marker at offset 257)
1939    if data.len() > 262 && &data[257..262] == b"ustar" {
1940        return Ok(ArchiveFormat::Tar);
1941    }
1942
1943    Err(Error::resolver_custom(
1944        "extract",
1945        "Unsupported archive format. Supported formats: ZIP, TAR, TAR.GZ",
1946    ))
1947}
1948
1949#[cfg(feature = "archive")]
1950fn extract_from_zip<R: std::io::Read + std::io::Seek>(
1951    reader: R,
1952    file_path: &str,
1953    password: Option<&String>,
1954    ctx: &ResolverContext,
1955) -> Result<ResolvedValue> {
1956    let mut archive = zip::ZipArchive::new(reader).map_err(|e| {
1957        Error::resolver_custom("extract", format!("Failed to open ZIP archive: {}", e))
1958            .with_path(ctx.config_path.clone())
1959    })?;
1960
1961    // Find the file in the archive
1962    let file = if let Some(pwd) = password {
1963        // by_name_decrypt may return an error if file not found or password is wrong
1964        archive
1965            .by_name_decrypt(file_path, pwd.as_bytes())
1966            .map_err(|e| {
1967                Error::resolver_custom(
1968                    "extract",
1969                    format!(
1970                        "Failed to access encrypted file '{}' in ZIP (check password): {}",
1971                        file_path, e
1972                    ),
1973                )
1974                .with_path(ctx.config_path.clone())
1975            })?
1976    } else {
1977        archive.by_name(file_path).map_err(|e| {
1978            match e {
1979                zip::result::ZipError::FileNotFound => {
1980                    Error::not_found(format!("File '{}' in ZIP archive", file_path), Some(ctx.config_path.clone()))
1981                }
1982                zip::result::ZipError::UnsupportedArchive(msg) => {
1983                    if msg.contains("encrypted") || msg.contains("password") {
1984                        Error::resolver_custom(
1985                            "extract",
1986                            format!("ZIP file '{}' is password-protected but no password provided. Use password=... kwarg", file_path),
1987                        )
1988                        .with_path(ctx.config_path.clone())
1989                    } else {
1990                        Error::resolver_custom("extract", format!("Unsupported ZIP feature: {}", msg))
1991                            .with_path(ctx.config_path.clone())
1992                    }
1993                }
1994                _ => Error::resolver_custom("extract", format!("Failed to access '{}' in ZIP: {}", file_path, e))
1995                    .with_path(ctx.config_path.clone()),
1996            }
1997        })?
1998    };
1999
2000    // Read the file contents with size limits (zip bomb protection)
2001    let compressed_size = file.compressed_size();
2002    let contents = read_with_limits(file, MAX_EXTRACTED_FILE_SIZE, Some(compressed_size))
2003        .map_err(|e| e.with_path(ctx.config_path.clone()))?;
2004
2005    Ok(ResolvedValue::new(Value::Bytes(contents)))
2006}
2007
2008#[cfg(feature = "archive")]
2009fn extract_from_tar<R: std::io::Read>(
2010    reader: R,
2011    file_path: &str,
2012    ctx: &ResolverContext,
2013) -> Result<ResolvedValue> {
2014    let mut archive = tar::Archive::new(reader);
2015
2016    // Iterate through entries to find the target file
2017    for entry_result in archive.entries().map_err(|e| {
2018        Error::resolver_custom("extract", format!("Failed to read TAR archive: {}", e))
2019            .with_path(ctx.config_path.clone())
2020    })? {
2021        let entry = entry_result.map_err(|e| {
2022            Error::resolver_custom("extract", format!("Failed to read TAR entry: {}", e))
2023                .with_path(ctx.config_path.clone())
2024        })?;
2025
2026        let path = entry.path().map_err(|e| {
2027            Error::resolver_custom("extract", format!("Invalid TAR entry path: {}", e))
2028                .with_path(ctx.config_path.clone())
2029        })?;
2030
2031        if path.to_string_lossy() == file_path {
2032            // Found the file - read its contents with size limits (zip bomb protection)
2033            // TAR doesn't store compressed size, so we can't check compression ratio
2034            let contents = read_with_limits(entry, MAX_EXTRACTED_FILE_SIZE, None)
2035                .map_err(|e| e.with_path(ctx.config_path.clone()))?;
2036
2037            return Ok(ResolvedValue::new(Value::Bytes(contents)));
2038        }
2039    }
2040
2041    // File not found in archive
2042    Err(Error::not_found(
2043        format!("File '{}' in TAR archive", file_path),
2044        Some(ctx.config_path.clone()),
2045    ))
2046}
2047
2048#[cfg(feature = "archive")]
2049fn extract_from_tar_gz<R: std::io::Read>(
2050    reader: R,
2051    file_path: &str,
2052    ctx: &ResolverContext,
2053) -> Result<ResolvedValue> {
2054    use flate2::read::GzDecoder;
2055
2056    // Decompress gzip layer
2057    let gz_decoder = GzDecoder::new(reader);
2058
2059    // Pass to TAR extractor
2060    extract_from_tar(gz_decoder, file_path, ctx)
2061}
2062
2063#[cfg(test)]
2064mod tests {
2065    use super::*;
2066
2067    #[test]
2068    fn test_env_resolver_with_value() {
2069        std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
2070
2071        let ctx = ResolverContext::new("test.path");
2072        let args = vec!["HOLOCONF_TEST_VAR".to_string()];
2073        let kwargs = HashMap::new();
2074
2075        let result = env_resolver(&args, &kwargs, &ctx).unwrap();
2076        assert_eq!(result.value.as_str(), Some("test_value"));
2077        assert!(!result.sensitive);
2078
2079        std::env::remove_var("HOLOCONF_TEST_VAR");
2080    }
2081
2082    #[test]
2083    fn test_env_resolver_missing_returns_error() {
2084        // Make sure the var doesn't exist
2085        std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
2086
2087        let registry = ResolverRegistry::with_builtins();
2088        let ctx = ResolverContext::new("test.path");
2089        let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
2090        let kwargs = HashMap::new();
2091
2092        // Registry doesn't handle defaults - that's done at Config level for lazy resolution
2093        // So this should return an error
2094        let result = registry.resolve("env", &args, &kwargs, &ctx);
2095        assert!(result.is_err());
2096    }
2097
2098    #[test]
2099    fn test_env_resolver_missing_no_default() {
2100        std::env::remove_var("HOLOCONF_MISSING_VAR");
2101
2102        let ctx = ResolverContext::new("test.path");
2103        let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
2104        let kwargs = HashMap::new();
2105
2106        let result = env_resolver(&args, &kwargs, &ctx);
2107        assert!(result.is_err());
2108    }
2109
2110    #[test]
2111    fn test_env_resolver_sensitive_kwarg() {
2112        std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
2113
2114        let registry = ResolverRegistry::with_builtins();
2115        let ctx = ResolverContext::new("test.path");
2116        let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
2117        let mut kwargs = HashMap::new();
2118        kwargs.insert("sensitive".to_string(), "true".to_string());
2119
2120        // Framework-level sensitive handling via registry
2121        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
2122        assert_eq!(result.value.as_str(), Some("secret_value"));
2123        assert!(result.sensitive);
2124
2125        std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
2126    }
2127
2128    #[test]
2129    fn test_env_resolver_sensitive_false() {
2130        std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
2131
2132        let registry = ResolverRegistry::with_builtins();
2133        let ctx = ResolverContext::new("test.path");
2134        let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
2135        let mut kwargs = HashMap::new();
2136        kwargs.insert("sensitive".to_string(), "false".to_string());
2137
2138        // Framework-level sensitive handling via registry
2139        let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
2140        assert_eq!(result.value.as_str(), Some("public_value"));
2141        assert!(!result.sensitive);
2142
2143        std::env::remove_var("HOLOCONF_NON_SENSITIVE");
2144    }
2145
2146    // Note: test_env_resolver_sensitive_with_default has moved to config.rs tests
2147    // since default handling with lazy resolution is done at the Config level
2148
2149    #[test]
2150    fn test_resolver_registry() {
2151        let registry = ResolverRegistry::with_builtins();
2152
2153        assert!(registry.contains("env"));
2154        assert!(!registry.contains("nonexistent"));
2155    }
2156
2157    #[test]
2158    fn test_custom_resolver() {
2159        let mut registry = ResolverRegistry::new();
2160
2161        registry.register_fn("custom", |args, _kwargs, _ctx| {
2162            let value = args.first().cloned().unwrap_or_default();
2163            Ok(ResolvedValue::new(Value::String(format!(
2164                "custom:{}",
2165                value
2166            ))))
2167        });
2168
2169        let ctx = ResolverContext::new("test");
2170        let result = registry
2171            .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
2172            .unwrap();
2173
2174        assert_eq!(result.value.as_str(), Some("custom:arg"));
2175    }
2176
2177    #[test]
2178    fn test_resolved_value_sensitivity() {
2179        let non_sensitive = ResolvedValue::new("public");
2180        assert!(!non_sensitive.sensitive);
2181
2182        let sensitive = ResolvedValue::sensitive("secret");
2183        assert!(sensitive.sensitive);
2184    }
2185
2186    #[test]
2187    fn test_resolver_context_cycle_detection() {
2188        let mut ctx = ResolverContext::new("root");
2189        ctx.push_resolution("a");
2190        ctx.push_resolution("b");
2191
2192        assert!(ctx.would_cause_cycle("a"));
2193        assert!(ctx.would_cause_cycle("b"));
2194        assert!(!ctx.would_cause_cycle("c"));
2195
2196        ctx.pop_resolution();
2197        assert!(!ctx.would_cause_cycle("b"));
2198    }
2199
2200    #[test]
2201    fn test_file_resolver() {
2202        use std::io::Write;
2203
2204        // Create a temporary file
2205        let temp_dir = std::env::temp_dir();
2206        let test_file = temp_dir.join("holoconf_test_file.txt");
2207        {
2208            let mut file = std::fs::File::create(&test_file).unwrap();
2209            writeln!(file, "test content").unwrap();
2210        }
2211
2212        let mut ctx = ResolverContext::new("test.path");
2213        ctx.base_path = Some(temp_dir.clone());
2214        ctx.file_roots.insert(temp_dir.clone());
2215
2216        let args = vec!["holoconf_test_file.txt".to_string()];
2217        let mut kwargs = HashMap::new();
2218        kwargs.insert("parse".to_string(), "text".to_string());
2219
2220        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2221        assert!(result.value.as_str().unwrap().contains("test content"));
2222        assert!(!result.sensitive);
2223
2224        // Cleanup
2225        std::fs::remove_file(test_file).ok();
2226    }
2227
2228    #[test]
2229    fn test_file_resolver_yaml() {
2230        use std::io::Write;
2231
2232        // Create a temporary YAML file
2233        let temp_dir = std::env::temp_dir();
2234        let test_file = temp_dir.join("holoconf_test.yaml");
2235        {
2236            let mut file = std::fs::File::create(&test_file).unwrap();
2237            writeln!(file, "key: value").unwrap();
2238            writeln!(file, "number: 42").unwrap();
2239        }
2240
2241        let mut ctx = ResolverContext::new("test.path");
2242        ctx.base_path = Some(temp_dir.clone());
2243        ctx.file_roots.insert(temp_dir.clone());
2244
2245        let args = vec!["holoconf_test.yaml".to_string()];
2246        let kwargs = HashMap::new();
2247
2248        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2249        // File resolver now returns text by default (no auto-parsing)
2250        // Use ${yaml:${file:...}} for structured data
2251        assert!(result.value.is_string());
2252        assert!(result.value.as_str().unwrap().contains("key: value"));
2253
2254        // Cleanup
2255        std::fs::remove_file(test_file).ok();
2256    }
2257
2258    #[test]
2259    fn test_file_resolver_not_found() {
2260        let ctx = ResolverContext::new("test.path");
2261        let args = vec!["nonexistent_file.txt".to_string()];
2262        let kwargs = HashMap::new();
2263
2264        let result = file_resolver(&args, &kwargs, &ctx);
2265        assert!(result.is_err());
2266    }
2267
2268    #[test]
2269    fn test_registry_with_file() {
2270        let registry = ResolverRegistry::with_builtins();
2271        assert!(registry.contains("file"));
2272    }
2273
2274    #[test]
2275    #[cfg(feature = "http")]
2276    fn test_http_resolver_disabled() {
2277        let ctx = ResolverContext::new("test.path");
2278        let args = vec!["example.com/config.yaml".to_string()];
2279        let kwargs = HashMap::new();
2280
2281        let result = http_resolver(&args, &kwargs, &ctx);
2282        assert!(result.is_err());
2283
2284        let err = result.unwrap_err();
2285        let display = format!("{}", err);
2286        // Error message uses uppercase HTTP
2287        assert!(display.contains("HTTP resolver is disabled"));
2288    }
2289
2290    #[test]
2291    #[cfg(feature = "http")]
2292    fn test_registry_with_http() {
2293        let registry = ResolverRegistry::with_builtins();
2294        assert!(registry.contains("http"));
2295    }
2296
2297    #[test]
2298    #[cfg(feature = "http")]
2299    fn test_registry_with_https() {
2300        let registry = ResolverRegistry::with_builtins();
2301        assert!(
2302            registry.contains("https"),
2303            "https resolver should be registered when http feature is enabled"
2304        );
2305    }
2306
2307    // Additional edge case tests for improved coverage
2308
2309    #[test]
2310    fn test_env_resolver_no_args() {
2311        let ctx = ResolverContext::new("test.path");
2312        let args = vec![];
2313        let kwargs = HashMap::new();
2314
2315        let result = env_resolver(&args, &kwargs, &ctx);
2316        assert!(result.is_err());
2317        let err = result.unwrap_err();
2318        assert!(err.to_string().contains("requires"));
2319    }
2320
2321    #[test]
2322    fn test_file_resolver_no_args() {
2323        let ctx = ResolverContext::new("test.path");
2324        let args = vec![];
2325        let kwargs = HashMap::new();
2326
2327        let result = file_resolver(&args, &kwargs, &ctx);
2328        assert!(result.is_err());
2329        let err = result.unwrap_err();
2330        assert!(err.to_string().contains("requires"));
2331    }
2332
2333    #[test]
2334    fn test_http_resolver_no_args() {
2335        let ctx = ResolverContext::new("test.path");
2336        let args = vec![];
2337        let kwargs = HashMap::new();
2338
2339        let result = http_resolver(&args, &kwargs, &ctx);
2340        assert!(result.is_err());
2341        let err = result.unwrap_err();
2342        assert!(err.to_string().contains("requires"));
2343    }
2344
2345    #[test]
2346    fn test_unknown_resolver() {
2347        let registry = ResolverRegistry::with_builtins();
2348        let ctx = ResolverContext::new("test.path");
2349
2350        let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
2351        assert!(result.is_err());
2352        let err = result.unwrap_err();
2353        assert!(err.to_string().contains("unknown_resolver"));
2354    }
2355
2356    #[test]
2357    fn test_resolved_value_from_traits() {
2358        let from_value: ResolvedValue = Value::String("test".to_string()).into();
2359        assert_eq!(from_value.value.as_str(), Some("test"));
2360        assert!(!from_value.sensitive);
2361
2362        let from_string: ResolvedValue = "hello".to_string().into();
2363        assert_eq!(from_string.value.as_str(), Some("hello"));
2364
2365        let from_str: ResolvedValue = "world".into();
2366        assert_eq!(from_str.value.as_str(), Some("world"));
2367    }
2368
2369    #[test]
2370    fn test_resolver_context_with_base_path() {
2371        let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
2372        assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
2373    }
2374
2375    #[test]
2376    fn test_resolver_context_with_config_root() {
2377        use std::sync::Arc;
2378        let root = Arc::new(Value::String("root".to_string()));
2379        let ctx = ResolverContext::new("test").with_config_root(root.clone());
2380        assert!(ctx.config_root.is_some());
2381    }
2382
2383    #[test]
2384    fn test_resolver_context_resolution_chain() {
2385        let mut ctx = ResolverContext::new("root");
2386        ctx.push_resolution("a");
2387        ctx.push_resolution("b");
2388        ctx.push_resolution("c");
2389
2390        let chain = ctx.get_resolution_chain();
2391        assert_eq!(chain, vec!["a", "b", "c"]);
2392    }
2393
2394    #[test]
2395    fn test_registry_get_resolver() {
2396        let registry = ResolverRegistry::with_builtins();
2397
2398        let env_resolver = registry.get("env");
2399        assert!(env_resolver.is_some());
2400        assert_eq!(env_resolver.unwrap().name(), "env");
2401
2402        let missing = registry.get("nonexistent");
2403        assert!(missing.is_none());
2404    }
2405
2406    #[test]
2407    fn test_registry_default() {
2408        let registry = ResolverRegistry::default();
2409        // Default registry is empty
2410        assert!(!registry.contains("env"));
2411    }
2412
2413    #[test]
2414    fn test_fn_resolver_name() {
2415        let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
2416        assert_eq!(resolver.name(), "my_resolver");
2417    }
2418
2419    #[test]
2420    fn test_file_resolver_json() {
2421        use std::io::Write;
2422
2423        // Create a temporary JSON file
2424        let temp_dir = std::env::temp_dir();
2425        let test_file = temp_dir.join("holoconf_test.json");
2426        {
2427            let mut file = std::fs::File::create(&test_file).unwrap();
2428            writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
2429        }
2430
2431        let mut ctx = ResolverContext::new("test.path");
2432        ctx.base_path = Some(temp_dir.clone());
2433        ctx.file_roots.insert(temp_dir.clone());
2434
2435        let args = vec!["holoconf_test.json".to_string()];
2436        let kwargs = HashMap::new();
2437
2438        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2439        // File resolver now returns text by default (no auto-parsing)
2440        // Use ${json:${file:...}} for structured data
2441        assert!(result.value.is_string());
2442        assert!(result.value.as_str().unwrap().contains(r#""key": "value""#));
2443
2444        // Cleanup
2445        std::fs::remove_file(test_file).ok();
2446    }
2447
2448    #[test]
2449    fn test_file_resolver_absolute_path() {
2450        use std::io::Write;
2451
2452        // Create a temporary file
2453        let temp_dir = std::env::temp_dir();
2454        let test_file = temp_dir.join("holoconf_abs_test.txt");
2455        {
2456            let mut file = std::fs::File::create(&test_file).unwrap();
2457            writeln!(file, "absolute path content").unwrap();
2458        }
2459
2460        let mut ctx = ResolverContext::new("test.path");
2461        ctx.file_roots.insert(temp_dir.clone());
2462        // No base path - using absolute path directly
2463        let args = vec![test_file.to_string_lossy().to_string()];
2464        let mut kwargs = HashMap::new();
2465        kwargs.insert("parse".to_string(), "text".to_string());
2466
2467        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2468        assert!(result
2469            .value
2470            .as_str()
2471            .unwrap()
2472            .contains("absolute path content"));
2473
2474        // Cleanup
2475        std::fs::remove_file(test_file).ok();
2476    }
2477
2478    #[test]
2479    fn test_file_resolver_invalid_yaml() {
2480        use std::io::Write;
2481
2482        // Create a temporary file with invalid YAML
2483        let temp_dir = std::env::temp_dir();
2484        let test_file = temp_dir.join("holoconf_invalid.yaml");
2485        {
2486            let mut file = std::fs::File::create(&test_file).unwrap();
2487            writeln!(file, "key: [invalid").unwrap();
2488        }
2489
2490        let mut ctx = ResolverContext::new("test.path");
2491        ctx.base_path = Some(temp_dir.clone());
2492        ctx.file_roots.insert(temp_dir.clone());
2493
2494        let args = vec!["holoconf_invalid.yaml".to_string()];
2495        let kwargs = HashMap::new();
2496
2497        // File resolver now returns text regardless of extension
2498        // Invalid YAML is just returned as text - parsing errors happen in yaml_resolver
2499        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2500        assert!(result.value.is_string());
2501
2502        // Cleanup
2503        std::fs::remove_file(test_file).ok();
2504    }
2505
2506    #[test]
2507    fn test_file_resolver_invalid_json() {
2508        use std::io::Write;
2509
2510        // Create a temporary file with invalid JSON
2511        let temp_dir = std::env::temp_dir();
2512        let test_file = temp_dir.join("holoconf_invalid.json");
2513        {
2514            let mut file = std::fs::File::create(&test_file).unwrap();
2515            writeln!(file, "{{invalid json}}").unwrap();
2516        }
2517
2518        let mut ctx = ResolverContext::new("test.path");
2519        ctx.base_path = Some(temp_dir.clone());
2520        ctx.file_roots.insert(temp_dir.clone());
2521
2522        let args = vec!["holoconf_invalid.json".to_string()];
2523        let kwargs = HashMap::new();
2524
2525        // File resolver now returns text regardless of extension
2526        // Invalid JSON is just returned as text - parsing errors happen in json_resolver
2527        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2528        assert!(result.value.is_string());
2529
2530        // Cleanup
2531        std::fs::remove_file(test_file).ok();
2532    }
2533
2534    #[test]
2535    fn test_file_resolver_unknown_extension() {
2536        use std::io::Write;
2537
2538        // Create a temporary file with unknown extension (treated as text)
2539        let temp_dir = std::env::temp_dir();
2540        let test_file = temp_dir.join("holoconf_test.xyz");
2541        {
2542            let mut file = std::fs::File::create(&test_file).unwrap();
2543            writeln!(file, "plain text content").unwrap();
2544        }
2545
2546        let mut ctx = ResolverContext::new("test.path");
2547        ctx.base_path = Some(temp_dir.clone());
2548        ctx.file_roots.insert(temp_dir.clone());
2549
2550        let args = vec!["holoconf_test.xyz".to_string()];
2551        let kwargs = HashMap::new();
2552
2553        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2554        // Unknown extension defaults to text mode
2555        assert!(result
2556            .value
2557            .as_str()
2558            .unwrap()
2559            .contains("plain text content"));
2560
2561        // Cleanup
2562        std::fs::remove_file(test_file).ok();
2563    }
2564
2565    #[test]
2566    fn test_file_resolver_encoding_utf8() {
2567        use std::io::Write;
2568
2569        // Create a temporary file with UTF-8 content including non-ASCII
2570        let temp_dir = std::env::temp_dir();
2571        let test_file = temp_dir.join("holoconf_utf8.txt");
2572        {
2573            let mut file = std::fs::File::create(&test_file).unwrap();
2574            writeln!(file, "Hello, 世界! 🌍").unwrap();
2575        }
2576
2577        let mut ctx = ResolverContext::new("test.path");
2578        ctx.base_path = Some(temp_dir.clone());
2579        ctx.file_roots.insert(temp_dir.clone());
2580
2581        let args = vec!["holoconf_utf8.txt".to_string()];
2582        let mut kwargs = HashMap::new();
2583        kwargs.insert("encoding".to_string(), "utf-8".to_string());
2584
2585        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2586        let content = result.value.as_str().unwrap();
2587        assert!(content.contains("世界"));
2588        assert!(content.contains("🌍"));
2589
2590        // Cleanup
2591        std::fs::remove_file(test_file).ok();
2592    }
2593
2594    #[test]
2595    fn test_file_resolver_encoding_ascii() {
2596        use std::io::Write;
2597
2598        // Create a temporary file with mixed ASCII and non-ASCII content
2599        let temp_dir = std::env::temp_dir();
2600        let test_file = temp_dir.join("holoconf_ascii.txt");
2601        {
2602            let mut file = std::fs::File::create(&test_file).unwrap();
2603            writeln!(file, "Hello, 世界! Welcome").unwrap();
2604        }
2605
2606        let mut ctx = ResolverContext::new("test.path");
2607        ctx.base_path = Some(temp_dir.clone());
2608        ctx.file_roots.insert(temp_dir.clone());
2609
2610        let args = vec!["holoconf_ascii.txt".to_string()];
2611        let mut kwargs = HashMap::new();
2612        kwargs.insert("encoding".to_string(), "ascii".to_string());
2613
2614        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2615        let content = result.value.as_str().unwrap();
2616        // ASCII mode should strip non-ASCII characters
2617        assert!(content.contains("Hello"));
2618        assert!(content.contains("Welcome"));
2619        assert!(!content.contains("世界"));
2620
2621        // Cleanup
2622        std::fs::remove_file(test_file).ok();
2623    }
2624
2625    #[test]
2626    fn test_file_resolver_encoding_base64() {
2627        use std::io::Write;
2628
2629        // Create a temporary file with binary content
2630        let temp_dir = std::env::temp_dir();
2631        let test_file = temp_dir.join("holoconf_binary.bin");
2632        {
2633            let mut file = std::fs::File::create(&test_file).unwrap();
2634            // Write some bytes that include non-UTF8 sequences
2635            file.write_all(b"Hello\x00\x01\x02World").unwrap();
2636        }
2637
2638        let mut ctx = ResolverContext::new("test.path");
2639        ctx.base_path = Some(temp_dir.clone());
2640        ctx.file_roots.insert(temp_dir.clone());
2641
2642        let args = vec!["holoconf_binary.bin".to_string()];
2643        let mut kwargs = HashMap::new();
2644        kwargs.insert("encoding".to_string(), "base64".to_string());
2645
2646        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2647        let content = result.value.as_str().unwrap();
2648
2649        // Verify the base64 encoding is correct
2650        use base64::{engine::general_purpose::STANDARD, Engine as _};
2651        let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
2652        assert_eq!(content, expected);
2653
2654        // Cleanup
2655        std::fs::remove_file(test_file).ok();
2656    }
2657
2658    #[test]
2659    fn test_file_resolver_encoding_default_is_utf8() {
2660        use std::io::Write;
2661
2662        // Create a temporary file with UTF-8 content
2663        let temp_dir = std::env::temp_dir();
2664        let test_file = temp_dir.join("holoconf_default_enc.txt");
2665        {
2666            let mut file = std::fs::File::create(&test_file).unwrap();
2667            writeln!(file, "café résumé").unwrap();
2668        }
2669
2670        let mut ctx = ResolverContext::new("test.path");
2671        ctx.base_path = Some(temp_dir.clone());
2672        ctx.file_roots.insert(temp_dir.clone());
2673
2674        let args = vec!["holoconf_default_enc.txt".to_string()];
2675        let kwargs = HashMap::new(); // No encoding specified
2676
2677        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2678        let content = result.value.as_str().unwrap();
2679        // Default encoding should be UTF-8, preserving accents
2680        assert!(content.contains("café"));
2681        assert!(content.contains("résumé"));
2682
2683        // Cleanup
2684        std::fs::remove_file(test_file).ok();
2685    }
2686
2687    #[test]
2688    fn test_file_resolver_encoding_binary() {
2689        use std::io::Write;
2690
2691        // Create a temporary file with binary content
2692        let temp_dir = std::env::temp_dir();
2693        let test_file = temp_dir.join("holoconf_binary_bytes.bin");
2694        let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
2695        {
2696            let mut file = std::fs::File::create(&test_file).unwrap();
2697            file.write_all(&binary_data).unwrap();
2698        }
2699
2700        let mut ctx = ResolverContext::new("test.path");
2701        ctx.base_path = Some(temp_dir.clone());
2702        ctx.file_roots.insert(temp_dir.clone());
2703
2704        let args = vec!["holoconf_binary_bytes.bin".to_string()];
2705        let mut kwargs = HashMap::new();
2706        kwargs.insert("encoding".to_string(), "binary".to_string());
2707
2708        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2709
2710        // Verify we get Value::Stream back (streaming support)
2711        assert!(result.value.is_stream());
2712
2713        // Materialize the stream and verify contents
2714        let materialized = result.value.materialize().unwrap();
2715        assert!(materialized.is_bytes());
2716        assert_eq!(materialized.as_bytes().unwrap(), &binary_data);
2717
2718        // Cleanup
2719        std::fs::remove_file(test_file).ok();
2720    }
2721
2722    #[test]
2723    fn test_file_resolver_encoding_binary_empty() {
2724        // Create an empty file
2725        let temp_dir = std::env::temp_dir();
2726        let test_file = temp_dir.join("holoconf_binary_empty.bin");
2727        {
2728            std::fs::File::create(&test_file).unwrap();
2729        }
2730
2731        let mut ctx = ResolverContext::new("test.path");
2732        ctx.base_path = Some(temp_dir.clone());
2733        ctx.file_roots.insert(temp_dir.clone());
2734
2735        let args = vec!["holoconf_binary_empty.bin".to_string()];
2736        let mut kwargs = HashMap::new();
2737        kwargs.insert("encoding".to_string(), "binary".to_string());
2738
2739        let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2740
2741        // Verify we get Value::Stream (streaming support)
2742        assert!(result.value.is_stream());
2743
2744        // Materialize and verify empty bytes
2745        let materialized = result.value.materialize().unwrap();
2746        assert!(materialized.is_bytes());
2747        let empty: &[u8] = &[];
2748        assert_eq!(materialized.as_bytes().unwrap(), empty);
2749
2750        // Cleanup
2751        std::fs::remove_file(test_file).ok();
2752    }
2753
2754    // Framework-level sensitive test (default handling moved to config tests)
2755
2756    #[test]
2757    fn test_file_resolver_with_sensitive() {
2758        use std::io::Write;
2759
2760        // Create a temporary file
2761        let temp_dir = std::env::temp_dir();
2762        let test_file = temp_dir.join("holoconf_sensitive_test.txt");
2763        {
2764            let mut file = std::fs::File::create(&test_file).unwrap();
2765            writeln!(file, "secret content").unwrap();
2766        }
2767
2768        let registry = ResolverRegistry::with_builtins();
2769        let mut ctx = ResolverContext::new("test.path");
2770        ctx.base_path = Some(temp_dir.clone());
2771        ctx.file_roots.insert(temp_dir.clone());
2772
2773        let args = vec!["holoconf_sensitive_test.txt".to_string()];
2774        let mut kwargs = HashMap::new();
2775        kwargs.insert("sensitive".to_string(), "true".to_string());
2776
2777        // Framework-level sensitive handling via registry
2778        let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
2779        assert!(result.value.as_str().unwrap().contains("secret content"));
2780        assert!(result.sensitive);
2781
2782        // Cleanup
2783        std::fs::remove_file(test_file).ok();
2784    }
2785
2786    #[test]
2787    fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
2788        // Ensure that 'sensitive' kwarg is NOT passed to the resolver
2789        // (Note: 'default' is handled at Config level, not registry level)
2790        let mut registry = ResolverRegistry::new();
2791
2792        // Register a test resolver that checks it doesn't receive sensitive kwarg
2793        registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
2794            // Sensitive kwarg should be filtered out
2795            assert!(
2796                !kwargs.contains_key("sensitive"),
2797                "sensitive kwarg should not be passed to resolver"
2798            );
2799            // But custom kwargs should be passed through
2800            if let Some(custom) = kwargs.get("custom") {
2801                Ok(ResolvedValue::new(Value::String(format!(
2802                    "custom={}",
2803                    custom
2804                ))))
2805            } else {
2806                Ok(ResolvedValue::new(Value::String("no custom".to_string())))
2807            }
2808        });
2809
2810        let ctx = ResolverContext::new("test.path");
2811        let args = vec![];
2812        let mut kwargs = HashMap::new();
2813        kwargs.insert("sensitive".to_string(), "true".to_string());
2814        kwargs.insert("custom".to_string(), "myvalue".to_string());
2815
2816        let result = registry
2817            .resolve("test_kwargs", &args, &kwargs, &ctx)
2818            .unwrap();
2819        assert_eq!(result.value.as_str(), Some("custom=myvalue"));
2820        // Sensitive override should still be applied by framework
2821        assert!(result.sensitive);
2822    }
2823
2824    // Tests for normalize_http_url function
2825    #[test]
2826    #[cfg(feature = "http")]
2827    fn test_normalize_http_url_clean_syntax() {
2828        assert_eq!(
2829            normalize_http_url("https", "example.com/path").unwrap(),
2830            "https://example.com/path"
2831        );
2832        assert_eq!(
2833            normalize_http_url("http", "example.com").unwrap(),
2834            "http://example.com"
2835        );
2836    }
2837
2838    #[test]
2839    #[cfg(feature = "http")]
2840    fn test_normalize_http_url_double_slash() {
2841        assert_eq!(
2842            normalize_http_url("https", "//example.com/path").unwrap(),
2843            "https://example.com/path"
2844        );
2845    }
2846
2847    #[test]
2848    #[cfg(feature = "http")]
2849    fn test_normalize_http_url_existing_https() {
2850        assert_eq!(
2851            normalize_http_url("https", "https://example.com/path").unwrap(),
2852            "https://example.com/path"
2853        );
2854    }
2855
2856    #[test]
2857    #[cfg(feature = "http")]
2858    fn test_normalize_http_url_wrong_scheme() {
2859        // Should strip http:// and add https://
2860        assert_eq!(
2861            normalize_http_url("https", "http://example.com").unwrap(),
2862            "https://example.com"
2863        );
2864    }
2865
2866    #[test]
2867    #[cfg(feature = "http")]
2868    fn test_normalize_http_url_with_query() {
2869        assert_eq!(
2870            normalize_http_url("https", "example.com/path?query=val&other=val2").unwrap(),
2871            "https://example.com/path?query=val&other=val2"
2872        );
2873    }
2874
2875    #[test]
2876    #[cfg(feature = "http")]
2877    fn test_normalize_http_url_empty() {
2878        let result = normalize_http_url("https", "");
2879        assert!(result.is_err());
2880        assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2881    }
2882
2883    #[test]
2884    #[cfg(feature = "http")]
2885    fn test_normalize_http_url_triple_slash() {
2886        // ${https:///example.com} should error (invalid syntax)
2887        let result = normalize_http_url("https", "///example.com");
2888        assert!(result.is_err());
2889        assert!(result
2890            .unwrap_err()
2891            .to_string()
2892            .contains("Invalid URL syntax"));
2893    }
2894
2895    #[test]
2896    #[cfg(feature = "http")]
2897    fn test_normalize_http_url_whitespace_only() {
2898        let result = normalize_http_url("https", "   ");
2899        assert!(result.is_err());
2900        assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2901    }
2902
2903    // Tests for is_localhost function
2904    #[test]
2905    fn test_is_localhost_ascii() {
2906        assert!(is_localhost("localhost"));
2907        assert!(is_localhost("LOCALHOST"));
2908        assert!(is_localhost("LocalHost"));
2909    }
2910
2911    #[test]
2912    fn test_is_localhost_ipv4() {
2913        assert!(is_localhost("127.0.0.1"));
2914        assert!(is_localhost("127.0.0.100"));
2915        assert!(is_localhost("127.1.2.3"));
2916        assert!(!is_localhost("128.0.0.1"));
2917    }
2918
2919    #[test]
2920    fn test_is_localhost_ipv6() {
2921        assert!(is_localhost("::1"));
2922        assert!(is_localhost("[::1]"));
2923        assert!(!is_localhost("::2"));
2924    }
2925
2926    #[test]
2927    fn test_is_localhost_not() {
2928        assert!(!is_localhost("example.com"));
2929        assert!(!is_localhost("remote.host"));
2930        assert!(!is_localhost("192.168.1.1"));
2931    }
2932
2933    // Tests for normalize_file_path function
2934    #[test]
2935    fn test_normalize_file_path_relative() {
2936        let (path, is_rel) = normalize_file_path("data.txt").unwrap();
2937        assert_eq!(path, "data.txt");
2938        assert!(is_rel);
2939
2940        let (path, is_rel) = normalize_file_path("./data.txt").unwrap();
2941        assert_eq!(path, "./data.txt");
2942        assert!(is_rel);
2943    }
2944
2945    #[test]
2946    fn test_normalize_file_path_absolute() {
2947        let (path, is_rel) = normalize_file_path("/etc/config.yaml").unwrap();
2948        assert_eq!(path, "/etc/config.yaml");
2949        assert!(!is_rel);
2950    }
2951
2952    #[test]
2953    fn test_normalize_file_path_rfc8089_empty_authority() {
2954        // file:/// (empty authority = localhost)
2955        let (path, is_rel) = normalize_file_path("///etc/config.yaml").unwrap();
2956        assert_eq!(path, "/etc/config.yaml");
2957        assert!(!is_rel);
2958    }
2959
2960    #[test]
2961    fn test_normalize_file_path_rfc8089_localhost() {
2962        // file://localhost/
2963        let (path, is_rel) = normalize_file_path("//localhost/var/data").unwrap();
2964        assert_eq!(path, "/var/data");
2965        assert!(!is_rel);
2966
2967        // file://localhost (no path)
2968        let (path, is_rel) = normalize_file_path("//localhost").unwrap();
2969        assert_eq!(path, "/");
2970        assert!(!is_rel);
2971    }
2972
2973    #[test]
2974    fn test_normalize_file_path_rfc8089_localhost_ipv4() {
2975        // file://127.0.0.1/
2976        let (path, is_rel) = normalize_file_path("//127.0.0.1/tmp/file.txt").unwrap();
2977        assert_eq!(path, "/tmp/file.txt");
2978        assert!(!is_rel);
2979    }
2980
2981    #[test]
2982    fn test_normalize_file_path_rfc8089_localhost_ipv6() {
2983        // file://::1/
2984        let (path, is_rel) = normalize_file_path("//::1/tmp/file.txt").unwrap();
2985        assert_eq!(path, "/tmp/file.txt");
2986        assert!(!is_rel);
2987    }
2988
2989    #[test]
2990    fn test_normalize_file_path_rfc8089_remote_rejected() {
2991        let result = normalize_file_path("//remote.host/path");
2992        assert!(result.is_err());
2993        let err_msg = result.unwrap_err().to_string();
2994        assert!(err_msg.contains("Remote file URIs not supported"));
2995        assert!(err_msg.contains("remote.host"));
2996
2997        let result = normalize_file_path("//server.example.com/share");
2998        assert!(result.is_err());
2999    }
3000
3001    #[test]
3002    fn test_normalize_file_path_rfc8089_empty_hostname() {
3003        // file:// with no authority
3004        let (path, is_rel) = normalize_file_path("//").unwrap();
3005        assert_eq!(path, "/");
3006        assert!(!is_rel);
3007    }
3008
3009    #[test]
3010    fn test_normalize_file_path_null_byte() {
3011        let result = normalize_file_path("/etc/passwd\0.txt");
3012        assert!(result.is_err());
3013        assert!(result.unwrap_err().to_string().contains("null byte"));
3014    }
3015
3016    #[test]
3017    fn test_normalize_file_path_null_byte_relative() {
3018        let result = normalize_file_path("data\0.txt");
3019        assert!(result.is_err());
3020        assert!(result.unwrap_err().to_string().contains("null byte"));
3021    }
3022
3023    // Tests for CertInput type
3024    #[test]
3025    fn test_cert_input_is_pem_content() {
3026        let pem_content = CertInput::Text("-----BEGIN CERTIFICATE-----\nMIIC...".to_string());
3027        assert!(pem_content.is_pem_content());
3028
3029        let file_path = CertInput::Text("/path/to/cert.pem".to_string());
3030        assert!(!file_path.is_pem_content());
3031
3032        let binary = CertInput::Binary(vec![0, 1, 2, 3]);
3033        assert!(!binary.is_pem_content());
3034    }
3035
3036    #[test]
3037    fn test_cert_input_is_p12_path() {
3038        assert!(CertInput::Text("/path/to/identity.p12".to_string()).is_p12_path());
3039        assert!(CertInput::Text("/path/to/identity.pfx".to_string()).is_p12_path());
3040        assert!(CertInput::Text("/path/to/identity.P12".to_string()).is_p12_path());
3041        assert!(CertInput::Text("/path/to/identity.PFX".to_string()).is_p12_path());
3042
3043        assert!(!CertInput::Text("/path/to/cert.pem".to_string()).is_p12_path());
3044        assert!(!CertInput::Text("-----BEGIN CERTIFICATE-----".to_string()).is_p12_path());
3045        assert!(!CertInput::Binary(vec![0, 1, 2, 3]).is_p12_path());
3046    }
3047
3048    #[test]
3049    fn test_cert_input_as_text() {
3050        let text_input = CertInput::Text("some text".to_string());
3051        assert_eq!(text_input.as_text(), Some("some text"));
3052
3053        let binary_input = CertInput::Binary(vec![0, 1, 2]);
3054        assert_eq!(binary_input.as_text(), None);
3055    }
3056
3057    #[test]
3058    fn test_cert_input_as_bytes() {
3059        let binary_input = CertInput::Binary(vec![0, 1, 2]);
3060        assert_eq!(binary_input.as_bytes(), Some(&[0, 1, 2][..]));
3061
3062        let text_input = CertInput::Text("some text".to_string());
3063        assert_eq!(text_input.as_bytes(), None);
3064    }
3065
3066    #[test]
3067    fn test_cert_input_from_string() {
3068        let input1 = CertInput::from("test".to_string());
3069        assert!(matches!(input1, CertInput::Text(_)));
3070        assert_eq!(input1.as_text(), Some("test"));
3071
3072        let input2 = CertInput::from("test");
3073        assert!(matches!(input2, CertInput::Text(_)));
3074        assert_eq!(input2.as_text(), Some("test"));
3075    }
3076
3077    #[test]
3078    fn test_cert_input_from_vec_u8() {
3079        let input = CertInput::from(vec![1, 2, 3]);
3080        assert!(matches!(input, CertInput::Binary(_)));
3081        assert_eq!(input.as_bytes(), Some(&[1, 2, 3][..]));
3082    }
3083}
3084
3085// Tests for global registry (TDD - written before implementation)
3086#[cfg(test)]
3087mod global_registry_tests {
3088    use super::*;
3089
3090    /// Test helper: create a mock resolver with a given name
3091    fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
3092        Arc::new(FnResolver::new(name, |_, _, _| {
3093            Ok(ResolvedValue::new("mock"))
3094        }))
3095    }
3096
3097    #[test]
3098    fn test_register_new_resolver_succeeds() {
3099        let mut registry = ResolverRegistry::new();
3100        let resolver = mock_resolver("test_new");
3101
3102        // Registering a new resolver should succeed with force=false
3103        let result = registry.register_with_force(resolver, false);
3104        assert!(result.is_ok());
3105        assert!(registry.contains("test_new"));
3106    }
3107
3108    #[test]
3109    fn test_register_duplicate_errors_without_force() {
3110        let mut registry = ResolverRegistry::new();
3111        let resolver1 = mock_resolver("test_dup");
3112        let resolver2 = mock_resolver("test_dup");
3113
3114        // First registration succeeds
3115        registry.register_with_force(resolver1, false).unwrap();
3116
3117        // Second registration with same name should fail without force
3118        let result = registry.register_with_force(resolver2, false);
3119        assert!(result.is_err());
3120        let err = result.unwrap_err();
3121        assert!(err.to_string().contains("already registered"));
3122    }
3123
3124    #[test]
3125    fn test_register_duplicate_succeeds_with_force() {
3126        let mut registry = ResolverRegistry::new();
3127        let resolver1 = mock_resolver("test_force");
3128        let resolver2 = mock_resolver("test_force");
3129
3130        // First registration succeeds
3131        registry.register_with_force(resolver1, false).unwrap();
3132
3133        // Second registration with force=true should succeed
3134        let result = registry.register_with_force(resolver2, true);
3135        assert!(result.is_ok());
3136    }
3137
3138    #[test]
3139    fn test_global_registry_is_singleton() {
3140        // The global registry should return the same instance
3141        let registry1 = global_registry();
3142        let registry2 = global_registry();
3143
3144        // They should point to the same instance (same address)
3145        assert!(std::ptr::eq(registry1, registry2));
3146    }
3147
3148    #[test]
3149    fn test_register_global_new_resolver() {
3150        // Clean slate - register a unique resolver name
3151        let resolver = mock_resolver("global_test_unique_42");
3152        let result = register_global(resolver, false);
3153        // May fail if already registered from previous test runs
3154        // That's expected behavior - the test verifies the API works
3155        assert!(result.is_ok() || result.is_err());
3156    }
3157}
3158
3159// Integration tests for lazy default resolution (requires Config)
3160#[cfg(test)]
3161mod lazy_resolution_tests {
3162    use super::*;
3163    use crate::Config;
3164    use std::sync::atomic::{AtomicBool, Ordering};
3165    use std::sync::Arc;
3166
3167    #[test]
3168    fn test_default_not_resolved_when_main_value_exists() {
3169        // Track whether the "fail" resolver was called
3170        let fail_called = Arc::new(AtomicBool::new(false));
3171        let fail_called_clone = fail_called.clone();
3172
3173        // Create a config with a custom resolver that would fail if called
3174        let yaml = r#"
3175value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
3176"#;
3177        // Set the env var so the default should NOT be needed
3178        std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
3179
3180        let mut config = Config::from_yaml(yaml).unwrap();
3181
3182        // Register a "fail" resolver that sets a flag and panics
3183        config.register_resolver(Arc::new(FnResolver::new(
3184            "fail",
3185            move |_args, _kwargs, _ctx| {
3186                fail_called_clone.store(true, Ordering::SeqCst);
3187                panic!("fail resolver should not have been called - lazy resolution failed!");
3188            },
3189        )));
3190
3191        // Access the value - should get main value, not call fail resolver
3192        let result = config.get("value").unwrap();
3193        assert_eq!(result.as_str(), Some("main_value"));
3194
3195        // Verify the fail resolver was never called
3196        assert!(
3197            !fail_called.load(Ordering::SeqCst),
3198            "The default resolver should not have been called when main value exists"
3199        );
3200
3201        std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
3202    }
3203
3204    #[test]
3205    fn test_default_is_resolved_when_main_value_missing() {
3206        // Track whether the default resolver was called
3207        let default_called = Arc::new(AtomicBool::new(false));
3208        let default_called_clone = default_called.clone();
3209
3210        // Create a config where env var doesn't exist
3211        let yaml = r#"
3212value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
3213"#;
3214        std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
3215
3216        let mut config = Config::from_yaml(yaml).unwrap();
3217
3218        // Register a custom default resolver
3219        config.register_resolver(Arc::new(FnResolver::new(
3220            "custom_default",
3221            move |args: &[String], _kwargs, _ctx| {
3222                default_called_clone.store(true, Ordering::SeqCst);
3223                let arg = args.first().cloned().unwrap_or_default();
3224                Ok(ResolvedValue::new(Value::String(format!(
3225                    "default_was_{}",
3226                    arg
3227                ))))
3228            },
3229        )));
3230
3231        // Access the value - should call default resolver since main value missing
3232        let result = config.get("value").unwrap();
3233        assert_eq!(result.as_str(), Some("default_was_fallback"));
3234
3235        // Verify the default resolver WAS called
3236        assert!(
3237            default_called.load(Ordering::SeqCst),
3238            "The default resolver should have been called when main value is missing"
3239        );
3240    }
3241}
3242
3243// HTTP resolver tests (require http feature and mockito)
3244#[cfg(all(test, feature = "http"))]
3245mod http_resolver_tests {
3246    use super::*;
3247    use mockito::Server;
3248
3249    #[test]
3250    fn test_http_fetch_json() {
3251        let mut server = Server::new();
3252        let mock = server
3253            .mock("GET", "/config.json")
3254            .with_status(200)
3255            .with_header("content-type", "application/json")
3256            .with_body(r#"{"key": "value", "number": 42}"#)
3257            .create();
3258
3259        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3260        let args = vec![format!("{}/config.json", server.url())];
3261        let kwargs = HashMap::new();
3262
3263        // HTTP resolver now returns text by default (no auto-parsing)
3264        // Use ${json:${http:...}} for structured data
3265        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3266        assert!(result.value.is_string());
3267        assert!(result.value.as_str().unwrap().contains(r#""key": "value""#));
3268
3269        mock.assert();
3270    }
3271
3272    #[test]
3273    fn test_http_fetch_yaml() {
3274        let mut server = Server::new();
3275        let mock = server
3276            .mock("GET", "/config.yaml")
3277            .with_status(200)
3278            .with_header("content-type", "application/yaml")
3279            .with_body("key: value\nnumber: 42")
3280            .create();
3281
3282        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3283        let args = vec![format!("{}/config.yaml", server.url())];
3284        let kwargs = HashMap::new();
3285
3286        // HTTP resolver now returns text by default (no auto-parsing)
3287        // Use ${yaml:${http:...}} for structured data
3288        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3289        assert!(result.value.is_string());
3290        assert!(result.value.as_str().unwrap().contains("key: value"));
3291
3292        mock.assert();
3293    }
3294
3295    #[test]
3296    fn test_http_fetch_text() {
3297        let mut server = Server::new();
3298        let mock = server
3299            .mock("GET", "/data.txt")
3300            .with_status(200)
3301            .with_header("content-type", "text/plain")
3302            .with_body("Hello, World!")
3303            .create();
3304
3305        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3306        let args = vec![format!("{}/data.txt", server.url())];
3307        let kwargs = HashMap::new();
3308
3309        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3310        assert_eq!(result.value.as_str(), Some("Hello, World!"));
3311
3312        mock.assert();
3313    }
3314
3315    #[test]
3316    fn test_http_fetch_binary() {
3317        let mut server = Server::new();
3318        let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
3319        let mock = server
3320            .mock("GET", "/data.bin")
3321            .with_status(200)
3322            .with_header("content-type", "application/octet-stream")
3323            .with_body(binary_data.clone())
3324            .create();
3325
3326        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3327        let args = vec![format!("{}/data.bin", server.url())];
3328        let mut kwargs = HashMap::new();
3329        kwargs.insert("parse".to_string(), "binary".to_string());
3330
3331        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3332
3333        // Verify we get Value::Stream (streaming support)
3334        assert!(result.value.is_stream());
3335
3336        // Materialize and verify contents
3337        let materialized = result.value.materialize().unwrap();
3338        assert!(materialized.is_bytes());
3339        assert_eq!(materialized.as_bytes().unwrap(), &binary_data);
3340
3341        mock.assert();
3342    }
3343
3344    #[test]
3345    fn test_http_fetch_explicit_parse_text() {
3346        let mut server = Server::new();
3347        // Return JSON but with parse=text it should be returned as string
3348        let mock = server
3349            .mock("GET", "/data")
3350            .with_status(200)
3351            .with_header("content-type", "application/json")
3352            .with_body(r#"{"key": "value"}"#)
3353            .create();
3354
3355        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3356        let args = vec![format!("{}/data", server.url())];
3357        let mut kwargs = HashMap::new();
3358        kwargs.insert("parse".to_string(), "text".to_string());
3359
3360        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3361        // With parse=text, JSON should be returned as string (not parsed)
3362        assert!(result.value.is_string());
3363        assert_eq!(result.value.as_str(), Some(r#"{"key": "value"}"#));
3364
3365        mock.assert();
3366    }
3367
3368    #[test]
3369    fn test_http_fetch_with_custom_header() {
3370        let mut server = Server::new();
3371        let mock = server
3372            .mock("GET", "/protected")
3373            .match_header("Authorization", "Bearer my-token")
3374            .with_status(200)
3375            .with_body("authorized content")
3376            .create();
3377
3378        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3379        let args = vec![format!("{}/protected", server.url())];
3380        let mut kwargs = HashMap::new();
3381        kwargs.insert(
3382            "header".to_string(),
3383            "Authorization:Bearer my-token".to_string(),
3384        );
3385
3386        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3387        assert_eq!(result.value.as_str(), Some("authorized content"));
3388
3389        mock.assert();
3390    }
3391
3392    #[test]
3393    fn test_http_fetch_404_error() {
3394        let mut server = Server::new();
3395        let mock = server.mock("GET", "/notfound").with_status(404).create();
3396
3397        let ctx = ResolverContext::new("test.path").with_allow_http(true);
3398        let args = vec![format!("{}/notfound", server.url())];
3399        let kwargs = HashMap::new();
3400
3401        let result = http_resolver(&args, &kwargs, &ctx);
3402        assert!(result.is_err());
3403        let err = result.unwrap_err();
3404        assert!(err.to_string().contains("HTTP"));
3405
3406        mock.assert();
3407    }
3408
3409    #[test]
3410    fn test_http_disabled_by_default() {
3411        let ctx = ResolverContext::new("test.path");
3412        // allow_http defaults to false
3413        let args = vec!["https://example.com/config.yaml".to_string()];
3414        let kwargs = HashMap::new();
3415
3416        let result = http_resolver(&args, &kwargs, &ctx);
3417        assert!(result.is_err());
3418        let err = result.unwrap_err();
3419        assert!(err.to_string().contains("disabled"));
3420    }
3421
3422    #[test]
3423    fn test_http_allowlist_blocks_url() {
3424        let ctx = ResolverContext::new("test.path")
3425            .with_allow_http(true)
3426            .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
3427
3428        let args = vec!["https://blocked.example.com/config.yaml".to_string()];
3429        let kwargs = HashMap::new();
3430
3431        let result = http_resolver(&args, &kwargs, &ctx);
3432        assert!(result.is_err());
3433        let err = result.unwrap_err();
3434        assert!(
3435            err.to_string().contains("not in allowlist")
3436                || err.to_string().contains("HttpNotAllowed")
3437        );
3438    }
3439
3440    #[test]
3441    fn test_http_allowlist_allows_matching_url() {
3442        let mut server = Server::new();
3443        let mock = server
3444            .mock("GET", "/config.yaml")
3445            .with_status(200)
3446            .with_body("key: value")
3447            .create();
3448
3449        // The allowlist pattern needs to match the server URL
3450        let server_url = server.url();
3451        let ctx = ResolverContext::new("test.path")
3452            .with_allow_http(true)
3453            .with_http_allowlist(vec![format!("{}/*", server_url)]);
3454
3455        let args = vec![format!("{}/config.yaml", server_url)];
3456        let kwargs = HashMap::new();
3457
3458        let result = http_resolver(&args, &kwargs, &ctx).unwrap();
3459        // HTTP resolver now returns text by default (no auto-parsing)
3460        assert!(result.value.is_string());
3461        assert!(result.value.as_str().unwrap().contains("key: value"));
3462
3463        mock.assert();
3464    }
3465
3466    #[test]
3467    fn test_url_matches_pattern_exact() {
3468        assert!(url_matches_pattern(
3469            "https://example.com/config.yaml",
3470            "https://example.com/config.yaml"
3471        ));
3472        assert!(!url_matches_pattern(
3473            "https://example.com/other.yaml",
3474            "https://example.com/config.yaml"
3475        ));
3476    }
3477
3478    #[test]
3479    fn test_url_matches_pattern_wildcard() {
3480        assert!(url_matches_pattern(
3481            "https://example.com/config.yaml",
3482            "https://example.com/*"
3483        ));
3484        assert!(url_matches_pattern(
3485            "https://example.com/path/to/config.yaml",
3486            "https://example.com/*"
3487        ));
3488        assert!(!url_matches_pattern(
3489            "https://other.com/config.yaml",
3490            "https://example.com/*"
3491        ));
3492    }
3493
3494    #[test]
3495    fn test_url_matches_pattern_subdomain() {
3496        assert!(url_matches_pattern(
3497            "https://api.example.com/config",
3498            "https://*.example.com/*"
3499        ));
3500        assert!(url_matches_pattern(
3501            "https://staging.example.com/config",
3502            "https://*.example.com/*"
3503        ));
3504        assert!(!url_matches_pattern(
3505            "https://example.com/config",
3506            "https://*.example.com/*"
3507        ));
3508    }
3509
3510    // ========================================
3511    // Transformation Resolver Tests
3512    // ========================================
3513
3514    #[test]
3515    fn test_json_resolver_valid() {
3516        let ctx = ResolverContext::new("test.path");
3517        let args = vec![r#"{"key": "value", "num": 42}"#.to_string()];
3518        let kwargs = HashMap::new();
3519
3520        let result = json_resolver(&args, &kwargs, &ctx).unwrap();
3521        assert!(result.value.is_mapping());
3522
3523        let map = result.value.as_mapping().unwrap();
3524        assert_eq!(map.get("key"), Some(&Value::String("value".to_string())));
3525        assert_eq!(map.get("num"), Some(&Value::Integer(42)));
3526        assert!(!result.sensitive);
3527    }
3528
3529    #[test]
3530    fn test_json_resolver_array() {
3531        let ctx = ResolverContext::new("test.path");
3532        let args = vec![r#"[1, 2, 3, "four"]"#.to_string()];
3533        let kwargs = HashMap::new();
3534
3535        let result = json_resolver(&args, &kwargs, &ctx).unwrap();
3536        assert!(result.value.is_sequence());
3537
3538        let seq = result.value.as_sequence().unwrap();
3539        assert_eq!(seq.len(), 4);
3540        assert_eq!(seq[0], Value::Integer(1));
3541        assert_eq!(seq[3], Value::String("four".to_string()));
3542    }
3543
3544    #[test]
3545    fn test_json_resolver_invalid() {
3546        let ctx = ResolverContext::new("test.path");
3547        let args = vec![r#"{"key": invalid}"#.to_string()];
3548        let kwargs = HashMap::new();
3549
3550        let result = json_resolver(&args, &kwargs, &ctx);
3551        assert!(result.is_err());
3552        let err = result.unwrap_err();
3553        assert!(err.to_string().contains("Invalid JSON"));
3554    }
3555
3556    #[test]
3557    fn test_json_resolver_no_args() {
3558        let ctx = ResolverContext::new("test.path");
3559        let args = vec![];
3560        let kwargs = HashMap::new();
3561
3562        let result = json_resolver(&args, &kwargs, &ctx);
3563        assert!(result.is_err());
3564        let err = result.unwrap_err();
3565        assert!(err.to_string().contains("requires a string argument"));
3566    }
3567
3568    #[test]
3569    fn test_yaml_resolver_valid() {
3570        let ctx = ResolverContext::new("test.path");
3571        let args = vec!["key: value\nnum: 42\nlist:\n  - a\n  - b".to_string()];
3572        let kwargs = HashMap::new();
3573
3574        let result = yaml_resolver(&args, &kwargs, &ctx).unwrap();
3575        assert!(result.value.is_mapping());
3576
3577        let map = result.value.as_mapping().unwrap();
3578        assert_eq!(map.get("key"), Some(&Value::String("value".to_string())));
3579        assert_eq!(map.get("num"), Some(&Value::Integer(42)));
3580        assert!(!result.sensitive);
3581    }
3582
3583    #[test]
3584    fn test_yaml_resolver_array() {
3585        let ctx = ResolverContext::new("test.path");
3586        let args = vec!["- one\n- two\n- three".to_string()];
3587        let kwargs = HashMap::new();
3588
3589        let result = yaml_resolver(&args, &kwargs, &ctx).unwrap();
3590        assert!(result.value.is_sequence());
3591
3592        let seq = result.value.as_sequence().unwrap();
3593        assert_eq!(seq.len(), 3);
3594        assert_eq!(seq[0], Value::String("one".to_string()));
3595    }
3596
3597    #[test]
3598    fn test_yaml_resolver_invalid() {
3599        let ctx = ResolverContext::new("test.path");
3600        let args = vec!["key: value\n  bad_indent: oops".to_string()];
3601        let kwargs = HashMap::new();
3602
3603        let result = yaml_resolver(&args, &kwargs, &ctx);
3604        assert!(result.is_err());
3605        let err = result.unwrap_err();
3606        assert!(err.to_string().contains("Invalid YAML"));
3607    }
3608
3609    #[test]
3610    fn test_yaml_resolver_no_args() {
3611        let ctx = ResolverContext::new("test.path");
3612        let args = vec![];
3613        let kwargs = HashMap::new();
3614
3615        let result = yaml_resolver(&args, &kwargs, &ctx);
3616        assert!(result.is_err());
3617        let err = result.unwrap_err();
3618        assert!(err.to_string().contains("requires a string argument"));
3619    }
3620
3621    #[test]
3622    fn test_split_resolver_basic() {
3623        let ctx = ResolverContext::new("test.path");
3624        let args = vec!["a,b,c".to_string()];
3625        let kwargs = HashMap::new();
3626
3627        let result = split_resolver(&args, &kwargs, &ctx).unwrap();
3628        assert!(result.value.is_sequence());
3629
3630        let seq = result.value.as_sequence().unwrap();
3631        assert_eq!(seq.len(), 3);
3632        assert_eq!(seq[0], Value::String("a".to_string()));
3633        assert_eq!(seq[1], Value::String("b".to_string()));
3634        assert_eq!(seq[2], Value::String("c".to_string()));
3635    }
3636
3637    #[test]
3638    fn test_split_resolver_custom_delim() {
3639        let ctx = ResolverContext::new("test.path");
3640        let args = vec!["one|two|three".to_string()];
3641        let mut kwargs = HashMap::new();
3642        kwargs.insert("delim".to_string(), "|".to_string());
3643
3644        let result = split_resolver(&args, &kwargs, &ctx).unwrap();
3645        let seq = result.value.as_sequence().unwrap();
3646        assert_eq!(seq.len(), 3);
3647        assert_eq!(seq[0], Value::String("one".to_string()));
3648    }
3649
3650    #[test]
3651    fn test_split_resolver_with_trim() {
3652        let ctx = ResolverContext::new("test.path");
3653        let args = vec!["  a  ,  b  ,  c  ".to_string()];
3654        let mut kwargs = HashMap::new();
3655        kwargs.insert("trim".to_string(), "true".to_string());
3656
3657        let result = split_resolver(&args, &kwargs, &ctx).unwrap();
3658        let seq = result.value.as_sequence().unwrap();
3659        assert_eq!(seq[0], Value::String("a".to_string()));
3660        assert_eq!(seq[1], Value::String("b".to_string()));
3661    }
3662
3663    #[test]
3664    fn test_split_resolver_no_trim() {
3665        let ctx = ResolverContext::new("test.path");
3666        let args = vec!["  a  ,  b  ".to_string()];
3667        let mut kwargs = HashMap::new();
3668        kwargs.insert("trim".to_string(), "false".to_string());
3669
3670        let result = split_resolver(&args, &kwargs, &ctx).unwrap();
3671        let seq = result.value.as_sequence().unwrap();
3672        assert_eq!(seq[0], Value::String("  a  ".to_string()));
3673        assert_eq!(seq[1], Value::String("  b  ".to_string()));
3674    }
3675
3676    #[test]
3677    fn test_split_resolver_with_limit() {
3678        let ctx = ResolverContext::new("test.path");
3679        let args = vec!["a,b,c,d,e".to_string()];
3680        let mut kwargs = HashMap::new();
3681        kwargs.insert("limit".to_string(), "2".to_string());
3682
3683        let result = split_resolver(&args, &kwargs, &ctx).unwrap();
3684        let seq = result.value.as_sequence().unwrap();
3685        assert_eq!(seq.len(), 3); // limit=2 means split into 3 parts max
3686        assert_eq!(seq[0], Value::String("a".to_string()));
3687        assert_eq!(seq[1], Value::String("b".to_string()));
3688        assert_eq!(seq[2], Value::String("c,d,e".to_string()));
3689    }
3690
3691    #[test]
3692    fn test_csv_resolver_with_headers() {
3693        let ctx = ResolverContext::new("test.path");
3694        let args = vec!["name,age\nAlice,30\nBob,25".to_string()];
3695        let kwargs = HashMap::new();
3696
3697        let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
3698        assert!(result.value.is_sequence());
3699
3700        let seq = result.value.as_sequence().unwrap();
3701        assert_eq!(seq.len(), 2);
3702
3703        // First row should be a mapping with keys from headers
3704        let first = seq[0].as_mapping().unwrap();
3705        assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
3706        assert_eq!(first.get("age"), Some(&Value::String("30".to_string())));
3707    }
3708
3709    #[test]
3710    fn test_csv_resolver_without_headers() {
3711        let ctx = ResolverContext::new("test.path");
3712        let args = vec!["Alice,30\nBob,25".to_string()];
3713        let mut kwargs = HashMap::new();
3714        kwargs.insert("header".to_string(), "false".to_string());
3715
3716        let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
3717        assert!(result.value.is_sequence());
3718
3719        let seq = result.value.as_sequence().unwrap();
3720        assert_eq!(seq.len(), 2);
3721
3722        // First row should be a sequence (array)
3723        let first = seq[0].as_sequence().unwrap();
3724        assert_eq!(first.len(), 2);
3725        assert_eq!(first[0], Value::String("Alice".to_string()));
3726        assert_eq!(first[1], Value::String("30".to_string()));
3727    }
3728
3729    #[test]
3730    fn test_csv_resolver_custom_delim() {
3731        let ctx = ResolverContext::new("test.path");
3732        let args = vec!["name|age\nAlice|30\nBob|25".to_string()];
3733        let mut kwargs = HashMap::new();
3734        kwargs.insert("delim".to_string(), "|".to_string());
3735
3736        let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
3737        let seq = result.value.as_sequence().unwrap();
3738        assert_eq!(seq.len(), 2);
3739
3740        let first = seq[0].as_mapping().unwrap();
3741        assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
3742    }
3743
3744    #[test]
3745    fn test_csv_resolver_with_trim() {
3746        let ctx = ResolverContext::new("test.path");
3747        let args = vec!["name , age\n  Alice  ,  30  ".to_string()];
3748        let mut kwargs = HashMap::new();
3749        kwargs.insert("trim".to_string(), "true".to_string());
3750
3751        let result = csv_resolver(&args, &kwargs, &ctx).unwrap();
3752        let seq = result.value.as_sequence().unwrap();
3753
3754        let first = seq[0].as_mapping().unwrap();
3755        assert_eq!(first.get("name"), Some(&Value::String("Alice".to_string())));
3756        assert_eq!(first.get("age"), Some(&Value::String("30".to_string())));
3757    }
3758
3759    #[test]
3760    fn test_csv_resolver_empty_delimiter() {
3761        let ctx = ResolverContext::new("test.path");
3762        let args = vec!["name,age".to_string()];
3763        let mut kwargs = HashMap::new();
3764        kwargs.insert("delim".to_string(), "".to_string());
3765
3766        let result = csv_resolver(&args, &kwargs, &ctx);
3767        assert!(result.is_err());
3768        let err = result.unwrap_err();
3769        assert!(err.to_string().contains("delimiter cannot be empty"));
3770    }
3771
3772    #[test]
3773    fn test_csv_resolver_no_args() {
3774        let ctx = ResolverContext::new("test.path");
3775        let args = vec![];
3776        let kwargs = HashMap::new();
3777
3778        let result = csv_resolver(&args, &kwargs, &ctx);
3779        assert!(result.is_err());
3780        let err = result.unwrap_err();
3781        assert!(err.to_string().contains("requires a string argument"));
3782    }
3783
3784    #[test]
3785    fn test_base64_resolver_valid() {
3786        let ctx = ResolverContext::new("test.path");
3787        // "Hello, World!" in base64
3788        let args = vec!["SGVsbG8sIFdvcmxkIQ==".to_string()];
3789        let kwargs = HashMap::new();
3790
3791        let result = base64_resolver(&args, &kwargs, &ctx).unwrap();
3792        // Base64-decoded UTF-8 text becomes a string
3793        assert!(result.value.is_string());
3794
3795        let text = result.value.as_str().unwrap();
3796        assert_eq!(text, "Hello, World!");
3797        assert!(!result.sensitive);
3798    }
3799
3800    #[test]
3801    fn test_base64_resolver_with_whitespace() {
3802        let ctx = ResolverContext::new("test.path");
3803        // Base64 with surrounding whitespace should be trimmed
3804        let args = vec!["  SGVsbG8=  ".to_string()];
3805        let kwargs = HashMap::new();
3806
3807        let result = base64_resolver(&args, &kwargs, &ctx).unwrap();
3808        let text = result.value.as_str().unwrap();
3809        assert_eq!(text, "Hello");
3810    }
3811
3812    #[test]
3813    fn test_base64_resolver_invalid() {
3814        let ctx = ResolverContext::new("test.path");
3815        let args = vec!["not-valid-base64!!!".to_string()];
3816        let kwargs = HashMap::new();
3817
3818        let result = base64_resolver(&args, &kwargs, &ctx);
3819        assert!(result.is_err());
3820        let err = result.unwrap_err();
3821        assert!(err.to_string().contains("Invalid base64"));
3822    }
3823
3824    #[test]
3825    fn test_base64_resolver_no_args() {
3826        let ctx = ResolverContext::new("test.path");
3827        let args = vec![];
3828        let kwargs = HashMap::new();
3829
3830        let result = base64_resolver(&args, &kwargs, &ctx);
3831        assert!(result.is_err());
3832        let err = result.unwrap_err();
3833        assert!(err.to_string().contains("requires a string argument"));
3834    }
3835
3836    #[test]
3837    fn test_transformation_resolvers_registered() {
3838        let registry = ResolverRegistry::with_builtins();
3839
3840        assert!(registry.contains("json"));
3841        assert!(registry.contains("yaml"));
3842        assert!(registry.contains("split"));
3843        assert!(registry.contains("csv"));
3844        assert!(registry.contains("base64"));
3845    }
3846}
3847
3848// Archive extraction tests (only compiled with archive feature)
3849#[cfg(all(test, feature = "archive"))]
3850mod extract_resolver_tests {
3851    use super::*;
3852    use std::io::Write;
3853
3854    fn create_test_zip_with_file(content: &[u8]) -> Vec<u8> {
3855        use zip::write::FileOptions;
3856
3857        let mut buffer = std::io::Cursor::new(Vec::new());
3858        {
3859            let mut zip = zip::ZipWriter::new(&mut buffer);
3860            zip.start_file::<&str, ()>("test.txt", FileOptions::default())
3861                .unwrap();
3862            zip.write_all(content).unwrap();
3863            zip.finish().unwrap();
3864        }
3865        buffer.into_inner()
3866    }
3867
3868    fn create_test_zip_with_password(content: &[u8], password: &str) -> Vec<u8> {
3869        use zip::unstable::write::FileOptionsExt;
3870        use zip::write::{ExtendedFileOptions, FileOptions};
3871
3872        let mut buffer = std::io::Cursor::new(Vec::new());
3873        {
3874            let mut zip = zip::ZipWriter::new(&mut buffer);
3875            let options: FileOptions<ExtendedFileOptions> =
3876                FileOptions::default().with_deprecated_encryption(password.as_bytes());
3877            zip.start_file("secret.txt", options).unwrap();
3878            zip.write_all(content).unwrap();
3879            zip.finish().unwrap();
3880        }
3881        buffer.into_inner()
3882    }
3883
3884    fn create_test_tar_with_file(content: &[u8]) -> Vec<u8> {
3885        let mut buffer = Vec::new();
3886        {
3887            let mut tar = tar::Builder::new(&mut buffer);
3888            let mut header = tar::Header::new_gnu();
3889            header.set_size(content.len() as u64);
3890            header.set_mode(0o644);
3891            header.set_cksum();
3892            tar.append_data(&mut header, "test.txt", content).unwrap();
3893            tar.finish().unwrap();
3894        }
3895        buffer
3896    }
3897
3898    fn create_test_tar_gz_with_file(content: &[u8]) -> Vec<u8> {
3899        use flate2::write::GzEncoder;
3900        use flate2::Compression;
3901
3902        let tar_data = create_test_tar_with_file(content);
3903        let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
3904        encoder.write_all(&tar_data).unwrap();
3905        encoder.finish().unwrap()
3906    }
3907
3908    #[test]
3909    fn test_extract_from_zip() {
3910        let content = b"Hello from ZIP!";
3911        let zip_data = create_test_zip_with_file(content);
3912
3913        // Encode as base64 (simulating what happens when bytes come through resolver chain)
3914        use base64::Engine;
3915        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
3916
3917        let ctx = ResolverContext::new("test.path");
3918        let args = vec![encoded];
3919        let mut kwargs = HashMap::new();
3920        kwargs.insert("path".to_string(), "test.txt".to_string());
3921
3922        let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
3923        assert!(result.value.is_bytes());
3924        assert_eq!(result.value.as_bytes().unwrap(), content);
3925    }
3926
3927    #[test]
3928    fn test_extract_from_zip_with_password() {
3929        let content = b"Secret data!";
3930        let password = "mysecret123";
3931        let zip_data = create_test_zip_with_password(content, password);
3932
3933        use base64::Engine;
3934        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
3935
3936        let ctx = ResolverContext::new("test.path");
3937        let args = vec![encoded];
3938        let mut kwargs = HashMap::new();
3939        kwargs.insert("path".to_string(), "secret.txt".to_string());
3940        kwargs.insert("password".to_string(), password.to_string());
3941
3942        let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
3943        assert!(result.value.is_bytes());
3944        assert_eq!(result.value.as_bytes().unwrap(), content);
3945    }
3946
3947    #[test]
3948    fn test_extract_from_zip_wrong_password() {
3949        let content = b"Secret data!";
3950        let zip_data = create_test_zip_with_password(content, "correct");
3951
3952        use base64::Engine;
3953        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
3954
3955        let ctx = ResolverContext::new("test.path");
3956        let args = vec![encoded];
3957        let mut kwargs = HashMap::new();
3958        kwargs.insert("path".to_string(), "secret.txt".to_string());
3959        kwargs.insert("password".to_string(), "wrong".to_string());
3960
3961        let result = extract_resolver(&args, &kwargs, &ctx);
3962        // May succeed due to ZipCrypto weakness, but if it fails, should be password error
3963        if let Err(err) = result {
3964            let msg = err.to_string();
3965            assert!(msg.contains("password") || msg.contains("decrypt"));
3966        }
3967    }
3968
3969    #[test]
3970    fn test_extract_from_zip_file_not_found() {
3971        let content = b"Hello";
3972        let zip_data = create_test_zip_with_file(content);
3973
3974        use base64::Engine;
3975        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
3976
3977        let ctx = ResolverContext::new("test.path");
3978        let args = vec![encoded];
3979        let mut kwargs = HashMap::new();
3980        kwargs.insert("path".to_string(), "nonexistent.txt".to_string());
3981
3982        let result = extract_resolver(&args, &kwargs, &ctx);
3983        assert!(result.is_err());
3984        let err = result.unwrap_err();
3985        assert!(err.to_string().contains("not found"));
3986    }
3987
3988    #[test]
3989    fn test_extract_from_tar() {
3990        let content = b"Hello from TAR!";
3991        let tar_data = create_test_tar_with_file(content);
3992
3993        use base64::Engine;
3994        let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_data);
3995
3996        let ctx = ResolverContext::new("test.path");
3997        let args = vec![encoded];
3998        let mut kwargs = HashMap::new();
3999        kwargs.insert("path".to_string(), "test.txt".to_string());
4000
4001        let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
4002        assert!(result.value.is_bytes());
4003        assert_eq!(result.value.as_bytes().unwrap(), content);
4004    }
4005
4006    #[test]
4007    fn test_extract_from_tar_gz() {
4008        let content = b"Hello from TAR.GZ!";
4009        let tar_gz_data = create_test_tar_gz_with_file(content);
4010
4011        use base64::Engine;
4012        let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_gz_data);
4013
4014        let ctx = ResolverContext::new("test.path");
4015        let args = vec![encoded];
4016        let mut kwargs = HashMap::new();
4017        kwargs.insert("path".to_string(), "test.txt".to_string());
4018
4019        let result = extract_resolver(&args, &kwargs, &ctx).unwrap();
4020        assert!(result.value.is_bytes());
4021        assert_eq!(result.value.as_bytes().unwrap(), content);
4022    }
4023
4024    #[test]
4025    fn test_extract_from_tar_file_not_found() {
4026        let content = b"Hello";
4027        let tar_data = create_test_tar_with_file(content);
4028
4029        use base64::Engine;
4030        let encoded = base64::engine::general_purpose::STANDARD.encode(&tar_data);
4031
4032        let ctx = ResolverContext::new("test.path");
4033        let args = vec![encoded];
4034        let mut kwargs = HashMap::new();
4035        kwargs.insert("path".to_string(), "missing.txt".to_string());
4036
4037        let result = extract_resolver(&args, &kwargs, &ctx);
4038        assert!(result.is_err());
4039        let err = result.unwrap_err();
4040        assert!(err.to_string().contains("not found"));
4041    }
4042
4043    #[test]
4044    fn test_extract_no_args() {
4045        let ctx = ResolverContext::new("test.path");
4046        let args = vec![];
4047        let mut kwargs = HashMap::new();
4048        kwargs.insert("path".to_string(), "test.txt".to_string());
4049
4050        let result = extract_resolver(&args, &kwargs, &ctx);
4051        assert!(result.is_err());
4052        assert!(result
4053            .unwrap_err()
4054            .to_string()
4055            .contains("requires archive data"));
4056    }
4057
4058    #[test]
4059    fn test_extract_no_path_kwarg() {
4060        let zip_data = create_test_zip_with_file(b"test");
4061        use base64::Engine;
4062        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_data);
4063
4064        let ctx = ResolverContext::new("test.path");
4065        let args = vec![encoded];
4066        let kwargs = HashMap::new();
4067
4068        let result = extract_resolver(&args, &kwargs, &ctx);
4069        assert!(result.is_err());
4070        assert!(result.unwrap_err().to_string().contains("requires 'path'"));
4071    }
4072
4073    #[test]
4074    fn test_extract_unsupported_format() {
4075        let invalid_data = b"Not an archive";
4076        use base64::Engine;
4077        let encoded = base64::engine::general_purpose::STANDARD.encode(invalid_data);
4078
4079        let ctx = ResolverContext::new("test.path");
4080        let args = vec![encoded];
4081        let mut kwargs = HashMap::new();
4082        kwargs.insert("path".to_string(), "test.txt".to_string());
4083
4084        let result = extract_resolver(&args, &kwargs, &ctx);
4085        assert!(result.is_err());
4086        assert!(result
4087            .unwrap_err()
4088            .to_string()
4089            .contains("Unsupported archive format"));
4090    }
4091
4092    #[test]
4093    fn test_extract_resolver_registered() {
4094        let registry = ResolverRegistry::with_builtins();
4095        assert!(registry.contains("extract"));
4096    }
4097}