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
416 pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
418 self.resolvers.insert(resolver.name().to_string(), resolver);
419 }
420
421 pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
432 let name = resolver.name().to_string();
433 if !force && self.resolvers.contains_key(&name) {
434 return Err(Error::resolver_already_registered(&name));
435 }
436 self.resolvers.insert(name, resolver);
437 Ok(())
438 }
439
440 pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
442 where
443 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
444 + Send
445 + Sync
446 + 'static,
447 {
448 let name = name.into();
449 self.register(Arc::new(FnResolver::new(name, func)));
450 }
451
452 pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
454 self.resolvers.get(name)
455 }
456
457 pub fn contains(&self, name: &str) -> bool {
459 self.resolvers.contains_key(name)
460 }
461
462 pub fn resolve(
470 &self,
471 resolver_name: &str,
472 args: &[String],
473 kwargs: &HashMap<String, String>,
474 ctx: &ResolverContext,
475 ) -> Result<ResolvedValue> {
476 let resolver = self
477 .resolvers
478 .get(resolver_name)
479 .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
480
481 let sensitive_override = kwargs
483 .get("sensitive")
484 .map(|v| v.eq_ignore_ascii_case("true"));
485
486 let resolver_kwargs: HashMap<String, String> = kwargs
488 .iter()
489 .filter(|(k, _)| *k != "sensitive")
490 .map(|(k, v)| (k.clone(), v.clone()))
491 .collect();
492
493 let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
495
496 if let Some(is_sensitive) = sensitive_override {
498 resolved.sensitive = is_sensitive;
499 }
500
501 Ok(resolved)
502 }
503}
504
505fn env_resolver(
515 args: &[String],
516 _kwargs: &HashMap<String, String>,
517 ctx: &ResolverContext,
518) -> Result<ResolvedValue> {
519 if args.is_empty() {
520 return Err(Error::parse("env resolver requires a variable name")
521 .with_path(ctx.config_path.clone()));
522 }
523
524 let var_name = &args[0];
525
526 match std::env::var(var_name) {
527 Ok(value) => {
528 Ok(ResolvedValue::new(Value::String(value)))
530 }
531 Err(_) => {
532 Err(Error::env_not_found(
534 var_name,
535 Some(ctx.config_path.clone()),
536 ))
537 }
538 }
539}
540
541fn is_localhost(hostname: &str) -> bool {
553 if hostname.eq_ignore_ascii_case("localhost") {
555 return true;
556 }
557
558 if hostname.starts_with("127.") {
560 return true;
561 }
562
563 if hostname == "::1" || hostname == "[::1]" {
565 return true;
566 }
567
568 false
569}
570
571fn normalize_file_path(arg: &str) -> Result<(String, bool)> {
582 if arg.contains('\0') {
584 return Err(Error::resolver_custom(
585 "file",
586 "File paths cannot contain null bytes",
587 ));
588 }
589
590 if let Some(after_slashes) = arg.strip_prefix("//") {
591 if after_slashes.starts_with('/') {
596 Ok((after_slashes.to_string(), false))
599 } else {
600 let parts: Vec<&str> = after_slashes.splitn(2, '/').collect();
603 let hostname = parts[0];
604
605 if hostname.is_empty() {
607 return Ok(("/".to_string(), false));
608 }
609
610 if is_localhost(hostname) {
611 let path = parts
613 .get(1)
614 .map(|s| format!("/{}", s))
615 .unwrap_or_else(|| "/".to_string());
616 Ok((path, false))
617 } else {
618 Err(Error::resolver_custom(
620 "file",
621 format!(
622 "Remote file URIs not supported: hostname '{}' is not localhost\n\
623 \n\
624 HoloConf only supports local files:\n\
625 - file:///path/to/file (absolute, empty authority)\n\
626 - file://localhost/path/to/file (absolute, explicit localhost)\n\
627 - file:/path/to/file (absolute, minimal)\n\
628 - relative/path/to/file (relative to config directory)",
629 hostname
630 ),
631 ))
632 }
633 }
634 } else if arg.starts_with('/') {
635 Ok((arg.to_string(), false))
637 } else {
638 Ok((arg.to_string(), true))
640 }
641}
642
643fn file_resolver(
663 args: &[String],
664 kwargs: &HashMap<String, String>,
665 ctx: &ResolverContext,
666) -> Result<ResolvedValue> {
667 if args.is_empty() {
668 return Err(
669 Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
670 );
671 }
672
673 let file_path_arg = &args[0];
674 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
675 let encoding = kwargs
676 .get("encoding")
677 .map(|s| s.as_str())
678 .unwrap_or("utf-8");
679
680 let (normalized_path, is_relative) = normalize_file_path(file_path_arg)?;
682
683 let file_path = if is_relative {
685 if let Some(base) = &ctx.base_path {
686 base.join(&normalized_path)
687 } else {
688 std::path::PathBuf::from(&normalized_path)
689 }
690 } else {
691 std::path::PathBuf::from(&normalized_path)
693 };
694
695 if ctx.file_roots.is_empty() {
698 return Err(Error::resolver_custom(
699 "file",
700 "File resolver requires allowed directories to be configured. \
701 Use Config.load() which auto-configures the parent directory, or \
702 specify file_roots explicitly for Config.loads()."
703 .to_string(),
704 )
705 .with_path(ctx.config_path.clone()));
706 }
707
708 let canonical_path = file_path.canonicalize().map_err(|e| {
711 if e.kind() == std::io::ErrorKind::NotFound {
713 return Error::file_not_found(file_path_arg, Some(ctx.config_path.clone()));
714 }
715 Error::resolver_custom("file", format!("Failed to resolve file path: {}", e))
716 .with_path(ctx.config_path.clone())
717 })?;
718
719 let mut canonicalization_errors = Vec::new();
721 let is_allowed = ctx.file_roots.iter().any(|root| {
722 match root.canonicalize() {
723 Ok(canonical_root) => canonical_path.starts_with(&canonical_root),
724 Err(e) => {
725 canonicalization_errors.push((root.clone(), e));
727 false
728 }
729 }
730 });
731
732 if !is_allowed {
733 let display_path = if let Some(base) = &ctx.base_path {
735 file_path
736 .strip_prefix(base)
737 .map(|p| p.display().to_string())
738 .unwrap_or_else(|_| "<outside allowed directories>".to_string())
739 } else {
740 "<outside allowed directories>".to_string()
741 };
742
743 let mut msg = format!(
744 "Access denied: file '{}' is outside allowed directories.",
745 display_path
746 );
747
748 if !canonicalization_errors.is_empty() {
749 msg.push_str(&format!(
750 " Note: {} configured root(s) could not be validated.",
751 canonicalization_errors.len()
752 ));
753 }
754
755 msg.push_str(" Use file_roots parameter to extend allowed directories.");
756
757 return Err(Error::resolver_custom("file", msg).with_path(ctx.config_path.clone()));
758 }
759
760 if encoding == "binary" {
762 let bytes = std::fs::read(&file_path)
763 .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
764 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
765 }
766
767 let content = match encoding {
769 "base64" => {
770 use base64::{engine::general_purpose::STANDARD, Engine as _};
772 let bytes = std::fs::read(&file_path)
773 .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
774 STANDARD.encode(bytes)
775 }
776 "ascii" => {
777 let raw = std::fs::read_to_string(&file_path)
779 .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?;
780 raw.chars().filter(|c| c.is_ascii()).collect()
781 }
782 _ => {
783 std::fs::read_to_string(&file_path)
785 .map_err(|_| Error::file_not_found(file_path_arg, Some(ctx.config_path.clone())))?
786 }
787 };
788
789 if encoding == "base64" {
791 return Ok(ResolvedValue::new(Value::String(content)));
792 }
793
794 let actual_parse_mode = if parse_mode == "auto" {
796 match file_path.extension().and_then(|e| e.to_str()) {
798 Some("yaml") | Some("yml") => "yaml",
799 Some("json") => "json",
800 _ => "text",
801 }
802 } else {
803 parse_mode
804 };
805
806 match actual_parse_mode {
808 "yaml" => {
809 let value: Value = serde_yaml::from_str(&content).map_err(|e| {
810 Error::parse(format!("Failed to parse YAML: {}", e))
811 .with_path(ctx.config_path.clone())
812 })?;
813 Ok(ResolvedValue::new(value))
814 }
815 "json" => {
816 let value: Value = serde_json::from_str(&content).map_err(|e| {
817 Error::parse(format!("Failed to parse JSON: {}", e))
818 .with_path(ctx.config_path.clone())
819 })?;
820 Ok(ResolvedValue::new(value))
821 }
822 _ => {
823 Ok(ResolvedValue::new(Value::String(content)))
825 }
826 }
827}
828
829#[cfg(feature = "http")]
838fn normalize_http_url(scheme: &str, arg: &str) -> Result<String> {
839 let clean = arg
841 .strip_prefix("http://")
842 .or_else(|| arg.strip_prefix("https://"))
843 .unwrap_or(arg);
844
845 let clean = clean.strip_prefix("//").unwrap_or(clean);
847
848 if clean.trim().is_empty() {
850 return Err(Error::resolver_custom(
851 scheme,
852 format!(
853 "{} resolver requires a non-empty URL",
854 scheme.to_uppercase()
855 ),
856 ));
857 }
858
859 if clean.starts_with('/') {
861 return Err(Error::resolver_custom(
862 scheme,
863 format!(
864 "Invalid URL syntax: '{}'. URLs must have a hostname after the ://\n\
865 Valid formats:\n\
866 - ${{{}:example.com/path}} (clean syntax)\n\
867 - ${{{}:{}://example.com/path}} (backwards compatible)",
868 arg, scheme, scheme, scheme
869 ),
870 ));
871 }
872
873 Ok(format!("{}://{}", scheme, clean))
875}
876
877fn http_or_https_resolver(
882 scheme: &str,
883 args: &[String],
884 kwargs: &HashMap<String, String>,
885 ctx: &ResolverContext,
886) -> Result<ResolvedValue> {
887 if args.is_empty() {
888 return Err(
889 Error::parse(format!("{} resolver requires a URL", scheme.to_uppercase()))
890 .with_path(ctx.config_path.clone()),
891 );
892 }
893
894 #[cfg(feature = "http")]
895 {
896 let url = normalize_http_url(scheme, &args[0])?;
898
899 if !ctx.allow_http {
901 return Err(Error {
902 kind: crate::error::ErrorKind::Resolver(
903 crate::error::ResolverErrorKind::HttpDisabled,
904 ),
905 path: Some(ctx.config_path.clone()),
906 source_location: None,
907 help: Some(format!(
908 "{} resolver is disabled. The URL specified by this config path cannot be fetched.\n\
909 Enable with Config.load(..., allow_http=True)",
910 scheme.to_uppercase()
911 )),
912 cause: None,
913 });
914 }
915
916 if !ctx.http_allowlist.is_empty() {
918 let url_allowed = ctx
919 .http_allowlist
920 .iter()
921 .any(|pattern| url_matches_pattern(&url, pattern));
922 if !url_allowed {
923 return Err(Error::http_not_in_allowlist(
924 &url,
925 &ctx.http_allowlist,
926 Some(ctx.config_path.clone()),
927 ));
928 }
929 }
930
931 http_fetch(&url, kwargs, ctx)
932 }
933
934 #[cfg(not(feature = "http"))]
935 {
936 let _ = (kwargs, ctx); Err(Error::resolver_custom(
939 scheme,
940 format!(
941 "{} support not compiled in. Rebuild with --features http",
942 scheme.to_uppercase()
943 ),
944 ))
945 }
946}
947
948fn http_resolver(
970 args: &[String],
971 kwargs: &HashMap<String, String>,
972 ctx: &ResolverContext,
973) -> Result<ResolvedValue> {
974 http_or_https_resolver("http", args, kwargs, ctx)
975}
976
977fn https_resolver(
999 args: &[String],
1000 kwargs: &HashMap<String, String>,
1001 ctx: &ResolverContext,
1002) -> Result<ResolvedValue> {
1003 http_or_https_resolver("https", args, kwargs, ctx)
1004}
1005
1006#[cfg(feature = "http")]
1012fn url_matches_pattern(url: &str, pattern: &str) -> bool {
1013 let parsed_url = match url::Url::parse(url) {
1015 Ok(u) => u,
1016 Err(_) => {
1017 log::warn!("Invalid URL '{}' rejected by allowlist", url);
1019 return false;
1020 }
1021 };
1022
1023 if pattern.contains("**") || pattern.contains(".*.*") {
1025 log::warn!(
1026 "Invalid allowlist pattern '{}' - contains dangerous sequence",
1027 pattern
1028 );
1029 return false;
1030 }
1031
1032 let glob_pattern = match glob::Pattern::new(pattern) {
1034 Ok(p) => p,
1035 Err(_) => {
1036 log::warn!(
1038 "Invalid glob pattern '{}' - falling back to exact match",
1039 pattern
1040 );
1041 return url == pattern;
1042 }
1043 };
1044
1045 glob_pattern.matches(parsed_url.as_str())
1051}
1052
1053#[cfg(feature = "http")]
1059fn parse_pem_certs(pem_bytes: &[u8], source: &str) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1060 use ureq::tls::PemItem;
1061
1062 let certs: Vec<_> = ureq::tls::parse_pem(pem_bytes)
1063 .filter_map(|item| item.ok())
1064 .filter_map(|item| match item {
1065 PemItem::Certificate(cert) => Some(cert.to_owned()),
1066 _ => None,
1067 })
1068 .collect();
1069
1070 if certs.is_empty() {
1071 return Err(Error::pem_load_error(
1072 source,
1073 "No valid certificates found in PEM data",
1074 ));
1075 }
1076
1077 Ok(certs)
1078}
1079
1080#[cfg(feature = "http")]
1082fn load_certs(input: &CertInput) -> Result<Vec<ureq::tls::Certificate<'static>>> {
1083 match input {
1084 CertInput::Binary(_) => {
1085 Err(Error::tls_config_error(
1086 "CA bundle must be PEM format, not binary. For P12 client certificates, use client_cert parameter."
1087 ))
1088 }
1089 CertInput::Text(text) => {
1090 let path = std::path::Path::new(text);
1092 if path.exists() {
1093 log::trace!("Loading certificates from file: {}", text);
1094 let bytes = std::fs::read(path).map_err(|e| {
1095 let display_path = if text.len() < 256 && !text.contains('\n') {
1097 text
1098 } else {
1099 "[PEM content or long path]"
1100 };
1101 Error::pem_load_error(
1102 display_path,
1103 format!("Failed to read certificate file: {}", e),
1104 )
1105 })?;
1106 parse_pem_certs(&bytes, text)
1107 } else {
1108 log::trace!("Path does not exist, attempting to parse as PEM content");
1110 parse_pem_certs(text.as_bytes(), "PEM content")
1111 }
1112 }
1113 }
1114}
1115
1116#[cfg(feature = "http")]
1118fn parse_pem_private_key(
1119 pem_content: &str,
1120 password: Option<&str>,
1121 source: &str,
1122) -> Result<ureq::tls::PrivateKey<'static>> {
1123 use pkcs8::der::Decode;
1124
1125 if pem_content.contains(PEM_BEGIN_ENCRYPTED_KEY) {
1127 let pwd = password.ok_or_else(|| {
1128 Error::tls_config_error(format!(
1129 "Password required for encrypted private key from: {}",
1130 source
1131 ))
1132 })?;
1133
1134 let der_bytes = pem_to_der(pem_content, "ENCRYPTED PRIVATE KEY")
1136 .map_err(|e| Error::pem_load_error(source, e))?;
1137
1138 let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
1139 .map_err(|e| Error::pem_load_error(source, e.to_string()))?;
1140
1141 let decrypted = encrypted
1142 .decrypt(pwd)
1143 .map_err(|e| Error::key_decryption_error(e.to_string()))?;
1144
1145 let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
1148
1149 ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1150 .map(|k| k.to_owned())
1151 .map_err(|e| {
1152 Error::pem_load_error(source, format!("Failed to parse decrypted key: {}", e))
1153 })
1154 } else {
1155 ureq::tls::PrivateKey::from_pem(pem_content.as_bytes())
1157 .map(|k| k.to_owned())
1158 .map_err(|e| {
1159 Error::pem_load_error(source, format!("Failed to parse private key: {}", e))
1160 })
1161 }
1162}
1163
1164#[cfg(feature = "http")]
1166fn load_private_key(
1167 input: &CertInput,
1168 password: Option<&str>,
1169) -> Result<ureq::tls::PrivateKey<'static>> {
1170 match input {
1171 CertInput::Binary(_) => {
1172 Err(Error::tls_config_error(
1173 "Private key must be PEM text format, not binary. For P12, use client_cert only (no client_key needed)."
1174 ))
1175 }
1176 CertInput::Text(text) => {
1177 let path = std::path::Path::new(text);
1179 if path.exists() {
1180 log::trace!("Loading private key from file: {}", text);
1181 let pem_content = std::fs::read_to_string(path).map_err(|e| {
1182 let display_path = if text.len() < 256 && !text.contains('\n') {
1184 text
1185 } else {
1186 "[PEM content or long path]"
1187 };
1188 Error::pem_load_error(
1189 display_path,
1190 format!("Failed to read key file: {}", e),
1191 )
1192 })?;
1193 parse_pem_private_key(&pem_content, password, text)
1194 } else {
1195 log::trace!("Path does not exist, attempting to parse as PEM content");
1197 parse_pem_private_key(text, password, "PEM content")
1198 }
1199 }
1200 }
1201}
1202
1203#[cfg(feature = "http")]
1205fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
1206 let begin_marker = format!("-----BEGIN {}-----", label);
1207 let end_marker = format!("-----END {}-----", label);
1208
1209 let start = pem
1210 .find(&begin_marker)
1211 .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
1212 let end = pem
1213 .find(&end_marker)
1214 .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
1215
1216 let base64_content: String = pem[start + begin_marker.len()..end]
1217 .chars()
1218 .filter(|c| !c.is_whitespace())
1219 .collect();
1220
1221 use base64::Engine;
1222 base64::engine::general_purpose::STANDARD
1223 .decode(&base64_content)
1224 .map_err(|e| format!("Failed to decode base64: {}", e))
1225}
1226
1227#[cfg(feature = "http")]
1229fn der_to_pem(der: &[u8], label: &str) -> String {
1230 use base64::Engine;
1231 let base64 = base64::engine::general_purpose::STANDARD.encode(der);
1232 let lines: Vec<&str> = base64
1234 .as_bytes()
1235 .chunks(64)
1236 .map(|chunk| std::str::from_utf8(chunk).unwrap())
1237 .collect();
1238 format!(
1239 "-----BEGIN {}-----\n{}\n-----END {}-----\n",
1240 label,
1241 lines.join("\n"),
1242 label
1243 )
1244}
1245
1246#[cfg(feature = "http")]
1248fn parse_p12_identity(
1249 p12_data: &[u8],
1250 password: &str,
1251 source: &str,
1252) -> Result<(
1253 Vec<ureq::tls::Certificate<'static>>,
1254 ureq::tls::PrivateKey<'static>,
1255)> {
1256 if password.is_empty() {
1258 log::warn!(
1259 "Loading P12 file without password from: {} - ensure file is properly protected",
1260 source
1261 );
1262 }
1263
1264 let keystore = p12_keystore::KeyStore::from_pkcs12(p12_data, password)
1265 .map_err(|e| Error::p12_load_error(source, e.to_string()))?;
1266
1267 let (_alias, key_chain) = keystore
1270 .private_key_chain()
1271 .ok_or_else(|| Error::p12_load_error(source, "No private key found in P12 data"))?;
1272
1273 let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
1275 let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
1276 .map(|k| k.to_owned())
1277 .map_err(|e| {
1278 Error::p12_load_error(source, format!("Failed to parse private key: {}", e))
1279 })?;
1280
1281 let certs: Vec<_> = key_chain
1283 .chain()
1284 .iter()
1285 .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
1286 .collect();
1287
1288 if certs.is_empty() {
1289 return Err(Error::p12_load_error(
1290 source,
1291 "No certificates found in P12 data",
1292 ));
1293 }
1294
1295 Ok((certs, private_key))
1296}
1297
1298#[cfg(feature = "http")]
1300fn load_client_identity(
1301 cert_input: &CertInput,
1302 key_input: Option<&CertInput>,
1303 password: Option<&str>,
1304) -> Result<(
1305 Vec<ureq::tls::Certificate<'static>>,
1306 ureq::tls::PrivateKey<'static>,
1307)> {
1308 match cert_input {
1309 CertInput::Binary(bytes) => {
1311 log::trace!("Loading client identity from P12 binary content");
1312 let pwd = password.unwrap_or("");
1314 parse_p12_identity(bytes, pwd, "P12 binary content")
1315 }
1316
1317 CertInput::Text(text) => {
1319 if cert_input.is_p12_path() {
1321 log::trace!("Loading client identity from P12 file: {}", text);
1322 let bytes = std::fs::read(text).map_err(|e| {
1323 Error::p12_load_error(text, format!("Failed to read P12 file: {}", e))
1324 })?;
1325 let pwd = password.unwrap_or("");
1327 return parse_p12_identity(&bytes, pwd, text);
1328 }
1329
1330 log::trace!("Loading client identity from PEM (cert + key)");
1332 let certs = load_certs(cert_input)?;
1333
1334 let key_input = key_input.ok_or_else(|| {
1335 Error::tls_config_error(
1336 "client_key required when using PEM certificate (not needed for P12)",
1337 )
1338 })?;
1339
1340 let key = load_private_key(key_input, password)?;
1341 Ok((certs, key))
1342 }
1343 }
1344}
1345
1346#[cfg(feature = "http")]
1348fn build_tls_config(
1349 ctx: &ResolverContext,
1350 kwargs: &HashMap<String, String>,
1351) -> Result<ureq::tls::TlsConfig> {
1352 use std::sync::Arc;
1353 use ureq::tls::{ClientCert, RootCerts, TlsConfig};
1354
1355 let mut builder = TlsConfig::builder();
1356
1357 let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
1359
1360 if insecure {
1361 eprintln!("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
1363 eprintln!("┃ ⚠️ WARNING: TLS CERTIFICATE VERIFICATION DISABLED ┃");
1364 eprintln!("┃ ┃");
1365 eprintln!("┃ You are using insecure=true which disables ALL ┃");
1366 eprintln!("┃ TLS certificate validation. This is DANGEROUS ┃");
1367 eprintln!("┃ and should ONLY be used in development. ┃");
1368 eprintln!("┃ ┃");
1369 eprintln!("┃ In production, use proper certificate ┃");
1370 eprintln!("┃ configuration with ca_bundle or extra_ca_bundle. ┃");
1371 eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n");
1372 log::warn!("TLS certificate verification is disabled (insecure=true)");
1373 builder = builder.disable_verification(true);
1374 }
1375
1376 let ca_bundle_input = kwargs
1378 .get("ca_bundle")
1379 .map(|s| CertInput::Text(s.clone()))
1380 .or_else(|| ctx.http_ca_bundle.clone());
1381
1382 let extra_ca_bundle_input = kwargs
1383 .get("extra_ca_bundle")
1384 .map(|s| CertInput::Text(s.clone()))
1385 .or_else(|| ctx.http_extra_ca_bundle.clone());
1386
1387 if let Some(ca_input) = ca_bundle_input.as_ref() {
1388 let certs = load_certs(ca_input)?;
1390 builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
1391 } else if let Some(extra_ca_input) = extra_ca_bundle_input.as_ref() {
1392 let extra_certs = load_certs(extra_ca_input)?;
1394 builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
1395 }
1396
1397 let client_cert_input = kwargs
1399 .get("client_cert")
1400 .map(|s| CertInput::Text(s.clone()))
1401 .or_else(|| ctx.http_client_cert.clone());
1402
1403 if let Some(cert_input) = client_cert_input.as_ref() {
1404 let client_key_input = kwargs
1405 .get("client_key")
1406 .map(|s| CertInput::Text(s.clone()))
1407 .or_else(|| ctx.http_client_key.clone());
1408
1409 let password = kwargs
1410 .get("key_password")
1411 .map(|s| s.as_str())
1412 .or(ctx.http_client_key_password.as_deref());
1413
1414 let (certs, key) = load_client_identity(cert_input, client_key_input.as_ref(), password)?;
1415
1416 let client_cert = ClientCert::new_with_certs(&certs, key);
1417 builder = builder.client_cert(Some(client_cert));
1418 }
1419
1420 Ok(builder.build())
1421}
1422
1423#[cfg(feature = "http")]
1425fn build_proxy_config(
1426 ctx: &ResolverContext,
1427 kwargs: &HashMap<String, String>,
1428) -> Result<Option<ureq::Proxy>> {
1429 let proxy_url = kwargs
1431 .get("proxy")
1432 .cloned()
1433 .or_else(|| ctx.http_proxy.clone());
1434
1435 let proxy_url = proxy_url.or_else(|| {
1437 if ctx.http_proxy_from_env {
1438 std::env::var("HTTPS_PROXY")
1440 .or_else(|_| std::env::var("https_proxy"))
1441 .or_else(|_| std::env::var("HTTP_PROXY"))
1442 .or_else(|_| std::env::var("http_proxy"))
1443 .ok()
1444 } else {
1445 None
1446 }
1447 });
1448
1449 if let Some(url) = proxy_url {
1450 let proxy = ureq::Proxy::new(&url).map_err(|e| {
1451 Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1452 })?;
1453 Ok(Some(proxy))
1454 } else {
1455 Ok(None)
1456 }
1457}
1458
1459#[cfg(feature = "http")]
1461fn http_fetch(
1462 url: &str,
1463 kwargs: &HashMap<String, String>,
1464 ctx: &ResolverContext,
1465) -> Result<ResolvedValue> {
1466 use std::time::Duration;
1467
1468 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
1469 let timeout_secs: u64 = kwargs
1470 .get("timeout")
1471 .and_then(|s| s.parse().ok())
1472 .unwrap_or(30);
1473
1474 let tls_config = build_tls_config(ctx, kwargs)?;
1476
1477 let proxy = build_proxy_config(ctx, kwargs)?;
1479
1480 let mut config_builder = ureq::Agent::config_builder()
1482 .timeout_global(Some(Duration::from_secs(timeout_secs)))
1483 .tls_config(tls_config);
1484
1485 if proxy.is_some() {
1486 config_builder = config_builder.proxy(proxy);
1487 }
1488
1489 let config = config_builder.build();
1490 let agent: ureq::Agent = config.into();
1491
1492 let mut request = agent.get(url);
1494
1495 for (key, value) in kwargs {
1497 if key == "header" {
1498 if let Some((name, val)) = value.split_once(':') {
1500 request = request.header(name.trim(), val.trim());
1501 }
1502 }
1503 }
1504
1505 let response = request.call().map_err(|e| {
1507 let error_msg = match &e {
1508 ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1509 ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1510 ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1511 _ => format!("HTTP request failed: {}", e),
1512 };
1513 Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1514 })?;
1515
1516 let content_type = response
1518 .headers()
1519 .get("content-type")
1520 .and_then(|v| v.to_str().ok())
1521 .map(|s| s.to_string())
1522 .unwrap_or_default();
1523
1524 if parse_mode == "binary" {
1526 let bytes = response.into_body().read_to_vec().map_err(|e| {
1527 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1528 })?;
1529 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
1530 }
1531
1532 let body = response.into_body().read_to_string().map_err(|e| {
1534 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1535 })?;
1536
1537 let actual_parse_mode = if parse_mode == "auto" {
1539 detect_parse_mode(url, &content_type)
1540 } else {
1541 parse_mode
1542 };
1543
1544 match actual_parse_mode {
1546 "yaml" => {
1547 let value: Value = serde_yaml::from_str(&body).map_err(|e| {
1548 Error::parse(format!("Failed to parse YAML from {}: {}", url, e))
1549 .with_path(ctx.config_path.clone())
1550 })?;
1551 Ok(ResolvedValue::new(value))
1552 }
1553 "json" => {
1554 let value: Value = serde_json::from_str(&body).map_err(|e| {
1555 Error::parse(format!("Failed to parse JSON from {}: {}", url, e))
1556 .with_path(ctx.config_path.clone())
1557 })?;
1558 Ok(ResolvedValue::new(value))
1559 }
1560 _ => {
1561 Ok(ResolvedValue::new(Value::String(body)))
1563 }
1564 }
1565}
1566
1567#[cfg(feature = "http")]
1569fn detect_parse_mode<'a>(url: &str, content_type: &str) -> &'a str {
1570 let ct_lower = content_type.to_lowercase();
1572 if ct_lower.contains("application/json") || ct_lower.contains("text/json") {
1573 return "json";
1574 }
1575 if ct_lower.contains("application/yaml")
1576 || ct_lower.contains("application/x-yaml")
1577 || ct_lower.contains("text/yaml")
1578 {
1579 return "yaml";
1580 }
1581
1582 if let Some(path) = url.split('?').next() {
1584 if path.ends_with(".json") {
1585 return "json";
1586 }
1587 if path.ends_with(".yaml") || path.ends_with(".yml") {
1588 return "yaml";
1589 }
1590 }
1591
1592 "text"
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598 use super::*;
1599
1600 #[test]
1601 fn test_env_resolver_with_value() {
1602 std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
1603
1604 let ctx = ResolverContext::new("test.path");
1605 let args = vec!["HOLOCONF_TEST_VAR".to_string()];
1606 let kwargs = HashMap::new();
1607
1608 let result = env_resolver(&args, &kwargs, &ctx).unwrap();
1609 assert_eq!(result.value.as_str(), Some("test_value"));
1610 assert!(!result.sensitive);
1611
1612 std::env::remove_var("HOLOCONF_TEST_VAR");
1613 }
1614
1615 #[test]
1616 fn test_env_resolver_missing_returns_error() {
1617 std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
1619
1620 let registry = ResolverRegistry::with_builtins();
1621 let ctx = ResolverContext::new("test.path");
1622 let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
1623 let kwargs = HashMap::new();
1624
1625 let result = registry.resolve("env", &args, &kwargs, &ctx);
1628 assert!(result.is_err());
1629 }
1630
1631 #[test]
1632 fn test_env_resolver_missing_no_default() {
1633 std::env::remove_var("HOLOCONF_MISSING_VAR");
1634
1635 let ctx = ResolverContext::new("test.path");
1636 let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
1637 let kwargs = HashMap::new();
1638
1639 let result = env_resolver(&args, &kwargs, &ctx);
1640 assert!(result.is_err());
1641 }
1642
1643 #[test]
1644 fn test_env_resolver_sensitive_kwarg() {
1645 std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
1646
1647 let registry = ResolverRegistry::with_builtins();
1648 let ctx = ResolverContext::new("test.path");
1649 let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
1650 let mut kwargs = HashMap::new();
1651 kwargs.insert("sensitive".to_string(), "true".to_string());
1652
1653 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1655 assert_eq!(result.value.as_str(), Some("secret_value"));
1656 assert!(result.sensitive);
1657
1658 std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
1659 }
1660
1661 #[test]
1662 fn test_env_resolver_sensitive_false() {
1663 std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
1664
1665 let registry = ResolverRegistry::with_builtins();
1666 let ctx = ResolverContext::new("test.path");
1667 let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
1668 let mut kwargs = HashMap::new();
1669 kwargs.insert("sensitive".to_string(), "false".to_string());
1670
1671 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1673 assert_eq!(result.value.as_str(), Some("public_value"));
1674 assert!(!result.sensitive);
1675
1676 std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1677 }
1678
1679 #[test]
1683 fn test_resolver_registry() {
1684 let registry = ResolverRegistry::with_builtins();
1685
1686 assert!(registry.contains("env"));
1687 assert!(!registry.contains("nonexistent"));
1688 }
1689
1690 #[test]
1691 fn test_custom_resolver() {
1692 let mut registry = ResolverRegistry::new();
1693
1694 registry.register_fn("custom", |args, _kwargs, _ctx| {
1695 let value = args.first().cloned().unwrap_or_default();
1696 Ok(ResolvedValue::new(Value::String(format!(
1697 "custom:{}",
1698 value
1699 ))))
1700 });
1701
1702 let ctx = ResolverContext::new("test");
1703 let result = registry
1704 .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
1705 .unwrap();
1706
1707 assert_eq!(result.value.as_str(), Some("custom:arg"));
1708 }
1709
1710 #[test]
1711 fn test_resolved_value_sensitivity() {
1712 let non_sensitive = ResolvedValue::new("public");
1713 assert!(!non_sensitive.sensitive);
1714
1715 let sensitive = ResolvedValue::sensitive("secret");
1716 assert!(sensitive.sensitive);
1717 }
1718
1719 #[test]
1720 fn test_resolver_context_cycle_detection() {
1721 let mut ctx = ResolverContext::new("root");
1722 ctx.push_resolution("a");
1723 ctx.push_resolution("b");
1724
1725 assert!(ctx.would_cause_cycle("a"));
1726 assert!(ctx.would_cause_cycle("b"));
1727 assert!(!ctx.would_cause_cycle("c"));
1728
1729 ctx.pop_resolution();
1730 assert!(!ctx.would_cause_cycle("b"));
1731 }
1732
1733 #[test]
1734 fn test_file_resolver() {
1735 use std::io::Write;
1736
1737 let temp_dir = std::env::temp_dir();
1739 let test_file = temp_dir.join("holoconf_test_file.txt");
1740 {
1741 let mut file = std::fs::File::create(&test_file).unwrap();
1742 writeln!(file, "test content").unwrap();
1743 }
1744
1745 let mut ctx = ResolverContext::new("test.path");
1746 ctx.base_path = Some(temp_dir.clone());
1747 ctx.file_roots.insert(temp_dir.clone());
1748
1749 let args = vec!["holoconf_test_file.txt".to_string()];
1750 let mut kwargs = HashMap::new();
1751 kwargs.insert("parse".to_string(), "text".to_string());
1752
1753 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1754 assert!(result.value.as_str().unwrap().contains("test content"));
1755 assert!(!result.sensitive);
1756
1757 std::fs::remove_file(test_file).ok();
1759 }
1760
1761 #[test]
1762 fn test_file_resolver_yaml() {
1763 use std::io::Write;
1764
1765 let temp_dir = std::env::temp_dir();
1767 let test_file = temp_dir.join("holoconf_test.yaml");
1768 {
1769 let mut file = std::fs::File::create(&test_file).unwrap();
1770 writeln!(file, "key: value").unwrap();
1771 writeln!(file, "number: 42").unwrap();
1772 }
1773
1774 let mut ctx = ResolverContext::new("test.path");
1775 ctx.base_path = Some(temp_dir.clone());
1776 ctx.file_roots.insert(temp_dir.clone());
1777
1778 let args = vec!["holoconf_test.yaml".to_string()];
1779 let kwargs = HashMap::new();
1780
1781 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1782 assert!(result.value.is_mapping());
1783
1784 std::fs::remove_file(test_file).ok();
1786 }
1787
1788 #[test]
1789 fn test_file_resolver_not_found() {
1790 let ctx = ResolverContext::new("test.path");
1791 let args = vec!["nonexistent_file.txt".to_string()];
1792 let kwargs = HashMap::new();
1793
1794 let result = file_resolver(&args, &kwargs, &ctx);
1795 assert!(result.is_err());
1796 }
1797
1798 #[test]
1799 fn test_registry_with_file() {
1800 let registry = ResolverRegistry::with_builtins();
1801 assert!(registry.contains("file"));
1802 }
1803
1804 #[test]
1805 fn test_http_resolver_disabled() {
1806 let ctx = ResolverContext::new("test.path");
1807 let args = vec!["example.com/config.yaml".to_string()];
1808 let kwargs = HashMap::new();
1809
1810 let result = http_resolver(&args, &kwargs, &ctx);
1811 assert!(result.is_err());
1812
1813 let err = result.unwrap_err();
1814 let display = format!("{}", err);
1815 assert!(display.contains("HTTP resolver is disabled"));
1817 }
1818
1819 #[test]
1820 fn test_registry_with_http() {
1821 let registry = ResolverRegistry::with_builtins();
1822 assert!(registry.contains("http"));
1823 }
1824
1825 #[test]
1826 #[cfg(feature = "http")]
1827 fn test_registry_with_https() {
1828 let registry = ResolverRegistry::with_builtins();
1829 assert!(
1830 registry.contains("https"),
1831 "https resolver should be registered when http feature is enabled"
1832 );
1833 }
1834
1835 #[test]
1838 fn test_env_resolver_no_args() {
1839 let ctx = ResolverContext::new("test.path");
1840 let args = vec![];
1841 let kwargs = HashMap::new();
1842
1843 let result = env_resolver(&args, &kwargs, &ctx);
1844 assert!(result.is_err());
1845 let err = result.unwrap_err();
1846 assert!(err.to_string().contains("requires"));
1847 }
1848
1849 #[test]
1850 fn test_file_resolver_no_args() {
1851 let ctx = ResolverContext::new("test.path");
1852 let args = vec![];
1853 let kwargs = HashMap::new();
1854
1855 let result = file_resolver(&args, &kwargs, &ctx);
1856 assert!(result.is_err());
1857 let err = result.unwrap_err();
1858 assert!(err.to_string().contains("requires"));
1859 }
1860
1861 #[test]
1862 fn test_http_resolver_no_args() {
1863 let ctx = ResolverContext::new("test.path");
1864 let args = vec![];
1865 let kwargs = HashMap::new();
1866
1867 let result = http_resolver(&args, &kwargs, &ctx);
1868 assert!(result.is_err());
1869 let err = result.unwrap_err();
1870 assert!(err.to_string().contains("requires"));
1871 }
1872
1873 #[test]
1874 fn test_unknown_resolver() {
1875 let registry = ResolverRegistry::with_builtins();
1876 let ctx = ResolverContext::new("test.path");
1877
1878 let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
1879 assert!(result.is_err());
1880 let err = result.unwrap_err();
1881 assert!(err.to_string().contains("unknown_resolver"));
1882 }
1883
1884 #[test]
1885 fn test_resolved_value_from_traits() {
1886 let from_value: ResolvedValue = Value::String("test".to_string()).into();
1887 assert_eq!(from_value.value.as_str(), Some("test"));
1888 assert!(!from_value.sensitive);
1889
1890 let from_string: ResolvedValue = "hello".to_string().into();
1891 assert_eq!(from_string.value.as_str(), Some("hello"));
1892
1893 let from_str: ResolvedValue = "world".into();
1894 assert_eq!(from_str.value.as_str(), Some("world"));
1895 }
1896
1897 #[test]
1898 fn test_resolver_context_with_base_path() {
1899 let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
1900 assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
1901 }
1902
1903 #[test]
1904 fn test_resolver_context_with_config_root() {
1905 use std::sync::Arc;
1906 let root = Arc::new(Value::String("root".to_string()));
1907 let ctx = ResolverContext::new("test").with_config_root(root.clone());
1908 assert!(ctx.config_root.is_some());
1909 }
1910
1911 #[test]
1912 fn test_resolver_context_resolution_chain() {
1913 let mut ctx = ResolverContext::new("root");
1914 ctx.push_resolution("a");
1915 ctx.push_resolution("b");
1916 ctx.push_resolution("c");
1917
1918 let chain = ctx.get_resolution_chain();
1919 assert_eq!(chain, vec!["a", "b", "c"]);
1920 }
1921
1922 #[test]
1923 fn test_registry_get_resolver() {
1924 let registry = ResolverRegistry::with_builtins();
1925
1926 let env_resolver = registry.get("env");
1927 assert!(env_resolver.is_some());
1928 assert_eq!(env_resolver.unwrap().name(), "env");
1929
1930 let missing = registry.get("nonexistent");
1931 assert!(missing.is_none());
1932 }
1933
1934 #[test]
1935 fn test_registry_default() {
1936 let registry = ResolverRegistry::default();
1937 assert!(!registry.contains("env"));
1939 }
1940
1941 #[test]
1942 fn test_fn_resolver_name() {
1943 let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
1944 assert_eq!(resolver.name(), "my_resolver");
1945 }
1946
1947 #[test]
1948 fn test_file_resolver_json() {
1949 use std::io::Write;
1950
1951 let temp_dir = std::env::temp_dir();
1953 let test_file = temp_dir.join("holoconf_test.json");
1954 {
1955 let mut file = std::fs::File::create(&test_file).unwrap();
1956 writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
1957 }
1958
1959 let mut ctx = ResolverContext::new("test.path");
1960 ctx.base_path = Some(temp_dir.clone());
1961 ctx.file_roots.insert(temp_dir.clone());
1962
1963 let args = vec!["holoconf_test.json".to_string()];
1964 let kwargs = HashMap::new();
1965
1966 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1967 assert!(result.value.is_mapping());
1968
1969 std::fs::remove_file(test_file).ok();
1971 }
1972
1973 #[test]
1974 fn test_file_resolver_absolute_path() {
1975 use std::io::Write;
1976
1977 let temp_dir = std::env::temp_dir();
1979 let test_file = temp_dir.join("holoconf_abs_test.txt");
1980 {
1981 let mut file = std::fs::File::create(&test_file).unwrap();
1982 writeln!(file, "absolute path content").unwrap();
1983 }
1984
1985 let mut ctx = ResolverContext::new("test.path");
1986 ctx.file_roots.insert(temp_dir.clone());
1987 let args = vec![test_file.to_string_lossy().to_string()];
1989 let mut kwargs = HashMap::new();
1990 kwargs.insert("parse".to_string(), "text".to_string());
1991
1992 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1993 assert!(result
1994 .value
1995 .as_str()
1996 .unwrap()
1997 .contains("absolute path content"));
1998
1999 std::fs::remove_file(test_file).ok();
2001 }
2002
2003 #[test]
2004 fn test_file_resolver_invalid_yaml() {
2005 use std::io::Write;
2006
2007 let temp_dir = std::env::temp_dir();
2009 let test_file = temp_dir.join("holoconf_invalid.yaml");
2010 {
2011 let mut file = std::fs::File::create(&test_file).unwrap();
2012 writeln!(file, "key: [invalid").unwrap();
2013 }
2014
2015 let mut ctx = ResolverContext::new("test.path");
2016 ctx.base_path = Some(temp_dir.clone());
2017 ctx.file_roots.insert(temp_dir.clone());
2018
2019 let args = vec!["holoconf_invalid.yaml".to_string()];
2020 let kwargs = HashMap::new();
2021
2022 let result = file_resolver(&args, &kwargs, &ctx);
2023 assert!(result.is_err());
2024 let err = result.unwrap_err();
2025 assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
2026
2027 std::fs::remove_file(test_file).ok();
2029 }
2030
2031 #[test]
2032 fn test_file_resolver_invalid_json() {
2033 use std::io::Write;
2034
2035 let temp_dir = std::env::temp_dir();
2037 let test_file = temp_dir.join("holoconf_invalid.json");
2038 {
2039 let mut file = std::fs::File::create(&test_file).unwrap();
2040 writeln!(file, "{{invalid json}}").unwrap();
2041 }
2042
2043 let mut ctx = ResolverContext::new("test.path");
2044 ctx.base_path = Some(temp_dir.clone());
2045 ctx.file_roots.insert(temp_dir.clone());
2046
2047 let args = vec!["holoconf_invalid.json".to_string()];
2048 let kwargs = HashMap::new();
2049
2050 let result = file_resolver(&args, &kwargs, &ctx);
2051 assert!(result.is_err());
2052 let err = result.unwrap_err();
2053 assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
2054
2055 std::fs::remove_file(test_file).ok();
2057 }
2058
2059 #[test]
2060 fn test_file_resolver_unknown_extension() {
2061 use std::io::Write;
2062
2063 let temp_dir = std::env::temp_dir();
2065 let test_file = temp_dir.join("holoconf_test.xyz");
2066 {
2067 let mut file = std::fs::File::create(&test_file).unwrap();
2068 writeln!(file, "plain text content").unwrap();
2069 }
2070
2071 let mut ctx = ResolverContext::new("test.path");
2072 ctx.base_path = Some(temp_dir.clone());
2073 ctx.file_roots.insert(temp_dir.clone());
2074
2075 let args = vec!["holoconf_test.xyz".to_string()];
2076 let kwargs = HashMap::new();
2077
2078 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2079 assert!(result
2081 .value
2082 .as_str()
2083 .unwrap()
2084 .contains("plain text content"));
2085
2086 std::fs::remove_file(test_file).ok();
2088 }
2089
2090 #[test]
2091 fn test_file_resolver_encoding_utf8() {
2092 use std::io::Write;
2093
2094 let temp_dir = std::env::temp_dir();
2096 let test_file = temp_dir.join("holoconf_utf8.txt");
2097 {
2098 let mut file = std::fs::File::create(&test_file).unwrap();
2099 writeln!(file, "Hello, 世界! 🌍").unwrap();
2100 }
2101
2102 let mut ctx = ResolverContext::new("test.path");
2103 ctx.base_path = Some(temp_dir.clone());
2104 ctx.file_roots.insert(temp_dir.clone());
2105
2106 let args = vec!["holoconf_utf8.txt".to_string()];
2107 let mut kwargs = HashMap::new();
2108 kwargs.insert("encoding".to_string(), "utf-8".to_string());
2109
2110 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2111 let content = result.value.as_str().unwrap();
2112 assert!(content.contains("世界"));
2113 assert!(content.contains("🌍"));
2114
2115 std::fs::remove_file(test_file).ok();
2117 }
2118
2119 #[test]
2120 fn test_file_resolver_encoding_ascii() {
2121 use std::io::Write;
2122
2123 let temp_dir = std::env::temp_dir();
2125 let test_file = temp_dir.join("holoconf_ascii.txt");
2126 {
2127 let mut file = std::fs::File::create(&test_file).unwrap();
2128 writeln!(file, "Hello, 世界! Welcome").unwrap();
2129 }
2130
2131 let mut ctx = ResolverContext::new("test.path");
2132 ctx.base_path = Some(temp_dir.clone());
2133 ctx.file_roots.insert(temp_dir.clone());
2134
2135 let args = vec!["holoconf_ascii.txt".to_string()];
2136 let mut kwargs = HashMap::new();
2137 kwargs.insert("encoding".to_string(), "ascii".to_string());
2138
2139 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2140 let content = result.value.as_str().unwrap();
2141 assert!(content.contains("Hello"));
2143 assert!(content.contains("Welcome"));
2144 assert!(!content.contains("世界"));
2145
2146 std::fs::remove_file(test_file).ok();
2148 }
2149
2150 #[test]
2151 fn test_file_resolver_encoding_base64() {
2152 use std::io::Write;
2153
2154 let temp_dir = std::env::temp_dir();
2156 let test_file = temp_dir.join("holoconf_binary.bin");
2157 {
2158 let mut file = std::fs::File::create(&test_file).unwrap();
2159 file.write_all(b"Hello\x00\x01\x02World").unwrap();
2161 }
2162
2163 let mut ctx = ResolverContext::new("test.path");
2164 ctx.base_path = Some(temp_dir.clone());
2165 ctx.file_roots.insert(temp_dir.clone());
2166
2167 let args = vec!["holoconf_binary.bin".to_string()];
2168 let mut kwargs = HashMap::new();
2169 kwargs.insert("encoding".to_string(), "base64".to_string());
2170
2171 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2172 let content = result.value.as_str().unwrap();
2173
2174 use base64::{engine::general_purpose::STANDARD, Engine as _};
2176 let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
2177 assert_eq!(content, expected);
2178
2179 std::fs::remove_file(test_file).ok();
2181 }
2182
2183 #[test]
2184 fn test_file_resolver_encoding_default_is_utf8() {
2185 use std::io::Write;
2186
2187 let temp_dir = std::env::temp_dir();
2189 let test_file = temp_dir.join("holoconf_default_enc.txt");
2190 {
2191 let mut file = std::fs::File::create(&test_file).unwrap();
2192 writeln!(file, "café résumé").unwrap();
2193 }
2194
2195 let mut ctx = ResolverContext::new("test.path");
2196 ctx.base_path = Some(temp_dir.clone());
2197 ctx.file_roots.insert(temp_dir.clone());
2198
2199 let args = vec!["holoconf_default_enc.txt".to_string()];
2200 let kwargs = HashMap::new(); let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2203 let content = result.value.as_str().unwrap();
2204 assert!(content.contains("café"));
2206 assert!(content.contains("résumé"));
2207
2208 std::fs::remove_file(test_file).ok();
2210 }
2211
2212 #[test]
2213 fn test_file_resolver_encoding_binary() {
2214 use std::io::Write;
2215
2216 let temp_dir = std::env::temp_dir();
2218 let test_file = temp_dir.join("holoconf_binary_bytes.bin");
2219 let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
2220 {
2221 let mut file = std::fs::File::create(&test_file).unwrap();
2222 file.write_all(&binary_data).unwrap();
2223 }
2224
2225 let mut ctx = ResolverContext::new("test.path");
2226 ctx.base_path = Some(temp_dir.clone());
2227 ctx.file_roots.insert(temp_dir.clone());
2228
2229 let args = vec!["holoconf_binary_bytes.bin".to_string()];
2230 let mut kwargs = HashMap::new();
2231 kwargs.insert("encoding".to_string(), "binary".to_string());
2232
2233 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2234
2235 assert!(result.value.is_bytes());
2237 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2238
2239 std::fs::remove_file(test_file).ok();
2241 }
2242
2243 #[test]
2244 fn test_file_resolver_encoding_binary_empty() {
2245 let temp_dir = std::env::temp_dir();
2247 let test_file = temp_dir.join("holoconf_binary_empty.bin");
2248 {
2249 std::fs::File::create(&test_file).unwrap();
2250 }
2251
2252 let mut ctx = ResolverContext::new("test.path");
2253 ctx.base_path = Some(temp_dir.clone());
2254 ctx.file_roots.insert(temp_dir.clone());
2255
2256 let args = vec!["holoconf_binary_empty.bin".to_string()];
2257 let mut kwargs = HashMap::new();
2258 kwargs.insert("encoding".to_string(), "binary".to_string());
2259
2260 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
2261
2262 assert!(result.value.is_bytes());
2264 let empty: &[u8] = &[];
2265 assert_eq!(result.value.as_bytes().unwrap(), empty);
2266
2267 std::fs::remove_file(test_file).ok();
2269 }
2270
2271 #[test]
2274 fn test_file_resolver_with_sensitive() {
2275 use std::io::Write;
2276
2277 let temp_dir = std::env::temp_dir();
2279 let test_file = temp_dir.join("holoconf_sensitive_test.txt");
2280 {
2281 let mut file = std::fs::File::create(&test_file).unwrap();
2282 writeln!(file, "secret content").unwrap();
2283 }
2284
2285 let registry = ResolverRegistry::with_builtins();
2286 let mut ctx = ResolverContext::new("test.path");
2287 ctx.base_path = Some(temp_dir.clone());
2288 ctx.file_roots.insert(temp_dir.clone());
2289
2290 let args = vec!["holoconf_sensitive_test.txt".to_string()];
2291 let mut kwargs = HashMap::new();
2292 kwargs.insert("sensitive".to_string(), "true".to_string());
2293
2294 let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
2296 assert!(result.value.as_str().unwrap().contains("secret content"));
2297 assert!(result.sensitive);
2298
2299 std::fs::remove_file(test_file).ok();
2301 }
2302
2303 #[test]
2304 fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
2305 let mut registry = ResolverRegistry::new();
2308
2309 registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
2311 assert!(
2313 !kwargs.contains_key("sensitive"),
2314 "sensitive kwarg should not be passed to resolver"
2315 );
2316 if let Some(custom) = kwargs.get("custom") {
2318 Ok(ResolvedValue::new(Value::String(format!(
2319 "custom={}",
2320 custom
2321 ))))
2322 } else {
2323 Ok(ResolvedValue::new(Value::String("no custom".to_string())))
2324 }
2325 });
2326
2327 let ctx = ResolverContext::new("test.path");
2328 let args = vec![];
2329 let mut kwargs = HashMap::new();
2330 kwargs.insert("sensitive".to_string(), "true".to_string());
2331 kwargs.insert("custom".to_string(), "myvalue".to_string());
2332
2333 let result = registry
2334 .resolve("test_kwargs", &args, &kwargs, &ctx)
2335 .unwrap();
2336 assert_eq!(result.value.as_str(), Some("custom=myvalue"));
2337 assert!(result.sensitive);
2339 }
2340
2341 #[test]
2343 #[cfg(feature = "http")]
2344 fn test_normalize_http_url_clean_syntax() {
2345 assert_eq!(
2346 normalize_http_url("https", "example.com/path").unwrap(),
2347 "https://example.com/path"
2348 );
2349 assert_eq!(
2350 normalize_http_url("http", "example.com").unwrap(),
2351 "http://example.com"
2352 );
2353 }
2354
2355 #[test]
2356 #[cfg(feature = "http")]
2357 fn test_normalize_http_url_double_slash() {
2358 assert_eq!(
2359 normalize_http_url("https", "//example.com/path").unwrap(),
2360 "https://example.com/path"
2361 );
2362 }
2363
2364 #[test]
2365 #[cfg(feature = "http")]
2366 fn test_normalize_http_url_existing_https() {
2367 assert_eq!(
2368 normalize_http_url("https", "https://example.com/path").unwrap(),
2369 "https://example.com/path"
2370 );
2371 }
2372
2373 #[test]
2374 #[cfg(feature = "http")]
2375 fn test_normalize_http_url_wrong_scheme() {
2376 assert_eq!(
2378 normalize_http_url("https", "http://example.com").unwrap(),
2379 "https://example.com"
2380 );
2381 }
2382
2383 #[test]
2384 #[cfg(feature = "http")]
2385 fn test_normalize_http_url_with_query() {
2386 assert_eq!(
2387 normalize_http_url("https", "example.com/path?query=val&other=val2").unwrap(),
2388 "https://example.com/path?query=val&other=val2"
2389 );
2390 }
2391
2392 #[test]
2393 #[cfg(feature = "http")]
2394 fn test_normalize_http_url_empty() {
2395 let result = normalize_http_url("https", "");
2396 assert!(result.is_err());
2397 assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2398 }
2399
2400 #[test]
2401 #[cfg(feature = "http")]
2402 fn test_normalize_http_url_triple_slash() {
2403 let result = normalize_http_url("https", "///example.com");
2405 assert!(result.is_err());
2406 assert!(result
2407 .unwrap_err()
2408 .to_string()
2409 .contains("Invalid URL syntax"));
2410 }
2411
2412 #[test]
2413 #[cfg(feature = "http")]
2414 fn test_normalize_http_url_whitespace_only() {
2415 let result = normalize_http_url("https", " ");
2416 assert!(result.is_err());
2417 assert!(result.unwrap_err().to_string().contains("non-empty URL"));
2418 }
2419
2420 #[test]
2422 fn test_is_localhost_ascii() {
2423 assert!(is_localhost("localhost"));
2424 assert!(is_localhost("LOCALHOST"));
2425 assert!(is_localhost("LocalHost"));
2426 }
2427
2428 #[test]
2429 fn test_is_localhost_ipv4() {
2430 assert!(is_localhost("127.0.0.1"));
2431 assert!(is_localhost("127.0.0.100"));
2432 assert!(is_localhost("127.1.2.3"));
2433 assert!(!is_localhost("128.0.0.1"));
2434 }
2435
2436 #[test]
2437 fn test_is_localhost_ipv6() {
2438 assert!(is_localhost("::1"));
2439 assert!(is_localhost("[::1]"));
2440 assert!(!is_localhost("::2"));
2441 }
2442
2443 #[test]
2444 fn test_is_localhost_not() {
2445 assert!(!is_localhost("example.com"));
2446 assert!(!is_localhost("remote.host"));
2447 assert!(!is_localhost("192.168.1.1"));
2448 }
2449
2450 #[test]
2452 fn test_normalize_file_path_relative() {
2453 let (path, is_rel) = normalize_file_path("data.txt").unwrap();
2454 assert_eq!(path, "data.txt");
2455 assert!(is_rel);
2456
2457 let (path, is_rel) = normalize_file_path("./data.txt").unwrap();
2458 assert_eq!(path, "./data.txt");
2459 assert!(is_rel);
2460 }
2461
2462 #[test]
2463 fn test_normalize_file_path_absolute() {
2464 let (path, is_rel) = normalize_file_path("/etc/config.yaml").unwrap();
2465 assert_eq!(path, "/etc/config.yaml");
2466 assert!(!is_rel);
2467 }
2468
2469 #[test]
2470 fn test_normalize_file_path_rfc8089_empty_authority() {
2471 let (path, is_rel) = normalize_file_path("///etc/config.yaml").unwrap();
2473 assert_eq!(path, "/etc/config.yaml");
2474 assert!(!is_rel);
2475 }
2476
2477 #[test]
2478 fn test_normalize_file_path_rfc8089_localhost() {
2479 let (path, is_rel) = normalize_file_path("//localhost/var/data").unwrap();
2481 assert_eq!(path, "/var/data");
2482 assert!(!is_rel);
2483
2484 let (path, is_rel) = normalize_file_path("//localhost").unwrap();
2486 assert_eq!(path, "/");
2487 assert!(!is_rel);
2488 }
2489
2490 #[test]
2491 fn test_normalize_file_path_rfc8089_localhost_ipv4() {
2492 let (path, is_rel) = normalize_file_path("//127.0.0.1/tmp/file.txt").unwrap();
2494 assert_eq!(path, "/tmp/file.txt");
2495 assert!(!is_rel);
2496 }
2497
2498 #[test]
2499 fn test_normalize_file_path_rfc8089_localhost_ipv6() {
2500 let (path, is_rel) = normalize_file_path("//::1/tmp/file.txt").unwrap();
2502 assert_eq!(path, "/tmp/file.txt");
2503 assert!(!is_rel);
2504 }
2505
2506 #[test]
2507 fn test_normalize_file_path_rfc8089_remote_rejected() {
2508 let result = normalize_file_path("//remote.host/path");
2509 assert!(result.is_err());
2510 let err_msg = result.unwrap_err().to_string();
2511 assert!(err_msg.contains("Remote file URIs not supported"));
2512 assert!(err_msg.contains("remote.host"));
2513
2514 let result = normalize_file_path("//server.example.com/share");
2515 assert!(result.is_err());
2516 }
2517
2518 #[test]
2519 fn test_normalize_file_path_rfc8089_empty_hostname() {
2520 let (path, is_rel) = normalize_file_path("//").unwrap();
2522 assert_eq!(path, "/");
2523 assert!(!is_rel);
2524 }
2525
2526 #[test]
2527 fn test_normalize_file_path_null_byte() {
2528 let result = normalize_file_path("/etc/passwd\0.txt");
2529 assert!(result.is_err());
2530 assert!(result.unwrap_err().to_string().contains("null byte"));
2531 }
2532
2533 #[test]
2534 fn test_normalize_file_path_null_byte_relative() {
2535 let result = normalize_file_path("data\0.txt");
2536 assert!(result.is_err());
2537 assert!(result.unwrap_err().to_string().contains("null byte"));
2538 }
2539
2540 #[test]
2542 fn test_cert_input_is_pem_content() {
2543 let pem_content = CertInput::Text("-----BEGIN CERTIFICATE-----\nMIIC...".to_string());
2544 assert!(pem_content.is_pem_content());
2545
2546 let file_path = CertInput::Text("/path/to/cert.pem".to_string());
2547 assert!(!file_path.is_pem_content());
2548
2549 let binary = CertInput::Binary(vec![0, 1, 2, 3]);
2550 assert!(!binary.is_pem_content());
2551 }
2552
2553 #[test]
2554 fn test_cert_input_is_p12_path() {
2555 assert!(CertInput::Text("/path/to/identity.p12".to_string()).is_p12_path());
2556 assert!(CertInput::Text("/path/to/identity.pfx".to_string()).is_p12_path());
2557 assert!(CertInput::Text("/path/to/identity.P12".to_string()).is_p12_path());
2558 assert!(CertInput::Text("/path/to/identity.PFX".to_string()).is_p12_path());
2559
2560 assert!(!CertInput::Text("/path/to/cert.pem".to_string()).is_p12_path());
2561 assert!(!CertInput::Text("-----BEGIN CERTIFICATE-----".to_string()).is_p12_path());
2562 assert!(!CertInput::Binary(vec![0, 1, 2, 3]).is_p12_path());
2563 }
2564
2565 #[test]
2566 fn test_cert_input_as_text() {
2567 let text_input = CertInput::Text("some text".to_string());
2568 assert_eq!(text_input.as_text(), Some("some text"));
2569
2570 let binary_input = CertInput::Binary(vec![0, 1, 2]);
2571 assert_eq!(binary_input.as_text(), None);
2572 }
2573
2574 #[test]
2575 fn test_cert_input_as_bytes() {
2576 let binary_input = CertInput::Binary(vec![0, 1, 2]);
2577 assert_eq!(binary_input.as_bytes(), Some(&[0, 1, 2][..]));
2578
2579 let text_input = CertInput::Text("some text".to_string());
2580 assert_eq!(text_input.as_bytes(), None);
2581 }
2582
2583 #[test]
2584 fn test_cert_input_from_string() {
2585 let input1 = CertInput::from("test".to_string());
2586 assert!(matches!(input1, CertInput::Text(_)));
2587 assert_eq!(input1.as_text(), Some("test"));
2588
2589 let input2 = CertInput::from("test");
2590 assert!(matches!(input2, CertInput::Text(_)));
2591 assert_eq!(input2.as_text(), Some("test"));
2592 }
2593
2594 #[test]
2595 fn test_cert_input_from_vec_u8() {
2596 let input = CertInput::from(vec![1, 2, 3]);
2597 assert!(matches!(input, CertInput::Binary(_)));
2598 assert_eq!(input.as_bytes(), Some(&[1, 2, 3][..]));
2599 }
2600}
2601
2602#[cfg(test)]
2604mod global_registry_tests {
2605 use super::*;
2606
2607 fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
2609 Arc::new(FnResolver::new(name, |_, _, _| {
2610 Ok(ResolvedValue::new("mock"))
2611 }))
2612 }
2613
2614 #[test]
2615 fn test_register_new_resolver_succeeds() {
2616 let mut registry = ResolverRegistry::new();
2617 let resolver = mock_resolver("test_new");
2618
2619 let result = registry.register_with_force(resolver, false);
2621 assert!(result.is_ok());
2622 assert!(registry.contains("test_new"));
2623 }
2624
2625 #[test]
2626 fn test_register_duplicate_errors_without_force() {
2627 let mut registry = ResolverRegistry::new();
2628 let resolver1 = mock_resolver("test_dup");
2629 let resolver2 = mock_resolver("test_dup");
2630
2631 registry.register_with_force(resolver1, false).unwrap();
2633
2634 let result = registry.register_with_force(resolver2, false);
2636 assert!(result.is_err());
2637 let err = result.unwrap_err();
2638 assert!(err.to_string().contains("already registered"));
2639 }
2640
2641 #[test]
2642 fn test_register_duplicate_succeeds_with_force() {
2643 let mut registry = ResolverRegistry::new();
2644 let resolver1 = mock_resolver("test_force");
2645 let resolver2 = mock_resolver("test_force");
2646
2647 registry.register_with_force(resolver1, false).unwrap();
2649
2650 let result = registry.register_with_force(resolver2, true);
2652 assert!(result.is_ok());
2653 }
2654
2655 #[test]
2656 fn test_global_registry_is_singleton() {
2657 let registry1 = global_registry();
2659 let registry2 = global_registry();
2660
2661 assert!(std::ptr::eq(registry1, registry2));
2663 }
2664
2665 #[test]
2666 fn test_register_global_new_resolver() {
2667 let resolver = mock_resolver("global_test_unique_42");
2669 let result = register_global(resolver, false);
2670 assert!(result.is_ok() || result.is_err());
2673 }
2674}
2675
2676#[cfg(test)]
2678mod lazy_resolution_tests {
2679 use super::*;
2680 use crate::Config;
2681 use std::sync::atomic::{AtomicBool, Ordering};
2682 use std::sync::Arc;
2683
2684 #[test]
2685 fn test_default_not_resolved_when_main_value_exists() {
2686 let fail_called = Arc::new(AtomicBool::new(false));
2688 let fail_called_clone = fail_called.clone();
2689
2690 let yaml = r#"
2692value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
2693"#;
2694 std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
2696
2697 let mut config = Config::from_yaml(yaml).unwrap();
2698
2699 config.register_resolver(Arc::new(FnResolver::new(
2701 "fail",
2702 move |_args, _kwargs, _ctx| {
2703 fail_called_clone.store(true, Ordering::SeqCst);
2704 panic!("fail resolver should not have been called - lazy resolution failed!");
2705 },
2706 )));
2707
2708 let result = config.get("value").unwrap();
2710 assert_eq!(result.as_str(), Some("main_value"));
2711
2712 assert!(
2714 !fail_called.load(Ordering::SeqCst),
2715 "The default resolver should not have been called when main value exists"
2716 );
2717
2718 std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
2719 }
2720
2721 #[test]
2722 fn test_default_is_resolved_when_main_value_missing() {
2723 let default_called = Arc::new(AtomicBool::new(false));
2725 let default_called_clone = default_called.clone();
2726
2727 let yaml = r#"
2729value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
2730"#;
2731 std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
2732
2733 let mut config = Config::from_yaml(yaml).unwrap();
2734
2735 config.register_resolver(Arc::new(FnResolver::new(
2737 "custom_default",
2738 move |args: &[String], _kwargs, _ctx| {
2739 default_called_clone.store(true, Ordering::SeqCst);
2740 let arg = args.first().cloned().unwrap_or_default();
2741 Ok(ResolvedValue::new(Value::String(format!(
2742 "default_was_{}",
2743 arg
2744 ))))
2745 },
2746 )));
2747
2748 let result = config.get("value").unwrap();
2750 assert_eq!(result.as_str(), Some("default_was_fallback"));
2751
2752 assert!(
2754 default_called.load(Ordering::SeqCst),
2755 "The default resolver should have been called when main value is missing"
2756 );
2757 }
2758}
2759
2760#[cfg(all(test, feature = "http"))]
2762mod http_resolver_tests {
2763 use super::*;
2764 use mockito::Server;
2765
2766 #[test]
2767 fn test_http_fetch_json() {
2768 let mut server = Server::new();
2769 let mock = server
2770 .mock("GET", "/config.json")
2771 .with_status(200)
2772 .with_header("content-type", "application/json")
2773 .with_body(r#"{"key": "value", "number": 42}"#)
2774 .create();
2775
2776 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2777 let args = vec![format!("{}/config.json", server.url())];
2778 let kwargs = HashMap::new();
2779
2780 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2781 assert!(result.value.is_mapping());
2782
2783 mock.assert();
2784 }
2785
2786 #[test]
2787 fn test_http_fetch_yaml() {
2788 let mut server = Server::new();
2789 let mock = server
2790 .mock("GET", "/config.yaml")
2791 .with_status(200)
2792 .with_header("content-type", "application/yaml")
2793 .with_body("key: value\nnumber: 42")
2794 .create();
2795
2796 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2797 let args = vec![format!("{}/config.yaml", server.url())];
2798 let kwargs = HashMap::new();
2799
2800 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2801 assert!(result.value.is_mapping());
2802
2803 mock.assert();
2804 }
2805
2806 #[test]
2807 fn test_http_fetch_text() {
2808 let mut server = Server::new();
2809 let mock = server
2810 .mock("GET", "/data.txt")
2811 .with_status(200)
2812 .with_header("content-type", "text/plain")
2813 .with_body("Hello, World!")
2814 .create();
2815
2816 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2817 let args = vec![format!("{}/data.txt", server.url())];
2818 let kwargs = HashMap::new();
2819
2820 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2821 assert_eq!(result.value.as_str(), Some("Hello, World!"));
2822
2823 mock.assert();
2824 }
2825
2826 #[test]
2827 fn test_http_fetch_binary() {
2828 let mut server = Server::new();
2829 let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
2830 let mock = server
2831 .mock("GET", "/data.bin")
2832 .with_status(200)
2833 .with_header("content-type", "application/octet-stream")
2834 .with_body(binary_data.clone())
2835 .create();
2836
2837 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2838 let args = vec![format!("{}/data.bin", server.url())];
2839 let mut kwargs = HashMap::new();
2840 kwargs.insert("parse".to_string(), "binary".to_string());
2841
2842 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2843 assert!(result.value.is_bytes());
2844 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2845
2846 mock.assert();
2847 }
2848
2849 #[test]
2850 fn test_http_fetch_explicit_parse_mode() {
2851 let mut server = Server::new();
2852 let mock = server
2854 .mock("GET", "/data")
2855 .with_status(200)
2856 .with_header("content-type", "text/plain")
2857 .with_body(r#"{"key": "value"}"#)
2858 .create();
2859
2860 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2861 let args = vec![format!("{}/data", server.url())];
2862 let mut kwargs = HashMap::new();
2863 kwargs.insert("parse".to_string(), "json".to_string());
2864
2865 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2866 assert!(result.value.is_mapping());
2867
2868 mock.assert();
2869 }
2870
2871 #[test]
2872 fn test_http_fetch_with_custom_header() {
2873 let mut server = Server::new();
2874 let mock = server
2875 .mock("GET", "/protected")
2876 .match_header("Authorization", "Bearer my-token")
2877 .with_status(200)
2878 .with_body("authorized content")
2879 .create();
2880
2881 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2882 let args = vec![format!("{}/protected", server.url())];
2883 let mut kwargs = HashMap::new();
2884 kwargs.insert(
2885 "header".to_string(),
2886 "Authorization:Bearer my-token".to_string(),
2887 );
2888
2889 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2890 assert_eq!(result.value.as_str(), Some("authorized content"));
2891
2892 mock.assert();
2893 }
2894
2895 #[test]
2896 fn test_http_fetch_404_error() {
2897 let mut server = Server::new();
2898 let mock = server.mock("GET", "/notfound").with_status(404).create();
2899
2900 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2901 let args = vec![format!("{}/notfound", server.url())];
2902 let kwargs = HashMap::new();
2903
2904 let result = http_resolver(&args, &kwargs, &ctx);
2905 assert!(result.is_err());
2906 let err = result.unwrap_err();
2907 assert!(err.to_string().contains("HTTP"));
2908
2909 mock.assert();
2910 }
2911
2912 #[test]
2913 fn test_http_disabled_by_default() {
2914 let ctx = ResolverContext::new("test.path");
2915 let args = vec!["https://example.com/config.yaml".to_string()];
2917 let kwargs = HashMap::new();
2918
2919 let result = http_resolver(&args, &kwargs, &ctx);
2920 assert!(result.is_err());
2921 let err = result.unwrap_err();
2922 assert!(err.to_string().contains("disabled"));
2923 }
2924
2925 #[test]
2926 fn test_http_allowlist_blocks_url() {
2927 let ctx = ResolverContext::new("test.path")
2928 .with_allow_http(true)
2929 .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
2930
2931 let args = vec!["https://blocked.example.com/config.yaml".to_string()];
2932 let kwargs = HashMap::new();
2933
2934 let result = http_resolver(&args, &kwargs, &ctx);
2935 assert!(result.is_err());
2936 let err = result.unwrap_err();
2937 assert!(
2938 err.to_string().contains("not in allowlist")
2939 || err.to_string().contains("HttpNotAllowed")
2940 );
2941 }
2942
2943 #[test]
2944 fn test_http_allowlist_allows_matching_url() {
2945 let mut server = Server::new();
2946 let mock = server
2947 .mock("GET", "/config.yaml")
2948 .with_status(200)
2949 .with_body("key: value")
2950 .create();
2951
2952 let server_url = server.url();
2954 let ctx = ResolverContext::new("test.path")
2955 .with_allow_http(true)
2956 .with_http_allowlist(vec![format!("{}/*", server_url)]);
2957
2958 let args = vec![format!("{}/config.yaml", server_url)];
2959 let kwargs = HashMap::new();
2960
2961 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2962 assert!(result.value.is_mapping());
2963
2964 mock.assert();
2965 }
2966
2967 #[test]
2968 fn test_url_matches_pattern_exact() {
2969 assert!(url_matches_pattern(
2970 "https://example.com/config.yaml",
2971 "https://example.com/config.yaml"
2972 ));
2973 assert!(!url_matches_pattern(
2974 "https://example.com/other.yaml",
2975 "https://example.com/config.yaml"
2976 ));
2977 }
2978
2979 #[test]
2980 fn test_url_matches_pattern_wildcard() {
2981 assert!(url_matches_pattern(
2982 "https://example.com/config.yaml",
2983 "https://example.com/*"
2984 ));
2985 assert!(url_matches_pattern(
2986 "https://example.com/path/to/config.yaml",
2987 "https://example.com/*"
2988 ));
2989 assert!(!url_matches_pattern(
2990 "https://other.com/config.yaml",
2991 "https://example.com/*"
2992 ));
2993 }
2994
2995 #[test]
2996 fn test_url_matches_pattern_subdomain() {
2997 assert!(url_matches_pattern(
2998 "https://api.example.com/config",
2999 "https://*.example.com/*"
3000 ));
3001 assert!(url_matches_pattern(
3002 "https://staging.example.com/config",
3003 "https://*.example.com/*"
3004 ));
3005 assert!(!url_matches_pattern(
3006 "https://example.com/config",
3007 "https://*.example.com/*"
3008 ));
3009 }
3010
3011 #[test]
3012 fn test_detect_parse_mode_from_content_type() {
3013 assert_eq!(
3014 detect_parse_mode("http://example.com/data", "application/json"),
3015 "json"
3016 );
3017 assert_eq!(
3018 detect_parse_mode("http://example.com/data", "text/json"),
3019 "json"
3020 );
3021 assert_eq!(
3022 detect_parse_mode("http://example.com/data", "application/yaml"),
3023 "yaml"
3024 );
3025 assert_eq!(
3026 detect_parse_mode("http://example.com/data", "application/x-yaml"),
3027 "yaml"
3028 );
3029 assert_eq!(
3030 detect_parse_mode("http://example.com/data", "text/yaml"),
3031 "yaml"
3032 );
3033 assert_eq!(
3034 detect_parse_mode("http://example.com/data", "text/plain"),
3035 "text"
3036 );
3037 }
3038
3039 #[test]
3040 fn test_detect_parse_mode_from_url_extension() {
3041 assert_eq!(
3042 detect_parse_mode("http://example.com/config.json", ""),
3043 "json"
3044 );
3045 assert_eq!(
3046 detect_parse_mode("http://example.com/config.yaml", ""),
3047 "yaml"
3048 );
3049 assert_eq!(
3050 detect_parse_mode("http://example.com/config.yml", ""),
3051 "yaml"
3052 );
3053 assert_eq!(
3054 detect_parse_mode("http://example.com/config.txt", ""),
3055 "text"
3056 );
3057 assert_eq!(detect_parse_mode("http://example.com/config", ""), "text");
3058 }
3059
3060 #[test]
3061 fn test_detect_parse_mode_content_type_takes_precedence() {
3062 assert_eq!(
3064 detect_parse_mode("http://example.com/config.yaml", "application/json"),
3065 "json"
3066 );
3067 }
3068}