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(Clone)]
42pub struct ResolvedValue {
43 pub value: Value,
45 pub sensitive: bool,
47}
48
49impl std::fmt::Debug for ResolvedValue {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.debug_struct("ResolvedValue")
52 .field(
53 "value",
54 if self.sensitive {
55 &"[REDACTED]"
56 } else {
57 &self.value
58 },
59 )
60 .field("sensitive", &self.sensitive)
61 .finish()
62 }
63}
64
65impl ResolvedValue {
66 pub fn new(value: impl Into<Value>) -> Self {
68 Self {
69 value: value.into(),
70 sensitive: false,
71 }
72 }
73
74 pub fn sensitive(value: impl Into<Value>) -> Self {
76 Self {
77 value: value.into(),
78 sensitive: true,
79 }
80 }
81}
82
83impl From<Value> for ResolvedValue {
84 fn from(value: Value) -> Self {
85 ResolvedValue::new(value)
86 }
87}
88
89impl From<String> for ResolvedValue {
90 fn from(s: String) -> Self {
91 ResolvedValue::new(Value::String(s))
92 }
93}
94
95impl From<&str> for ResolvedValue {
96 fn from(s: &str) -> Self {
97 ResolvedValue::new(Value::String(s.to_string()))
98 }
99}
100
101#[derive(Debug, Clone)]
103pub struct ResolverContext {
104 pub config_path: String,
106 pub config_root: Option<Arc<Value>>,
108 pub base_path: Option<std::path::PathBuf>,
110 pub file_roots: std::collections::HashSet<std::path::PathBuf>,
112 pub resolution_stack: Vec<String>,
114 pub allow_http: bool,
116 pub http_allowlist: Vec<String>,
118 pub http_proxy: Option<String>,
120 pub http_proxy_from_env: bool,
122 pub http_ca_bundle: Option<std::path::PathBuf>,
124 pub http_extra_ca_bundle: Option<std::path::PathBuf>,
126 pub http_client_cert: Option<std::path::PathBuf>,
128 pub http_client_key: Option<std::path::PathBuf>,
130 pub http_client_key_password: Option<String>,
132 }
134
135impl ResolverContext {
136 pub fn new(config_path: impl Into<String>) -> Self {
138 Self {
139 config_path: config_path.into(),
140 config_root: None,
141 base_path: None,
142 file_roots: std::collections::HashSet::new(),
143 resolution_stack: Vec::new(),
144 allow_http: false,
145 http_allowlist: Vec::new(),
146 http_proxy: None,
147 http_proxy_from_env: false,
148 http_ca_bundle: None,
149 http_extra_ca_bundle: None,
150 http_client_cert: None,
151 http_client_key: None,
152 http_client_key_password: None,
153 }
154 }
155
156 pub fn with_allow_http(mut self, allow: bool) -> Self {
158 self.allow_http = allow;
159 self
160 }
161
162 pub fn with_http_allowlist(mut self, allowlist: Vec<String>) -> Self {
164 self.http_allowlist = allowlist;
165 self
166 }
167
168 pub fn with_http_proxy(mut self, proxy: impl Into<String>) -> Self {
170 self.http_proxy = Some(proxy.into());
171 self
172 }
173
174 pub fn with_http_proxy_from_env(mut self, enabled: bool) -> Self {
176 self.http_proxy_from_env = enabled;
177 self
178 }
179
180 pub fn with_http_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
182 self.http_ca_bundle = Some(path.into());
183 self
184 }
185
186 pub fn with_http_extra_ca_bundle(mut self, path: impl Into<std::path::PathBuf>) -> Self {
188 self.http_extra_ca_bundle = Some(path.into());
189 self
190 }
191
192 pub fn with_http_client_cert(mut self, path: impl Into<std::path::PathBuf>) -> Self {
194 self.http_client_cert = Some(path.into());
195 self
196 }
197
198 pub fn with_http_client_key(mut self, path: impl Into<std::path::PathBuf>) -> Self {
200 self.http_client_key = Some(path.into());
201 self
202 }
203
204 pub fn with_http_client_key_password(mut self, password: impl Into<String>) -> Self {
206 self.http_client_key_password = Some(password.into());
207 self
208 }
209
210 pub fn with_config_root(mut self, root: Arc<Value>) -> Self {
214 self.config_root = Some(root);
215 self
216 }
217
218 pub fn with_base_path(mut self, path: std::path::PathBuf) -> Self {
220 self.base_path = Some(path);
221 self
222 }
223
224 pub fn would_cause_cycle(&self, path: &str) -> bool {
226 self.resolution_stack.contains(&path.to_string())
227 }
228
229 pub fn push_resolution(&mut self, path: &str) {
231 self.resolution_stack.push(path.to_string());
232 }
233
234 pub fn pop_resolution(&mut self) {
236 self.resolution_stack.pop();
237 }
238
239 pub fn get_resolution_chain(&self) -> Vec<String> {
241 self.resolution_stack.clone()
242 }
243}
244
245pub trait Resolver: Send + Sync {
247 fn resolve(
254 &self,
255 args: &[String],
256 kwargs: &HashMap<String, String>,
257 ctx: &ResolverContext,
258 ) -> Result<ResolvedValue>;
259
260 fn name(&self) -> &str;
262}
263
264pub struct FnResolver<F>
266where
267 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
268 + Send
269 + Sync,
270{
271 name: String,
272 func: F,
273}
274
275impl<F> FnResolver<F>
276where
277 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
278 + Send
279 + Sync,
280{
281 pub fn new(name: impl Into<String>, func: F) -> Self {
283 Self {
284 name: name.into(),
285 func,
286 }
287 }
288}
289
290impl<F> Resolver for FnResolver<F>
291where
292 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
293 + Send
294 + Sync,
295{
296 fn resolve(
297 &self,
298 args: &[String],
299 kwargs: &HashMap<String, String>,
300 ctx: &ResolverContext,
301 ) -> Result<ResolvedValue> {
302 (self.func)(args, kwargs, ctx)
303 }
304
305 fn name(&self) -> &str {
306 &self.name
307 }
308}
309
310#[derive(Clone)]
312pub struct ResolverRegistry {
313 resolvers: HashMap<String, Arc<dyn Resolver>>,
314}
315
316impl Default for ResolverRegistry {
317 fn default() -> Self {
318 Self::new()
319 }
320}
321
322impl ResolverRegistry {
323 pub fn new() -> Self {
325 Self {
326 resolvers: HashMap::new(),
327 }
328 }
329
330 pub fn with_builtins() -> Self {
332 let mut registry = Self::new();
333 registry.register_builtin_resolvers();
334 registry
335 }
336
337 fn register_builtin_resolvers(&mut self) {
339 self.register(Arc::new(FnResolver::new("env", env_resolver)));
341 self.register(Arc::new(FnResolver::new("file", file_resolver)));
343 self.register(Arc::new(FnResolver::new("http", http_resolver)));
345 }
346
347 pub fn register(&mut self, resolver: Arc<dyn Resolver>) {
349 self.resolvers.insert(resolver.name().to_string(), resolver);
350 }
351
352 pub fn register_with_force(&mut self, resolver: Arc<dyn Resolver>, force: bool) -> Result<()> {
363 let name = resolver.name().to_string();
364 if !force && self.resolvers.contains_key(&name) {
365 return Err(Error::resolver_already_registered(&name));
366 }
367 self.resolvers.insert(name, resolver);
368 Ok(())
369 }
370
371 pub fn register_fn<F>(&mut self, name: impl Into<String>, func: F)
373 where
374 F: Fn(&[String], &HashMap<String, String>, &ResolverContext) -> Result<ResolvedValue>
375 + Send
376 + Sync
377 + 'static,
378 {
379 let name = name.into();
380 self.register(Arc::new(FnResolver::new(name, func)));
381 }
382
383 pub fn get(&self, name: &str) -> Option<&Arc<dyn Resolver>> {
385 self.resolvers.get(name)
386 }
387
388 pub fn contains(&self, name: &str) -> bool {
390 self.resolvers.contains_key(name)
391 }
392
393 pub fn resolve(
401 &self,
402 resolver_name: &str,
403 args: &[String],
404 kwargs: &HashMap<String, String>,
405 ctx: &ResolverContext,
406 ) -> Result<ResolvedValue> {
407 let resolver = self
408 .resolvers
409 .get(resolver_name)
410 .ok_or_else(|| Error::unknown_resolver(resolver_name, Some(ctx.config_path.clone())))?;
411
412 let sensitive_override = kwargs
414 .get("sensitive")
415 .map(|v| v.eq_ignore_ascii_case("true"));
416
417 let resolver_kwargs: HashMap<String, String> = kwargs
419 .iter()
420 .filter(|(k, _)| *k != "sensitive")
421 .map(|(k, v)| (k.clone(), v.clone()))
422 .collect();
423
424 let mut resolved = resolver.resolve(args, &resolver_kwargs, ctx)?;
426
427 if let Some(is_sensitive) = sensitive_override {
429 resolved.sensitive = is_sensitive;
430 }
431
432 Ok(resolved)
433 }
434}
435
436fn env_resolver(
446 args: &[String],
447 _kwargs: &HashMap<String, String>,
448 ctx: &ResolverContext,
449) -> Result<ResolvedValue> {
450 if args.is_empty() {
451 return Err(Error::parse("env resolver requires a variable name")
452 .with_path(ctx.config_path.clone()));
453 }
454
455 let var_name = &args[0];
456
457 match std::env::var(var_name) {
458 Ok(value) => {
459 Ok(ResolvedValue::new(Value::String(value)))
461 }
462 Err(_) => {
463 Err(Error::env_not_found(
465 var_name,
466 Some(ctx.config_path.clone()),
467 ))
468 }
469 }
470}
471
472fn file_resolver(
489 args: &[String],
490 kwargs: &HashMap<String, String>,
491 ctx: &ResolverContext,
492) -> Result<ResolvedValue> {
493 use std::path::Path;
494
495 if args.is_empty() {
496 return Err(
497 Error::parse("file resolver requires a file path").with_path(ctx.config_path.clone())
498 );
499 }
500
501 let file_path_str = &args[0];
502 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
503 let encoding = kwargs
504 .get("encoding")
505 .map(|s| s.as_str())
506 .unwrap_or("utf-8");
507
508 let file_path = if Path::new(file_path_str).is_relative() {
510 if let Some(base) = &ctx.base_path {
511 base.join(file_path_str)
512 } else {
513 std::path::PathBuf::from(file_path_str)
514 }
515 } else {
516 std::path::PathBuf::from(file_path_str)
517 };
518
519 if ctx.file_roots.is_empty() {
522 return Err(Error::resolver_custom(
523 "file",
524 "File resolver requires allowed directories to be configured. \
525 Use Config.load() which auto-configures the parent directory, or \
526 specify file_roots explicitly for Config.loads()."
527 .to_string(),
528 )
529 .with_path(ctx.config_path.clone()));
530 }
531
532 let canonical_path = file_path.canonicalize().map_err(|e| {
535 if e.kind() == std::io::ErrorKind::NotFound {
537 return Error::file_not_found(file_path_str, Some(ctx.config_path.clone()));
538 }
539 Error::resolver_custom("file", format!("Failed to resolve file path: {}", e))
540 .with_path(ctx.config_path.clone())
541 })?;
542
543 let mut canonicalization_errors = Vec::new();
545 let is_allowed = ctx.file_roots.iter().any(|root| {
546 match root.canonicalize() {
547 Ok(canonical_root) => canonical_path.starts_with(&canonical_root),
548 Err(e) => {
549 canonicalization_errors.push((root.clone(), e));
551 false
552 }
553 }
554 });
555
556 if !is_allowed {
557 let display_path = if let Some(base) = &ctx.base_path {
559 file_path
560 .strip_prefix(base)
561 .map(|p| p.display().to_string())
562 .unwrap_or_else(|_| "<outside allowed directories>".to_string())
563 } else {
564 "<outside allowed directories>".to_string()
565 };
566
567 let mut msg = format!(
568 "Access denied: file '{}' is outside allowed directories.",
569 display_path
570 );
571
572 if !canonicalization_errors.is_empty() {
573 msg.push_str(&format!(
574 " Note: {} configured root(s) could not be validated.",
575 canonicalization_errors.len()
576 ));
577 }
578
579 msg.push_str(" Use file_roots parameter to extend allowed directories.");
580
581 return Err(Error::resolver_custom("file", msg).with_path(ctx.config_path.clone()));
582 }
583
584 if encoding == "binary" {
586 let bytes = std::fs::read(&file_path)
587 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
588 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
589 }
590
591 let content = match encoding {
593 "base64" => {
594 use base64::{engine::general_purpose::STANDARD, Engine as _};
596 let bytes = std::fs::read(&file_path)
597 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
598 STANDARD.encode(bytes)
599 }
600 "ascii" => {
601 let raw = std::fs::read_to_string(&file_path)
603 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?;
604 raw.chars().filter(|c| c.is_ascii()).collect()
605 }
606 _ => {
607 std::fs::read_to_string(&file_path)
609 .map_err(|_| Error::file_not_found(file_path_str, Some(ctx.config_path.clone())))?
610 }
611 };
612
613 if encoding == "base64" {
615 return Ok(ResolvedValue::new(Value::String(content)));
616 }
617
618 let actual_parse_mode = if parse_mode == "auto" {
620 match file_path.extension().and_then(|e| e.to_str()) {
622 Some("yaml") | Some("yml") => "yaml",
623 Some("json") => "json",
624 _ => "text",
625 }
626 } else {
627 parse_mode
628 };
629
630 match actual_parse_mode {
632 "yaml" => {
633 let value: Value = serde_yaml::from_str(&content).map_err(|e| {
634 Error::parse(format!("Failed to parse YAML: {}", e))
635 .with_path(ctx.config_path.clone())
636 })?;
637 Ok(ResolvedValue::new(value))
638 }
639 "json" => {
640 let value: Value = serde_json::from_str(&content).map_err(|e| {
641 Error::parse(format!("Failed to parse JSON: {}", e))
642 .with_path(ctx.config_path.clone())
643 })?;
644 Ok(ResolvedValue::new(value))
645 }
646 _ => {
647 Ok(ResolvedValue::new(Value::String(content)))
649 }
650 }
651}
652
653fn http_resolver(
672 args: &[String],
673 kwargs: &HashMap<String, String>,
674 ctx: &ResolverContext,
675) -> Result<ResolvedValue> {
676 if args.is_empty() {
677 return Err(Error::parse("http resolver requires a URL").with_path(ctx.config_path.clone()));
678 }
679
680 let url = &args[0];
681
682 #[cfg(feature = "http")]
684 {
685 if !ctx.allow_http {
687 return Err(Error {
688 kind: crate::error::ErrorKind::Resolver(crate::error::ResolverErrorKind::HttpDisabled),
689 path: Some(ctx.config_path.clone()),
690 source_location: None,
691 help: Some(
692 "HTTP resolver is disabled. The URL specified by this config path cannot be fetched.\n\
693 Enable with Config.load(..., allow_http=True)".to_string()
694 ),
695 cause: None,
696 });
697 }
698
699 if !ctx.http_allowlist.is_empty() {
701 let url_allowed = ctx
702 .http_allowlist
703 .iter()
704 .any(|pattern| url_matches_pattern(url, pattern));
705 if !url_allowed {
706 return Err(Error::http_not_in_allowlist(
707 url,
708 &ctx.http_allowlist,
709 Some(ctx.config_path.clone()),
710 ));
711 }
712 }
713
714 http_fetch(url, kwargs, ctx)
715 }
716
717 #[cfg(not(feature = "http"))]
718 {
719 let _ = kwargs; let _ = ctx; Err(Error::resolver_custom(
723 "http",
724 "HTTP support not compiled in. Rebuild with --features http",
725 ))
726 }
727}
728
729#[cfg(feature = "http")]
735fn url_matches_pattern(url: &str, pattern: &str) -> bool {
736 let parsed_url = match url::Url::parse(url) {
738 Ok(u) => u,
739 Err(_) => {
740 log::warn!("Invalid URL '{}' rejected by allowlist", url);
742 return false;
743 }
744 };
745
746 if pattern.contains("**") || pattern.contains(".*.*") {
748 log::warn!(
749 "Invalid allowlist pattern '{}' - contains dangerous sequence",
750 pattern
751 );
752 return false;
753 }
754
755 let glob_pattern = match glob::Pattern::new(pattern) {
757 Ok(p) => p,
758 Err(_) => {
759 log::warn!(
761 "Invalid glob pattern '{}' - falling back to exact match",
762 pattern
763 );
764 return url == pattern;
765 }
766 };
767
768 glob_pattern.matches(parsed_url.as_str())
774}
775
776#[cfg(feature = "http")]
782fn load_certs_from_pem(path: &std::path::Path) -> Result<Vec<ureq::tls::Certificate<'static>>> {
783 use ureq::tls::PemItem;
784
785 let pem_content = std::fs::read(path).map_err(|e| {
786 Error::pem_load_error(
787 path.display().to_string(),
788 format!("Failed to open file: {}", e),
789 )
790 })?;
791
792 let certs: Vec<_> = ureq::tls::parse_pem(&pem_content)
793 .filter_map(|item| item.ok())
794 .filter_map(|item| match item {
795 PemItem::Certificate(cert) => Some(cert.to_owned()),
796 _ => None,
797 })
798 .collect();
799
800 if certs.is_empty() {
801 return Err(Error::pem_load_error(
802 path.display().to_string(),
803 "No valid certificates found in PEM file",
804 ));
805 }
806
807 Ok(certs)
808}
809
810#[cfg(feature = "http")]
812fn load_private_key_from_pem(path: &std::path::Path) -> Result<ureq::tls::PrivateKey<'static>> {
813 let pem_content = std::fs::read(path).map_err(|e| {
814 Error::pem_load_error(
815 path.display().to_string(),
816 format!("Failed to open file: {}", e),
817 )
818 })?;
819
820 let key = ureq::tls::PrivateKey::from_pem(&pem_content).map_err(|e| {
821 Error::pem_load_error(
822 path.display().to_string(),
823 format!("Failed to parse key: {}", e),
824 )
825 })?;
826
827 Ok(key.to_owned())
828}
829
830#[cfg(feature = "http")]
832fn load_encrypted_private_key_from_pem(
833 path: &std::path::Path,
834 password: &str,
835) -> Result<ureq::tls::PrivateKey<'static>> {
836 use pkcs8::der::Decode;
837
838 let pem_content = std::fs::read_to_string(path).map_err(|e| {
839 Error::pem_load_error(
840 path.display().to_string(),
841 format!("Failed to read file: {}", e),
842 )
843 })?;
844
845 if pem_content.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") {
847 let der_bytes = pem_to_der(&pem_content, "ENCRYPTED PRIVATE KEY")
849 .map_err(|e| Error::pem_load_error(path.display().to_string(), e))?;
850
851 let encrypted = pkcs8::EncryptedPrivateKeyInfo::from_der(&der_bytes)
852 .map_err(|e| Error::pem_load_error(path.display().to_string(), e.to_string()))?;
853
854 let decrypted = encrypted
855 .decrypt(password)
856 .map_err(|e| Error::key_decryption_error(e.to_string()))?;
857
858 let pem_key = der_to_pem(decrypted.as_bytes(), "PRIVATE KEY");
861
862 ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
863 .map(|k| k.to_owned())
864 .map_err(|e| {
865 Error::pem_load_error(
866 path.display().to_string(),
867 format!("Failed to parse decrypted key: {}", e),
868 )
869 })
870 } else {
871 load_private_key_from_pem(path)
873 }
874}
875
876#[cfg(feature = "http")]
878fn pem_to_der(pem: &str, label: &str) -> std::result::Result<Vec<u8>, String> {
879 let begin_marker = format!("-----BEGIN {}-----", label);
880 let end_marker = format!("-----END {}-----", label);
881
882 let start = pem
883 .find(&begin_marker)
884 .ok_or_else(|| format!("PEM begin marker not found for {}", label))?;
885 let end = pem
886 .find(&end_marker)
887 .ok_or_else(|| format!("PEM end marker not found for {}", label))?;
888
889 let base64_content: String = pem[start + begin_marker.len()..end]
890 .chars()
891 .filter(|c| !c.is_whitespace())
892 .collect();
893
894 use base64::Engine;
895 base64::engine::general_purpose::STANDARD
896 .decode(&base64_content)
897 .map_err(|e| format!("Failed to decode base64: {}", e))
898}
899
900#[cfg(feature = "http")]
902fn der_to_pem(der: &[u8], label: &str) -> String {
903 use base64::Engine;
904 let base64 = base64::engine::general_purpose::STANDARD.encode(der);
905 let lines: Vec<&str> = base64
907 .as_bytes()
908 .chunks(64)
909 .map(|chunk| std::str::from_utf8(chunk).unwrap())
910 .collect();
911 format!(
912 "-----BEGIN {}-----\n{}\n-----END {}-----\n",
913 label,
914 lines.join("\n"),
915 label
916 )
917}
918
919#[cfg(feature = "http")]
921fn is_p12_file(path: &std::path::Path) -> bool {
922 path.extension()
923 .and_then(|ext| ext.to_str())
924 .map(|ext| ext.eq_ignore_ascii_case("p12") || ext.eq_ignore_ascii_case("pfx"))
925 .unwrap_or(false)
926}
927
928#[cfg(feature = "http")]
930fn load_identity_from_p12(
931 path: &std::path::Path,
932 password: &str,
933) -> Result<(
934 Vec<ureq::tls::Certificate<'static>>,
935 ureq::tls::PrivateKey<'static>,
936)> {
937 let p12_data = std::fs::read(path).map_err(|e| {
938 Error::p12_load_error(
939 path.display().to_string(),
940 format!("Failed to read file: {}", e),
941 )
942 })?;
943
944 let keystore = p12_keystore::KeyStore::from_pkcs12(&p12_data, password)
945 .map_err(|e| Error::p12_load_error(path.display().to_string(), e.to_string()))?;
946
947 let (_alias, key_chain) = keystore.private_key_chain().ok_or_else(|| {
950 Error::p12_load_error(
951 path.display().to_string(),
952 "No private key found in P12 file",
953 )
954 })?;
955
956 let pem_key = der_to_pem(key_chain.key(), "PRIVATE KEY");
958 let private_key = ureq::tls::PrivateKey::from_pem(pem_key.as_bytes())
959 .map(|k| k.to_owned())
960 .map_err(|e| {
961 Error::p12_load_error(
962 path.display().to_string(),
963 format!("Failed to parse private key: {}", e),
964 )
965 })?;
966
967 let certs: Vec<_> = key_chain
969 .chain()
970 .iter()
971 .map(|cert| ureq::tls::Certificate::from_der(cert.as_der()).to_owned())
972 .collect();
973
974 if certs.is_empty() {
975 return Err(Error::p12_load_error(
976 path.display().to_string(),
977 "No certificates found in P12 file",
978 ));
979 }
980
981 Ok((certs, private_key))
982}
983
984#[cfg(feature = "http")]
986fn load_client_identity(
987 cert_path: &std::path::Path,
988 key_path: Option<&std::path::Path>,
989 password: Option<&str>,
990) -> Result<(
991 Vec<ureq::tls::Certificate<'static>>,
992 ureq::tls::PrivateKey<'static>,
993)> {
994 if is_p12_file(cert_path) {
996 let pwd = password.unwrap_or("");
997 return load_identity_from_p12(cert_path, pwd);
998 }
999
1000 let cert_chain = load_certs_from_pem(cert_path)?;
1002
1003 let key_path = key_path.ok_or_else(|| {
1004 Error::tls_config_error("Client key path required when using PEM certificate (not P12)")
1005 })?;
1006
1007 let private_key = if let Some(pwd) = password {
1008 load_encrypted_private_key_from_pem(key_path, pwd)?
1009 } else {
1010 load_private_key_from_pem(key_path)?
1011 };
1012
1013 Ok((cert_chain, private_key))
1014}
1015
1016#[cfg(feature = "http")]
1018fn build_tls_config(
1019 ctx: &ResolverContext,
1020 kwargs: &HashMap<String, String>,
1021) -> Result<ureq::tls::TlsConfig> {
1022 use std::sync::Arc;
1023 use ureq::tls::{ClientCert, RootCerts, TlsConfig};
1024
1025 let mut builder = TlsConfig::builder();
1026
1027 let insecure = kwargs.get("insecure").map(|v| v == "true").unwrap_or(false);
1029
1030 if insecure {
1031 eprintln!("\n┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓");
1033 eprintln!("┃ ⚠️ WARNING: TLS CERTIFICATE VERIFICATION DISABLED ┃");
1034 eprintln!("┃ ┃");
1035 eprintln!("┃ You are using insecure=true which disables ALL ┃");
1036 eprintln!("┃ TLS certificate validation. This is DANGEROUS ┃");
1037 eprintln!("┃ and should ONLY be used in development. ┃");
1038 eprintln!("┃ ┃");
1039 eprintln!("┃ In production, use proper certificate ┃");
1040 eprintln!("┃ configuration with ca_bundle or extra_ca_bundle. ┃");
1041 eprintln!("┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛\n");
1042 log::warn!("TLS certificate verification is disabled (insecure=true)");
1043 builder = builder.disable_verification(true);
1044 }
1045
1046 let ca_bundle_path = kwargs
1048 .get("ca_bundle")
1049 .map(std::path::PathBuf::from)
1050 .or_else(|| ctx.http_ca_bundle.clone());
1051
1052 let extra_ca_bundle_path = kwargs
1053 .get("extra_ca_bundle")
1054 .map(std::path::PathBuf::from)
1055 .or_else(|| ctx.http_extra_ca_bundle.clone());
1056
1057 if let Some(ca_path) = ca_bundle_path {
1058 let certs = load_certs_from_pem(&ca_path)?;
1060 builder = builder.root_certs(RootCerts::Specific(Arc::new(certs)));
1061 } else if let Some(extra_ca_path) = extra_ca_bundle_path {
1062 let extra_certs = load_certs_from_pem(&extra_ca_path)?;
1064 builder = builder.root_certs(RootCerts::new_with_certs(&extra_certs));
1065 }
1066
1067 let client_cert_path = kwargs
1069 .get("client_cert")
1070 .map(std::path::PathBuf::from)
1071 .or_else(|| ctx.http_client_cert.clone());
1072
1073 if let Some(cert_path) = client_cert_path {
1074 let client_key_path = kwargs
1075 .get("client_key")
1076 .map(std::path::PathBuf::from)
1077 .or_else(|| ctx.http_client_key.clone());
1078
1079 let password = kwargs
1080 .get("key_password")
1081 .map(|s| s.as_str())
1082 .or(ctx.http_client_key_password.as_deref());
1083
1084 let (certs, key) = load_client_identity(&cert_path, client_key_path.as_deref(), password)?;
1085
1086 let client_cert = ClientCert::new_with_certs(&certs, key);
1087 builder = builder.client_cert(Some(client_cert));
1088 }
1089
1090 Ok(builder.build())
1091}
1092
1093#[cfg(feature = "http")]
1095fn build_proxy_config(
1096 ctx: &ResolverContext,
1097 kwargs: &HashMap<String, String>,
1098) -> Result<Option<ureq::Proxy>> {
1099 let proxy_url = kwargs
1101 .get("proxy")
1102 .cloned()
1103 .or_else(|| ctx.http_proxy.clone());
1104
1105 let proxy_url = proxy_url.or_else(|| {
1107 if ctx.http_proxy_from_env {
1108 std::env::var("HTTPS_PROXY")
1110 .or_else(|_| std::env::var("https_proxy"))
1111 .or_else(|_| std::env::var("HTTP_PROXY"))
1112 .or_else(|_| std::env::var("http_proxy"))
1113 .ok()
1114 } else {
1115 None
1116 }
1117 });
1118
1119 if let Some(url) = proxy_url {
1120 let proxy = ureq::Proxy::new(&url).map_err(|e| {
1121 Error::proxy_config_error(format!("Invalid proxy URL '{}': {}", url, e))
1122 })?;
1123 Ok(Some(proxy))
1124 } else {
1125 Ok(None)
1126 }
1127}
1128
1129#[cfg(feature = "http")]
1131fn http_fetch(
1132 url: &str,
1133 kwargs: &HashMap<String, String>,
1134 ctx: &ResolverContext,
1135) -> Result<ResolvedValue> {
1136 use std::time::Duration;
1137
1138 let parse_mode = kwargs.get("parse").map(|s| s.as_str()).unwrap_or("auto");
1139 let timeout_secs: u64 = kwargs
1140 .get("timeout")
1141 .and_then(|s| s.parse().ok())
1142 .unwrap_or(30);
1143
1144 let tls_config = build_tls_config(ctx, kwargs)?;
1146
1147 let proxy = build_proxy_config(ctx, kwargs)?;
1149
1150 let mut config_builder = ureq::Agent::config_builder()
1152 .timeout_global(Some(Duration::from_secs(timeout_secs)))
1153 .tls_config(tls_config);
1154
1155 if proxy.is_some() {
1156 config_builder = config_builder.proxy(proxy);
1157 }
1158
1159 let config = config_builder.build();
1160 let agent: ureq::Agent = config.into();
1161
1162 let mut request = agent.get(url);
1164
1165 for (key, value) in kwargs {
1167 if key == "header" {
1168 if let Some((name, val)) = value.split_once(':') {
1170 request = request.header(name.trim(), val.trim());
1171 }
1172 }
1173 }
1174
1175 let response = request.call().map_err(|e| {
1177 let error_msg = match &e {
1178 ureq::Error::StatusCode(code) => format!("HTTP {}", code),
1179 ureq::Error::Timeout(kind) => format!("Request timeout: {:?}", kind),
1180 ureq::Error::Io(io_err) => format!("Connection error: {}", io_err),
1181 _ => format!("HTTP request failed: {}", e),
1182 };
1183 Error::http_request_failed(url, &error_msg, Some(ctx.config_path.clone()))
1184 })?;
1185
1186 let content_type = response
1188 .headers()
1189 .get("content-type")
1190 .and_then(|v| v.to_str().ok())
1191 .map(|s| s.to_string())
1192 .unwrap_or_default();
1193
1194 if parse_mode == "binary" {
1196 let bytes = response.into_body().read_to_vec().map_err(|e| {
1197 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1198 })?;
1199 return Ok(ResolvedValue::new(Value::Bytes(bytes)));
1200 }
1201
1202 let body = response.into_body().read_to_string().map_err(|e| {
1204 Error::http_request_failed(url, e.to_string(), Some(ctx.config_path.clone()))
1205 })?;
1206
1207 let actual_parse_mode = if parse_mode == "auto" {
1209 detect_parse_mode(url, &content_type)
1210 } else {
1211 parse_mode
1212 };
1213
1214 match actual_parse_mode {
1216 "yaml" => {
1217 let value: Value = serde_yaml::from_str(&body).map_err(|e| {
1218 Error::parse(format!("Failed to parse YAML from {}: {}", url, e))
1219 .with_path(ctx.config_path.clone())
1220 })?;
1221 Ok(ResolvedValue::new(value))
1222 }
1223 "json" => {
1224 let value: Value = serde_json::from_str(&body).map_err(|e| {
1225 Error::parse(format!("Failed to parse JSON from {}: {}", url, e))
1226 .with_path(ctx.config_path.clone())
1227 })?;
1228 Ok(ResolvedValue::new(value))
1229 }
1230 _ => {
1231 Ok(ResolvedValue::new(Value::String(body)))
1233 }
1234 }
1235}
1236
1237#[cfg(feature = "http")]
1239fn detect_parse_mode<'a>(url: &str, content_type: &str) -> &'a str {
1240 let ct_lower = content_type.to_lowercase();
1242 if ct_lower.contains("application/json") || ct_lower.contains("text/json") {
1243 return "json";
1244 }
1245 if ct_lower.contains("application/yaml")
1246 || ct_lower.contains("application/x-yaml")
1247 || ct_lower.contains("text/yaml")
1248 {
1249 return "yaml";
1250 }
1251
1252 if let Some(path) = url.split('?').next() {
1254 if path.ends_with(".json") {
1255 return "json";
1256 }
1257 if path.ends_with(".yaml") || path.ends_with(".yml") {
1258 return "yaml";
1259 }
1260 }
1261
1262 "text"
1264}
1265
1266#[cfg(test)]
1267mod tests {
1268 use super::*;
1269
1270 #[test]
1271 fn test_env_resolver_with_value() {
1272 std::env::set_var("HOLOCONF_TEST_VAR", "test_value");
1273
1274 let ctx = ResolverContext::new("test.path");
1275 let args = vec!["HOLOCONF_TEST_VAR".to_string()];
1276 let kwargs = HashMap::new();
1277
1278 let result = env_resolver(&args, &kwargs, &ctx).unwrap();
1279 assert_eq!(result.value.as_str(), Some("test_value"));
1280 assert!(!result.sensitive);
1281
1282 std::env::remove_var("HOLOCONF_TEST_VAR");
1283 }
1284
1285 #[test]
1286 fn test_env_resolver_missing_returns_error() {
1287 std::env::remove_var("HOLOCONF_NONEXISTENT_VAR");
1289
1290 let registry = ResolverRegistry::with_builtins();
1291 let ctx = ResolverContext::new("test.path");
1292 let args = vec!["HOLOCONF_NONEXISTENT_VAR".to_string()];
1293 let kwargs = HashMap::new();
1294
1295 let result = registry.resolve("env", &args, &kwargs, &ctx);
1298 assert!(result.is_err());
1299 }
1300
1301 #[test]
1302 fn test_env_resolver_missing_no_default() {
1303 std::env::remove_var("HOLOCONF_MISSING_VAR");
1304
1305 let ctx = ResolverContext::new("test.path");
1306 let args = vec!["HOLOCONF_MISSING_VAR".to_string()];
1307 let kwargs = HashMap::new();
1308
1309 let result = env_resolver(&args, &kwargs, &ctx);
1310 assert!(result.is_err());
1311 }
1312
1313 #[test]
1314 fn test_env_resolver_sensitive_kwarg() {
1315 std::env::set_var("HOLOCONF_SENSITIVE_VAR", "secret_value");
1316
1317 let registry = ResolverRegistry::with_builtins();
1318 let ctx = ResolverContext::new("test.path");
1319 let args = vec!["HOLOCONF_SENSITIVE_VAR".to_string()];
1320 let mut kwargs = HashMap::new();
1321 kwargs.insert("sensitive".to_string(), "true".to_string());
1322
1323 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1325 assert_eq!(result.value.as_str(), Some("secret_value"));
1326 assert!(result.sensitive);
1327
1328 std::env::remove_var("HOLOCONF_SENSITIVE_VAR");
1329 }
1330
1331 #[test]
1332 fn test_env_resolver_sensitive_false() {
1333 std::env::set_var("HOLOCONF_NON_SENSITIVE", "public_value");
1334
1335 let registry = ResolverRegistry::with_builtins();
1336 let ctx = ResolverContext::new("test.path");
1337 let args = vec!["HOLOCONF_NON_SENSITIVE".to_string()];
1338 let mut kwargs = HashMap::new();
1339 kwargs.insert("sensitive".to_string(), "false".to_string());
1340
1341 let result = registry.resolve("env", &args, &kwargs, &ctx).unwrap();
1343 assert_eq!(result.value.as_str(), Some("public_value"));
1344 assert!(!result.sensitive);
1345
1346 std::env::remove_var("HOLOCONF_NON_SENSITIVE");
1347 }
1348
1349 #[test]
1353 fn test_resolver_registry() {
1354 let registry = ResolverRegistry::with_builtins();
1355
1356 assert!(registry.contains("env"));
1357 assert!(!registry.contains("nonexistent"));
1358 }
1359
1360 #[test]
1361 fn test_custom_resolver() {
1362 let mut registry = ResolverRegistry::new();
1363
1364 registry.register_fn("custom", |args, _kwargs, _ctx| {
1365 let value = args.first().cloned().unwrap_or_default();
1366 Ok(ResolvedValue::new(Value::String(format!(
1367 "custom:{}",
1368 value
1369 ))))
1370 });
1371
1372 let ctx = ResolverContext::new("test");
1373 let result = registry
1374 .resolve("custom", &["arg".to_string()], &HashMap::new(), &ctx)
1375 .unwrap();
1376
1377 assert_eq!(result.value.as_str(), Some("custom:arg"));
1378 }
1379
1380 #[test]
1381 fn test_resolved_value_sensitivity() {
1382 let non_sensitive = ResolvedValue::new("public");
1383 assert!(!non_sensitive.sensitive);
1384
1385 let sensitive = ResolvedValue::sensitive("secret");
1386 assert!(sensitive.sensitive);
1387 }
1388
1389 #[test]
1390 fn test_resolver_context_cycle_detection() {
1391 let mut ctx = ResolverContext::new("root");
1392 ctx.push_resolution("a");
1393 ctx.push_resolution("b");
1394
1395 assert!(ctx.would_cause_cycle("a"));
1396 assert!(ctx.would_cause_cycle("b"));
1397 assert!(!ctx.would_cause_cycle("c"));
1398
1399 ctx.pop_resolution();
1400 assert!(!ctx.would_cause_cycle("b"));
1401 }
1402
1403 #[test]
1404 fn test_file_resolver() {
1405 use std::io::Write;
1406
1407 let temp_dir = std::env::temp_dir();
1409 let test_file = temp_dir.join("holoconf_test_file.txt");
1410 {
1411 let mut file = std::fs::File::create(&test_file).unwrap();
1412 writeln!(file, "test content").unwrap();
1413 }
1414
1415 let mut ctx = ResolverContext::new("test.path");
1416 ctx.base_path = Some(temp_dir.clone());
1417 ctx.file_roots.insert(temp_dir.clone());
1418
1419 let args = vec!["holoconf_test_file.txt".to_string()];
1420 let mut kwargs = HashMap::new();
1421 kwargs.insert("parse".to_string(), "text".to_string());
1422
1423 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1424 assert!(result.value.as_str().unwrap().contains("test content"));
1425 assert!(!result.sensitive);
1426
1427 std::fs::remove_file(test_file).ok();
1429 }
1430
1431 #[test]
1432 fn test_file_resolver_yaml() {
1433 use std::io::Write;
1434
1435 let temp_dir = std::env::temp_dir();
1437 let test_file = temp_dir.join("holoconf_test.yaml");
1438 {
1439 let mut file = std::fs::File::create(&test_file).unwrap();
1440 writeln!(file, "key: value").unwrap();
1441 writeln!(file, "number: 42").unwrap();
1442 }
1443
1444 let mut ctx = ResolverContext::new("test.path");
1445 ctx.base_path = Some(temp_dir.clone());
1446 ctx.file_roots.insert(temp_dir.clone());
1447
1448 let args = vec!["holoconf_test.yaml".to_string()];
1449 let kwargs = HashMap::new();
1450
1451 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1452 assert!(result.value.is_mapping());
1453
1454 std::fs::remove_file(test_file).ok();
1456 }
1457
1458 #[test]
1459 fn test_file_resolver_not_found() {
1460 let ctx = ResolverContext::new("test.path");
1461 let args = vec!["nonexistent_file.txt".to_string()];
1462 let kwargs = HashMap::new();
1463
1464 let result = file_resolver(&args, &kwargs, &ctx);
1465 assert!(result.is_err());
1466 }
1467
1468 #[test]
1469 fn test_registry_with_file() {
1470 let registry = ResolverRegistry::with_builtins();
1471 assert!(registry.contains("file"));
1472 }
1473
1474 #[test]
1475 fn test_http_resolver_disabled() {
1476 let ctx = ResolverContext::new("test.path");
1477 let args = vec!["https://example.com/config.yaml".to_string()];
1478 let kwargs = HashMap::new();
1479
1480 let result = http_resolver(&args, &kwargs, &ctx);
1481 assert!(result.is_err());
1482
1483 let err = result.unwrap_err();
1484 let display = format!("{}", err);
1485 assert!(display.contains("HTTP resolver is disabled"));
1486 }
1487
1488 #[test]
1489 fn test_registry_with_http() {
1490 let registry = ResolverRegistry::with_builtins();
1491 assert!(registry.contains("http"));
1492 }
1493
1494 #[test]
1497 fn test_env_resolver_no_args() {
1498 let ctx = ResolverContext::new("test.path");
1499 let args = vec![];
1500 let kwargs = HashMap::new();
1501
1502 let result = env_resolver(&args, &kwargs, &ctx);
1503 assert!(result.is_err());
1504 let err = result.unwrap_err();
1505 assert!(err.to_string().contains("requires"));
1506 }
1507
1508 #[test]
1509 fn test_file_resolver_no_args() {
1510 let ctx = ResolverContext::new("test.path");
1511 let args = vec![];
1512 let kwargs = HashMap::new();
1513
1514 let result = file_resolver(&args, &kwargs, &ctx);
1515 assert!(result.is_err());
1516 let err = result.unwrap_err();
1517 assert!(err.to_string().contains("requires"));
1518 }
1519
1520 #[test]
1521 fn test_http_resolver_no_args() {
1522 let ctx = ResolverContext::new("test.path");
1523 let args = vec![];
1524 let kwargs = HashMap::new();
1525
1526 let result = http_resolver(&args, &kwargs, &ctx);
1527 assert!(result.is_err());
1528 let err = result.unwrap_err();
1529 assert!(err.to_string().contains("requires"));
1530 }
1531
1532 #[test]
1533 fn test_unknown_resolver() {
1534 let registry = ResolverRegistry::with_builtins();
1535 let ctx = ResolverContext::new("test.path");
1536
1537 let result = registry.resolve("unknown_resolver", &[], &HashMap::new(), &ctx);
1538 assert!(result.is_err());
1539 let err = result.unwrap_err();
1540 assert!(err.to_string().contains("unknown_resolver"));
1541 }
1542
1543 #[test]
1544 fn test_resolved_value_from_traits() {
1545 let from_value: ResolvedValue = Value::String("test".to_string()).into();
1546 assert_eq!(from_value.value.as_str(), Some("test"));
1547 assert!(!from_value.sensitive);
1548
1549 let from_string: ResolvedValue = "hello".to_string().into();
1550 assert_eq!(from_string.value.as_str(), Some("hello"));
1551
1552 let from_str: ResolvedValue = "world".into();
1553 assert_eq!(from_str.value.as_str(), Some("world"));
1554 }
1555
1556 #[test]
1557 fn test_resolver_context_with_base_path() {
1558 let ctx = ResolverContext::new("test").with_base_path(std::path::PathBuf::from("/tmp"));
1559 assert_eq!(ctx.base_path, Some(std::path::PathBuf::from("/tmp")));
1560 }
1561
1562 #[test]
1563 fn test_resolver_context_with_config_root() {
1564 use std::sync::Arc;
1565 let root = Arc::new(Value::String("root".to_string()));
1566 let ctx = ResolverContext::new("test").with_config_root(root.clone());
1567 assert!(ctx.config_root.is_some());
1568 }
1569
1570 #[test]
1571 fn test_resolver_context_resolution_chain() {
1572 let mut ctx = ResolverContext::new("root");
1573 ctx.push_resolution("a");
1574 ctx.push_resolution("b");
1575 ctx.push_resolution("c");
1576
1577 let chain = ctx.get_resolution_chain();
1578 assert_eq!(chain, vec!["a", "b", "c"]);
1579 }
1580
1581 #[test]
1582 fn test_registry_get_resolver() {
1583 let registry = ResolverRegistry::with_builtins();
1584
1585 let env_resolver = registry.get("env");
1586 assert!(env_resolver.is_some());
1587 assert_eq!(env_resolver.unwrap().name(), "env");
1588
1589 let missing = registry.get("nonexistent");
1590 assert!(missing.is_none());
1591 }
1592
1593 #[test]
1594 fn test_registry_default() {
1595 let registry = ResolverRegistry::default();
1596 assert!(!registry.contains("env"));
1598 }
1599
1600 #[test]
1601 fn test_fn_resolver_name() {
1602 let resolver = FnResolver::new("my_resolver", |_, _, _| Ok(ResolvedValue::new("test")));
1603 assert_eq!(resolver.name(), "my_resolver");
1604 }
1605
1606 #[test]
1607 fn test_file_resolver_json() {
1608 use std::io::Write;
1609
1610 let temp_dir = std::env::temp_dir();
1612 let test_file = temp_dir.join("holoconf_test.json");
1613 {
1614 let mut file = std::fs::File::create(&test_file).unwrap();
1615 writeln!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
1616 }
1617
1618 let mut ctx = ResolverContext::new("test.path");
1619 ctx.base_path = Some(temp_dir.clone());
1620 ctx.file_roots.insert(temp_dir.clone());
1621
1622 let args = vec!["holoconf_test.json".to_string()];
1623 let kwargs = HashMap::new();
1624
1625 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1626 assert!(result.value.is_mapping());
1627
1628 std::fs::remove_file(test_file).ok();
1630 }
1631
1632 #[test]
1633 fn test_file_resolver_absolute_path() {
1634 use std::io::Write;
1635
1636 let temp_dir = std::env::temp_dir();
1638 let test_file = temp_dir.join("holoconf_abs_test.txt");
1639 {
1640 let mut file = std::fs::File::create(&test_file).unwrap();
1641 writeln!(file, "absolute path content").unwrap();
1642 }
1643
1644 let mut ctx = ResolverContext::new("test.path");
1645 ctx.file_roots.insert(temp_dir.clone());
1646 let args = vec![test_file.to_string_lossy().to_string()];
1648 let mut kwargs = HashMap::new();
1649 kwargs.insert("parse".to_string(), "text".to_string());
1650
1651 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1652 assert!(result
1653 .value
1654 .as_str()
1655 .unwrap()
1656 .contains("absolute path content"));
1657
1658 std::fs::remove_file(test_file).ok();
1660 }
1661
1662 #[test]
1663 fn test_file_resolver_invalid_yaml() {
1664 use std::io::Write;
1665
1666 let temp_dir = std::env::temp_dir();
1668 let test_file = temp_dir.join("holoconf_invalid.yaml");
1669 {
1670 let mut file = std::fs::File::create(&test_file).unwrap();
1671 writeln!(file, "key: [invalid").unwrap();
1672 }
1673
1674 let mut ctx = ResolverContext::new("test.path");
1675 ctx.base_path = Some(temp_dir.clone());
1676 ctx.file_roots.insert(temp_dir.clone());
1677
1678 let args = vec!["holoconf_invalid.yaml".to_string()];
1679 let kwargs = HashMap::new();
1680
1681 let result = file_resolver(&args, &kwargs, &ctx);
1682 assert!(result.is_err());
1683 let err = result.unwrap_err();
1684 assert!(err.to_string().contains("parse") || err.to_string().contains("YAML"));
1685
1686 std::fs::remove_file(test_file).ok();
1688 }
1689
1690 #[test]
1691 fn test_file_resolver_invalid_json() {
1692 use std::io::Write;
1693
1694 let temp_dir = std::env::temp_dir();
1696 let test_file = temp_dir.join("holoconf_invalid.json");
1697 {
1698 let mut file = std::fs::File::create(&test_file).unwrap();
1699 writeln!(file, "{{invalid json}}").unwrap();
1700 }
1701
1702 let mut ctx = ResolverContext::new("test.path");
1703 ctx.base_path = Some(temp_dir.clone());
1704 ctx.file_roots.insert(temp_dir.clone());
1705
1706 let args = vec!["holoconf_invalid.json".to_string()];
1707 let kwargs = HashMap::new();
1708
1709 let result = file_resolver(&args, &kwargs, &ctx);
1710 assert!(result.is_err());
1711 let err = result.unwrap_err();
1712 assert!(err.to_string().contains("parse") || err.to_string().contains("JSON"));
1713
1714 std::fs::remove_file(test_file).ok();
1716 }
1717
1718 #[test]
1719 fn test_file_resolver_unknown_extension() {
1720 use std::io::Write;
1721
1722 let temp_dir = std::env::temp_dir();
1724 let test_file = temp_dir.join("holoconf_test.xyz");
1725 {
1726 let mut file = std::fs::File::create(&test_file).unwrap();
1727 writeln!(file, "plain text content").unwrap();
1728 }
1729
1730 let mut ctx = ResolverContext::new("test.path");
1731 ctx.base_path = Some(temp_dir.clone());
1732 ctx.file_roots.insert(temp_dir.clone());
1733
1734 let args = vec!["holoconf_test.xyz".to_string()];
1735 let kwargs = HashMap::new();
1736
1737 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1738 assert!(result
1740 .value
1741 .as_str()
1742 .unwrap()
1743 .contains("plain text content"));
1744
1745 std::fs::remove_file(test_file).ok();
1747 }
1748
1749 #[test]
1750 fn test_file_resolver_encoding_utf8() {
1751 use std::io::Write;
1752
1753 let temp_dir = std::env::temp_dir();
1755 let test_file = temp_dir.join("holoconf_utf8.txt");
1756 {
1757 let mut file = std::fs::File::create(&test_file).unwrap();
1758 writeln!(file, "Hello, 世界! 🌍").unwrap();
1759 }
1760
1761 let mut ctx = ResolverContext::new("test.path");
1762 ctx.base_path = Some(temp_dir.clone());
1763 ctx.file_roots.insert(temp_dir.clone());
1764
1765 let args = vec!["holoconf_utf8.txt".to_string()];
1766 let mut kwargs = HashMap::new();
1767 kwargs.insert("encoding".to_string(), "utf-8".to_string());
1768
1769 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1770 let content = result.value.as_str().unwrap();
1771 assert!(content.contains("世界"));
1772 assert!(content.contains("🌍"));
1773
1774 std::fs::remove_file(test_file).ok();
1776 }
1777
1778 #[test]
1779 fn test_file_resolver_encoding_ascii() {
1780 use std::io::Write;
1781
1782 let temp_dir = std::env::temp_dir();
1784 let test_file = temp_dir.join("holoconf_ascii.txt");
1785 {
1786 let mut file = std::fs::File::create(&test_file).unwrap();
1787 writeln!(file, "Hello, 世界! Welcome").unwrap();
1788 }
1789
1790 let mut ctx = ResolverContext::new("test.path");
1791 ctx.base_path = Some(temp_dir.clone());
1792 ctx.file_roots.insert(temp_dir.clone());
1793
1794 let args = vec!["holoconf_ascii.txt".to_string()];
1795 let mut kwargs = HashMap::new();
1796 kwargs.insert("encoding".to_string(), "ascii".to_string());
1797
1798 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1799 let content = result.value.as_str().unwrap();
1800 assert!(content.contains("Hello"));
1802 assert!(content.contains("Welcome"));
1803 assert!(!content.contains("世界"));
1804
1805 std::fs::remove_file(test_file).ok();
1807 }
1808
1809 #[test]
1810 fn test_file_resolver_encoding_base64() {
1811 use std::io::Write;
1812
1813 let temp_dir = std::env::temp_dir();
1815 let test_file = temp_dir.join("holoconf_binary.bin");
1816 {
1817 let mut file = std::fs::File::create(&test_file).unwrap();
1818 file.write_all(b"Hello\x00\x01\x02World").unwrap();
1820 }
1821
1822 let mut ctx = ResolverContext::new("test.path");
1823 ctx.base_path = Some(temp_dir.clone());
1824 ctx.file_roots.insert(temp_dir.clone());
1825
1826 let args = vec!["holoconf_binary.bin".to_string()];
1827 let mut kwargs = HashMap::new();
1828 kwargs.insert("encoding".to_string(), "base64".to_string());
1829
1830 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1831 let content = result.value.as_str().unwrap();
1832
1833 use base64::{engine::general_purpose::STANDARD, Engine as _};
1835 let expected = STANDARD.encode(b"Hello\x00\x01\x02World");
1836 assert_eq!(content, expected);
1837
1838 std::fs::remove_file(test_file).ok();
1840 }
1841
1842 #[test]
1843 fn test_file_resolver_encoding_default_is_utf8() {
1844 use std::io::Write;
1845
1846 let temp_dir = std::env::temp_dir();
1848 let test_file = temp_dir.join("holoconf_default_enc.txt");
1849 {
1850 let mut file = std::fs::File::create(&test_file).unwrap();
1851 writeln!(file, "café résumé").unwrap();
1852 }
1853
1854 let mut ctx = ResolverContext::new("test.path");
1855 ctx.base_path = Some(temp_dir.clone());
1856 ctx.file_roots.insert(temp_dir.clone());
1857
1858 let args = vec!["holoconf_default_enc.txt".to_string()];
1859 let kwargs = HashMap::new(); let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1862 let content = result.value.as_str().unwrap();
1863 assert!(content.contains("café"));
1865 assert!(content.contains("résumé"));
1866
1867 std::fs::remove_file(test_file).ok();
1869 }
1870
1871 #[test]
1872 fn test_file_resolver_encoding_binary() {
1873 use std::io::Write;
1874
1875 let temp_dir = std::env::temp_dir();
1877 let test_file = temp_dir.join("holoconf_binary_bytes.bin");
1878 let binary_data: Vec<u8> = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x01, 0x02, 0xFF, 0xFE];
1879 {
1880 let mut file = std::fs::File::create(&test_file).unwrap();
1881 file.write_all(&binary_data).unwrap();
1882 }
1883
1884 let mut ctx = ResolverContext::new("test.path");
1885 ctx.base_path = Some(temp_dir.clone());
1886 ctx.file_roots.insert(temp_dir.clone());
1887
1888 let args = vec!["holoconf_binary_bytes.bin".to_string()];
1889 let mut kwargs = HashMap::new();
1890 kwargs.insert("encoding".to_string(), "binary".to_string());
1891
1892 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1893
1894 assert!(result.value.is_bytes());
1896 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
1897
1898 std::fs::remove_file(test_file).ok();
1900 }
1901
1902 #[test]
1903 fn test_file_resolver_encoding_binary_empty() {
1904 let temp_dir = std::env::temp_dir();
1906 let test_file = temp_dir.join("holoconf_binary_empty.bin");
1907 {
1908 std::fs::File::create(&test_file).unwrap();
1909 }
1910
1911 let mut ctx = ResolverContext::new("test.path");
1912 ctx.base_path = Some(temp_dir.clone());
1913 ctx.file_roots.insert(temp_dir.clone());
1914
1915 let args = vec!["holoconf_binary_empty.bin".to_string()];
1916 let mut kwargs = HashMap::new();
1917 kwargs.insert("encoding".to_string(), "binary".to_string());
1918
1919 let result = file_resolver(&args, &kwargs, &ctx).unwrap();
1920
1921 assert!(result.value.is_bytes());
1923 let empty: &[u8] = &[];
1924 assert_eq!(result.value.as_bytes().unwrap(), empty);
1925
1926 std::fs::remove_file(test_file).ok();
1928 }
1929
1930 #[test]
1933 fn test_file_resolver_with_sensitive() {
1934 use std::io::Write;
1935
1936 let temp_dir = std::env::temp_dir();
1938 let test_file = temp_dir.join("holoconf_sensitive_test.txt");
1939 {
1940 let mut file = std::fs::File::create(&test_file).unwrap();
1941 writeln!(file, "secret content").unwrap();
1942 }
1943
1944 let registry = ResolverRegistry::with_builtins();
1945 let mut ctx = ResolverContext::new("test.path");
1946 ctx.base_path = Some(temp_dir.clone());
1947 ctx.file_roots.insert(temp_dir.clone());
1948
1949 let args = vec!["holoconf_sensitive_test.txt".to_string()];
1950 let mut kwargs = HashMap::new();
1951 kwargs.insert("sensitive".to_string(), "true".to_string());
1952
1953 let result = registry.resolve("file", &args, &kwargs, &ctx).unwrap();
1955 assert!(result.value.as_str().unwrap().contains("secret content"));
1956 assert!(result.sensitive);
1957
1958 std::fs::remove_file(test_file).ok();
1960 }
1961
1962 #[test]
1963 fn test_framework_sensitive_kwarg_not_passed_to_resolver() {
1964 let mut registry = ResolverRegistry::new();
1967
1968 registry.register_fn("test_kwargs", |_args, kwargs, _ctx| {
1970 assert!(
1972 !kwargs.contains_key("sensitive"),
1973 "sensitive kwarg should not be passed to resolver"
1974 );
1975 if let Some(custom) = kwargs.get("custom") {
1977 Ok(ResolvedValue::new(Value::String(format!(
1978 "custom={}",
1979 custom
1980 ))))
1981 } else {
1982 Ok(ResolvedValue::new(Value::String("no custom".to_string())))
1983 }
1984 });
1985
1986 let ctx = ResolverContext::new("test.path");
1987 let args = vec![];
1988 let mut kwargs = HashMap::new();
1989 kwargs.insert("sensitive".to_string(), "true".to_string());
1990 kwargs.insert("custom".to_string(), "myvalue".to_string());
1991
1992 let result = registry
1993 .resolve("test_kwargs", &args, &kwargs, &ctx)
1994 .unwrap();
1995 assert_eq!(result.value.as_str(), Some("custom=myvalue"));
1996 assert!(result.sensitive);
1998 }
1999}
2000
2001#[cfg(test)]
2003mod global_registry_tests {
2004 use super::*;
2005
2006 fn mock_resolver(name: &str) -> Arc<dyn Resolver> {
2008 Arc::new(FnResolver::new(name, |_, _, _| {
2009 Ok(ResolvedValue::new("mock"))
2010 }))
2011 }
2012
2013 #[test]
2014 fn test_register_new_resolver_succeeds() {
2015 let mut registry = ResolverRegistry::new();
2016 let resolver = mock_resolver("test_new");
2017
2018 let result = registry.register_with_force(resolver, false);
2020 assert!(result.is_ok());
2021 assert!(registry.contains("test_new"));
2022 }
2023
2024 #[test]
2025 fn test_register_duplicate_errors_without_force() {
2026 let mut registry = ResolverRegistry::new();
2027 let resolver1 = mock_resolver("test_dup");
2028 let resolver2 = mock_resolver("test_dup");
2029
2030 registry.register_with_force(resolver1, false).unwrap();
2032
2033 let result = registry.register_with_force(resolver2, false);
2035 assert!(result.is_err());
2036 let err = result.unwrap_err();
2037 assert!(err.to_string().contains("already registered"));
2038 }
2039
2040 #[test]
2041 fn test_register_duplicate_succeeds_with_force() {
2042 let mut registry = ResolverRegistry::new();
2043 let resolver1 = mock_resolver("test_force");
2044 let resolver2 = mock_resolver("test_force");
2045
2046 registry.register_with_force(resolver1, false).unwrap();
2048
2049 let result = registry.register_with_force(resolver2, true);
2051 assert!(result.is_ok());
2052 }
2053
2054 #[test]
2055 fn test_global_registry_is_singleton() {
2056 let registry1 = global_registry();
2058 let registry2 = global_registry();
2059
2060 assert!(std::ptr::eq(registry1, registry2));
2062 }
2063
2064 #[test]
2065 fn test_register_global_new_resolver() {
2066 let resolver = mock_resolver("global_test_unique_42");
2068 let result = register_global(resolver, false);
2069 assert!(result.is_ok() || result.is_err());
2072 }
2073}
2074
2075#[cfg(test)]
2077mod lazy_resolution_tests {
2078 use super::*;
2079 use crate::Config;
2080 use std::sync::atomic::{AtomicBool, Ordering};
2081 use std::sync::Arc;
2082
2083 #[test]
2084 fn test_default_not_resolved_when_main_value_exists() {
2085 let fail_called = Arc::new(AtomicBool::new(false));
2087 let fail_called_clone = fail_called.clone();
2088
2089 let yaml = r#"
2091value: ${env:HOLOCONF_LAZY_TEST_VAR,default=${fail:should_not_be_called}}
2092"#;
2093 std::env::set_var("HOLOCONF_LAZY_TEST_VAR", "main_value");
2095
2096 let mut config = Config::from_yaml(yaml).unwrap();
2097
2098 config.register_resolver(Arc::new(FnResolver::new(
2100 "fail",
2101 move |_args, _kwargs, _ctx| {
2102 fail_called_clone.store(true, Ordering::SeqCst);
2103 panic!("fail resolver should not have been called - lazy resolution failed!");
2104 },
2105 )));
2106
2107 let result = config.get("value").unwrap();
2109 assert_eq!(result.as_str(), Some("main_value"));
2110
2111 assert!(
2113 !fail_called.load(Ordering::SeqCst),
2114 "The default resolver should not have been called when main value exists"
2115 );
2116
2117 std::env::remove_var("HOLOCONF_LAZY_TEST_VAR");
2118 }
2119
2120 #[test]
2121 fn test_default_is_resolved_when_main_value_missing() {
2122 let default_called = Arc::new(AtomicBool::new(false));
2124 let default_called_clone = default_called.clone();
2125
2126 let yaml = r#"
2128value: ${env:HOLOCONF_LAZY_MISSING_VAR,default=${custom_default:fallback}}
2129"#;
2130 std::env::remove_var("HOLOCONF_LAZY_MISSING_VAR");
2131
2132 let mut config = Config::from_yaml(yaml).unwrap();
2133
2134 config.register_resolver(Arc::new(FnResolver::new(
2136 "custom_default",
2137 move |args: &[String], _kwargs, _ctx| {
2138 default_called_clone.store(true, Ordering::SeqCst);
2139 let arg = args.first().cloned().unwrap_or_default();
2140 Ok(ResolvedValue::new(Value::String(format!(
2141 "default_was_{}",
2142 arg
2143 ))))
2144 },
2145 )));
2146
2147 let result = config.get("value").unwrap();
2149 assert_eq!(result.as_str(), Some("default_was_fallback"));
2150
2151 assert!(
2153 default_called.load(Ordering::SeqCst),
2154 "The default resolver should have been called when main value is missing"
2155 );
2156 }
2157}
2158
2159#[cfg(all(test, feature = "http"))]
2161mod http_resolver_tests {
2162 use super::*;
2163 use mockito::Server;
2164
2165 #[test]
2166 fn test_http_fetch_json() {
2167 let mut server = Server::new();
2168 let mock = server
2169 .mock("GET", "/config.json")
2170 .with_status(200)
2171 .with_header("content-type", "application/json")
2172 .with_body(r#"{"key": "value", "number": 42}"#)
2173 .create();
2174
2175 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2176 let args = vec![format!("{}/config.json", server.url())];
2177 let kwargs = HashMap::new();
2178
2179 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2180 assert!(result.value.is_mapping());
2181
2182 mock.assert();
2183 }
2184
2185 #[test]
2186 fn test_http_fetch_yaml() {
2187 let mut server = Server::new();
2188 let mock = server
2189 .mock("GET", "/config.yaml")
2190 .with_status(200)
2191 .with_header("content-type", "application/yaml")
2192 .with_body("key: value\nnumber: 42")
2193 .create();
2194
2195 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2196 let args = vec![format!("{}/config.yaml", server.url())];
2197 let kwargs = HashMap::new();
2198
2199 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2200 assert!(result.value.is_mapping());
2201
2202 mock.assert();
2203 }
2204
2205 #[test]
2206 fn test_http_fetch_text() {
2207 let mut server = Server::new();
2208 let mock = server
2209 .mock("GET", "/data.txt")
2210 .with_status(200)
2211 .with_header("content-type", "text/plain")
2212 .with_body("Hello, World!")
2213 .create();
2214
2215 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2216 let args = vec![format!("{}/data.txt", server.url())];
2217 let kwargs = HashMap::new();
2218
2219 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2220 assert_eq!(result.value.as_str(), Some("Hello, World!"));
2221
2222 mock.assert();
2223 }
2224
2225 #[test]
2226 fn test_http_fetch_binary() {
2227 let mut server = Server::new();
2228 let binary_data = vec![0x00, 0x01, 0x02, 0xFF, 0xFE];
2229 let mock = server
2230 .mock("GET", "/data.bin")
2231 .with_status(200)
2232 .with_header("content-type", "application/octet-stream")
2233 .with_body(binary_data.clone())
2234 .create();
2235
2236 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2237 let args = vec![format!("{}/data.bin", server.url())];
2238 let mut kwargs = HashMap::new();
2239 kwargs.insert("parse".to_string(), "binary".to_string());
2240
2241 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2242 assert!(result.value.is_bytes());
2243 assert_eq!(result.value.as_bytes().unwrap(), &binary_data);
2244
2245 mock.assert();
2246 }
2247
2248 #[test]
2249 fn test_http_fetch_explicit_parse_mode() {
2250 let mut server = Server::new();
2251 let mock = server
2253 .mock("GET", "/data")
2254 .with_status(200)
2255 .with_header("content-type", "text/plain")
2256 .with_body(r#"{"key": "value"}"#)
2257 .create();
2258
2259 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2260 let args = vec![format!("{}/data", server.url())];
2261 let mut kwargs = HashMap::new();
2262 kwargs.insert("parse".to_string(), "json".to_string());
2263
2264 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2265 assert!(result.value.is_mapping());
2266
2267 mock.assert();
2268 }
2269
2270 #[test]
2271 fn test_http_fetch_with_custom_header() {
2272 let mut server = Server::new();
2273 let mock = server
2274 .mock("GET", "/protected")
2275 .match_header("Authorization", "Bearer my-token")
2276 .with_status(200)
2277 .with_body("authorized content")
2278 .create();
2279
2280 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2281 let args = vec![format!("{}/protected", server.url())];
2282 let mut kwargs = HashMap::new();
2283 kwargs.insert(
2284 "header".to_string(),
2285 "Authorization:Bearer my-token".to_string(),
2286 );
2287
2288 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2289 assert_eq!(result.value.as_str(), Some("authorized content"));
2290
2291 mock.assert();
2292 }
2293
2294 #[test]
2295 fn test_http_fetch_404_error() {
2296 let mut server = Server::new();
2297 let mock = server.mock("GET", "/notfound").with_status(404).create();
2298
2299 let ctx = ResolverContext::new("test.path").with_allow_http(true);
2300 let args = vec![format!("{}/notfound", server.url())];
2301 let kwargs = HashMap::new();
2302
2303 let result = http_resolver(&args, &kwargs, &ctx);
2304 assert!(result.is_err());
2305 let err = result.unwrap_err();
2306 assert!(err.to_string().contains("HTTP"));
2307
2308 mock.assert();
2309 }
2310
2311 #[test]
2312 fn test_http_disabled_by_default() {
2313 let ctx = ResolverContext::new("test.path");
2314 let args = vec!["https://example.com/config.yaml".to_string()];
2316 let kwargs = HashMap::new();
2317
2318 let result = http_resolver(&args, &kwargs, &ctx);
2319 assert!(result.is_err());
2320 let err = result.unwrap_err();
2321 assert!(err.to_string().contains("disabled"));
2322 }
2323
2324 #[test]
2325 fn test_http_allowlist_blocks_url() {
2326 let ctx = ResolverContext::new("test.path")
2327 .with_allow_http(true)
2328 .with_http_allowlist(vec!["https://allowed.example.com/*".to_string()]);
2329
2330 let args = vec!["https://blocked.example.com/config.yaml".to_string()];
2331 let kwargs = HashMap::new();
2332
2333 let result = http_resolver(&args, &kwargs, &ctx);
2334 assert!(result.is_err());
2335 let err = result.unwrap_err();
2336 assert!(
2337 err.to_string().contains("not in allowlist")
2338 || err.to_string().contains("HttpNotAllowed")
2339 );
2340 }
2341
2342 #[test]
2343 fn test_http_allowlist_allows_matching_url() {
2344 let mut server = Server::new();
2345 let mock = server
2346 .mock("GET", "/config.yaml")
2347 .with_status(200)
2348 .with_body("key: value")
2349 .create();
2350
2351 let server_url = server.url();
2353 let ctx = ResolverContext::new("test.path")
2354 .with_allow_http(true)
2355 .with_http_allowlist(vec![format!("{}/*", server_url)]);
2356
2357 let args = vec![format!("{}/config.yaml", server_url)];
2358 let kwargs = HashMap::new();
2359
2360 let result = http_resolver(&args, &kwargs, &ctx).unwrap();
2361 assert!(result.value.is_mapping());
2362
2363 mock.assert();
2364 }
2365
2366 #[test]
2367 fn test_url_matches_pattern_exact() {
2368 assert!(url_matches_pattern(
2369 "https://example.com/config.yaml",
2370 "https://example.com/config.yaml"
2371 ));
2372 assert!(!url_matches_pattern(
2373 "https://example.com/other.yaml",
2374 "https://example.com/config.yaml"
2375 ));
2376 }
2377
2378 #[test]
2379 fn test_url_matches_pattern_wildcard() {
2380 assert!(url_matches_pattern(
2381 "https://example.com/config.yaml",
2382 "https://example.com/*"
2383 ));
2384 assert!(url_matches_pattern(
2385 "https://example.com/path/to/config.yaml",
2386 "https://example.com/*"
2387 ));
2388 assert!(!url_matches_pattern(
2389 "https://other.com/config.yaml",
2390 "https://example.com/*"
2391 ));
2392 }
2393
2394 #[test]
2395 fn test_url_matches_pattern_subdomain() {
2396 assert!(url_matches_pattern(
2397 "https://api.example.com/config",
2398 "https://*.example.com/*"
2399 ));
2400 assert!(url_matches_pattern(
2401 "https://staging.example.com/config",
2402 "https://*.example.com/*"
2403 ));
2404 assert!(!url_matches_pattern(
2405 "https://example.com/config",
2406 "https://*.example.com/*"
2407 ));
2408 }
2409
2410 #[test]
2411 fn test_detect_parse_mode_from_content_type() {
2412 assert_eq!(
2413 detect_parse_mode("http://example.com/data", "application/json"),
2414 "json"
2415 );
2416 assert_eq!(
2417 detect_parse_mode("http://example.com/data", "text/json"),
2418 "json"
2419 );
2420 assert_eq!(
2421 detect_parse_mode("http://example.com/data", "application/yaml"),
2422 "yaml"
2423 );
2424 assert_eq!(
2425 detect_parse_mode("http://example.com/data", "application/x-yaml"),
2426 "yaml"
2427 );
2428 assert_eq!(
2429 detect_parse_mode("http://example.com/data", "text/yaml"),
2430 "yaml"
2431 );
2432 assert_eq!(
2433 detect_parse_mode("http://example.com/data", "text/plain"),
2434 "text"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_detect_parse_mode_from_url_extension() {
2440 assert_eq!(
2441 detect_parse_mode("http://example.com/config.json", ""),
2442 "json"
2443 );
2444 assert_eq!(
2445 detect_parse_mode("http://example.com/config.yaml", ""),
2446 "yaml"
2447 );
2448 assert_eq!(
2449 detect_parse_mode("http://example.com/config.yml", ""),
2450 "yaml"
2451 );
2452 assert_eq!(
2453 detect_parse_mode("http://example.com/config.txt", ""),
2454 "text"
2455 );
2456 assert_eq!(detect_parse_mode("http://example.com/config", ""), "text");
2457 }
2458
2459 #[test]
2460 fn test_detect_parse_mode_content_type_takes_precedence() {
2461 assert_eq!(
2463 detect_parse_mode("http://example.com/config.yaml", "application/json"),
2464 "json"
2465 );
2466 }
2467}