use serde_json::Value;
use std::collections::HashMap;
const FORBIDDEN_SHELL_CHARS: &[char] = &[';', '&', '|', '>', '<'];
const FORBIDDEN_NEWLINE_CHARS: &[char] = &['\n', '\r'];
#[derive(Clone, Copy)]
enum ForbiddenPattern {
AnyChar(&'static [char]),
Substring(&'static str),
Char(char),
}
impl ForbiddenPattern {
fn is_match(self, s: &str) -> bool {
match self {
Self::AnyChar(chars) => s.contains(chars),
Self::Substring(substr) => s.contains(substr),
Self::Char(ch) => s.contains(ch),
}
}
}
pub struct SecurityValidator {
forbidden: Vec<ForbiddenPattern>,
max_len: usize,
}
impl Default for SecurityValidator {
fn default() -> Self {
Self {
forbidden: vec![
ForbiddenPattern::AnyChar(FORBIDDEN_SHELL_CHARS), ForbiddenPattern::Substring("$("), ForbiddenPattern::Char('`'), ForbiddenPattern::Substring("${"), ForbiddenPattern::Substring(".."), ForbiddenPattern::AnyChar(FORBIDDEN_NEWLINE_CHARS), ForbiddenPattern::Char('\0'), ],
max_len: 1024,
}
}
}
impl SecurityValidator {
pub fn validate(&self, args: &HashMap<String, Value>) -> Result<(), String> {
for (name, val) in args {
self.validate_value(name, val)?;
}
Ok(())
}
fn validate_value(&self, name: &str, val: &Value) -> Result<(), String> {
if let Value::Array(items) = val {
for (i, item) in items.iter().enumerate() {
self.validate_value(&format!("{name}[{i}]"), item)?;
}
} else {
let s = value_to_string(val)?;
if s.len() > self.max_len {
return Err(format!(
"Argument '{}' exceeds max length {}",
name, self.max_len
));
}
if s.starts_with('/') || s.starts_with('~') {
return Err(format!(
"Argument '{name}' looks like absolute path; use repo-relative"
));
}
for pat in &self.forbidden {
if pat.is_match(&s) {
return Err(format!("Argument '{name}' contains forbidden pattern"));
}
}
}
Ok(())
}
}
pub fn value_to_string(v: &Value) -> Result<String, String> {
match v {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
Value::Array(a) => Ok(a
.iter()
.map(value_to_string)
.collect::<Result<Vec<_>, _>>()?
.join(" ")),
Value::Null => Ok(String::new()),
Value::Object(_) => Err("Object arguments are not supported".into()),
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn allows_safe_values() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("name".into(), json!("hello"));
args.insert("count".into(), json!(42));
args.insert("enabled".into(), json!(true));
assert!(v.validate(&args).is_ok());
}
#[test]
fn blocks_shell_operators() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("cmd".into(), json!("foo; bar"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("foo && bar"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("foo | bar"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("foo > out"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_command_substitution() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("cmd".into(), json!("$(whoami)"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("`whoami`"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("${HOME}"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_path_traversal() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("path".into(), json!("../secret"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_newlines() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("cmd".into(), json!("foo\nbar"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("foo\rbar"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("cmd".into(), json!("foo\r\nbar"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_null_bytes() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("cmd".into(), json!("foo\x00bar"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_absolute_paths() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("path".into(), json!("/etc/passwd"));
assert!(v.validate(&args).is_err());
args.clear();
args.insert("path".into(), json!("~/secret"));
assert!(v.validate(&args).is_err());
}
#[test]
fn blocks_long_values() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("data".into(), json!("x".repeat(2000)));
assert!(v.validate(&args).is_err());
}
#[test]
fn validates_array_items() {
let v = SecurityValidator::default();
let mut args = HashMap::new();
args.insert("items".into(), json!(["foo", "bar", "baz"]));
assert!(v.validate(&args).is_ok());
args.clear();
args.insert("items".into(), json!(["foo", "$(bad)", "baz"]));
assert!(v.validate(&args).is_err());
}
#[test]
fn value_to_string_conversions() {
assert_eq!(value_to_string(&json!("hello")).unwrap(), "hello");
assert_eq!(value_to_string(&json!(42)).unwrap(), "42");
assert_eq!(value_to_string(&json!(true)).unwrap(), "true");
assert_eq!(value_to_string(&json!(["a", "b", "c"])).unwrap(), "a b c");
assert_eq!(value_to_string(&json!(null)).unwrap(), "");
assert!(value_to_string(&json!({"key": "value"})).is_err());
}
fn args_map<const N: usize>(pairs: [(&str, &str); N]) -> HashMap<String, Value> {
pairs
.into_iter()
.map(|(k, v)| (k.to_string(), json!(v)))
.collect()
}
#[test]
fn allows_dollar_alone() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", "$")])).is_ok());
}
#[test]
fn blocks_dollar_paren() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", "$(")])).is_err());
}
#[test]
fn allows_single_dot() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", ".")])).is_ok());
}
#[test]
fn blocks_double_dot() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", "..")])).is_err());
}
#[test]
fn allows_dollar_space_paren() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", "$ (")])).is_ok());
}
#[test]
fn allows_escaped_newline_literal() {
let v = SecurityValidator::default();
assert!(v.validate(&args_map([("x", "\\n")])).is_ok());
}
}