1use std::collections::HashMap;
7use std::sync::{Arc, OnceLock, RwLock};
8
9use crate::error::{Error, Result};
10use crate::value::Value;
11
12static GLOBAL_REGISTRY: OnceLock<RwLock<ResolverRegistry>> = OnceLock::new();
14
15pub fn global_registry() -> &'static RwLock<ResolverRegistry> {
20 GLOBAL_REGISTRY.get_or_init(|| RwLock::new(ResolverRegistry::with_builtins()))
21}
22
23pub fn register_global(resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
34 let mut registry = global_registry()
35 .write()
36 .expect("Global registry lock poisoned");
37 registry.register_with_force(resolver, force)
38}
39
40#[derive(Debug, Clone)]
42pub struct ResolvedValue {
43 pub value: Value,
45 pub sensitive: bool,
47}
48
49impl ResolvedValue {
50 pub fn new(value: impl Into<Value>) -> Self {
52 Self {
53 value: value.into(),
54 sensitive: false,
55 }
56 }
57
58 pub fn sensitive(value: impl Into<Value>) -> Self {
60 Self {
61 value: value.into(),
62 sensitive: true,
63 }
64 }
65}
66
67impl From<Value> for ResolvedValue {
68 fn from(value: Value) -> Self {
69 ResolvedValue::new(value)
70 }
71}
72
73impl From<String> for ResolvedValue {
74 fn from(s: String) -> Self {
75 ResolvedValue::new(Value::String(s))
76 }
77}
78
79impl From<&str> for ResolvedValue {
80 fn from(s: &str) -> Self {
81 ResolvedValue::new(Value::String(s.to_string()))
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct ResolverContext {
88 pub config_path: String,
90 pub config_root: Option<Arc<Value>>,
92 pub base_path: Option<std::path::PathBuf>,
94 pub resolution_stack: Vec<String>,
96 pub allow_http: bool,
98 pub http_allowlist: Vec<String>,
100 pub http_proxy: Option<String>,
102 pub http_proxy_from_env: bool,
104 pub http_ca_bundle: Option<std::path::PathBuf>,
106 pub http_extra_ca_bundle: Option<std::path::PathBuf>,
108 pub http_client_cert: Option<std::path::PathBuf>,
110 pub http_client_key: Option<std::path::PathBuf>,
112 pub http_client_key_password: Option<String>,
114 pub http_insecure: bool,
116}
117
118impl ResolverContext {
119 pub fn new(config_path: impl Into<String>) -> Self {
121 Self {
122 config_path: config_path.into(),
123 config_root: None,
124 base_path: None,
125 resolution_stack: Vec::new(),
126 allow_http: false,
127 http_allowlist: Vec::new(),
128 http_proxy: None,
129 http_proxy_from_env: false,
130 http_ca_bundle: None,
131 http_extra_ca_bundle: None,
132 http_client_cert: None,
133 http_client_key: None,
134 http_client_key_password: None,
135 http_insecure: false,
136 }
137 }
138
139 pub fn with_allow_http(mut self, allow: bool) -> Self {
141 self.allow_http = allow;
142 self
143 }
144
145 pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
147 self.http_allowlist = allowlist;
148 self
149 }
150
151 pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
153 self.http_proxy = Some(proxy.into());
154 self
155 }
156
157 pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
159 self.http_proxy_from_env = enabled;
160 self
161 }
162
163 pub fn with_http_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
165 self.http_ca_bundle = Some(path.into());
166 self
167 }
168
169 pub fn with_http_extra_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
171 self.http_extra_ca_bundle = Some(path.into());
172 self
173 }
174
175 pub fn with_http_client_cert(mut self, path: impl Into<std::path::PathBuf>) -> Self {
177 self.http_client_cert = Some(path.into());
178 self
179 }
180
181 pub fn with_http_client_key(mut self, path: impl Into<std::path::PathBuf>) -> Self {
183 self.http_client_key = Some(path.into());
184 self
185 }
186
187 pub fn with_http_client_key_password(mut self, password: impl Into<String>) -> Self {
189 self.http_client_key_password = Some(password.into());
190 self
191 }
192
193 pub fn with_http_insecure(mut self, insecure: bool) -> Self {
195 self.http_insecure = insecure;
196 self
197 }
198
199 pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
201 self.config_root = Some(root);
202 self
203 }
204
205 pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
207 self.base_path = Some(path);
208 self
209 }
210
211 pub fn would_cause_cycle(&self, path: &str) -> bool {
213 self.resolution_stack.contains(&path.to_string())
214 }
215
216 pub fn push_resolution(&mut self, path: &str) {
218 self.resolution_stack.push(path.to_string());
219 }
220
221 pub fn pop_resolution(&mut self) {
223 self.resolution_stack.pop();
224 }
225
226 pub fn get_resolution_chain(&self) -> Vec<String> {
228 self.resolution_stack.clone()
229 }
230}
231
232pub trait Resolver: Send + Sync {
234 fn resolve(
241 &self,
242 args: &[String],
243 kwargs: &HashMap<String, String>,
244 ctx: &ResolverContext,
245 ) -> Result<ResolvedValue>;
246
247 fn name(&self) -> &str;
249}
250
251pub struct FnResolver<F>
253where
254 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
255 + Send
256 + Sync,
257{
258 name: String,
259 func: F,
260}
261
262impl<F> FnResolver<F>
263where
264 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
265 + Send
266 + Sync,
267{
268 pub fn new(name: impl Into<String>, func: F) -> Self {
270 Self {
271 name: name.into(),
272 func,
273 }
274 }
275}
276
277impl<F> Resolver for FnResolver<F>
278where
279 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
280 + Send
281 + Sync,
282{
283 fn resolve(
284 &self,
285 args: &[String],
286 kwargs: &HashMap<String, String>,
287 ctx: &ResolverContext,
288 ) -> Result<ResolvedValue> {
289 (self.func)(args, kwargs, ctx)
290 }
291
292 fn name(&self) -> &str {
293 &self.name
294 }
295}
296
297#[derive(Clone)]
299pub struct ResolverRegistry {
300 resolvers: HashMap<String, Arc<dyn Resolver>>,
301}
302
303impl Default for ResolverRegistry {
304 fn default() -> Self {
305 Self::new()
306 }
307}
308
309impl ResolverRegistry {
310 pub fn new() -> Self {
312 Self {
313 resolvers: HashMap::new(),
314 }
315 }
316
317 pub fn with_builtins() -> Self {
319 let mut registry = Self::new();
320 registry.register_builtin_resolvers();
321 registry
322 }
323
324 fn register_builtin_resolvers(&mut self) {
326 self.register(Arc::new(FnResolver::new("env", env_resolver)));
328 self.register(Arc::new(FnResolver::new("file", file_resolver)));
330 self.register(Arc::new(FnResolver::new("http", http_resolver)));
332 }
333
334 pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
336 self.resolvers.insert(resolver.name().to_string(), resolver);
337 }
338
339 pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
350 let name = resolver.name().to_string();
351 if !force && self.resolvers.contains_key(&name) {
352 return Err(Error::resolver_already_registered(&name));
353 }
354 self.resolvers.insert(name, resolver);
355 Ok(())
356 }
357
358 pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
360 where
361 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
362 + Send
363 + Sync
364 + 'static,
365 {
366 let name = name.into();
367 self.register(Arc::new(FnResolver::new(name, func)));
368 }
369
370 pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
372 self.resolvers.get(name)
373 }
374
375 pub fn contains(&self, name: &str) -> bool {
377 self.resolvers.contains_key(name)
378 }
379
380 pub fn resolve(
388 &self,
389 resolver_name: &str,
390 args: &[String],
391 kwargs: &HashMap<String, String>,
392 ctx: &ResolverContext,
393 ) -> Result<ResolvedValue> {
394 let resolver = self
395 .resolvers
396 .get(resolver_name)
397 .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
398
399 let sensitive_override = kwargs
401 .get("sensitive")
402 .map(|v| v.eq_ignore_ascii_case("true"));
403
404 let resolver_kwargs: HashMap<String, String> = kwargs
406 .iter()
407 .filter(|(k, _)| *k != "sensitive")
408 .map(|(k, v)| (k.clone(), v.clone()))
409 .collect();
410
411 let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
413
414 if let Some(is_sensitive) = sensitive_override {
416 resolved.sensitive = is_sensitive;
417 }
418
419 Ok(resolved)
420 }
421}
422
423fn env_resolver(
433 args: &[String],
434 _kwargs: &HashMap<String, String>,
435 ctx: &ResolverContext,
436) -> Result<ResolvedValue> {
437 if args.is_empty() {
438 return Err(Error::parse("env resolver requires a variable name")
439 .with_path(ctx.config_path.clone()));
440 }
441
442 let var_name = &args[0];
443
444 match std::env::var(var_name) {
445 Ok(value) => {
446 Ok(ResolvedValue::new(Value::String(value)))
448 }
449 Err(_) => {
450 Err(Error::env_not_found(
452 var_name,
453 Some(ctx.config_path.clone()),
454 ))
455 }
456 }
457}
458
459fn file_resolver(
476 args: &[String],
477 kwargs: &HashMap<String, String>,
478 ctx: &ResolverContext,
479) -> Result<ResolvedValue> {
480 use std::path::Path;
481
482 if args.is_empty() {
483 return Err(
484 Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
485 );
486 }
487
488 let file_path_str = &args[0];
489 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
490 let encoding = kwargs
491 .get("encoding")
492 .map(|s| s.as_str())
493 .unwrap_or("utf-8");
494
495 let file_path = if Path::new(file_path_str).is_relative() {
497 if let Some(base) = &ctx.base_path {
498 base.join(file_path_str)
499 } else {
500 std::path::PathBuf::from(file_path_str)
501 }
502 } else {
503 std::path::PathBuf::from(file_path_str)
504 };
505
506 if encoding == "binary" {
508 let bytes = std::fs::read(&file_path)
509 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
510 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
511 }
512
513 let content = match encoding {
515 "base64" => {
516 use base64::{engine::general_purpose::STANDARD, Engine as _};
518 let bytes = std::fs::read(&file_path)
519 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
520 STANDARD.encode(bytes)
521 }
522 "ascii" => {
523 let raw = std::fs::read_to_string(&file_path)
525 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
526 raw.chars().filter(|c| c.is_ascii()).collect()
527 }
528 _ => {
529 std::fs::read_to_string(&file_path)
531 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?
532 }
533 };
534
535 if encoding == "base64" {
537 return Ok(ResolvedValue::new(Value::String(content)));
538 }
539
540 let actual_parse_mode = if parse_mode == "auto" {
542 match file_path.extension().and_then(|e| e.to_str()) {
544 Some("yaml") | Some("yml") => "yaml",
545 Some("json") => "json",
546 _ => "text",
547 }
548 } else {
549 parse_mode
550 };
551
552 match actual_parse_mode {
554 "yaml" => {
555 let value: Value = serde_yaml::from_str(&content).map_err(|e| {
556 Error::parse(format!("Failed to parse YAML: {}", e))
557 .with_path(ctx.config_path.clone())
558 })?;
559 Ok(ResolvedValue::new(value))
560 }
561 "json" => {
562 let value: Value = serde_json::from_str(&content).map_err(|e| {
563 Error::parse(format!("Failed to parse JSON: {}", e))
564 .with_path(ctx.config_path.clone())
565 })?;
566 Ok(ResolvedValue::new(value))
567 }
568 _ => {
569 Ok(ResolvedValue::new(Value::String(content)))
571 }
572 }
573}
574
575fn http_resolver(
594 args: &[String],
595 kwargs: &HashMap<String, String>,
596 ctx: &ResolverContext,
597) -> Result<ResolvedValue> {
598 if args.is_empty() {
599 return Err(Error::parse("http resolver requires a URL").with_path(ctx.config_path.clone()));
600 }
601
602 let url = &args[0];
603
604 if !ctx.allow_http {
606 return Err(Error {
607 kind: crate::error::ErrorKind::Resolver(crate::error::ResolverErrorKind::HttpDisabled),
608 path: Some(ctx.config_path.clone()),
609 source_location: None,
610 help: Some(format!(
611 "Enable HTTP resolver with Config.load(..., allow_http=True)\nURL: {}",
612 url
613 )),
614 cause: None,
615 });
616 }
617
618 if !ctx.http_allowlist.is_empty() {
620 let url_allowed = ctx
621 .http_allowlist
622 .iter()
623 .any(|pattern| url_matches_pattern(url, pattern));
624 if !url_allowed {
625 return Err(Error::http_not_in_allowlist(
626 url,
627 &ctx.http_allowlist,
628 Some(ctx.config_path.clone()),
629 ));
630 }
631 }
632
633 #[cfg(feature = "http")]
635 {
636 http_fetch(url, kwargs, ctx)
637 }
638
639 #[cfg(not(feature = "http"))]
640 {
641 let _ = kwargs; Err(Error::resolver_custom(
644 "http",
645 "HTTP support not compiled in. Rebuild with --features http",
646 ))
647 }
648}
649
650fn url_matches_pattern(url: &str, pattern: &str) -> bool {
656 let regex_pattern = pattern
658 .replace('.', r"\.")
659 .replace('*', ".*")
660 .replace('?', ".");
661
662 if let Ok(re) = regex::Regex::new(&format!("^{}$", regex_pattern)) {
663 re.is_match(url)
664 } else {
665 url == pattern
667 }
668}
669
670#[cfg(feature = "http")]
676fn load_certs_from_pem(path: &std::path::Path) -> Result<Vec<ureq::tls::Certificate<'static>>> {
677 use ureq::tls::PemItem;
678
679 let pem_content = std::fs::read(path).map_err(|e| {
680 Error::pem_load_error(
681 path.display().to_string(),
682 format!("Failed to open file: {}", e),
683 )
684 })?;
685
686 let certs: Vec<_> = ureq::tls::parse_pem(&pem_content)
687 .filter_map(|item| item.ok())
688 .filter_map(|item| match item {
689 PemItem::Certificate(cert) => Some(cert.to_owned()),
690 _ => None,
691 })
692 .collect();
693
694 if certs.is_empty() {
695 return Err(Error::pem_load_error(
696 path.display().to_string(),
697 "No valid certificates found in PEM file",
698 ));
699 }
700
701 Ok(certs)
702}
703
704#[cfg(feature = "http")]
706fn load_private_key_from_pem(path: &std::path::Path) -> Result<ureq::tls::PrivateKey<'static>> {
707 let pem_content = std::fs::read(path).map_err(|e| {
708 Error::pem_load_error(
709 path.display().to_string(),
710 format!("Failed to open file: {}", e),
711 )
712 })?;
713
714 let key = ureq::tls::PrivateKey::from_pem(&pem_content).map_err(|e| {
715 Error::pem_load_error(
716 path.display().to_string(),
717 format!("Failed to parse key: {}", e),
718 )
719 })?;
720
721 Ok(key.to_owned())
722}
723
724#[cfg(feature = "http")]
726fn load_encrypted_private_key_from_pem(
727 path: &std::path::Path,
728 password: &str,
729) -> Result<ureq::tls::PrivateKey<'static>> {
730 use pkcs8::der::Decode;
731
732 let pem_content = std::fs::read_to_string(path).map_err(|e| {
733 Error::pem_load_error(
734 path.display().to_string(),
735 format!("Failed to read file: {}", e),
736 )
737 })?;
738
739 if pem_content.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") {
741 let der_bytes = pem_to_der(&pem_content, "ENCRYPTED PRIVATE KEY")
743 .map_err(|e| Error::pem_load_error(path.display().to_string(), e))?;
744
745 let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
746 .map_err(|e| Error::pem_load_error(path.display().to_string(), e.to_string()))?;
747
748 let decrypted = encrypted
749 .decrypt(password)
750 .map_err(|e| Error::key_decryption_error(e.to_string()))?;
751
752 let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
755
756 ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
757 .map(|k| k.to_owned())
758 .map_err(|e| {
759 Error::pem_load_error(
760 path.display().to_string(),
761 format!("Failed to parse decrypted key: {}", e),
762 )
763 })
764 } else {
765 load_private_key_from_pem(path)
767 }
768}
769
770#[cfg(feature = "http")]
772fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
773 let begin_marker = format!("-----BEGIN {}-----", label);
774 let end_marker = format!("-----END {}-----", label);
775
776 let start = pem
777 .find(&begin_marker)
778 .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
779 let end = pem
780 .find(&end_marker)
781 .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
782
783 let base64_content: String = pem[start + begin_marker.len()..end]
784 .chars()
785 .filter(|c| !c.is_whitespace())
786 .collect();
787
788 use base64::Engine;
789 base64::engine::general_purpose::STANDARD
790 .decode(&base64_content)
791 .map_err(|e| format!("Failed to decode base64: {}", e))
792}
793
794#[cfg(feature = "http")]
796fn der_to_pem(der: &[u8], label: &str) -> String {
797 use base64::Engine;
798 let base64 = base64::engine::general_purpose::STANDARD.encode(der);
799 let lines: Vec<&str> = base64
801 .as_bytes()
802 .chunks(64)
803 .map(|chunk| std::str::from_utf8(chunk).unwrap())
804 .collect();
805 format!(
806 "-----BEGIN {}-----\n{}\n-----END {}-----\n",
807 label,
808 lines.join("\n"),
809 label
810 )
811}
812
813#[cfg(feature = "http")]
815fn is_p12_file(path: &std::path::Path) -> bool {
816 path.extension()
817 .and_then(|ext| ext.to_str())
818 .map(|ext| ext.eq_ignore_ascii_case("p12") || ext.eq_ignore_ascii_case("pfx"))
819 .unwrap_or(false)
820}
821
822#[cfg(feature = "http")]
824fn load_identity_from_p12(
825 path: &std::path::Path,
826 password: &str,
827) -> Result<(
828 Vec<ureq::tls::Certificate<'static>>,
829 ureq::tls::PrivateKey<'static>,
830)> {
831 let p12_data = std::fs::read(path).map_err(|e| {
832 Error::p12_load_error(
833 path.display().to_string(),
834 format!("Failed to read file: {}", e),
835 )
836 })?;
837
838 let keystore = p12_keystore::KeyStore::from_pkcs12(&p12_data, password)
839 .map_err(|e| Error::p12_load_error(path.display().to_string(), e.to_string()))?;
840
841 let (_alias, key_chain) = keystore.private_key_chain().ok_or_else(|| {
844 Error::p12_load_error(
845 path.display().to_string(),
846 "No private key found in P12 file",
847 )
848 })?;
849
850 let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
852 let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
853 .map(|k| k.to_owned())
854 .map_err(|e| {
855 Error::p12_load_error(
856 path.display().to_string(),
857 format!("Failed to parse private key: {}", e),
858 )
859 })?;
860
861 let certs: Vec<_> = key_chain
863 .chain()
864 .iter()
865 .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
866 .collect();
867
868 if certs.is_empty() {
869 return Err(Error::p12_load_error(
870 path.display().to_string(),
871 "No certificates found in P12 file",
872 ));
873 }
874
875 Ok((certs, private_key))
876}
877
878#[cfg(feature = "http")]
880fn load_client_identity(
881 cert_path: &std::path::Path,
882 key_path: Option<&std::path::Path>,
883 password: Option<&str>,
884) -> Result<(
885 Vec<ureq::tls::Certificate<'static>>,
886 ureq::tls::PrivateKey<'static>,
887)> {
888 if is_p12_file(cert_path) {
890 let pwd = password.unwrap_or("");
891 return load_identity_from_p12(cert_path, pwd);
892 }
893
894 let cert_chain = load_certs_from_pem(cert_path)?;
896
897 let key_path = key_path.ok_or_else(|| {
898 Error::tls_config_error("Client key path required when using PEM certificate (not P12)")
899 })?;
900
901 let private_key = if let Some(pwd) = password {
902 load_encrypted_private_key_from_pem(key_path, pwd)?
903 } else {
904 load_private_key_from_pem(key_path)?
905 };
906
907 Ok((cert_chain, private_key))
908}
909
910#[cfg(feature = "http")]
912fn build_tls_config(
913 ctx: &ResolverContext,
914 kwargs: &HashMap<String, String>,
915) -> Result<ureq::tls::TlsConfig> {
916 use std::sync::Arc;
917 use ureq::tls::{ClientCert, RootCerts, TlsConfig};
918
919 let mut builder = TlsConfig::builder();
920
921 let insecure = kwargs
923 .get("insecure")
924 .map(|v| v == "true")
925 .unwrap_or(ctx.http_insecure);
926
927 if insecure {
928 eprintln!("WARNING: TLS certificate verification is disabled (http_insecure=true)");
931 builder = builder.disable_verification(true);
932 }
933
934 let ca_bundle_path = kwargs
936 .get("ca_bundle")
937 .map(std::path::PathBuf::from)
938 .or_else(|| ctx.http_ca_bundle.clone());
939
940 let extra_ca_bundle_path = kwargs
941 .get("extra_ca_bundle")
942 .map(std::path::PathBuf::from)
943 .or_else(|| ctx.http_extra_ca_bundle.clone());
944
945 if let Some(ca_path) = ca_bundle_path {
946 let certs = load_certs_from_pem(&ca_path)?;
948 builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
949 } else if let Some(extra_ca_path) = extra_ca_bundle_path {
950 let extra_certs = load_certs_from_pem(&extra_ca_path)?;
952 builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
953 }
954
955 let client_cert_path = kwargs
957 .get("client_cert")
958 .map(std::path::PathBuf::from)
959 .or_else(|| ctx.http_client_cert.clone());
960
961 if let Some(cert_path) = client_cert_path {
962 let client_key_path = kwargs
963 .get("client_key")
964 .map(std::path::PathBuf::from)
965 .or_else(|| ctx.http_client_key.clone());
966
967 let password = kwargs
968 .get("key_password")
969 .map(|s| s.as_str())
970 .or(ctx.http_client_key_password.as_deref());
971
972 let (certs, key) = load_client_identity(&cert_path, client_key_path.as_deref(), password)?;
973
974 let client_cert = ClientCert::new_with_certs(&certs, key);
975 builder = builder.client_cert(Some(client_cert));
976 }
977
978 Ok(builder.build())
979}
980
981#[cfg(feature = "http")]
983fn build_proxy_config(
984 ctx: &ResolverContext,
985 kwargs: &HashMap<String, String>,
986) -> Result<Option<ureq::Proxy>> {
987 let proxy_url = kwargs
989 .get("proxy")
990 .cloned()
991 .or_else(|| ctx.http_proxy.clone());
992
993 let proxy_url = proxy_url.or_else(|| {
995 if ctx.http_proxy_from_env {
996 std::env::var("HTTPS_PROXY")
998 .or_else(|_| std::env::var("https_proxy"))
999 .or_else(|_| std::env::var("HTTP_PROXY"))
1000 .or_else(|_| std::env::var("http_proxy"))
1001 .ok()
1002 } else {
1003 None
1004 }
1005 });
1006
1007 if let Some(url) = proxy_url {
1008 let proxy = ureq::Proxy::new(&url).map_err(|e| {
1009 Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1010 })?;
1011 Ok(Some(proxy))
1012 } else {
1013 Ok(None)
1014 }
1015}
1016
1017#[cfg(feature = "http")]
1019fn http_fetch(
1020 url: &str,
1021 kwargs: &HashMap<String, String>,
1022 ctx: &ResolverContext,
1023) -> Result<ResolvedValue> {
1024 use std::time::Duration;
1025
1026 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
1027 let timeout_secs: u64 = kwargs
1028 .get("timeout")
1029 .and_then(|s| s.parse().ok())
1030 .unwrap_or(30);
1031
1032 let tls_config = build_tls_config(ctx, kwargs)?;
1034
1035 let proxy = build_proxy_config(ctx, kwargs)?;
1037
1038 let mut config_builder = ureq::Agent::config_builder()
1040 .timeout_global(Some(Duration::from_secs(timeout_secs)))
1041 .tls_config(tls_config);
1042
1043 if proxy.is_some() {
1044 config_builder = config_builder.proxy(proxy);
1045 }
1046
1047 let config = config_builder.build();
1048 let agent: ureq::Agent = config.into();
1049
1050 let mut request = agent.get(url);
1052
1053 for (key, value) in kwargs {
1055 if key == "header" {
1056 if let Some((name, val)) = value.split_once(':') {
1058 request = request.header(name.trim(), val.trim());
1059 }
1060 }
1061 }
1062
1063 let response = request.call().map_err(|e| {
1065 let error_msg = match &e {
1066 ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1067 ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1068 ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1069 _ => format!("HTTP request failed: {}", e),
1070 };
1071 Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1072 })?;
1073
1074 let content_type = response
1076 .headers()
1077 .get("content-type")
1078 .and_then(|v| v.to_str().ok())
1079 .map(|s| s.to_string())
1080 .unwrap_or_default();
1081
1082 if parse_mode == "binary" {
1084 let bytes = response.into_body().read_to_vec().map_err(|e| {
1085 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1086 })?;
1087 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
1088 }
1089
1090 let body = response.into_body().read_to_string().map_err(|e| {
1092 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1093 })?;
1094
1095 let actual_parse_mode = if parse_mode == "auto" {
1097 detect_parse_mode(url, &content_type)
1098 } else {
1099 parse_mode
1100 };
1101
1102 match actual_parse_mode {
1104 "yaml" => {
1105 let value: Value = serde_yaml::from_str(&body).map_err(|e| {
1106 Error::parse(format!("Failed to parse YAML from {}: {}", url, e))
1107 .with_path(ctx.config_path.clone())
1108 })?;
1109 Ok(ResolvedValue::new(value))
1110 }
1111 "json" => {
1112 let value: Value = serde_json::from_str(&body).map_err(|e| {
1113 Error::parse(format!("Failed to parse JSON from {}: {}", url, e))
1114 .with_path(ctx.config_path.clone())
1115 })?;
1116 Ok(ResolvedValue::new(value))
1117 }
1118 _ => {
1119 Ok(ResolvedValue::new(Value::String(body)))
1121 }
1122 }
1123}
1124
1125#[cfg(feature = "http")]
1127fn detect_parse_mode<'a>(url: &str, content_type: &str) -> &'a str {
1128 let ct_lower = content_type.to_lowercase();
1130 if ct_lower.contains("application/json") || ct_lower.contains("text/json") {
1131 return "json";
1132 }
1133 if ct_lower.contains("application/yaml")
1134 || ct_lower.contains("application/x-yaml")
1135 || ct_lower.contains("text/yaml")
1136 {
1137 return "yaml";
1138 }
1139
1140 if let Some(path) = url.split('?').next() {
1142 if path.ends_with(".json") {
1143 return "json";
1144 }
1145 if path.ends_with(".yaml") || path.ends_with(".yml") {
1146 return "yaml";
1147 }
1148 }
1149
1150 "text"
1152}
1153
1154#[cfg(test)]
1155mod tests {
1156 use super::*;
1157
1158 #[test]
1159 fn test_env_resolver_with_value() {
1160 std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
1161
1162 let ctx = ResolverContext::new("test.path");
1163 let args = vec!["HOLOCONF_TEST_VAR".to_string()];
1164 let kwargs = HashMap::new();
1165
1166 let result = env_resolver(&args, &kwargs, &ctx).unwrap();
1167 assert_eq!(result.value.as_str(), Some("test_value"));
1168 assert!(!result.sensitive);
1169
1170 std::env::remove_var("HOLOCONF_TEST_VAR");
1171 }
1172
1173 #[test]
1174 fn test_env_resolver_missing_returns_error() {
1175 std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
1177
1178 let registry = ResolverRegistry::with_builtins();
1179 let ctx = ResolverContext::new("test.path");
1180 let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
1181 let kwargs = HashMap::new();
1182
1183 let result = registry.resolve("env", &args, &kwargs, &ctx);
1186 assert!(result.is_err());
1187 }
1188
1189 #[test]
1190 fn test_env_resolver_missing_no_default() {
1191 std::env::remove_var("HOLOCONF_MISSING_VAR");
1192
1193 let ctx = ResolverContext::new("test.path");
1194 let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
1195 let kwargs = HashMap::new();
1196
1197 let result = env_resolver(&args, &kwargs, &ctx);
1198 assert!(result.is_err());
1199 }
1200
1201 #[test]
1202 fn test_env_resolver_sensitive_kwarg() {
1203 std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
1204
1205 let registry = ResolverRegistry::with_builtins();
1206 let ctx = ResolverContext::new("test.path");
1207 let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
1208 let mut kwargs = HashMap::new();
1209 kwargs.insert("sensitive".to_string(), "true".to_string());
1210
1211 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1213 assert_eq!(result.value.as_str(), Some("secret_value"));
1214 assert!(result.sensitive);
1215
1216 std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
1217 }
1218
1219 #[test]
1220 fn test_env_resolver_sensitive_false() {
1221 std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
1222
1223 let registry = ResolverRegistry::with_builtins();
1224 let ctx = ResolverContext::new("test.path");
1225 let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
1226 let mut kwargs = HashMap::new();
1227 kwargs.insert("sensitive".to_string(), "false".to_string());
1228
1229 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1231 assert_eq!(result.value.as_str(), Some("public_value"));
1232 assert!(!result.sensitive);
1233
1234 std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1235 }
1236
1237 #[test]
1241 fn test_resolver_registry() {
1242 let registry = ResolverRegistry::with_builtins();
1243
1244 assert!(registry.contains("env"));
1245 assert!(!registry.contains("nonexistent"));
1246 }
1247
1248 #[test]
1249 fn test_custom_resolver() {
1250 let mut registry = ResolverRegistry::new();
1251
1252 registry.register_fn("custom", |args, _kwargs, _ctx| {
1253 let value = args.first().cloned().unwrap_or_default();
1254 Ok(ResolvedValue::new(Value::String(format!(
1255 "custom:{}",
1256 value
1257 ))))
1258 });
1259
1260 let ctx = ResolverContext::new("test");
1261 let result = registry
1262 .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
1263 .unwrap();
1264
1265 assert_eq!(result.value.as_str(), Some("custom:arg"));
1266 }
1267
1268 #[test]
1269 fn test_resolved_value_sensitivity() {
1270 let non_sensitive = ResolvedValue::new("public");
1271 assert!(!non_sensitive.sensitive);
1272
1273 let sensitive = ResolvedValue::sensitive("secret");
1274 assert!(sensitive.sensitive);
1275 }
1276
1277 #[test]
1278 fn test_resolver_context_cycle_detection() {
1279 let mut ctx = ResolverContext::new("root");
1280 ctx.push_resolution("a");
1281 ctx.push_resolution("b");
1282
1283 assert!(ctx.would_cause_cycle("a"));
1284 assert!(ctx.would_cause_cycle("b"));
1285 assert!(!ctx.would_cause_cycle("c"));
1286
1287 ctx.pop_resolution();
1288 assert!(!ctx.would_cause_cycle("b"));
1289 }
1290
1291 #[test]
1292 fn test_file_resolver() {
1293 use std::io::Write;
1294
1295 let temp_dir = std::env::temp_dir();
1297 let test_file = temp_dir.join("holoconf_test_file.txt");
1298 {
1299 let mut file = std::fs::File::create(&test_file).unwrap();
1300 writeln!(file, "test content").unwrap();
1301 }
1302
1303 let mut ctx = ResolverContext::new("test.path");
1304 ctx.base_path = Some(temp_dir.clone());
1305
1306 let args = vec!["holoconf_test_file.txt".to_string()];
1307 let mut kwargs = HashMap::new();
1308 kwargs.insert("parse".to_string(), "text".to_string());
1309
1310 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1311 assert!(result.value.as_str().unwrap().contains("test content"));
1312 assert!(!result.sensitive);
1313
1314 std::fs::remove_file(test_file).ok();
1316 }
1317
1318 #[test]
1319 fn test_file_resolver_yaml() {
1320 use std::io::Write;
1321
1322 let temp_dir = std::env::temp_dir();
1324 let test_file = temp_dir.join("holoconf_test.yaml");
1325 {
1326 let mut file = std::fs::File::create(&test_file).unwrap();
1327 writeln!(file, "key: value").unwrap();
1328 writeln!(file, "number: 42").unwrap();
1329 }
1330
1331 let mut ctx = ResolverContext::new("test.path");
1332 ctx.base_path = Some(temp_dir.clone());
1333
1334 let args = vec!["holoconf_test.yaml".to_string()];
1335 let kwargs = HashMap::new();
1336
1337 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1338 assert!(result.value.is_mapping());
1339
1340 std::fs::remove_file(test_file).ok();
1342 }
1343
1344 #[test]
1345 fn test_file_resolver_not_found() {
1346 let ctx = ResolverContext::new("test.path");
1347 let args = vec!["nonexistent_file.txt".to_string()];
1348 let kwargs = HashMap::new();
1349
1350 let result = file_resolver(&args, &kwargs, &ctx);
1351 assert!(result.is_err());
1352 }
1353
1354 #[test]
1355 fn test_registry_with_file() {
1356 let registry = ResolverRegistry::with_builtins();
1357 assert!(registry.contains("file"));
1358 }
1359
1360 #[test]
1361 fn test_http_resolver_disabled() {
1362 let ctx = ResolverContext::new("test.path");
1363 let args = vec!["https://example.com/config.yaml".to_string()];
1364 let kwargs = HashMap::new();
1365
1366 let result = http_resolver(&args, &kwargs, &ctx);
1367 assert!(result.is_err());
1368
1369 let err = result.unwrap_err();
1370 let display = format!("{}", err);
1371 assert!(display.contains("HTTP resolver is disabled"));
1372 }
1373
1374 #[test]
1375 fn test_registry_with_http() {
1376 let registry = ResolverRegistry::with_builtins();
1377 assert!(registry.contains("http"));
1378 }
1379
1380 #[test]
1383 fn test_env_resolver_no_args() {
1384 let ctx = ResolverContext::new("test.path");
1385 let args = vec![];
1386 let kwargs = HashMap::new();
1387
1388 let result = env_resolver(&args, &kwargs, &ctx);
1389 assert!(result.is_err());
1390 let err = result.unwrap_err();
1391 assert!(err.to_string().contains("requires"));
1392 }
1393
1394 #[test]
1395 fn test_file_resolver_no_args() {
1396 let ctx = ResolverContext::new("test.path");
1397 let args = vec![];
1398 let kwargs = HashMap::new();
1399
1400 let result = file_resolver(&args, &kwargs, &ctx);
1401 assert!(result.is_err());
1402 let err = result.unwrap_err();
1403 assert!(err.to_string().contains("requires"));
1404 }
1405
1406 #[test]
1407 fn test_http_resolver_no_args() {
1408 let ctx = ResolverContext::new("test.path");
1409 let args = vec![];
1410 let kwargs = HashMap::new();
1411
1412 let result = http_resolver(&args, &kwargs, &ctx);
1413 assert!(result.is_err());
1414 let err = result.unwrap_err();
1415 assert!(err.to_string().contains("requires"));
1416 }
1417
1418 #[test]
1419 fn test_unknown_resolver() {
1420 let registry = ResolverRegistry::with_builtins();
1421 let ctx = ResolverContext::new("test.path");
1422
1423 let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
1424 assert!(result.is_err());
1425 let err = result.unwrap_err();
1426 assert!(err.to_string().contains("unknown_resolver"));
1427 }
1428
1429 #[test]
1430 fn test_resolved_value_from_traits() {
1431 let from_value: ResolvedValue = Value::String("test".to_string()).into();
1432 assert_eq!(from_value.value.as_str(), Some("test"));
1433 assert!(!from_value.sensitive);
1434
1435 let from_string: ResolvedValue = "hello".to_string().into();
1436 assert_eq!(from_string.value.as_str(), Some("hello"));
1437
1438 let from_str: ResolvedValue = "world".into();
1439 assert_eq!(from_str.value.as_str(), Some("world"));
1440 }
1441
1442 #[test]
1443 fn test_resolver_context_with_base_path() {
1444 let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
1445 assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
1446 }
1447
1448 #[test]
1449 fn test_resolver_context_with_config_root() {
1450 use std::sync::Arc;
1451 let root = Arc::new(Value::String("root".to_string()));
1452 let ctx = ResolverContext::new("test").with_config_root(root.clone());
1453 assert!(ctx.config_root.is_some());
1454 }
1455
1456 #[test]
1457 fn test_resolver_context_resolution_chain() {
1458 let mut ctx = ResolverContext::new("root");
1459 ctx.push_resolution("a");
1460 ctx.push_resolution("b");
1461 ctx.push_resolution("c");
1462
1463 let chain = ctx.get_resolution_chain();
1464 assert_eq!(chain, vec!["a", "b", "c"]);
1465 }
1466
1467 #[test]
1468 fn test_registry_get_resolver() {
1469 let registry = ResolverRegistry::with_builtins();
1470
1471 let env_resolver = registry.get("env");
1472 assert!(env_resolver.is_some());
1473 assert_eq!(env_resolver.unwrap().name(), "env");
1474
1475 let missing = registry.get("nonexistent");
1476 assert!(missing.is_none());
1477 }
1478
1479 #[test]
1480 fn test_registry_default() {
1481 let registry = ResolverRegistry::default();
1482 assert!(!registry.contains("env"));
1484 }
1485
1486 #[test]
1487 fn test_fn_resolver_name() {
1488 let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
1489 assert_eq!(resolver.name(), "my_resolver");
1490 }
1491
1492 #[test]
1493 fn test_file_resolver_json() {
1494 use std::io::Write;
1495
1496 let temp_dir = std::env::temp_dir();
1498 let test_file = temp_dir.join("holoconf_test.json");
1499 {
1500 let mut file = std::fs::File::create(&test_file).unwrap();
1501 writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
1502 }
1503
1504 let mut ctx = ResolverContext::new("test.path");
1505 ctx.base_path = Some(temp_dir.clone());
1506
1507 let args = vec!["holoconf_test.json".to_string()];
1508 let kwargs = HashMap::new();
1509
1510 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1511 assert!(result.value.is_mapping());
1512
1513 std::fs::remove_file(test_file).ok();
1515 }
1516
1517 #[test]
1518 fn test_file_resolver_absolute_path() {
1519 use std::io::Write;
1520
1521 let temp_dir = std::env::temp_dir();
1523 let test_file = temp_dir.join("holoconf_abs_test.txt");
1524 {
1525 let mut file = std::fs::File::create(&test_file).unwrap();
1526 writeln!(file, "absolute path content").unwrap();
1527 }
1528
1529 let ctx = ResolverContext::new("test.path");
1530 let args = vec![test_file.to_string_lossy().to_string()];
1532 let mut kwargs = HashMap::new();
1533 kwargs.insert("parse".to_string(), "text".to_string());
1534
1535 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1536 assert!(result
1537 .value
1538 .as_str()
1539 .unwrap()
1540 .contains("absolute path content"));
1541
1542 std::fs::remove_file(test_file).ok();
1544 }
1545
1546 #[test]
1547 fn test_file_resolver_invalid_yaml() {
1548 use std::io::Write;
1549
1550 let temp_dir = std::env::temp_dir();
1552 let test_file = temp_dir.join("holoconf_invalid.yaml");
1553 {
1554 let mut file = std::fs::File::create(&test_file).unwrap();
1555 writeln!(file, "key: [invalid").unwrap();
1556 }
1557
1558 let mut ctx = ResolverContext::new("test.path");
1559 ctx.base_path = Some(temp_dir.clone());
1560
1561 let args = vec!["holoconf_invalid.yaml".to_string()];
1562 let kwargs = HashMap::new();
1563
1564 let result = file_resolver(&args, &kwargs, &ctx);
1565 assert!(result.is_err());
1566 let err = result.unwrap_err();
1567 assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
1568
1569 std::fs::remove_file(test_file).ok();
1571 }
1572
1573 #[test]
1574 fn test_file_resolver_invalid_json() {
1575 use std::io::Write;
1576
1577 let temp_dir = std::env::temp_dir();
1579 let test_file = temp_dir.join("holoconf_invalid.json");
1580 {
1581 let mut file = std::fs::File::create(&test_file).unwrap();
1582 writeln!(file, "{{invalid json}}").unwrap();
1583 }
1584
1585 let mut ctx = ResolverContext::new("test.path");
1586 ctx.base_path = Some(temp_dir.clone());
1587
1588 let args = vec!["holoconf_invalid.json".to_string()];
1589 let kwargs = HashMap::new();
1590
1591 let result = file_resolver(&args, &kwargs, &ctx);
1592 assert!(result.is_err());
1593 let err = result.unwrap_err();
1594 assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
1595
1596 std::fs::remove_file(test_file).ok();
1598 }
1599
1600 #[test]
1601 fn test_file_resolver_unknown_extension() {
1602 use std::io::Write;
1603
1604 let temp_dir = std::env::temp_dir();
1606 let test_file = temp_dir.join("holoconf_test.xyz");
1607 {
1608 let mut file = std::fs::File::create(&test_file).unwrap();
1609 writeln!(file, "plain text content").unwrap();
1610 }
1611
1612 let mut ctx = ResolverContext::new("test.path");
1613 ctx.base_path = Some(temp_dir.clone());
1614
1615 let args = vec!["holoconf_test.xyz".to_string()];
1616 let kwargs = HashMap::new();
1617
1618 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1619 assert!(result
1621 .value
1622 .as_str()
1623 .unwrap()
1624 .contains("plain text content"));
1625
1626 std::fs::remove_file(test_file).ok();
1628 }
1629
1630 #[test]
1631 fn test_file_resolver_encoding_utf8() {
1632 use std::io::Write;
1633
1634 let temp_dir = std::env::temp_dir();
1636 let test_file = temp_dir.join("holoconf_utf8.txt");
1637 {
1638 let mut file = std::fs::File::create(&test_file).unwrap();
1639 writeln!(file, "Hello, 世界! 🌍").unwrap();
1640 }
1641
1642 let mut ctx = ResolverContext::new("test.path");
1643 ctx.base_path = Some(temp_dir.clone());
1644
1645 let args = vec!["holoconf_utf8.txt".to_string()];
1646 let mut kwargs = HashMap::new();
1647 kwargs.insert("encoding".to_string(), "utf-8".to_string());
1648
1649 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1650 let content = result.value.as_str().unwrap();
1651 assert!(content.contains("世界"));
1652 assert!(content.contains("🌍"));
1653
1654 std::fs::remove_file(test_file).ok();
1656 }
1657
1658 #[test]
1659 fn test_file_resolver_encoding_ascii() {
1660 use std::io::Write;
1661
1662 let temp_dir = std::env::temp_dir();
1664 let test_file = temp_dir.join("holoconf_ascii.txt");
1665 {
1666 let mut file = std::fs::File::create(&test_file).unwrap();
1667 writeln!(file, "Hello, 世界! Welcome").unwrap();
1668 }
1669
1670 let mut ctx = ResolverContext::new("test.path");
1671 ctx.base_path = Some(temp_dir.clone());
1672
1673 let args = vec!["holoconf_ascii.txt".to_string()];
1674 let mut kwargs = HashMap::new();
1675 kwargs.insert("encoding".to_string(), "ascii".to_string());
1676
1677 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1678 let content = result.value.as_str().unwrap();
1679 assert!(content.contains("Hello"));
1681 assert!(content.contains("Welcome"));
1682 assert!(!content.contains("世界"));
1683
1684 std::fs::remove_file(test_file).ok();
1686 }
1687
1688 #[test]
1689 fn test_file_resolver_encoding_base64() {
1690 use std::io::Write;
1691
1692 let temp_dir = std::env::temp_dir();
1694 let test_file = temp_dir.join("holoconf_binary.bin");
1695 {
1696 let mut file = std::fs::File::create(&test_file).unwrap();
1697 file.write_all(b"Hello\x00\x01\x02World").unwrap();
1699 }
1700
1701 let mut ctx = ResolverContext::new("test.path");
1702 ctx.base_path = Some(temp_dir.clone());
1703
1704 let args = vec!["holoconf_binary.bin".to_string()];
1705 let mut kwargs = HashMap::new();
1706 kwargs.insert("encoding".to_string(), "base64".to_string());
1707
1708 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1709 let content = result.value.as_str().unwrap();
1710
1711 use base64::{engine::general_purpose::STANDARD, Engine as _};
1713 let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
1714 assert_eq!(content, expected);
1715
1716 std::fs::remove_file(test_file).ok();
1718 }
1719
1720 #[test]
1721 fn test_file_resolver_encoding_default_is_utf8() {
1722 use std::io::Write;
1723
1724 let temp_dir = std::env::temp_dir();
1726 let test_file = temp_dir.join("holoconf_default_enc.txt");
1727 {
1728 let mut file = std::fs::File::create(&test_file).unwrap();
1729 writeln!(file, "café résumé").unwrap();
1730 }
1731
1732 let mut ctx = ResolverContext::new("test.path");
1733 ctx.base_path = Some(temp_dir.clone());
1734
1735 let args = vec!["holoconf_default_enc.txt".to_string()];
1736 let kwargs = HashMap::new(); let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1739 let content = result.value.as_str().unwrap();
1740 assert!(content.contains("café"));
1742 assert!(content.contains("résumé"));
1743
1744 std::fs::remove_file(test_file).ok();
1746 }
1747
1748 #[test]
1749 fn test_file_resolver_encoding_binary() {
1750 use std::io::Write;
1751
1752 let temp_dir = std::env::temp_dir();
1754 let test_file = temp_dir.join("holoconf_binary_bytes.bin");
1755 let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
1756 {
1757 let mut file = std::fs::File::create(&test_file).unwrap();
1758 file.write_all(&binary_data).unwrap();
1759 }
1760
1761 let mut ctx = ResolverContext::new("test.path");
1762 ctx.base_path = Some(temp_dir.clone());
1763
1764 let args = vec!["holoconf_binary_bytes.bin".to_string()];
1765 let mut kwargs = HashMap::new();
1766 kwargs.insert("encoding".to_string(), "binary".to_string());
1767
1768 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1769
1770 assert!(result.value.is_bytes());
1772 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
1773
1774 std::fs::remove_file(test_file).ok();
1776 }
1777
1778 #[test]
1779 fn test_file_resolver_encoding_binary_empty() {
1780 let temp_dir = std::env::temp_dir();
1782 let test_file = temp_dir.join("holoconf_binary_empty.bin");
1783 {
1784 std::fs::File::create(&test_file).unwrap();
1785 }
1786
1787 let mut ctx = ResolverContext::new("test.path");
1788 ctx.base_path = Some(temp_dir.clone());
1789
1790 let args = vec!["holoconf_binary_empty.bin".to_string()];
1791 let mut kwargs = HashMap::new();
1792 kwargs.insert("encoding".to_string(), "binary".to_string());
1793
1794 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1795
1796 assert!(result.value.is_bytes());
1798 let empty: &[u8] = &[];
1799 assert_eq!(result.value.as_bytes().unwrap(), empty);
1800
1801 std::fs::remove_file(test_file).ok();
1803 }
1804
1805 #[test]
1808 fn test_file_resolver_with_sensitive() {
1809 use std::io::Write;
1810
1811 let temp_dir = std::env::temp_dir();
1813 let test_file = temp_dir.join("holoconf_sensitive_test.txt");
1814 {
1815 let mut file = std::fs::File::create(&test_file).unwrap();
1816 writeln!(file, "secret content").unwrap();
1817 }
1818
1819 let registry = ResolverRegistry::with_builtins();
1820 let mut ctx = ResolverContext::new("test.path");
1821 ctx.base_path = Some(temp_dir.clone());
1822
1823 let args = vec!["holoconf_sensitive_test.txt".to_string()];
1824 let mut kwargs = HashMap::new();
1825 kwargs.insert("sensitive".to_string(), "true".to_string());
1826
1827 let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
1829 assert!(result.value.as_str().unwrap().contains("secret content"));
1830 assert!(result.sensitive);
1831
1832 std::fs::remove_file(test_file).ok();
1834 }
1835
1836 #[test]
1837 fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
1838 let mut registry = ResolverRegistry::new();
1841
1842 registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
1844 assert!(
1846 !kwargs.contains_key("sensitive"),
1847 "sensitive kwarg should not be passed to resolver"
1848 );
1849 if let Some(custom) = kwargs.get("custom") {
1851 Ok(ResolvedValue::new(Value::String(format!(
1852 "custom={}",
1853 custom
1854 ))))
1855 } else {
1856 Ok(ResolvedValue::new(Value::String("no custom".to_string())))
1857 }
1858 });
1859
1860 let ctx = ResolverContext::new("test.path");
1861 let args = vec![];
1862 let mut kwargs = HashMap::new();
1863 kwargs.insert("sensitive".to_string(), "true".to_string());
1864 kwargs.insert("custom".to_string(), "myvalue".to_string());
1865
1866 let result = registry
1867 .resolve("test_kwargs", &args, &kwargs, &ctx)
1868 .unwrap();
1869 assert_eq!(result.value.as_str(), Some("custom=myvalue"));
1870 assert!(result.sensitive);
1872 }
1873}
1874
1875#[cfg(test)]
1877mod global_registry_tests {
1878 use super::*;
1879
1880 fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
1882 Arc::new(FnResolver::new(name, |_, _, _| {
1883 Ok(ResolvedValue::new("mock"))
1884 }))
1885 }
1886
1887 #[test]
1888 fn test_register_new_resolver_succeeds() {
1889 let mut registry = ResolverRegistry::new();
1890 let resolver = mock_resolver("test_new");
1891
1892 let result = registry.register_with_force(resolver, false);
1894 assert!(result.is_ok());
1895 assert!(registry.contains("test_new"));
1896 }
1897
1898 #[test]
1899 fn test_register_duplicate_errors_without_force() {
1900 let mut registry = ResolverRegistry::new();
1901 let resolver1 = mock_resolver("test_dup");
1902 let resolver2 = mock_resolver("test_dup");
1903
1904 registry.register_with_force(resolver1, false).unwrap();
1906
1907 let result = registry.register_with_force(resolver2, false);
1909 assert!(result.is_err());
1910 let err = result.unwrap_err();
1911 assert!(err.to_string().contains("already registered"));
1912 }
1913
1914 #[test]
1915 fn test_register_duplicate_succeeds_with_force() {
1916 let mut registry = ResolverRegistry::new();
1917 let resolver1 = mock_resolver("test_force");
1918 let resolver2 = mock_resolver("test_force");
1919
1920 registry.register_with_force(resolver1, false).unwrap();
1922
1923 let result = registry.register_with_force(resolver2, true);
1925 assert!(result.is_ok());
1926 }
1927
1928 #[test]
1929 fn test_global_registry_is_singleton() {
1930 let registry1 = global_registry();
1932 let registry2 = global_registry();
1933
1934 assert!(std::ptr::eq(registry1, registry2));
1936 }
1937
1938 #[test]
1939 fn test_register_global_new_resolver() {
1940 let resolver = mock_resolver("global_test_unique_42");
1942 let result = register_global(resolver, false);
1943 assert!(result.is_ok() || result.is_err());
1946 }
1947}
1948
1949#[cfg(test)]
1951mod lazy_resolution_tests {
1952 use super::*;
1953 use crate::Config;
1954 use std::sync::atomic::{AtomicBool, Ordering};
1955 use std::sync::Arc;
1956
1957 #[test]
1958 fn test_default_not_resolved_when_main_value_exists() {
1959 let fail_called = Arc::new(AtomicBool::new(false));
1961 let fail_called_clone = fail_called.clone();
1962
1963 let yaml = r#"
1965value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
1966"#;
1967 std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
1969
1970 let mut config = Config::from_yaml(yaml).unwrap();
1971
1972 config.register_resolver(Arc::new(FnResolver::new(
1974 "fail",
1975 move |_args, _kwargs, _ctx| {
1976 fail_called_clone.store(true, Ordering::SeqCst);
1977 panic!("fail resolver should not have been called - lazy resolution failed!");
1978 },
1979 )));
1980
1981 let result = config.get("value").unwrap();
1983 assert_eq!(result.as_str(), Some("main_value"));
1984
1985 assert!(
1987 !fail_called.load(Ordering::SeqCst),
1988 "The default resolver should not have been called when main value exists"
1989 );
1990
1991 std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
1992 }
1993
1994 #[test]
1995 fn test_default_is_resolved_when_main_value_missing() {
1996 let default_called = Arc::new(AtomicBool::new(false));
1998 let default_called_clone = default_called.clone();
1999
2000 let yaml = r#"
2002value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
2003"#;
2004 std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
2005
2006 let mut config = Config::from_yaml(yaml).unwrap();
2007
2008 config.register_resolver(Arc::new(FnResolver::new(
2010 "custom_default",
2011 move |args: &[String], _kwargs, _ctx| {
2012 default_called_clone.store(true, Ordering::SeqCst);
2013 let arg = args.first().cloned().unwrap_or_default();
2014 Ok(ResolvedValue::new(Value::String(format!(
2015 "default_was_{}",
2016 arg
2017 ))))
2018 },
2019 )));
2020
2021 let result = config.get("value").unwrap();
2023 assert_eq!(result.as_str(), Some("default_was_fallback"));
2024
2025 assert!(
2027 default_called.load(Ordering::SeqCst),
2028 "The default resolver should have been called when main value is missing"
2029 );
2030 }
2031}
2032
2033#[cfg(all(test, feature = "http"))]
2035mod http_resolver_tests {
2036 use super::*;
2037 use mockito::Server;
2038
2039 #[test]
2040 fn test_http_fetch_json() {
2041 let mut server = Server::new();
2042 let mock = server
2043 .mock("GET", "/config.json")
2044 .with_status(200)
2045 .with_header("content-type", "application/json")
2046 .with_body(r#"{"key": "value", "number": 42}"#)
2047 .create();
2048
2049 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2050 let args = vec![format!("{}/config.json", server.url())];
2051 let kwargs = HashMap::new();
2052
2053 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2054 assert!(result.value.is_mapping());
2055
2056 mock.assert();
2057 }
2058
2059 #[test]
2060 fn test_http_fetch_yaml() {
2061 let mut server = Server::new();
2062 let mock = server
2063 .mock("GET", "/config.yaml")
2064 .with_status(200)
2065 .with_header("content-type", "application/yaml")
2066 .with_body("key: value\nnumber: 42")
2067 .create();
2068
2069 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2070 let args = vec![format!("{}/config.yaml", server.url())];
2071 let kwargs = HashMap::new();
2072
2073 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2074 assert!(result.value.is_mapping());
2075
2076 mock.assert();
2077 }
2078
2079 #[test]
2080 fn test_http_fetch_text() {
2081 let mut server = Server::new();
2082 let mock = server
2083 .mock("GET", "/data.txt")
2084 .with_status(200)
2085 .with_header("content-type", "text/plain")
2086 .with_body("Hello, World!")
2087 .create();
2088
2089 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2090 let args = vec![format!("{}/data.txt", server.url())];
2091 let kwargs = HashMap::new();
2092
2093 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2094 assert_eq!(result.value.as_str(), Some("Hello, World!"));
2095
2096 mock.assert();
2097 }
2098
2099 #[test]
2100 fn test_http_fetch_binary() {
2101 let mut server = Server::new();
2102 let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
2103 let mock = server
2104 .mock("GET", "/data.bin")
2105 .with_status(200)
2106 .with_header("content-type", "application/octet-stream")
2107 .with_body(binary_data.clone())
2108 .create();
2109
2110 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2111 let args = vec![format!("{}/data.bin", server.url())];
2112 let mut kwargs = HashMap::new();
2113 kwargs.insert("parse".to_string(), "binary".to_string());
2114
2115 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2116 assert!(result.value.is_bytes());
2117 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2118
2119 mock.assert();
2120 }
2121
2122 #[test]
2123 fn test_http_fetch_explicit_parse_mode() {
2124 let mut server = Server::new();
2125 let mock = server
2127 .mock("GET", "/data")
2128 .with_status(200)
2129 .with_header("content-type", "text/plain")
2130 .with_body(r#"{"key": "value"}"#)
2131 .create();
2132
2133 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2134 let args = vec![format!("{}/data", server.url())];
2135 let mut kwargs = HashMap::new();
2136 kwargs.insert("parse".to_string(), "json".to_string());
2137
2138 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2139 assert!(result.value.is_mapping());
2140
2141 mock.assert();
2142 }
2143
2144 #[test]
2145 fn test_http_fetch_with_custom_header() {
2146 let mut server = Server::new();
2147 let mock = server
2148 .mock("GET", "/protected")
2149 .match_header("Authorization", "Bearer my-token")
2150 .with_status(200)
2151 .with_body("authorized content")
2152 .create();
2153
2154 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2155 let args = vec![format!("{}/protected", server.url())];
2156 let mut kwargs = HashMap::new();
2157 kwargs.insert(
2158 "header".to_string(),
2159 "Authorization:Bearer my-token".to_string(),
2160 );
2161
2162 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2163 assert_eq!(result.value.as_str(), Some("authorized content"));
2164
2165 mock.assert();
2166 }
2167
2168 #[test]
2169 fn test_http_fetch_404_error() {
2170 let mut server = Server::new();
2171 let mock = server.mock("GET", "/notfound").with_status(404).create();
2172
2173 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2174 let args = vec![format!("{}/notfound", server.url())];
2175 let kwargs = HashMap::new();
2176
2177 let result = http_resolver(&args, &kwargs, &ctx);
2178 assert!(result.is_err());
2179 let err = result.unwrap_err();
2180 assert!(err.to_string().contains("HTTP"));
2181
2182 mock.assert();
2183 }
2184
2185 #[test]
2186 fn test_http_disabled_by_default() {
2187 let ctx = ResolverContext::new("test.path");
2188 let args = vec!["https://example.com/config.yaml".to_string()];
2190 let kwargs = HashMap::new();
2191
2192 let result = http_resolver(&args, &kwargs, &ctx);
2193 assert!(result.is_err());
2194 let err = result.unwrap_err();
2195 assert!(err.to_string().contains("disabled"));
2196 }
2197
2198 #[test]
2199 fn test_http_allowlist_blocks_url() {
2200 let ctx = ResolverContext::new("test.path")
2201 .with_allow_http(true)
2202 .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
2203
2204 let args = vec!["https://blocked.example.com/config.yaml".to_string()];
2205 let kwargs = HashMap::new();
2206
2207 let result = http_resolver(&args, &kwargs, &ctx);
2208 assert!(result.is_err());
2209 let err = result.unwrap_err();
2210 assert!(
2211 err.to_string().contains("not in allowlist")
2212 || err.to_string().contains("HttpNotAllowed")
2213 );
2214 }
2215
2216 #[test]
2217 fn test_http_allowlist_allows_matching_url() {
2218 let mut server = Server::new();
2219 let mock = server
2220 .mock("GET", "/config.yaml")
2221 .with_status(200)
2222 .with_body("key: value")
2223 .create();
2224
2225 let server_url = server.url();
2227 let ctx = ResolverContext::new("test.path")
2228 .with_allow_http(true)
2229 .with_http_allowlist(vec![format!("{}/*", server_url)]);
2230
2231 let args = vec![format!("{}/config.yaml", server_url)];
2232 let kwargs = HashMap::new();
2233
2234 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2235 assert!(result.value.is_mapping());
2236
2237 mock.assert();
2238 }
2239
2240 #[test]
2241 fn test_url_matches_pattern_exact() {
2242 assert!(url_matches_pattern(
2243 "https://example.com/config.yaml",
2244 "https://example.com/config.yaml"
2245 ));
2246 assert!(!url_matches_pattern(
2247 "https://example.com/other.yaml",
2248 "https://example.com/config.yaml"
2249 ));
2250 }
2251
2252 #[test]
2253 fn test_url_matches_pattern_wildcard() {
2254 assert!(url_matches_pattern(
2255 "https://example.com/config.yaml",
2256 "https://example.com/*"
2257 ));
2258 assert!(url_matches_pattern(
2259 "https://example.com/path/to/config.yaml",
2260 "https://example.com/*"
2261 ));
2262 assert!(!url_matches_pattern(
2263 "https://other.com/config.yaml",
2264 "https://example.com/*"
2265 ));
2266 }
2267
2268 #[test]
2269 fn test_url_matches_pattern_subdomain() {
2270 assert!(url_matches_pattern(
2271 "https://api.example.com/config",
2272 "https://*.example.com/*"
2273 ));
2274 assert!(url_matches_pattern(
2275 "https://staging.example.com/config",
2276 "https://*.example.com/*"
2277 ));
2278 assert!(!url_matches_pattern(
2279 "https://example.com/config",
2280 "https://*.example.com/*"
2281 ));
2282 }
2283
2284 #[test]
2285 fn test_detect_parse_mode_from_content_type() {
2286 assert_eq!(
2287 detect_parse_mode("http://example.com/data", "application/json"),
2288 "json"
2289 );
2290 assert_eq!(
2291 detect_parse_mode("http://example.com/data", "text/json"),
2292 "json"
2293 );
2294 assert_eq!(
2295 detect_parse_mode("http://example.com/data", "application/yaml"),
2296 "yaml"
2297 );
2298 assert_eq!(
2299 detect_parse_mode("http://example.com/data", "application/x-yaml"),
2300 "yaml"
2301 );
2302 assert_eq!(
2303 detect_parse_mode("http://example.com/data", "text/yaml"),
2304 "yaml"
2305 );
2306 assert_eq!(
2307 detect_parse_mode("http://example.com/data", "text/plain"),
2308 "text"
2309 );
2310 }
2311
2312 #[test]
2313 fn test_detect_parse_mode_from_url_extension() {
2314 assert_eq!(
2315 detect_parse_mode("http://example.com/config.json", ""),
2316 "json"
2317 );
2318 assert_eq!(
2319 detect_parse_mode("http://example.com/config.yaml", ""),
2320 "yaml"
2321 );
2322 assert_eq!(
2323 detect_parse_mode("http://example.com/config.yml", ""),
2324 "yaml"
2325 );
2326 assert_eq!(
2327 detect_parse_mode("http://example.com/config.txt", ""),
2328 "text"
2329 );
2330 assert_eq!(detect_parse_mode("http://example.com/config", ""), "text");
2331 }
2332
2333 #[test]
2334 fn test_detect_parse_mode_content_type_takes_precedence() {
2335 assert_eq!(
2337 detect_parse_mode("http://example.com/config.yaml", "application/json"),
2338 "json"
2339 );
2340 }
2341}