use crate::capability::CapabilityRequest;
pub const MAX_HARNESS_INPUT_BYTES: usize = 32_768;
pub const MAX_FORGE_INPUT_BYTES: usize = 65_536;
pub const MAX_CAPABILITY_NAME_BYTES: usize = 64;
pub const MAX_REASON_BYTES: usize = 1_024;
pub const MAX_CONTRACT_BYTES: usize = 256;
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationError(pub String);
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
pub fn validate_harness_input(input: &str) -> Result<(), ValidationError> {
if input.len() > MAX_HARNESS_INPUT_BYTES {
return Err(ValidationError(format!(
"input too long: {} bytes (max {})",
input.len(),
MAX_HARNESS_INPUT_BYTES
)));
}
check_string_chars(input, "harness input")?;
Ok(())
}
pub fn validate_forge_input(input: &str) -> Result<(), ValidationError> {
if input.len() > MAX_FORGE_INPUT_BYTES {
return Err(ValidationError(format!(
"forge input too long: {} bytes (max {})",
input.len(),
MAX_FORGE_INPUT_BYTES
)));
}
check_string_chars(input, "forge input")?;
Ok(())
}
pub fn validate_capability_fields(req: &CapabilityRequest) -> Result<(), ValidationError> {
check_field_len("capability", &req.capability, MAX_CAPABILITY_NAME_BYTES)?;
check_field_len("reason", &req.reason, MAX_REASON_BYTES)?;
check_field_len("input_contract", &req.input_contract, MAX_CONTRACT_BYTES)?;
check_field_len("output_contract", &req.output_contract, MAX_CONTRACT_BYTES)?;
check_string_chars(&req.capability, "capability")?;
check_string_chars(&req.reason, "reason")?;
Ok(())
}
pub fn validate_endpoint(url: &str) -> Result<(), ValidationError> {
let lower = url.to_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Ok(())
} else {
Err(ValidationError(format!(
"endpoint must use http:// or https:// — got: {url}"
)))
}
}
pub fn validate_soul_path(path: &str) -> Result<(), ValidationError> {
if path.is_empty() {
return Ok(());
}
if path.contains("../") || path.contains("..\\") || path.starts_with("..") {
return Err(ValidationError(format!(
"soul_path contains path traversal: {path}"
)));
}
if !path.ends_with(".md") {
return Err(ValidationError(format!(
"soul_path must be a .md file: {path}"
)));
}
Ok(())
}
fn check_field_len(name: &str, value: &str, max: usize) -> Result<(), ValidationError> {
if value.len() > max {
Err(ValidationError(format!(
"{name} too long: {} bytes (max {max})",
value.len()
)))
} else {
Ok(())
}
}
fn check_string_chars(s: &str, label: &str) -> Result<(), ValidationError> {
for (i, ch) in s.char_indices() {
if ch == '\0' {
return Err(ValidationError(format!(
"{label}: null byte at byte offset {i}"
)));
}
if ch.is_control() && ch != '\n' && ch != '\r' && ch != '\t' {
return Err(ValidationError(format!(
"{label}: disallowed control character {:?} at byte offset {i}",
ch
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::capability::{CapabilityConstraints, CapabilityRequest};
fn clean_req() -> CapabilityRequest {
CapabilityRequest {
kind: "capability_request".into(),
capability: "word_count".into(),
input_contract: "utf8 text".into(),
output_contract: "json".into(),
constraints: CapabilityConstraints::default(),
reason: "text reasoning insufficient".into(),
}
}
#[test]
fn harness_input_valid_text() {
assert!(validate_harness_input("hello world").is_ok());
}
#[test]
fn harness_input_with_newlines_allowed() {
assert!(validate_harness_input("line one\nline two\r\n").is_ok());
}
#[test]
fn harness_input_too_long() {
let big = "a".repeat(MAX_HARNESS_INPUT_BYTES + 1);
let err = validate_harness_input(&big).unwrap_err();
assert!(err.0.contains("too long"));
}
#[test]
fn harness_input_null_byte_rejected() {
let err = validate_harness_input("hello\x00world").unwrap_err();
assert!(err.0.contains("null byte"));
}
#[test]
fn harness_input_control_char_rejected() {
let err = validate_harness_input("hello\x01world").unwrap_err();
assert!(err.0.contains("control character"));
}
#[test]
fn harness_input_tab_allowed() {
assert!(validate_harness_input("col1\tcol2").is_ok());
}
#[test]
fn forge_input_valid() {
assert!(validate_forge_input("log line 200 OK").is_ok());
}
#[test]
fn forge_input_too_long() {
let big = "x".repeat(MAX_FORGE_INPUT_BYTES + 1);
let err = validate_forge_input(&big).unwrap_err();
assert!(err.0.contains("too long"));
}
#[test]
fn forge_input_null_byte_rejected() {
assert!(validate_forge_input("a\x00b").is_err());
}
#[test]
fn capability_fields_valid() {
assert!(validate_capability_fields(&clean_req()).is_ok());
}
#[test]
fn capability_name_too_long() {
let mut req = clean_req();
req.capability = "x".repeat(MAX_CAPABILITY_NAME_BYTES + 1);
let err = validate_capability_fields(&req).unwrap_err();
assert!(err.0.contains("capability"));
}
#[test]
fn reason_too_long() {
let mut req = clean_req();
req.reason = "r".repeat(MAX_REASON_BYTES + 1);
let err = validate_capability_fields(&req).unwrap_err();
assert!(err.0.contains("reason"));
}
#[test]
fn input_contract_too_long() {
let mut req = clean_req();
req.input_contract = "c".repeat(MAX_CONTRACT_BYTES + 1);
let err = validate_capability_fields(&req).unwrap_err();
assert!(err.0.contains("input_contract"));
}
#[test]
fn output_contract_too_long() {
let mut req = clean_req();
req.output_contract = "c".repeat(MAX_CONTRACT_BYTES + 1);
let err = validate_capability_fields(&req).unwrap_err();
assert!(err.0.contains("output_contract"));
}
#[test]
fn capability_null_byte_rejected() {
let mut req = clean_req();
req.capability = "foo\x00bar".into();
let err = validate_capability_fields(&req).unwrap_err();
assert!(err.0.contains("null byte"));
}
#[test]
fn https_endpoint_accepted() {
assert!(validate_endpoint("https://api.example.com/v1").is_ok());
}
#[test]
fn http_endpoint_accepted() {
assert!(validate_endpoint("http://localhost:11434").is_ok());
}
#[test]
fn file_url_rejected() {
let err = validate_endpoint("file:///etc/passwd").unwrap_err();
assert!(err.0.contains("http://"));
}
#[test]
fn javascript_url_rejected() {
assert!(validate_endpoint("javascript:alert(1)").is_err());
}
#[test]
fn bare_hostname_rejected() {
assert!(validate_endpoint("localhost:8080").is_err());
}
#[test]
fn empty_soul_path_accepted() {
assert!(validate_soul_path("").is_ok());
}
#[test]
fn valid_soul_path_accepted() {
assert!(validate_soul_path("/home/user/soul.md").is_ok());
}
#[test]
fn path_traversal_rejected() {
assert!(validate_soul_path("../../etc/passwd.md").is_err());
}
#[test]
fn relative_traversal_rejected() {
assert!(validate_soul_path("../config/soul.md").is_err());
}
#[test]
fn non_md_extension_rejected() {
let err = validate_soul_path("/home/user/soul.txt").unwrap_err();
assert!(err.0.contains(".md"));
}
}