use std::{io::Read as _, path::Path};
use crate::errors::ProxyError;
const MAX_YAML_BYTES: usize = 4_194_304;
const MAX_YAML_READ_BYTES: u64 = 4_194_305;
pub(crate) fn check_file_size(path: &Path) -> Result<(), ProxyError> {
let meta = std::fs::metadata(path).map_err(|e| {
let display = path.display();
ProxyError::Config(format!("failed to read metadata for {display}: {e}"))
})?;
if !meta.is_file() {
let display = path.display();
return Err(ProxyError::Config(format!(
"config path {display} is not a regular file"
)));
}
let len = meta.len();
if len > MAX_YAML_READ_BYTES {
return Err(ProxyError::Config(format!(
"config file too large ({len} bytes, max {MAX_YAML_BYTES})"
)));
}
Ok(())
}
pub fn read_config_file(path: &Path) -> Result<String, ProxyError> {
check_file_size(path)?;
let file = std::fs::File::open(path).map_err(|e| {
let display = path.display();
ProxyError::Config(format!("failed to read {display}: {e}"))
})?;
let mut content = String::new();
file.take(MAX_YAML_READ_BYTES)
.read_to_string(&mut content)
.map_err(|e| {
let display = path.display();
ProxyError::Config(format!("failed to read {display}: {e}"))
})?;
Ok(content)
}
pub(crate) fn check_yaml_safety(raw: &str) -> Result<(), ProxyError> {
check_yaml_size(raw)?;
reject_yaml_aliases(raw)
}
fn check_yaml_size(raw: &str) -> Result<(), ProxyError> {
if raw.len() > MAX_YAML_BYTES {
return Err(ProxyError::Config(format!(
"YAML input too large ({} bytes, max {MAX_YAML_BYTES})",
raw.len()
)));
}
Ok(())
}
fn reject_yaml_aliases(raw: &str) -> Result<(), ProxyError> {
match raw.lines().position(line_contains_alias) {
Some(idx) => Err(ProxyError::Config(format!(
"YAML alias nodes (`*anchor`) are not supported (line {}); \
they enable alias-expansion denial-of-service and are not used by any Praxis config",
idx + 1
))),
None => Ok(()),
}
}
fn line_contains_alias(line: &str) -> bool {
let (mut at_boundary, mut prev_ws) = (true, true);
let mut quote: Option<u8> = None;
let (mut prev_star, mut escaped) = (false, false);
for &c in line.as_bytes() {
if prev_star && (c.is_ascii_alphanumeric() || c == b'_') {
return true;
}
prev_star = false;
if let Some(q) = quote {
let close = c == q && !escaped;
escaped = q == b'"' && c == b'\\' && !escaped;
quote = (!close).then_some(q);
at_boundary = false;
} else {
match c {
b'#' if prev_ws => return false,
b'\'' | b'"' if at_boundary => (quote, at_boundary) = (Some(c), false),
b'*' if at_boundary => prev_star = true,
_ => at_boundary = matches!(c, b' ' | b'\t' | b'[' | b'{' | b',' | b':' | b'-'),
}
}
prev_ws = matches!(c, b' ' | b'\t');
}
false
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::needless_raw_strings,
clippy::needless_raw_string_hashes,
reason = "tests use unwrap/expect/indexing/raw strings for brevity"
)]
mod tests {
use super::*;
#[test]
fn reject_oversized_yaml() {
let huge = "x".repeat(5 * 1024 * 1024);
let err = check_yaml_size(&huge).unwrap_err();
assert!(err.to_string().contains("too large"), "should reject oversized YAML");
}
#[test]
fn accept_small_yaml() {
check_yaml_size("a: 1\n").expect("small YAML should pass size check");
}
#[test]
fn read_config_file_reads_regular_file() {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("praxis.yaml");
std::fs::write(&path, "listeners: []\n").expect("write config");
let content = read_config_file(&path).expect("regular file should read");
assert_eq!(content, "listeners: []\n", "content should round-trip");
}
#[test]
fn read_config_file_rejects_non_regular_file() {
let dir = tempfile::TempDir::new().expect("tempdir");
let err = read_config_file(dir.path()).expect_err("non-regular file must be rejected");
assert!(
err.to_string().contains("not a regular file"),
"error should name the non-regular-file cause, got: {err}"
);
}
#[test]
fn check_file_size_rejects_directory() {
let dir = tempfile::TempDir::new().expect("tempdir");
let err = check_file_size(dir.path()).expect_err("directory must be rejected");
assert!(
err.to_string().contains("not a regular file"),
"error should name the non-regular-file cause, got: {err}"
);
}
#[test]
fn reject_yaml_alias_bomb() {
let err = reject_yaml_aliases("a: &a x\nb: &b [*a,*a,*a]\nlisteners: []\n");
assert!(err.is_err(), "should reject alias nodes before parsing");
assert!(
err.unwrap_err().to_string().contains("alias nodes"),
"error message should mention alias nodes"
);
}
#[test]
fn reject_single_alias() {
let err = reject_yaml_aliases("a: &a x\nb: *a\nlisteners: []\n");
assert!(err.is_err(), "any alias node should be rejected");
}
#[test]
fn accept_anchor_without_alias() {
reject_yaml_aliases("a: &a x\nlisteners: []\n").expect("unused anchor should pass");
}
#[test]
fn accept_asterisk_in_string_and_comment() {
reject_yaml_aliases("pattern: \"a*b\"\nglob: '*.txt'\nnote: ok # *not an alias\n")
.expect("asterisks in strings/comments are not alias nodes");
}
#[test]
fn accept_bare_asterisk_value() {
reject_yaml_aliases("wildcard: /*\n").expect("glob-like value should pass");
}
#[test]
fn reject_alias_after_mid_scalar_apostrophe() {
let err = reject_yaml_aliases("a: &a x\nb: [don't, *a]\n");
assert!(err.is_err(), "alias after mid-scalar apostrophe should be rejected");
}
#[test]
fn reject_alias_after_mid_scalar_hash() {
let err = reject_yaml_aliases("a: &a x\nb: [a#b, *a]\n");
assert!(err.is_err(), "alias after mid-scalar hash should be rejected");
}
#[test]
fn accept_escaped_quote_in_double_quoted_scalar() {
reject_yaml_aliases("k: \"a\\\" *not-an-alias b\"\n").expect("escaped quote should not end the string");
}
#[test]
fn safety_check_rejects_oversized() {
let huge = "x".repeat(5 * 1024 * 1024);
let err = check_yaml_safety(&huge).unwrap_err();
assert!(err.to_string().contains("too large"), "should reject oversized YAML");
}
#[test]
fn accept_yaml_at_exact_max_size() {
let exact = "x".repeat(MAX_YAML_BYTES);
check_yaml_size(&exact).expect("YAML at exactly MAX_YAML_BYTES should pass");
}
#[test]
fn reject_yaml_one_byte_over_max() {
let over = "x".repeat(MAX_YAML_BYTES + 1);
let err = check_yaml_size(&over).unwrap_err();
assert!(err.to_string().contains("too large"), "got: {err}");
}
#[test]
fn safety_check_passes_valid_yaml() {
check_yaml_safety("a: 1\n").expect("valid small YAML should pass all safety checks");
}
#[test]
fn alias_check_ignores_unparseable_non_alias_yaml() {
reject_yaml_aliases("{{{{invalid yaml").expect("non-alias garbage passes the alias check");
}
#[test]
fn alias_line_number_reported() {
let err = reject_yaml_aliases("listeners: []\nfoo: bar\nbomb: *a\n").unwrap_err();
assert!(err.to_string().contains("line 3"), "got: {err}");
}
}