1use std::collections::HashMap;
7use std::sync::{Arc, OnceLock, RwLock};
8
9use crate::error::{Error, Result};
10use crate::value::Value;
11
12const PEM_BEGIN_MARKER: &str = "-----BEGIN";
14const PEM_BEGIN_ENCRYPTED_KEY: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
15
16#[derive(Clone, Debug)]
18pub enum CertInput {
19 Text(String),
21 Binary(Vec<u8>),
23}
24
25impl CertInput {
26 pub fn is_pem_content(&self) -> bool {
28 matches!(self, CertInput::Text(s) if s.contains(PEM_BEGIN_MARKER))
29 }
30
31 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 pub fn as_text(&self) -> Option<&str> {
41 match self {
42 CertInput::Text(s) => Some(s),
43 CertInput::Binary(_) => None,
44 }
45 }
46
47 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
74static GLOBAL_REGISTRY: OnceLock<RwLock<ResolverRegistry>> = OnceLock::new();
76
77pub fn global_registry() -> &'static RwLock<ResolverRegistry> {
82 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(ResolverRegistry::with_builtins()))
83}
84
85pub 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#[derive(Clone)]
104pub struct ResolvedValue {
105 pub value: Value,
107 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 pub fn new(value: impl Into<Value>) -> Self {
130 Self {
131 value: value.into(),
132 sensitive: false,
133 }
134 }
135
136 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#[derive(Debug, Clone)]
165pub struct ResolverContext {
166 pub config_path: String,
168 pub config_root: Option<Arc<Value>>,
170 pub base_path: Option<std::path::PathBuf>,
172 pub file_roots: std::collections::HashSet<std::path::PathBuf>,
174 pub resolution_stack: Vec<String>,
176 pub allow_http: bool,
178 pub http_allowlist: Vec<String>,
180 pub http_proxy: Option<String>,
182 pub http_proxy_from_env: bool,
184 pub http_ca_bundle: Option<CertInput>,
186 pub http_extra_ca_bundle: Option<CertInput>,
188 pub http_client_cert: Option<CertInput>,
190 pub http_client_key: Option<CertInput>,
192 pub http_client_key_password: Option<String>,
194 }
196
197impl ResolverContext {
198 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 pub fn with_allow_http(mut self, allow: bool) -> Self {
220 self.allow_http = allow;
221 self
222 }
223
224 pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
226 self.http_allowlist = allowlist;
227 self
228 }
229
230 pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
232 self.http_proxy = Some(proxy.into());
233 self
234 }
235
236 pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
238 self.http_proxy_from_env = enabled;
239 self
240 }
241
242 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 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 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 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 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 pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
276 self.config_root = Some(root);
277 self
278 }
279
280 pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
282 self.base_path = Some(path);
283 self
284 }
285
286 pub fn would_cause_cycle(&self, path: &str) -> bool {
288 self.resolution_stack.contains(&path.to_string())
289 }
290
291 pub fn push_resolution(&mut self, path: &str) {
293 self.resolution_stack.push(path.to_string());
294 }
295
296 pub fn pop_resolution(&mut self) {
298 self.resolution_stack.pop();
299 }
300
301 pub fn get_resolution_chain(&self) -> Vec<String> {
303 self.resolution_stack.clone()
304 }
305}
306
307pub trait Resolver: Send + Sync {
309 fn resolve(
316 &self,
317 args: &[String],
318 kwargs: &HashMap<String, String>,
319 ctx: &ResolverContext,
320 ) -> Result<ResolvedValue>;
321
322 fn name(&self) -> &str;
324}
325
326pub 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 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#[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 pub fn new() -> Self {
387 Self {
388 resolvers: HashMap::new(),
389 }
390 }
391
392 pub fn with_builtins() -> Self {
394 let mut registry = Self::new();
395 registry.register_builtin_resolvers();
396 registry
397 }
398
399 fn register_builtin_resolvers(&mut self) {
401 self.register(Arc::new(FnResolver::new("env", env_resolver)));
403 self.register(Arc::new(FnResolver::new("file", file_resolver)));
405
406 #[cfg(feature = "http")]
408 {
409 self.register(Arc::new(FnResolver::new("http", http_resolver)));
411 self.register(Arc::new(FnResolver::new("https", https_resolver)));
413 }
414
415 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 #[cfg(feature = "archive")]
424 {
425 self.register(Arc::new(FnResolver::new("extract", extract_resolver)));
426 }
427 }
428
429 pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
431 self.resolvers.insert(resolver.name().to_string(), resolver);
432 }
433
434 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 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 pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
467 self.resolvers.get(name)
468 }
469
470 pub fn contains(&self, name: &str) -> bool {
472 self.resolvers.contains_key(name)
473 }
474
475 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 let sensitive_override = kwargs
496 .get("sensitive")
497 .map(|v| v.eq_ignore_ascii_case("true"));
498
499 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 let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
508
509 if let Some(is_sensitive) = sensitive_override {
511 resolved.sensitive = is_sensitive;
512 }
513
514 Ok(resolved)
515 }
516}
517
518fn 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 Ok(ResolvedValue::new(Value::String(value)))
543 }
544 Err(_) => {
545 Err(Error::env_not_found(
547 var_name,
548 Some(ctx.config_path.clone()),
549 ))
550 }
551 }
552}
553
554fn is_localhost(hostname: &str) -> bool {
566 if hostname.eq_ignore_ascii_case("localhost") {
568 return true;
569 }
570
571 if hostname.starts_with("127.") {
573 return true;
574 }
575
576 if hostname == "::1" || hostname == "[::1]" {
578 return true;
579 }
580
581 false
582}
583
584fn normalize_file_path(arg: &str) -> Result<(String, bool)> {
595 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 if after_slashes.starts_with('/') {
609 Ok((after_slashes.to_string(), false))
612 } else {
613 let parts: Vec<&str> = after_slashes.splitn(2, '/').collect();
616 let hostname = parts[0];
617
618 if hostname.is_empty() {
620 return Ok(("/".to_string(), false));
621 }
622
623 if is_localhost(hostname) {
624 let path = parts
626 .get(1)
627 .map(|s| format!("/{}", s))
628 .unwrap_or_else(|| "/".to_string());
629 Ok((path, false))
630 } else {
631 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 Ok((arg.to_string(), false))
650 } else {
651 Ok((arg.to_string(), true))
653 }
654}
655
656fn 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 let (normalized_path, is_relative) = normalize_file_path(file_path_arg)?;
698
699 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 std::path::PathBuf::from(&normalized_path)
709 };
710
711 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 let canonical_path = file_path.canonicalize().map_err(|e| {
727 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 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 canonicalization_errors.push((root.clone(), e));
743 false
744 }
745 }
746 });
747
748 if !is_allowed {
749 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 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 let content = match encoding {
785 "base64" => {
786 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 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 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 if encoding == "base64" {
807 return Ok(ResolvedValue::new(Value::String(content)));
808 }
809
810 match parse_mode {
814 "none" => {
815 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 Ok(ResolvedValue::new(Value::String(content)))
823 }
824 }
825}
826
827#[cfg(feature = "http")]
836fn normalize_http_url(scheme: &str, arg: &str) -> Result<String> {
837 let clean = arg
839 .strip_prefix("http://")
840 .or_else(|| arg.strip_prefix("https://"))
841 .unwrap_or(arg);
842
843 let clean = clean.strip_prefix("//").unwrap_or(clean);
845
846 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 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 Ok(format!("{}://{}", scheme, clean))
873}
874
875fn 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 let url = normalize_http_url(scheme, &args[0])?;
896
897 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 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 let _ = (kwargs, ctx); 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
946fn 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
978fn 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#[cfg(feature = "http")]
1016fn url_matches_pattern(url: &str, pattern: &str) -> bool {
1017 let parsed_url = match url::Url::parse(url) {
1019 Ok(u) => u,
1020 Err(_) => {
1021 log::warn!("Invalid URL '{}' rejected by allowlist", url);
1023 return false;
1024 }
1025 };
1026
1027 if pattern.contains("**") || pattern.contains(".*.*") {
1029 log::warn!(
1030 "Invalid allowlist pattern '{}' - contains dangerous sequence",
1031 pattern
1032 );
1033 return false;
1034 }
1035
1036 let glob_pattern = match glob::Pattern::new(pattern) {
1038 Ok(p) => p,
1039 Err(_) => {
1040 log::warn!(
1042 "Invalid glob pattern '{}' - falling back to exact match",
1043 pattern
1044 );
1045 return url == pattern;
1046 }
1047 };
1048
1049 glob_pattern.matches(parsed_url.as_str())
1055}
1056
1057#[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#[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 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 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 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#[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 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 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 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 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#[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 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 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 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#[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#[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 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#[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 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 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 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 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#[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 CertInput::Binary(bytes) => {
1315 log::trace!("Loading client identity from P12 binary content");
1316 let pwd = password.unwrap_or("");
1318 parse_p12_identity(bytes, pwd, "P12 binary content")
1319 }
1320
1321 CertInput::Text(text) => {
1323 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 let pwd = password.unwrap_or("");
1331 return parse_p12_identity(&bytes, pwd, text);
1332 }
1333
1334 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#[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 let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
1363
1364 if insecure {
1365 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 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 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 let extra_certs = load_certs(extra_ca_input)?;
1398 builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
1399 }
1400
1401 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#[cfg(feature = "http")]
1429fn build_proxy_config(
1430 ctx: &ResolverContext,
1431 kwargs: &HashMap<String, String>,
1432) -> Result<Option<ureq::Proxy>> {
1433 let proxy_url = kwargs
1435 .get("proxy")
1436 .cloned()
1437 .or_else(|| ctx.http_proxy.clone());
1438
1439 let proxy_url = proxy_url.or_else(|| {
1441 if ctx.http_proxy_from_env {
1442 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#[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 let tls_config = build_tls_config(ctx, kwargs)?;
1480
1481 let proxy = build_proxy_config(ctx, kwargs)?;
1483
1484 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 let mut request = agent.get(url);
1498
1499 for (key, value) in kwargs {
1501 if key == "header" {
1502 if let Some((name, val)) = value.split_once(':') {
1504 request = request.header(name.trim(), val.trim());
1505 }
1506 }
1507 }
1508
1509 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 match parse_mode {
1524 "binary" => {
1525 Ok(ResolvedValue::new(Value::Stream(Box::new(
1528 response.into_body().into_reader(),
1529 ))))
1530 }
1531 _ => {
1532 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
1541fn 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
1554fn 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 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
1582fn 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 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
1615fn 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); 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 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 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
1656fn 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); 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 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 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 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 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 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 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
1745fn 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 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#[cfg(feature = "archive")]
1782mod archive_limits {
1783 pub const MAX_EXTRACTED_FILE_SIZE: u64 = 10 * 1024 * 1024;
1786
1787 pub const MAX_COMPRESSION_RATIO: u64 = 100;
1791}
1792
1793#[cfg(feature = "archive")]
1794use archive_limits::*;
1795
1796#[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, Ok(n) => {
1814 total_read += n as u64;
1815
1816 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 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#[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 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 let password = kwargs.get("password");
1895
1896 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 args[0].as_bytes().to_vec()
1905 };
1906
1907 let format = detect_archive_format(&archive_bytes)?;
1909
1910 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 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 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 let file = if let Some(pwd) = password {
1963 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 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 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 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 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 let gz_decoder = GzDecoder::new(reader);
2058
2059 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 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 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 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 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 #[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 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 std::fs::remove_file(test_file).ok();
2226 }
2227
2228 #[test]
2229 fn test_file_resolver_yaml() {
2230 use std::io::Write;
2231
2232 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 assert!(result.value.is_string());
2252 assert!(result.value.as_str().unwrap().contains("key: value"));
2253
2254 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 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 #[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 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 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 assert!(result.value.is_string());
2442 assert!(result.value.as_str().unwrap().contains(r#""key": "value""#));
2443
2444 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 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 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 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 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 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2500 assert!(result.value.is_string());
2501
2502 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 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 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2528 assert!(result.value.is_string());
2529
2530 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 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 assert!(result
2556 .value
2557 .as_str()
2558 .unwrap()
2559 .contains("plain text content"));
2560
2561 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 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 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 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 assert!(content.contains("Hello"));
2618 assert!(content.contains("Welcome"));
2619 assert!(!content.contains("世界"));
2620
2621 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 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 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 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 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 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(); let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2678 let content = result.value.as_str().unwrap();
2679 assert!(content.contains("café"));
2681 assert!(content.contains("résumé"));
2682
2683 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 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 assert!(result.value.is_stream());
2712
2713 let materialized = result.value.materialize().unwrap();
2715 assert!(materialized.is_bytes());
2716 assert_eq!(materialized.as_bytes().unwrap(), &binary_data);
2717
2718 std::fs::remove_file(test_file).ok();
2720 }
2721
2722 #[test]
2723 fn test_file_resolver_encoding_binary_empty() {
2724 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 assert!(result.value.is_stream());
2743
2744 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 std::fs::remove_file(test_file).ok();
2752 }
2753
2754 #[test]
2757 fn test_file_resolver_with_sensitive() {
2758 use std::io::Write;
2759
2760 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 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 std::fs::remove_file(test_file).ok();
2784 }
2785
2786 #[test]
2787 fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
2788 let mut registry = ResolverRegistry::new();
2791
2792 registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
2794 assert!(
2796 !kwargs.contains_key("sensitive"),
2797 "sensitive kwarg should not be passed to resolver"
2798 );
2799 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 assert!(result.sensitive);
2822 }
2823
2824 #[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 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 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 #[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 #[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 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 let (path, is_rel) = normalize_file_path("//localhost/var/data").unwrap();
2964 assert_eq!(path, "/var/data");
2965 assert!(!is_rel);
2966
2967 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 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 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 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 #[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#[cfg(test)]
3087mod global_registry_tests {
3088 use super::*;
3089
3090 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 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 registry.register_with_force(resolver1, false).unwrap();
3116
3117 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 registry.register_with_force(resolver1, false).unwrap();
3132
3133 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 let registry1 = global_registry();
3142 let registry2 = global_registry();
3143
3144 assert!(std::ptr::eq(registry1, registry2));
3146 }
3147
3148 #[test]
3149 fn test_register_global_new_resolver() {
3150 let resolver = mock_resolver("global_test_unique_42");
3152 let result = register_global(resolver, false);
3153 assert!(result.is_ok() || result.is_err());
3156 }
3157}
3158
3159#[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 let fail_called = Arc::new(AtomicBool::new(false));
3171 let fail_called_clone = fail_called.clone();
3172
3173 let yaml = r#"
3175value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
3176"#;
3177 std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
3179
3180 let mut config = Config::from_yaml(yaml).unwrap();
3181
3182 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 let result = config.get("value").unwrap();
3193 assert_eq!(result.as_str(), Some("main_value"));
3194
3195 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 let default_called = Arc::new(AtomicBool::new(false));
3208 let default_called_clone = default_called.clone();
3209
3210 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 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 let result = config.get("value").unwrap();
3233 assert_eq!(result.as_str(), Some("default_was_fallback"));
3234
3235 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#[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 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 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 assert!(result.value.is_stream());
3335
3336 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 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 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 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 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 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 #[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); 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 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 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 let args = vec!["SGVsbG8sIFdvcmxkIQ==".to_string()];
3789 let kwargs = HashMap::new();
3790
3791 let result = base64_resolver(&args, &kwargs, &ctx).unwrap();
3792 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 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#[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 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 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}