#[cfg(any(feature = "toml", feature = "yaml"))]
use crate::error::ParseLocation;
use crate::error::{ConfigError, ConfigResult};
use crate::types::{AnnotatedValue, SourceId};
use std::path::{Component, Path, PathBuf};
#[cfg(feature = "ini")]
use crate::types::ConfigValue;
#[cfg(feature = "ini")]
use std::sync::Arc;
#[cfg(feature = "json")]
use super::convert::json_to_config_value;
#[cfg(feature = "toml")]
use super::convert::toml_table_to_config_value;
#[cfg(feature = "yaml")]
use super::convert::yaml_to_config_value;
const DEFAULT_MAX_SIZE: usize = 10 * 1024 * 1024;
const DEFAULT_ALLOWED_BASE_DIRS: &[&str] = &["."];
const MAX_PATH_LENGTH: usize = 4096;
#[derive(Debug, Clone)]
pub struct LoaderConfig {
pub max_size: usize,
pub allowed_base_dirs: Vec<PathBuf>,
pub allow_absolute: bool,
pub check_symlinks: bool,
}
impl Default for LoaderConfig {
fn default() -> Self {
Self {
max_size: DEFAULT_MAX_SIZE,
allowed_base_dirs: DEFAULT_ALLOWED_BASE_DIRS
.iter()
.map(PathBuf::from)
.collect(),
allow_absolute: false,
check_symlinks: true,
}
}
}
impl LoaderConfig {
pub fn new() -> Self {
Self::default()
}
pub fn max_size(mut self, size: usize) -> Self {
self.max_size = size;
self
}
pub fn add_allowed_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.allowed_base_dirs.push(dir.into());
self
}
pub fn allowed_dirs(mut self, dirs: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
self.allowed_base_dirs = dirs.into_iter().map(|d| d.into()).collect();
self
}
pub fn allow_absolute(mut self) -> Self {
self.allow_absolute = true;
self
}
pub fn no_symlink_check(mut self) -> Self {
self.check_symlinks = false;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathTraversalError {
TooLong,
AbsolutePath,
ParentDirectoryReference,
InvalidComponent,
EncodedTraversal,
NotFound,
CurrentDirUnavailable,
OutsideAllowedDirectory,
SymlinkTraversal,
IoError(String),
}
impl std::fmt::Display for PathTraversalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLong => write!(
f,
"Path exceeds maximum length of {} bytes",
MAX_PATH_LENGTH
),
Self::AbsolutePath => write!(f, "Absolute paths are not allowed for security reasons"),
Self::ParentDirectoryReference => {
write!(f, "Parent directory references (..) are not allowed")
}
Self::InvalidComponent => write!(f, "Invalid path component detected"),
Self::EncodedTraversal => write!(f, "URL-encoded directory traversal pattern detected"),
Self::NotFound => write!(f, "The specified path does not exist"),
Self::CurrentDirUnavailable => write!(f, "Cannot determine the current directory"),
Self::OutsideAllowedDirectory => {
write!(f, "Path resolves outside the allowed directories")
}
Self::SymlinkTraversal => write!(f, "Symlink resolves outside the allowed directories"),
Self::IoError(msg) => write!(f, "IO error during path validation: {}", msg),
}
}
}
impl std::error::Error for PathTraversalError {}
pub fn check_path_traversal_attempt(path_str: &str) -> bool {
if path_str.len() > MAX_PATH_LENGTH {
return false;
}
let lower = path_str.to_lowercase();
if lower.contains("%2e")
|| lower.contains("%252e")
|| lower.contains("%5c")
|| lower.contains("%255c")
{
return false;
}
if lower.contains("%2e.") || lower.contains(".%2e") {
return false;
}
true
}
fn check_path_components(path: &Path) -> Result<(), PathTraversalError> {
for component in path.components() {
match component {
Component::ParentDir => {
return Err(PathTraversalError::ParentDirectoryReference);
}
Component::Prefix(_) => {
return Err(PathTraversalError::InvalidComponent);
}
Component::RootDir | Component::CurDir | Component::Normal(_) => {}
}
}
Ok(())
}
pub fn normalize_and_validate_path(
path: &Path,
allowed_dirs: &[PathBuf],
allow_absolute: bool,
check_symlinks: bool,
) -> Result<PathBuf, PathTraversalError> {
let path_str = path.to_string_lossy();
if !check_path_traversal_attempt(&path_str) {
return Err(PathTraversalError::EncodedTraversal);
}
check_path_components(path)?;
if path.is_absolute() {
if !allow_absolute {
return Err(PathTraversalError::AbsolutePath);
}
let canonical =
std::fs::canonicalize(path).map_err(|e| PathTraversalError::IoError(e.to_string()))?;
return Ok(canonical);
}
let current_dir =
std::env::current_dir().map_err(|_| PathTraversalError::CurrentDirUnavailable)?;
let full_path = current_dir.join(path);
if check_symlinks {
let canonical = full_path.canonicalize().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => PathTraversalError::NotFound,
_ => PathTraversalError::IoError(e.to_string()),
})?;
let is_allowed = if allowed_dirs.is_empty() {
canonical.starts_with(¤t_dir) || canonical == current_dir
} else {
allowed_dirs.iter().any(|dir| {
let allowed = if dir.is_absolute() {
dir.clone()
} else {
current_dir
.join(dir)
.canonicalize()
.unwrap_or_else(|_| current_dir.join(dir))
};
canonical.starts_with(&allowed) || canonical == allowed
})
};
if !is_allowed {
return Err(PathTraversalError::SymlinkTraversal);
}
Ok(canonical)
} else {
let mut normalized = PathBuf::new();
for component in full_path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
normalized = component.as_os_str().into();
}
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
Component::Normal(s) => {
normalized.push(s);
}
}
}
if normalized.is_absolute() {
if !allow_absolute {
return Err(PathTraversalError::AbsolutePath);
}
} else if !normalized.starts_with(¤t_dir) {
return Err(PathTraversalError::OutsideAllowedDirectory);
}
Ok(normalized)
}
}
pub fn validate_path_with_config(
path: &Path,
config: &LoaderConfig,
) -> Result<PathBuf, PathTraversalError> {
normalize_and_validate_path(
path,
&config.allowed_base_dirs,
config.allow_absolute,
config.check_symlinks,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Toml,
Json,
Yaml,
Ini,
}
impl std::fmt::Display for Format {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Format::Toml => write!(f, "TOML"),
Format::Json => write!(f, "JSON"),
Format::Yaml => write!(f, "YAML"),
Format::Ini => write!(f, "INI"),
}
}
}
impl Format {
pub const fn ext(&self) -> &'static str {
match self {
Format::Toml => "toml",
Format::Json => "json",
Format::Yaml => "yaml",
Format::Ini => "ini",
}
}
pub const fn all() -> &'static [Format] {
&[Format::Toml, Format::Json, Format::Yaml, Format::Ini]
}
}
impl std::str::FromStr for Format {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"toml" => Ok(Format::Toml),
"json" => Ok(Format::Json),
"yaml" | "yml" => Ok(Format::Yaml),
"ini" => Ok(Format::Ini),
_ => Err(()),
}
}
}
impl Format {
pub fn try_parse(s: &str) -> Option<Format> {
s.parse().ok()
}
}
pub fn detect_format_from_path(path: &Path) -> Option<Format> {
match path.extension()?.to_str()?.to_lowercase().as_str() {
"toml" => Some(Format::Toml),
"json" => Some(Format::Json),
"yaml" | "yml" => Some(Format::Yaml),
"ini" => Some(Format::Ini),
_ => None,
}
}
pub fn detect_format_from_content(content: &str) -> Option<Format> {
let trimmed = content.trim_start();
let first_char = trimmed.chars().next()?;
if first_char == '{' || first_char == '[' {
if trimmed.contains("\"") && (trimmed.contains(":") || trimmed.contains(",")) {
return Some(Format::Json);
}
}
if trimmed.starts_with("---") {
return Some(Format::Yaml);
}
if trimmed.contains(" = ") || trimmed.contains("=\t") {
return Some(Format::Toml);
}
if trimmed.contains(": ") {
if !trimmed.contains(" = ") && !trimmed.contains("{") {
return Some(Format::Yaml);
}
}
if trimmed.contains('[') && trimmed.contains(']') {
if trimmed.starts_with('[') {
return Some(Format::Ini);
}
}
None
}
pub fn load_file(path: &Path, config: &LoaderConfig) -> ConfigResult<AnnotatedValue> {
let validated_path =
validate_path_with_config(path, config).map_err(|e| ConfigError::InvalidValue {
key: "path".to_string(),
expected_type: "safe relative path".to_string(),
message: format!("Path validation failed: {}", e),
})?;
let metadata = std::fs::metadata(&validated_path).map_err(|e| ConfigError::FileNotFound {
filename: path.to_path_buf(),
source: Some(e),
})?;
if metadata.len() as usize > config.max_size {
return Err(ConfigError::SizeLimitExceeded {
actual: metadata.len() as usize,
limit: config.max_size,
});
}
let format =
detect_format_from_path(&validated_path).ok_or_else(|| ConfigError::ParseError {
format: "unknown".into(),
message: format!("Unknown extension: {:?}", validated_path.extension()),
location: None,
source: None,
})?;
let content = std::fs::read_to_string(&validated_path).map_err(ConfigError::IoError)?;
let source = SourceId::new(
validated_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown"),
);
parse_content(&content, format, source, Some(&validated_path))
}
pub fn parse_content(
content: &str,
format: Format,
source: SourceId,
path: Option<&Path>,
) -> ConfigResult<AnnotatedValue> {
match format {
Format::Toml => parse_toml(content, source, path),
Format::Json => parse_json(content, source, path),
Format::Yaml => parse_yaml(content, source, path),
Format::Ini => parse_ini(content, source, path),
}
}
#[cfg(feature = "toml")]
pub fn parse_toml(
content: &str,
source: SourceId,
path: Option<&Path>,
) -> ConfigResult<AnnotatedValue> {
use toml::de::Error as TomlError;
let table: toml::Table = toml::from_str(content).map_err(|e: TomlError| {
let location = e.span().map(|span| {
let line = content[..span.start].matches('\n').count() + 1;
let col = span.start
- content[..span.start]
.rfind('\n')
.map(|i| i + 1)
.unwrap_or(0)
+ 1;
path.map(|p| ParseLocation::from_path(p, line, col))
.unwrap_or_else(|| ParseLocation::new(source.as_str(), line, col))
});
ConfigError::ParseError {
format: "TOML".into(),
message: e.message().to_string(),
location,
source: Some(Box::new(e)),
}
})?;
Ok(AnnotatedValue::new(
toml_table_to_config_value(&table, &source, ""),
source,
"",
))
}
#[cfg(feature = "json")]
pub fn parse_json(
content: &str,
source: SourceId,
_: Option<&Path>,
) -> ConfigResult<AnnotatedValue> {
let v: serde_json::Value =
serde_json::from_str(content).map_err(|e| ConfigError::ParseError {
format: "JSON".into(),
message: e.to_string(),
location: None,
source: Some(Box::new(e)),
})?;
Ok(AnnotatedValue::new(
json_to_config_value(&v, &source, ""),
source,
"",
))
}
#[cfg(feature = "yaml")]
pub fn parse_yaml(
content: &str,
source: SourceId,
path: Option<&Path>,
) -> ConfigResult<AnnotatedValue> {
let v: serde_yaml_ng::Value = serde_yaml_ng::from_str(content).map_err(|e| {
let loc = e.location().map(|l| {
path.map(|p| ParseLocation::from_path(p, l.line(), l.column()))
.unwrap_or_else(|| ParseLocation::new(source.as_str(), l.line(), l.column()))
});
ConfigError::ParseError {
format: "YAML".into(),
message: e.to_string(),
location: loc,
source: Some(Box::new(e)),
}
})?;
Ok(AnnotatedValue::new(
yaml_to_config_value(&v, &source, ""),
source,
"",
))
}
#[cfg(not(feature = "toml"))]
pub fn parse_toml(_: &str, _: SourceId, _: Option<&Path>) -> ConfigResult<AnnotatedValue> {
Err(ConfigError::ParseError {
format: "TOML".into(),
message: "Add 'toml' feature".into(),
location: None,
source: None,
})
}
#[cfg(not(feature = "json"))]
pub fn parse_json(_: &str, _: SourceId, _: Option<&Path>) -> ConfigResult<AnnotatedValue> {
Err(ConfigError::ParseError {
format: "JSON".into(),
message: "Add 'json' feature".into(),
location: None,
source: None,
})
}
#[cfg(not(feature = "yaml"))]
pub fn parse_yaml(_: &str, _: SourceId, _: Option<&Path>) -> ConfigResult<AnnotatedValue> {
Err(ConfigError::ParseError {
format: "YAML".into(),
message: "Add 'yaml' feature".into(),
location: None,
source: None,
})
}
#[cfg(not(feature = "ini"))]
pub fn parse_ini(_: &str, _: SourceId, _: Option<&Path>) -> ConfigResult<AnnotatedValue> {
Err(ConfigError::ParseError {
format: "INI".into(),
message: "Add 'ini' feature".into(),
location: None,
source: None,
})
}
#[cfg(feature = "toml")]
pub fn parse_toml_table(table: &toml::Table, source: &SourceId, prefix: &str) -> AnnotatedValue {
AnnotatedValue::new(
toml_table_to_config_value(table, source, prefix),
source.clone(),
prefix,
)
}
#[cfg(feature = "json")]
pub fn parse_json_value(v: &serde_json::Value, source: &SourceId, prefix: &str) -> AnnotatedValue {
AnnotatedValue::new(
json_to_config_value(v, source, prefix),
source.clone(),
prefix,
)
}
#[cfg(feature = "yaml")]
pub fn parse_yaml_value(
v: &serde_yaml_ng::Value,
source: &SourceId,
prefix: &str,
) -> AnnotatedValue {
AnnotatedValue::new(
yaml_to_config_value(v, source, prefix),
source.clone(),
prefix,
)
}
#[cfg(feature = "ini")]
pub fn parse_ini(
content: &str,
source: SourceId,
_path: Option<&Path>,
) -> ConfigResult<AnnotatedValue> {
let mut sections: indexmap::IndexMap<String, indexmap::IndexMap<String, String>> =
indexmap::IndexMap::new();
let mut cur = String::new();
let mut invalid_lines = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let l = line.trim();
if l.is_empty() || l.starts_with('#') || l.starts_with(';') {
continue;
}
if l.starts_with('[') && l.ends_with(']') {
cur = l[1..l.len() - 1].trim().into();
sections.entry(cur.clone()).or_default();
continue;
}
if let Some(eq) = l.find('=') {
let key = l[..eq].trim();
let value = l[eq + 1..].trim();
if key.is_empty() {
invalid_lines.push((line_num + 1, line.to_string(), "empty key"));
continue;
}
sections
.entry(cur.clone())
.or_default()
.insert(key.into(), value.into());
continue;
}
invalid_lines.push((line_num + 1, line.to_string(), "invalid INI syntax"));
}
if !invalid_lines.is_empty() {
eprintln!(
"WARN: parse_ini skipped {} invalid line(s):",
invalid_lines.len()
);
for (line_num, line_text, reason) in &invalid_lines {
eprintln!(" line {}: {} — {}", line_num, line_text, reason);
}
}
let mut entries: Vec<(Arc<str>, AnnotatedValue)> = Vec::new();
for (sec, keys) in sections.iter() {
for (k, v) in keys.iter() {
let key = if sec.is_empty() {
k.clone()
} else {
format!("{}.{}", sec, k)
};
entries.push((
Arc::from(key.clone()),
AnnotatedValue::new(ConfigValue::String(v.clone()), source.clone(), key),
));
}
}
Ok(AnnotatedValue::new(ConfigValue::map(entries), source, ""))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_display() {
assert_eq!(Format::Toml.to_string(), "TOML");
assert_eq!(Format::Json.to_string(), "JSON");
assert_eq!(Format::Yaml.to_string(), "YAML");
}
#[test]
fn test_format_try_parse() {
assert_eq!(Format::try_parse("toml"), Some(Format::Toml));
assert_eq!(Format::try_parse("json"), Some(Format::Json));
assert_eq!(Format::try_parse("yaml"), Some(Format::Yaml));
assert_eq!(Format::try_parse("ini"), Some(Format::Ini));
assert_eq!(Format::try_parse("unknown"), None);
}
#[test]
fn test_format_ext() {
assert_eq!(Format::Toml.ext(), "toml");
assert_eq!(Format::Json.ext(), "json");
assert_eq!(Format::Yaml.ext(), "yaml");
assert_eq!(Format::Ini.ext(), "ini");
}
#[test]
fn test_format_all() {
let all = Format::all();
assert!(all.contains(&Format::Toml));
assert!(all.contains(&Format::Json));
assert!(all.contains(&Format::Yaml));
assert!(all.contains(&Format::Ini));
}
#[test]
fn test_detect_from_path_case_insensitive() {
assert_eq!(
detect_format_from_path(Path::new("config.TOML")),
Some(Format::Toml)
);
assert_eq!(
detect_format_from_path(Path::new("config.JSON")),
Some(Format::Json)
);
assert_eq!(
detect_format_from_path(Path::new("config.YAML")),
Some(Format::Yaml)
);
}
#[test]
fn test_detect_from_path_none() {
assert_eq!(detect_format_from_path(Path::new("config")), None);
assert_eq!(detect_format_from_path(Path::new("")), None);
}
#[test]
fn test_detect_format_from_path() {
assert_eq!(
detect_format_from_path(Path::new("a.toml")),
Some(Format::Toml)
);
assert_eq!(
detect_format_from_path(Path::new("a.json")),
Some(Format::Json)
);
}
#[test]
fn test_detect_format_from_content() {
assert_eq!(
detect_format_from_content(r#"{"k":"v"}"#),
Some(Format::Json)
);
assert_eq!(detect_format_from_content(r#"k = "v""#), Some(Format::Toml));
}
#[test]
#[cfg(feature = "toml")]
fn test_parse_toml() {
let r = parse_toml(
"\n[db]\nhost = \"localhost\"\nport = 5432\n",
SourceId::new("test"),
None,
);
assert!(r.is_ok(), "{:?}", r.err());
assert!(r.unwrap().is_map());
}
#[test]
#[cfg(feature = "json")]
fn test_parse_json() {
let r = parse_json(
r#"{"db":{"host":"localhost","port":5432}}"#,
SourceId::new("test"),
None,
);
assert!(r.is_ok(), "{:?}", r.err());
assert!(r.unwrap().is_map());
}
#[test]
#[cfg(feature = "yaml")]
fn test_parse_yaml() {
let r = parse_yaml(
"\ndb:\n host: localhost\n port: 5432\n",
SourceId::new("test"),
None,
);
assert!(r.is_ok(), "{:?}", r.err());
assert!(r.unwrap().is_map());
}
#[test]
#[cfg(feature = "toml")]
fn test_parse_toml_error() {
assert!(parse_toml("[section", SourceId::new("t"), None).is_err());
}
#[test]
fn test_loader_config_default() {
assert_eq!(LoaderConfig::default().max_size, DEFAULT_MAX_SIZE);
}
#[test]
fn test_check_path_traversal_attempt_rejects_encoded() {
assert!(!check_path_traversal_attempt("%2e%2e/etc/passwd"));
assert!(!check_path_traversal_attempt("%252e%252e/etc/passwd"));
assert!(!check_path_traversal_attempt("%2e./etc/passwd"));
assert!(!check_path_traversal_attempt("%2e%2e\\etc\\passwd"));
}
#[test]
fn test_check_path_traversal_attempt_accepts_normal() {
assert!(check_path_traversal_attempt("config/app.toml"));
assert!(check_path_traversal_attempt("configs/defaults.yaml"));
assert!(check_path_traversal_attempt("secrets/database.json"));
}
#[test]
fn test_check_path_traversal_attempt_rejects_too_long() {
let long = "a/".repeat(5000);
assert!(!check_path_traversal_attempt(&long));
}
#[test]
fn test_normalize_validates_parent_directory() {
let result = normalize_and_validate_path(
Path::new("../../../etc/passwd"),
&[PathBuf::from(".")],
false,
true,
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
PathTraversalError::ParentDirectoryReference
);
}
#[test]
fn test_normalize_rejects_absolute_path() {
let result = normalize_and_validate_path(
Path::new("/etc/passwd"),
&[PathBuf::from(".")],
false,
true,
);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::AbsolutePath);
}
#[test]
fn test_normalize_accepts_valid_relative_path() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("confers_test_config.toml");
std::fs::write(&test_file, "[test]\nkey = \"value\"").unwrap();
let _relative = format!(
"{}",
test_file
.strip_prefix(std::env::current_dir().unwrap().parent().unwrap())
.unwrap_or_else(|_| &test_file)
.display()
);
let result = normalize_and_validate_path(&test_file, &[], false, true);
if result.is_ok() {
} else if let Err(PathTraversalError::OutsideAllowedDirectory) = result {
}
let _ = std::fs::remove_file(test_file);
}
#[test]
fn test_loader_config_builder() {
let config = LoaderConfig::new()
.max_size(1024)
.add_allowed_dir("/etc/confers")
.add_allowed_dir("/run/confers")
.allow_absolute();
assert_eq!(config.max_size, 1024);
assert!(config.allow_absolute);
assert!(config.check_symlinks); assert_eq!(config.allowed_base_dirs.len(), 3); }
#[test]
fn test_loader_config_allowed_dirs() {
let config = LoaderConfig::new().allowed_dirs(["/configs", "/secrets"]);
assert_eq!(config.allowed_base_dirs.len(), 2);
assert!(!config.allow_absolute);
}
#[test]
fn test_loader_config_symlink_check() {
let config = LoaderConfig::new().no_symlink_check();
assert!(!config.check_symlinks);
}
#[test]
fn test_validate_path_with_config_rejects_traversal() {
let config = LoaderConfig::new();
let result = validate_path_with_config(Path::new("../../../etc/passwd"), &config);
assert!(result.is_err());
}
#[test]
fn test_loader_config_allows_normal_path() {
let config = LoaderConfig::new();
let result = validate_path_with_config(Path::new("Cargo.toml"), &config);
match result {
Ok(_) | Err(PathTraversalError::NotFound) | Err(PathTraversalError::IoError(_)) => {}
Err(e) => {
panic!("Unexpected error for normal path: {:?}", e);
}
}
}
#[cfg(feature = "toml")]
#[test]
fn test_parse_toml_content() {
let result = parse_toml("key = \"value\"", SourceId::new("test"), None);
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.is_map());
}
#[cfg(feature = "json")]
#[test]
fn test_parse_json_content() {
let result = parse_json("{\"key\": \"value\"}", SourceId::new("test"), None);
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.is_map());
}
#[cfg(feature = "yaml")]
#[test]
fn test_parse_yaml_content() {
let result = parse_yaml("key: value", SourceId::new("test"), None);
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.is_map());
}
#[cfg(feature = "toml")]
#[test]
fn test_parse_content_toml() {
let r = parse_content("name = \"test\"", Format::Toml, SourceId::new("t"), None);
assert!(r.is_ok());
}
#[cfg(feature = "json")]
#[test]
fn test_parse_content_json() {
let r = parse_content("{\"k\":\"v\"}", Format::Json, SourceId::new("t"), None);
assert!(r.is_ok());
}
#[test]
fn test_detect_format_toml_json_yaml() {
assert_eq!(detect_format_from_content("x = 1"), Some(Format::Toml));
assert_eq!(detect_format_from_content("{\"a\":1}"), Some(Format::Json));
assert_eq!(detect_format_from_content(""), None);
}
#[cfg(feature = "toml")]
#[test]
fn test_parse_toml_table() {
let mut table = toml::Table::new();
table.insert("key".to_string(), toml::Value::String("val".to_string()));
let result = parse_toml_table(&table, &SourceId::new("t"), "");
assert!(result.is_map());
}
#[test]
fn test_check_traversal_rejects_long_path() {
let long = "a".repeat(10000);
assert!(!check_path_traversal_attempt(&long));
}
#[test]
fn test_loader_config_allow_absolute() {
let config = LoaderConfig::new().allow_absolute();
assert!(config.allow_absolute);
}
#[test]
fn test_normalize_accepts_valid_relative() {
let allowed = vec![std::path::PathBuf::from(".")];
let r =
normalize_and_validate_path(std::path::Path::new("Cargo.toml"), &allowed, false, true);
assert!(r.is_ok() || matches!(r, Err(PathTraversalError::NotFound)));
}
#[test]
fn test_normalize_rejects_absolute_when_disallowed() {
let allowed = vec![std::path::PathBuf::from(".")];
let r =
normalize_and_validate_path(std::path::Path::new("/etc/passwd"), &allowed, false, true);
assert_eq!(r, Err(PathTraversalError::AbsolutePath));
}
#[test]
fn test_detect_format_toml_content() {
assert_eq!(detect_format_from_content("key = 1"), Some(Format::Toml));
}
#[test]
fn test_detect_format_json_content() {
assert_eq!(detect_format_from_content("{\"k\":1}"), Some(Format::Json));
}
#[test]
fn test_detect_format_yaml_content() {
let r = detect_format_from_content("key: value");
assert!(r.is_some());
}
#[test]
fn test_detect_format_empty_content() {
assert_eq!(detect_format_from_content(""), None);
assert_eq!(detect_format_from_content(" "), None);
}
#[test]
fn test_path_traversal_error_display_all_variants() {
assert!(!PathTraversalError::TooLong.to_string().is_empty());
assert!(!PathTraversalError::AbsolutePath.to_string().is_empty());
assert!(!PathTraversalError::ParentDirectoryReference
.to_string()
.is_empty());
assert!(!PathTraversalError::InvalidComponent.to_string().is_empty());
assert!(!PathTraversalError::EncodedTraversal.to_string().is_empty());
assert!(!PathTraversalError::NotFound.to_string().is_empty());
assert!(!PathTraversalError::CurrentDirUnavailable
.to_string()
.is_empty());
assert!(!PathTraversalError::OutsideAllowedDirectory
.to_string()
.is_empty());
assert!(!PathTraversalError::SymlinkTraversal.to_string().is_empty());
assert!(!PathTraversalError::IoError("disk error".to_string())
.to_string()
.is_empty());
}
#[test]
fn test_path_traversal_error_toolong_message_contains_max() {
let msg = PathTraversalError::TooLong.to_string();
assert!(msg.contains(&MAX_PATH_LENGTH.to_string()));
}
#[test]
fn test_path_traversal_error_clone_debug_eq() {
let e1 = PathTraversalError::TooLong;
let e2 = e1.clone();
assert_eq!(e1, e2);
assert!(!format!("{:?}", e1).is_empty());
let e3 = PathTraversalError::IoError("a".to_string());
let e4 = PathTraversalError::IoError("a".to_string());
assert_eq!(e3, e4);
assert_ne!(e1, e3);
}
#[test]
fn test_path_traversal_error_implements_std_error() {
fn check(err: &dyn std::error::Error) -> bool {
!err.to_string().is_empty()
}
assert!(check(&PathTraversalError::TooLong));
assert!(check(&PathTraversalError::IoError("x".to_string())));
assert!(check(&PathTraversalError::SymlinkTraversal));
}
#[test]
fn test_check_path_traversal_rejects_backslash_and_mixed_encoding() {
assert!(!check_path_traversal_attempt("%5c%5cwindows"));
assert!(!check_path_traversal_attempt("%255c%255c"));
assert!(!check_path_traversal_attempt(".%2e"));
}
#[test]
fn test_check_path_traversal_accepts_empty_string() {
assert!(check_path_traversal_attempt(""));
}
#[test]
fn test_check_path_traversal_boundary_length() {
let exact: String = "a".repeat(MAX_PATH_LENGTH);
assert!(check_path_traversal_attempt(&exact));
let over: String = "a".repeat(MAX_PATH_LENGTH + 1);
assert!(!check_path_traversal_attempt(&over));
}
#[test]
fn test_normalize_rejects_encoded_traversal() {
let result = normalize_and_validate_path(
Path::new("%2e%2e/etc/passwd"),
&[PathBuf::from(".")],
false,
true,
);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::EncodedTraversal);
}
#[test]
fn test_normalize_too_long_returns_encoded_traversal() {
let long = "a/".repeat(5000);
let result =
normalize_and_validate_path(Path::new(&long), &[PathBuf::from(".")], false, true);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::EncodedTraversal);
}
#[test]
fn test_normalize_no_symlink_check_with_absolute_allowed() {
let result = normalize_and_validate_path(
Path::new("any_relative_path.toml"),
&[PathBuf::from(".")],
true, false, );
assert!(result.is_ok(), "{:?}", result.err());
assert!(result.unwrap().is_absolute());
}
#[test]
fn test_normalize_no_symlink_check_rejects_when_absolute_disallowed() {
let result = normalize_and_validate_path(
Path::new("any_relative_path.toml"),
&[PathBuf::from(".")],
false,
false,
);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::AbsolutePath);
}
#[test]
fn test_normalize_no_symlink_check_rejects_parent_dir() {
let result = normalize_and_validate_path(
Path::new("../etc/passwd"),
&[PathBuf::from(".")],
false,
false,
);
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
PathTraversalError::ParentDirectoryReference
);
}
#[test]
fn test_normalize_absolute_path_with_allow_succeeds() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("confers_test_normalize_abs.txt");
std::fs::write(&test_file, "test").unwrap();
let result = normalize_and_validate_path(&test_file, &[], true, true);
assert!(result.is_ok(), "{:?}", result.err());
let _ = std::fs::remove_file(test_file);
}
#[test]
fn test_validate_path_with_config_encoded_traversal() {
let config = LoaderConfig::new();
let result = validate_path_with_config(Path::new("%2e%2e/etc/passwd"), &config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::EncodedTraversal);
}
#[test]
fn test_validate_path_with_config_absolute_rejected() {
let config = LoaderConfig::new();
let result = validate_path_with_config(Path::new("/etc/passwd"), &config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), PathTraversalError::AbsolutePath);
}
#[test]
fn test_loader_config_default_values() {
let config = LoaderConfig::default();
assert_eq!(config.max_size, DEFAULT_MAX_SIZE);
assert!(!config.allow_absolute);
assert!(config.check_symlinks);
assert_eq!(config.allowed_base_dirs, vec![PathBuf::from(".")]);
}
#[test]
fn test_loader_config_new_matches_default() {
let new = LoaderConfig::new();
let default = LoaderConfig::default();
assert_eq!(new.max_size, default.max_size);
assert_eq!(new.allow_absolute, default.allow_absolute);
assert_eq!(new.check_symlinks, default.check_symlinks);
assert_eq!(new.allowed_base_dirs.len(), default.allowed_base_dirs.len());
}
#[test]
fn test_loader_config_max_size_builder() {
let config = LoaderConfig::new().max_size(512);
assert_eq!(config.max_size, 512);
}
#[test]
fn test_loader_config_add_multiple_dirs() {
let config = LoaderConfig::new()
.add_allowed_dir("a")
.add_allowed_dir("b")
.add_allowed_dir("c");
assert_eq!(config.allowed_base_dirs.len(), 4); }
#[test]
fn test_format_from_str_via_parse() {
assert_eq!("toml".parse::<Format>(), Ok(Format::Toml));
assert_eq!("JSON".parse::<Format>(), Ok(Format::Json));
assert_eq!("Yaml".parse::<Format>(), Ok(Format::Yaml));
assert_eq!("yml".parse::<Format>(), Ok(Format::Yaml));
assert_eq!("ini".parse::<Format>(), Ok(Format::Ini));
assert_eq!("INI".parse::<Format>(), Ok(Format::Ini));
assert_eq!("unknown".parse::<Format>(), Err(()));
assert_eq!("".parse::<Format>(), Err(()));
}
#[test]
fn test_format_try_parse_extra_cases() {
assert_eq!(Format::try_parse("TOML"), Some(Format::Toml));
assert_eq!(Format::try_parse("YML"), Some(Format::Yaml));
assert_eq!(Format::try_parse(""), None);
assert_eq!(Format::try_parse("toml/json"), None);
}
#[test]
fn test_detect_format_from_path_ini_and_yml() {
assert_eq!(
detect_format_from_path(Path::new("conf.ini")),
Some(Format::Ini)
);
assert_eq!(
detect_format_from_path(Path::new("conf.INI")),
Some(Format::Ini)
);
assert_eq!(
detect_format_from_path(Path::new("conf.yml")),
Some(Format::Yaml)
);
}
#[test]
fn test_detect_format_from_path_unknown_extension() {
assert_eq!(detect_format_from_path(Path::new("conf.txt")), None);
assert_eq!(detect_format_from_path(Path::new("conf.xml")), None);
assert_eq!(detect_format_from_path(Path::new("conf")), None);
}
#[test]
fn test_detect_format_from_content_yaml_marker() {
assert_eq!(
detect_format_from_content("---\nkey: value"),
Some(Format::Yaml)
);
}
#[test]
fn test_detect_format_from_content_ini_section() {
assert_eq!(
detect_format_from_content("[section]\nkey=value"),
Some(Format::Ini)
);
}
#[test]
fn test_detect_format_from_content_yaml_colon() {
assert_eq!(detect_format_from_content("key: value"), Some(Format::Yaml));
}
#[cfg(feature = "yaml")]
#[test]
fn test_parse_content_yaml() {
let r = parse_content("key: value", Format::Yaml, SourceId::new("t"), None);
assert!(r.is_ok(), "{:?}", r.err());
assert!(r.unwrap().is_map());
}
#[cfg(feature = "ini")]
#[test]
fn test_parse_content_ini() {
let r = parse_content(
"[section]\nkey=value",
Format::Ini,
SourceId::new("t"),
None,
);
assert!(r.is_ok(), "{:?}", r.err());
assert!(r.unwrap().is_map());
}
#[cfg(feature = "ini")]
#[test]
fn test_parse_ini_with_comments_and_invalid_lines() {
let content =
"; ini comment\n# hash comment\n[db]\nhost = localhost\nport = 5432\n=value\nbadline\n";
let result = parse_ini(content, SourceId::new("test"), None);
assert!(result.is_ok(), "{:?}", result.err());
assert!(result.unwrap().is_map());
}
#[cfg(feature = "ini")]
#[test]
fn test_parse_ini_empty_key_skipped() {
let result = parse_ini("=value\n[section]\nvalid=1\n", SourceId::new("test"), None);
assert!(result.is_ok());
let val = result.unwrap();
assert!(val.is_map());
}
#[cfg(feature = "ini")]
#[test]
fn test_parse_ini_no_section_key_value() {
let result = parse_ini("key=value", SourceId::new("test"), None);
assert!(result.is_ok());
assert!(result.unwrap().is_map());
}
#[cfg(feature = "json")]
#[test]
fn test_parse_json_error() {
assert!(parse_json("{invalid}", SourceId::new("t"), None).is_err());
}
#[cfg(feature = "yaml")]
#[test]
fn test_parse_yaml_error() {
assert!(parse_yaml("{a: b", SourceId::new("t"), None).is_err());
}
#[cfg(feature = "toml")]
#[test]
fn test_parse_toml_table_helper_multiple_keys() {
let mut table = toml::Table::new();
table.insert("k1".to_string(), toml::Value::Integer(42));
table.insert("k2".to_string(), toml::Value::String("v2".to_string()));
let av = parse_toml_table(&table, &SourceId::new("src"), "prefix");
assert!(av.is_map());
}
#[cfg(feature = "json")]
#[test]
fn test_parse_json_value_helper() {
let v = serde_json::json!({"key": "value", "num": 123});
let av = parse_json_value(&v, &SourceId::new("src"), "prefix");
assert!(av.is_map());
}
#[cfg(feature = "yaml")]
#[test]
fn test_parse_yaml_value_helper() {
let v: serde_yaml_ng::Value = serde_yaml_ng::from_str("key: value").unwrap();
let av = parse_yaml_value(&v, &SourceId::new("src"), "prefix");
assert!(av.is_map());
}
#[test]
fn test_load_file_traversal_rejected() {
let config = LoaderConfig::new();
let result = load_file(Path::new("../../etc/passwd"), &config);
assert!(result.is_err());
}
#[test]
fn test_load_file_encoded_traversal_rejected() {
let config = LoaderConfig::new();
let result = load_file(Path::new("%2e%2e/etc/passwd"), &config);
assert!(result.is_err());
}
#[test]
fn test_load_file_absolute_rejected() {
let config = LoaderConfig::new();
let result = load_file(Path::new("/etc/passwd"), &config);
assert!(result.is_err());
}
#[cfg(feature = "toml")]
#[test]
fn test_load_file_success() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("confers_test_load_success.toml");
std::fs::write(&test_file, "key = \"value\"\n").unwrap();
let config = LoaderConfig::new().allow_absolute();
let result = load_file(&test_file, &config);
assert!(result.is_ok(), "{:?}", result.err());
assert!(result.unwrap().is_map());
let _ = std::fs::remove_file(test_file);
}
#[cfg(feature = "toml")]
#[test]
fn test_load_file_size_limit_exceeded() {
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("confers_test_size_limit.toml");
std::fs::write(&test_file, "key = \"value\"\n").unwrap();
let config = LoaderConfig::new().allow_absolute().max_size(1);
let result = load_file(&test_file, &config);
assert!(result.is_err());
let _ = std::fs::remove_file(test_file);
}
}