#[derive(Debug)]
#[allow(dead_code)]
pub enum InputError {
ControlChars,
NullByte,
Empty,
PathTraversal,
InvalidUrl(String),
InvalidScheme,
DoubleEncoding,
}
impl std::fmt::Display for InputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ControlChars => write!(f, "control character detected in input"),
Self::NullByte => write!(f, "null byte detected in input"),
Self::Empty => write!(f, "empty input"),
Self::PathTraversal => write!(f, "path traversal detected: output path escapes CWD"),
Self::InvalidUrl(msg) => write!(f, "invalid URL: {msg}"),
Self::InvalidScheme => write!(f, "invalid URL scheme: only http and https are allowed"),
Self::DoubleEncoding => write!(f, "double encoding detected in URL"),
}
}
}
impl std::error::Error for InputError {}
impl InputError {
pub fn error_code(&self) -> &'static str {
match self {
Self::ControlChars => "INPUT_VALIDATION",
Self::NullByte => "INPUT_VALIDATION",
Self::Empty => "INPUT_VALIDATION",
Self::PathTraversal => "PATH_TRAVERSAL",
Self::InvalidUrl(_) => "INVALID_URL",
Self::InvalidScheme => "INVALID_URL",
Self::DoubleEncoding => "INPUT_VALIDATION",
}
}
#[allow(dead_code)]
pub fn exit_code(&self) -> i32 {
crate::output::exit_code::INPUT
}
}
pub fn reject_control_chars(input: &str) -> Result<&str, InputError> {
if input
.chars()
.any(|c| (c < '\x20' && c != '\n' && c != '\r' && c != '\t') || c == '\x7f')
{
return Err(InputError::ControlChars);
}
Ok(input)
}
pub fn reject_null_bytes(input: &str) -> Result<&str, InputError> {
if input.contains('\0') {
return Err(InputError::NullByte);
}
Ok(input)
}
pub fn validate_url(url: &str) -> Result<url::Url, InputError> {
reject_control_chars(url)?;
reject_null_bytes(url)?;
if url.contains("%25") && url.matches('%').count() > 1 {
return Err(InputError::DoubleEncoding);
}
let parsed = url::Url::parse(url).map_err(|e| InputError::InvalidUrl(e.to_string()))?;
match parsed.scheme() {
"http" | "https" => Ok(parsed),
_ => Err(InputError::InvalidScheme),
}
}
pub fn validate_selector(selector: &str) -> Result<&str, InputError> {
reject_null_bytes(selector)?;
reject_control_chars(selector)?;
if selector.trim().is_empty() {
return Err(InputError::Empty);
}
Ok(selector)
}
pub fn validate_expression(expr: &str) -> Result<&str, InputError> {
reject_null_bytes(expr)?;
reject_control_chars(expr)?;
Ok(expr)
}
#[allow(dead_code)]
pub fn safe_output_path(path: &str) -> Result<std::path::PathBuf, InputError> {
reject_null_bytes(path)?;
reject_control_chars(path)?;
let cwd = std::env::current_dir().unwrap_or_default();
let resolved = cwd.join(path);
let parent = resolved.parent().unwrap_or(&cwd);
let canonical_parent = match parent.canonicalize() {
Ok(p) => p,
Err(_) => return Err(InputError::PathTraversal),
};
let canonical_cwd = cwd.canonicalize().unwrap_or(cwd);
if !canonical_parent.starts_with(&canonical_cwd) {
return Err(InputError::PathTraversal);
}
Ok(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_reject_control_chars_ok() {
assert!(reject_control_chars("hello world").is_ok());
assert!(reject_control_chars("hello\nworld").is_ok());
assert!(reject_control_chars("hello\tworld").is_ok());
}
#[test]
fn test_reject_control_chars_rejects() {
assert!(reject_control_chars("hello\x01world").is_err());
assert!(reject_control_chars("hello\x7fworld").is_err());
assert!(reject_control_chars("hello\x00world").is_err());
}
#[test]
fn test_validate_url_ok() {
assert!(validate_url("https://example.com").is_ok());
assert!(validate_url("http://example.com/path?q=1").is_ok());
}
#[test]
fn test_validate_url_rejects_bad_scheme() {
assert!(validate_url("file:///etc/passwd").is_err());
assert!(validate_url("javascript:alert(1)").is_err());
assert!(validate_url("ftp://example.com").is_err());
}
#[test]
fn test_validate_url_rejects_garbage() {
assert!(validate_url("not-a-url").is_err());
}
#[test]
fn test_validate_selector_ok() {
assert!(validate_selector("div.class").is_ok());
assert!(validate_selector("#id").is_ok());
assert!(validate_selector("a[href]").is_ok());
}
#[test]
fn test_validate_selector_rejects() {
assert!(validate_selector("").is_err());
assert!(validate_selector(" ").is_err());
assert!(validate_selector("div\0.class").is_err());
assert!(validate_selector("div\x01").is_err());
}
#[test]
fn test_validate_expression_ok() {
assert!(validate_expression("document.title").is_ok());
assert!(validate_expression("1 + 2").is_ok());
}
#[test]
fn test_validate_expression_rejects() {
assert!(validate_expression("alert\x01(1)").is_err());
assert!(validate_expression("foo\0bar").is_err());
}
#[test]
fn test_safe_output_path_ok() {
assert!(safe_output_path("output.png").is_ok());
assert!(
safe_output_path("output.png")
.unwrap()
.ends_with("output.png")
);
}
#[test]
fn test_safe_output_path_rejects_traversal() {
assert!(safe_output_path("../../etc/passwd").is_err());
assert!(safe_output_path("/etc/passwd").is_err());
}
}