use serde_json::Value;
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, PoisonError, RwLock};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct InputViolation {
pub pointer: String,
pub keyword: &'static str,
pub expected: String,
}
pub fn validate_input(
schema: &Value,
arguments: Option<&Value>,
schema_key: Option<&str>,
) -> Result<(), Vec<InputViolation>> {
let validator = match cached_input_validator(schema, schema_key) {
Ok(v) => v,
Err(detail) => {
tracing::warn!(
detail = %detail,
"declared inputSchema is not a valid JSON Schema; refusing every call to this tool"
);
return Err(vec![InputViolation {
pointer: String::new(),
keyword: "schema",
expected: UNCOMPILABLE_SCHEMA.to_string(),
}]);
},
};
let arguments = effective_arguments(arguments);
if validator.is_valid(arguments) {
return Ok(());
}
let violations: Vec<InputViolation> = validator
.iter_errors(arguments)
.map(|e| violation(&e, schema))
.collect();
if violations.is_empty() {
return Err(vec![InputViolation {
pointer: String::new(),
keyword: "schema",
expected: GENERIC_MISMATCH.to_string(),
}]);
}
Err(violations)
}
const UNCOMPILABLE_SCHEMA: &str = "the tool's declared inputSchema is not a valid JSON Schema";
const GENERIC_MISMATCH: &str = "does not match the declared schema";
fn effective_arguments(arguments: Option<&Value>) -> &Value {
static EMPTY: OnceLock<Value> = OnceLock::new();
match arguments {
Some(v) if !v.is_null() => v,
_ => EMPTY.get_or_init(|| Value::Object(serde_json::Map::new())),
}
}
fn violation(e: &jsonschema::ValidationError<'_>, schema: &Value) -> InputViolation {
let (keyword, expected) =
expectation(e).unwrap_or_else(|| ("schema", GENERIC_MISMATCH.to_string()));
InputViolation {
pointer: safe_pointer(e, schema),
keyword,
expected,
}
}
pub(crate) const REDACTED_SEGMENT: &str = "<redacted>";
fn safe_pointer(e: &jsonschema::ValidationError<'_>, schema: &Value) -> String {
let raw = e.instance_path().as_str();
if raw.is_empty() {
return String::new();
}
let mut node = Some(schema);
let mut out = String::new();
for token in raw.trim_start_matches('/').split('/') {
let (rendered, next) = project_pointer_segment(node, token);
out.push('/');
out.push_str(rendered);
node = next;
}
out
}
fn project_pointer_segment<'a>(
node: Option<&'a Value>,
token: &'a str,
) -> (&'a str, Option<&'a Value>) {
if !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_digit()) {
return (token, node.and_then(|n| n.get("items")));
}
let decoded = unescape_pointer_token(token);
match node
.and_then(|n| n.get("properties"))
.and_then(|properties| properties.get(decoded.as_ref()))
{
Some(child) => (token, Some(child)),
None => (REDACTED_SEGMENT, None),
}
}
fn unescape_pointer_token(token: &str) -> Cow<'_, str> {
if token.contains('~') {
Cow::Owned(token.replace("~1", "/").replace("~0", "~"))
} else {
Cow::Borrowed(token)
}
}
fn compile_error_detail(error: &jsonschema::ValidationError<'_>) -> String {
format!("{error}")
}
fn expectation(e: &jsonschema::ValidationError<'_>) -> Option<(&'static str, String)> {
use jsonschema::error::ValidationErrorKind as K;
Some(match e.kind() {
K::AdditionalProperties { unexpected } => (
"additionalProperties",
format!("unknown argument(s): {}", unexpected.len()),
),
K::Required { property } => (
"required",
format!(
"`{}` is required",
property.as_str().unwrap_or(UNNAMED_PROPERTY)
),
),
K::MaxLength { limit } => ("maxLength", format!("at most {limit} characters")),
K::MinLength { limit } => ("minLength", format!("at least {limit} characters")),
K::Pattern { pattern } => ("pattern", format!("must match {pattern}")),
K::Maximum { limit } => ("maximum", format!("at most {limit}")),
K::Minimum { limit } => ("minimum", format!("at least {limit}")),
K::Enum { options } => ("enum", format!("one of {options}")),
K::MaxItems { limit } => ("maxItems", format!("at most {limit} items")),
K::Type { kind } => ("type", format!("must be {}", type_expectation(kind))),
K::Format { format } => ("format", format!("must be a valid {format}")),
K::BacktrackLimitExceeded { .. } => {
tracing::warn!(
schema_path = %e.schema_path(),
"declared `pattern` hit the regex backtracking limit on caller input; refusing"
);
("pattern", BACKTRACK_LIMIT.to_string())
},
_ => return None,
})
}
fn type_expectation(kind: &jsonschema::error::TypeKind) -> String {
use jsonschema::error::TypeKind;
match kind {
TypeKind::Single(declared) => declared.as_str().to_owned(),
TypeKind::Multiple(declared) => declared
.iter()
.map(jsonschema::JsonType::as_str)
.collect::<Vec<&str>>()
.join(" or "),
}
}
const BACKTRACK_LIMIT: &str = "could not be evaluated against the declared pattern";
const UNNAMED_PROPERTY: &str = "<unnamed>";
fn compile_input_2020_12(
schema: &Value,
) -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
let normalized = super::output_validation::normalize_schema_dialect(schema);
jsonschema::options()
.with_draft(jsonschema::Draft::Draft202012)
.should_validate_formats(true)
.build(&normalized)
}
type InputValidatorCache = RwLock<HashMap<String, Result<Arc<jsonschema::Validator>, Arc<str>>>>;
static INPUT_VALIDATOR_CACHE: OnceLock<InputValidatorCache> = OnceLock::new();
const MAX_CACHED_VALIDATORS: usize = 4096;
fn cached_input_validator(
schema: &Value,
schema_key: Option<&str>,
) -> Result<Arc<jsonschema::Validator>, Arc<str>> {
let cache = INPUT_VALIDATOR_CACHE.get_or_init(InputValidatorCache::default);
let key: Cow<'_, str> = match schema_key {
Some(k) => Cow::Borrowed(k),
None => Cow::Owned(schema.to_string()),
};
{
let map = cache.read().unwrap_or_else(PoisonError::into_inner);
if let Some(hit) = map.get(key.as_ref()) {
return hit.clone();
}
}
let compiled = compile_input_2020_12(schema)
.map(Arc::new)
.map_err(|error| Arc::from(compile_error_detail(&error).as_str()));
let mut map = cache.write().unwrap_or_else(PoisonError::into_inner);
remember_bounded(&mut map, MAX_CACHED_VALIDATORS, key.into_owned(), compiled)
}
fn remember_bounded(
map: &mut HashMap<String, Result<Arc<jsonschema::Validator>, Arc<str>>>,
cap: usize,
key: String,
compiled: Result<Arc<jsonschema::Validator>, Arc<str>>,
) -> Result<Arc<jsonschema::Validator>, Arc<str>> {
if map.len() >= cap && !map.contains_key(&key) {
return compiled;
}
map.entry(key).or_insert(compiled).clone()
}
pub fn check_input_schema_compiles(schema: &Value) -> Result<(), InputViolation> {
match compile_input_2020_12(schema) {
Ok(_) => Ok(()),
Err(error) => Err(InputViolation {
pointer: error.schema_path().as_str().to_string(),
keyword: "schema",
expected: compile_error_detail(&error),
}),
}
}
#[must_use]
pub fn render_refusal(violations: &[InputViolation], declared: &[&str]) -> String {
let allowed = if declared.is_empty() {
"(this tool declares no parameters)".to_string()
} else {
declared.join(", ")
};
if violations.is_empty() {
return format!("arguments do not match the declared schema; allowed: {allowed}");
}
violations
.iter()
.map(|v| render_one(v, declared, &allowed))
.collect::<Vec<String>>()
.join("; ")
}
fn render_one(v: &InputViolation, declared: &[&str], allowed: &str) -> String {
if v.keyword == "additionalProperties" {
return format!("{}; allowed: {allowed}", v.expected);
}
let first = v.pointer.trim_start_matches('/').split('/').next();
match first {
Some(name) if !name.is_empty() && declared.contains(&name) => {
format!("{}: {}", v.pointer, v.expected)
},
_ => v.expected.clone(),
}
}
pub const PLACEHOLDER_MAX_LENGTH: usize = 256;
#[non_exhaustive]
#[derive(Debug, Clone, Default)]
pub struct PlaceholderRules<'a> {
pub declared_pattern: Option<&'a str>,
pub declared_max_length: Option<usize>,
pub allow_slash: bool,
}
impl<'a> PlaceholderRules<'a> {
#[must_use]
pub fn with_pattern(mut self, pattern: Option<&'a str>) -> Self {
self.declared_pattern = pattern;
self
}
#[must_use]
pub fn with_max_length(mut self, max_length: Option<usize>) -> Self {
self.declared_max_length = max_length;
self
}
#[must_use]
pub fn allowing_slash(mut self, allow_slash: bool) -> Self {
self.allow_slash = allow_slash;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct PlaceholderRefusal {
pub param: String,
pub rule: &'static str,
pub expected: String,
}
impl std::fmt::Display for PlaceholderRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "param '{}' {}", self.param, self.expected)
}
}
impl std::error::Error for PlaceholderRefusal {}
const FLOOR_EXPECTATION: &str = "must not contain a path separator, a \
parent-directory sequence, a query or fragment marker, a backslash or any \
control character — in literal or percent-encoded form — and must not be a \
single dot";
const FLOOR_EXPECTATION_SLASH_ALLOWED: &str = "must not contain a \
parent-directory sequence, a query or fragment marker, a backslash or any \
control character — in literal or percent-encoded form — and must not be a \
single dot";
const PERCENT_EXPECTATION: &str = "must not contain an encoded percent sign \
(`%25`) or a malformed percent escape";
const COMPOSED_POSITION: &str = "path segment";
pub fn validate_path_placeholder(
param: &str,
value: &str,
rules: &PlaceholderRules<'_>,
) -> Result<(), PlaceholderRefusal> {
placeholder_floor(param, value, rules.allow_slash)?;
let length = value.chars().count();
if length > PLACEHOLDER_MAX_LENGTH {
return Err(refusal(
param,
"maxLength",
format!("must be at most {PLACEHOLDER_MAX_LENGTH} characters"),
));
}
if let Some(pattern) = rules.declared_pattern {
declared_pattern_check(param, value, pattern)?;
}
if let Some(declared) = rules.declared_max_length {
if length > declared {
return Err(refusal(
param,
"maxLength",
format!("must be at most {declared} characters"),
));
}
}
Ok(())
}
pub fn validate_resolved_path(path: &str) -> Result<(), PlaceholderRefusal> {
let decoded = decode_once(COMPOSED_POSITION, path)?;
if decoded
.iter()
.any(|byte| denied_byte(*byte, true) || matches!(byte, b'{' | b'}'))
{
return Err(refusal(
COMPOSED_POSITION,
"pathSegment",
format!(
"{FLOOR_EXPECTATION_SLASH_ALLOWED}, and must carry no unsubstituted placeholder"
),
));
}
check_resolved_segments(&decoded)
}
pub fn validate_resolved_target(path: &str) -> Result<(), PlaceholderRefusal> {
match path.split_once('?') {
None => validate_resolved_path(path),
Some((path_part, query_part)) => {
validate_resolved_path(path_part).and_then(|()| validate_resolved_path(query_part))
},
}
}
fn check_resolved_segments(decoded: &[u8]) -> Result<(), PlaceholderRefusal> {
if decoded == b"/" {
return Ok(());
}
let leading_slash = decoded.first() == Some(&b'/');
for (index, segment) in decoded.split(|byte| *byte == b'/').enumerate() {
check_one_resolved_segment(segment, index == 0 && leading_slash)?;
}
Ok(())
}
fn check_one_resolved_segment(segment: &[u8], leading: bool) -> Result<(), PlaceholderRefusal> {
if segment.is_empty() {
if leading {
return Ok(());
}
return Err(refusal(
COMPOSED_POSITION,
"pathSegment",
"must not be empty".to_string(),
));
}
if segment == b".." || segment == b"." {
return Err(refusal(
COMPOSED_POSITION,
"pathSegment",
"must not be a relative path reference".to_string(),
));
}
if String::from_utf8_lossy(segment).chars().count() > PLACEHOLDER_MAX_LENGTH {
return Err(refusal(
COMPOSED_POSITION,
"segmentMaxLength",
format!("must be at most {PLACEHOLDER_MAX_LENGTH} characters"),
));
}
Ok(())
}
fn refusal(param: &str, rule: &'static str, expected: String) -> PlaceholderRefusal {
PlaceholderRefusal {
param: param.to_owned(),
rule,
expected,
}
}
fn placeholder_floor(
param: &str,
value: &str,
allow_slash: bool,
) -> Result<(), PlaceholderRefusal> {
if value.is_empty() {
return Err(refusal(param, "nonEmpty", "must not be empty".to_string()));
}
let decoded = decode_once(param, value)?;
let floor_expectation = if allow_slash {
FLOOR_EXPECTATION_SLASH_ALLOWED
} else {
FLOOR_EXPECTATION
};
let denied = decoded.iter().any(|byte| denied_byte(*byte, allow_slash))
|| decoded.windows(2).any(|pair| pair == b"..")
|| &decoded[..] == b".";
if denied {
return Err(refusal(
param,
"characterFloor",
floor_expectation.to_string(),
));
}
Ok(())
}
fn decode_once<'v>(param: &str, value: &'v str) -> Result<Cow<'v, [u8]>, PlaceholderRefusal> {
let bytes = value.as_bytes();
if !bytes.contains(&b'%') {
return Ok(Cow::Borrowed(bytes));
}
if contains_ascii_case_insensitive(value, "%25") {
return Err(refusal(
param,
"percentEncoding",
PERCENT_EXPECTATION.to_string(),
));
}
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let decoded = bytes
.get(index + 1)
.zip(bytes.get(index + 2))
.and_then(|(high, low)| Some(hex_nibble(*high)? * 16 + hex_nibble(*low)?));
let Some(byte) = decoded else {
return Err(refusal(
param,
"percentEncoding",
PERCENT_EXPECTATION.to_string(),
));
};
out.push(byte);
index += 3;
} else {
out.push(bytes[index]);
index += 1;
}
}
Ok(Cow::Owned(out))
}
fn hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
const fn denied_byte(byte: u8, allow_slash: bool) -> bool {
match byte {
b'?' | b'#' | b'\\' => true,
b'/' => !allow_slash,
0x00..=0x1F | 0x7F => true,
_ => false,
}
}
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
let (haystack, needle) = (haystack.as_bytes(), needle.as_bytes());
needle.len() <= haystack.len()
&& haystack
.windows(needle.len())
.any(|window| window.eq_ignore_ascii_case(needle))
}
fn declared_pattern_check(
param: &str,
value: &str,
pattern: &str,
) -> Result<(), PlaceholderRefusal> {
let schema = serde_json::json!({ "type": "string", "pattern": pattern });
match cached_input_validator(&schema, None) {
Ok(validator) => {
if validator.is_valid(&Value::String(value.to_owned())) {
Ok(())
} else {
Err(refusal(param, "pattern", format!("must match {pattern}")))
}
},
Err(_) => Err(refusal(
param,
"pattern",
"has a declared pattern that is not a valid regular expression".to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn two_param_schema() -> Value {
json!({
"type": "object",
"properties": {
"cui": { "type": "string" },
"version": { "type": "string" },
},
"required": ["cui"],
"additionalProperties": false,
})
}
fn zero_param_schema() -> Value {
json!({
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false,
})
}
#[test]
fn schema_validation_refuses_undeclared_argument_with_a_count_only_message() {
let schema = two_param_schema();
let violations = validate_input(
&schema,
Some(&json!({ "cui": "C0018787", "apiKey": "secret" })),
None,
)
.expect_err("an undeclared argument must be refused");
assert_eq!(violations.len(), 1, "one additionalProperties violation");
assert_eq!(violations[0].keyword, "additionalProperties");
assert_eq!(
violations[0].pointer, "",
"0.49.2 reports the instance root for additionalProperties"
);
let msg = render_refusal(&violations, &["cui", "version"]);
assert!(
msg.contains('1'),
"must carry the unknown-argument count: {msg}"
);
assert!(msg.contains("cui"), "must name the declared params: {msg}");
assert!(
msg.contains("version"),
"must name the declared params: {msg}"
);
assert!(!msg.contains("apiKey"), "must not echo the key: {msg}");
assert!(!msg.contains("secret"), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_accepts_absent_arguments_on_a_zero_parameter_tool() {
let schema = zero_param_schema();
assert!(validate_input(&schema, None, None).is_ok());
assert!(validate_input(&schema, Some(&Value::Null), None).is_ok());
}
#[test]
fn schema_validation_refuses_absent_arguments_by_required_never_by_type() {
let schema = two_param_schema();
let violations = validate_input(&schema, None, None)
.expect_err("a required parameter must make absent arguments a refusal");
assert!(
violations.iter().any(|v| v.keyword == "required"),
"expected a `required` violation, got {violations:?}"
);
assert!(
violations.iter().all(|v| v.keyword != "type"),
"a `type` violation would mean `null` reached the validator: {violations:?}"
);
let msg = render_refusal(&violations, &["cui", "version"]);
assert!(msg.contains("cui"), "must name the required param: {msg}");
assert!(!msg.contains("null"), "must never echo `null`: {msg}");
}
#[test]
fn schema_validation_renders_byte_identical_refusals_across_repeat_calls() {
let schema = two_param_schema();
let declared = ["cui", "version"];
let args = json!({ "cui": "C0018787", "apiKey": "secret" });
let refuse = || {
let violations = validate_input(&schema, Some(&args), None)
.expect_err("the pair must actually have been refused");
render_refusal(&violations, &declared)
};
let first = refuse();
let second = refuse();
assert_eq!(
first, second,
"0.49.2 error iteration order is deterministic, so refusals must be stable"
);
assert!(!first.is_empty(), "a refusal must never render empty");
}
fn one_prop(property: &Value) -> Value {
json!({
"type": "object",
"properties": { "p": property },
"additionalProperties": false,
})
}
fn refusal_for(schema: &Value, args: &Value, declared: &[&str]) -> String {
let violations =
validate_input(schema, Some(args), None).expect_err("the pair must be refused");
render_refusal(&violations, declared)
}
#[test]
fn schema_validation_max_length_refusal_names_the_limit_not_the_value() {
let schema = one_prop(&json!({ "type": "string", "maxLength": 8 }));
let value = "x".repeat(5000);
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
assert!(msg.contains('8'), "must carry the declared limit: {msg}");
assert!(
!msg.contains(&"x".repeat(10)),
"must not echo the rejected value: {msg}"
);
assert!(!msg.contains(&value), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_enum_refusal_lists_declared_options_only() {
let schema = one_prop(&json!({ "enum": ["exact", "words"] }));
let value = "Jane Doe DOB 1970-01-01";
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
assert!(msg.contains("exact"), "must list declared options: {msg}");
assert!(msg.contains("words"), "must list declared options: {msg}");
assert!(!msg.contains(value), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_pattern_refusal_names_the_declared_pattern() {
let schema = one_prop(&json!({ "type": "string", "pattern": "^C[0-9]+$" }));
let value = "Jane Doe DOB 1970-01-01";
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
assert!(
msg.contains("^C[0-9]+$"),
"must name the declared pattern: {msg}"
);
assert!(!msg.contains(value), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_bound_refusals_name_the_declared_bound_only() {
let cases: &[(Value, Value, &str)] = &[
(
json!({ "type": "integer", "maximum": 10 }),
json!(4242),
"10",
),
(json!({ "type": "integer", "minimum": 10 }), json!(-7), "10"),
(
json!({ "type": "string", "minLength": 4 }),
json!("ab"),
"4",
),
(
json!({ "type": "array", "maxItems": 2 }),
json!(["a", "b", "c"]),
"2",
),
];
for (property, value, declared_bound) in cases {
let schema = one_prop(property);
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
assert!(
msg.contains(declared_bound),
"must name the declared bound {declared_bound}: {msg}"
);
let rendered_value = format!("{value}");
assert!(
!msg.contains(&rendered_value),
"must not echo the rejected value: {msg}"
);
}
}
#[test]
fn schema_validation_type_refusal_names_the_declared_type_only() {
let schema = one_prop(&json!({ "type": "integer" }));
let msg = refusal_for(&schema, &json!({ "p": "Jane Doe" }), &["p"]);
assert!(
msg.contains("integer"),
"must name the declared type: {msg}"
);
assert!(!msg.contains("Jane Doe"), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_format_refusal_names_the_declared_format_only() {
let schema = one_prop(&json!({ "type": "string", "format": "uri" }));
let value = "!!!not-a-uri!!!";
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
assert!(msg.contains("uri"), "must name the declared format: {msg}");
assert!(!msg.contains(value), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_required_refusal_names_the_declared_property() {
let schema = two_param_schema();
let msg = refusal_for(
&schema,
&json!({ "version": "2026AA" }),
&["cui", "version"],
);
assert!(
msg.contains("cui"),
"must name the declared required property: {msg}"
);
}
#[test]
fn schema_validation_additional_properties_refusal_carries_a_count_and_no_keys() {
let schema = two_param_schema();
let msg = refusal_for(
&schema,
&json!({ "cui": "C1", "apiKey": "secret", "Jane Doe DOB 1970-01-01": "x" }),
&["cui", "version"],
);
assert!(msg.contains('2'), "must carry the count 2: {msg}");
assert!(msg.contains("cui"), "must name the allow-list: {msg}");
assert!(msg.contains("version"), "must name the allow-list: {msg}");
assert!(!msg.contains("apiKey"), "must not echo a key: {msg}");
assert!(
!msg.contains("Jane Doe"),
"must not echo a caller key: {msg}"
);
}
#[test]
fn schema_validation_empty_rejected_value_refusal_is_value_free() {
let schema = one_prop(&json!({ "type": "string", "minLength": 3 }));
let msg = refusal_for(&schema, &json!({ "p": "" }), &["p"]);
assert!(msg.contains('3'), "must name the declared minimum: {msg}");
assert!(
msg.contains("at least"),
"must state the declared expectation: {msg}"
);
}
#[test]
fn schema_validation_astral_and_combining_value_never_reaches_the_refusal() {
let schema = one_prop(&json!({ "type": "string", "maxLength": 3 }));
let value = "\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{00E9}\u{0301}";
let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
for ch in value.chars() {
assert!(
!msg.contains(ch),
"code point {ch:?} from the rejected value reached the refusal: {msg}"
);
}
assert!(!msg.contains(value), "must not echo the value: {msg}");
}
#[test]
fn schema_validation_array_form_items_schema_refuses_with_a_schema_keyword() {
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": { "tags": { "type": "array", "items": [ { "type": "string" } ] } },
});
let violations = validate_input(&schema, Some(&json!({ "tags": ["a"] })), None)
.expect_err("a non-compiling declared schema must refuse every call");
assert_eq!(violations.len(), 1, "exactly one schema violation");
assert_eq!(violations[0].keyword, "schema");
let msg = render_refusal(&violations, &["tags"]);
assert!(
!msg.contains("type"),
"the compile detail is logged server-side, never rendered: {msg}"
);
}
#[test]
fn schema_validation_pattern_properties_key_never_reaches_the_refusal() {
let schema = json!({
"type": "object",
"patternProperties": { "^.*$": { "type": "integer" } },
});
let args = json!({ "Jane Doe DOB 1970-01-01": "x" });
let violations =
validate_input(&schema, Some(&args), None).expect_err("a string is not an integer");
assert!(
!violations[0].pointer.contains("Jane Doe"),
"a caller-chosen property name reached `InputViolation::pointer`: {}",
violations[0].pointer
);
let msg = render_refusal(&violations, &["p"]);
assert!(
!msg.contains("Jane Doe"),
"a caller-chosen property name reached the refusal: {msg}"
);
assert!(
!msg.contains("1970-01-01"),
"a caller-chosen property name reached the refusal: {msg}"
);
}
#[test]
fn schema_validation_additional_properties_subschema_key_never_reaches_the_refusal() {
let schema = json!({
"type": "object",
"properties": { "cui": { "type": "string" } },
"additionalProperties": { "type": "integer" },
});
let args = json!({ "Jane Doe DOB 1970-01-01": "x" });
let violations =
validate_input(&schema, Some(&args), None).expect_err("a string is not an integer");
assert!(
!violations[0].pointer.contains("Jane Doe"),
"a caller-chosen property name reached `InputViolation::pointer`: {}",
violations[0].pointer
);
let msg = render_refusal(&violations, &["cui"]);
assert!(
!msg.contains("Jane Doe"),
"a caller-chosen property name reached the refusal: {msg}"
);
}
#[test]
fn schema_validation_declared_property_pointer_survives_the_projection() {
let schema = one_prop(&json!({ "type": "string", "maxLength": 2 }));
let violations = validate_input(&schema, Some(&json!({ "p": "abc" })), None)
.expect_err("over the declared maxLength");
assert_eq!(violations[0].pointer, "/p", "declared names stay verbatim");
}
#[test]
fn schema_validation_declared_array_index_pointer_survives_the_projection() {
let schema = json!({
"type": "object",
"properties": {
"tags": { "type": "array", "items": { "type": "string", "maxLength": 2 } },
},
});
let violations = validate_input(&schema, Some(&json!({ "tags": ["ok", "toolong"] })), None)
.expect_err("the second item is over the declared maxLength");
assert_eq!(
violations[0].pointer, "/tags/1",
"a base-10 array index carries no caller-chosen text"
);
}
#[test]
fn schema_validation_check_input_schema_compiles_names_the_offending_property_path() {
let schema = json!({
"type": "object",
"properties": { "bad": { "type": "string", "pattern": "^[A-Z" } },
});
let violation = check_input_schema_compiles(&schema)
.expect_err("a nested non-compiling `pattern` must be caught at config time");
assert_eq!(violation.keyword, "schema");
assert!(
violation.pointer.contains("bad"),
"the pointer must name the offending property path: {}",
violation.pointer
);
}
#[test]
fn schema_validation_check_input_schema_compiles_accepts_a_well_formed_schema() {
assert!(check_input_schema_compiles(&two_param_schema()).is_ok());
}
const CR01_PAYLOADS: &[&str] = &[
"2026AA?string=x",
"current/../../search/current",
"current/../../search/current?string=x",
"%2e%2e%2f",
"%3Fstring%3Dx",
"a%00b",
"a#frag",
];
#[test]
fn placeholder_accepts_the_cap_and_refuses_one_more() {
let rules = PlaceholderRules::default();
let at_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH);
assert!(validate_path_placeholder("v", &at_cap, &rules).is_ok());
let over_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH + 1);
let refusal = validate_path_placeholder("v", &over_cap, &rules)
.expect_err("one code point over the cap must be refused");
assert_eq!(refusal.rule, "maxLength");
}
#[test]
fn placeholder_counts_code_points_not_bytes() {
let rules = PlaceholderRules::default();
let at_cap = "\u{1F600}".repeat(PLACEHOLDER_MAX_LENGTH);
assert_eq!(at_cap.len(), PLACEHOLDER_MAX_LENGTH * 4, "1024 bytes");
assert!(validate_path_placeholder("v", &at_cap, &rules).is_ok());
}
#[test]
fn placeholder_refuses_an_empty_value() {
let refusal = validate_path_placeholder("v", "", &PlaceholderRules::default())
.expect_err("an empty path segment changes the URL shape");
assert_eq!(refusal.rule, "nonEmpty");
}
#[test]
fn placeholder_refuses_a_bare_slash_unless_the_parameter_opts_in() {
assert!(
validate_path_placeholder("v", "/", &PlaceholderRules::default()).is_err(),
"`/` is denied by default"
);
let opted_in = PlaceholderRules::default().allowing_slash(true);
assert!(
validate_path_placeholder("v", "/", &opted_in).is_ok(),
"D-11: `/` is liftable by per-parameter CONFIG opt-in"
);
}
#[test]
fn placeholder_refuses_the_parent_directory_sequence_with_and_without_slash_opt_in() {
for allow_slash in [false, true] {
let rules = PlaceholderRules::default().allowing_slash(allow_slash);
for value in ["..", "a/../b", "%2e%2e", "%2E%2E"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"traversal has NO escape even with allow_slash={allow_slash}: {value}"
);
}
}
}
#[test]
fn placeholder_refuses_every_cr01_payload() {
let rules = PlaceholderRules::default();
for payload in CR01_PAYLOADS {
assert!(
validate_path_placeholder("version", payload, &rules).is_err(),
"CR-01 payload must be refused: {payload}"
);
}
}
#[test]
fn placeholder_percent_scan_is_case_insensitive_on_hex() {
let rules = PlaceholderRules::default();
for value in ["%2e%2e%2f", "%2E%2E%2F", "%2f", "%2F", "%3f", "%3F"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"the hex scan must be ASCII-case-insensitive: {value}"
);
}
}
#[test]
fn placeholder_refuses_double_encoded_percent() {
let rules = PlaceholderRules::default();
for value in ["%252e", "%252E", "%25", "a%2525b"] {
let refusal = validate_path_placeholder("v", value, &rules)
.expect_err("`%25` must be refused outright, in any hex case");
assert_eq!(
refusal.rule, "percentEncoding",
"refusing `%25` up front is what BOUNDS the decode to one pass: {value}"
);
}
}
#[test]
fn placeholder_refuses_a_malformed_percent_escape() {
let rules = PlaceholderRules::default();
for value in ["%zz", "%2", "%", "a%g0b"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"a malformed escape has no legitimate use in a path value: {value}"
);
}
}
#[test]
fn placeholder_refuses_mixed_literal_and_encoded_traversal() {
let rules = PlaceholderRules::default();
for value in [".%2E", "%2E.", ".%2e", "%2e."] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"step 1 was implemented as an enumeration, not as decode-once: {value}"
);
}
}
#[test]
fn placeholder_refuses_backslash_in_literal_and_encoded_form() {
let rules = PlaceholderRules::default();
for value in ["\\", "..\\", "a\\b", "%5c", "%5C"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"reverse proxies normalize `\\` toward `/`: {value}"
);
}
}
#[test]
fn placeholder_refuses_carriage_return_and_line_feed_in_both_forms() {
let rules = PlaceholderRules::default();
for value in ["a\rb", "a\nb", "a%0db", "a%0Db", "a%0ab", "a%0Ab", "a\tb"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"a control character in a path is response-splitting surface: {value:?}"
);
}
}
#[test]
fn placeholder_refuses_a_bare_single_dot() {
let rules = PlaceholderRules::default();
for value in [".", "%2e", "%2E"] {
assert!(
validate_path_placeholder("v", value, &rules).is_err(),
"two adjacent single dots compose to traversal: {value}"
);
}
}
#[test]
fn placeholder_floor_runs_before_a_permissive_declared_pattern() {
let rules = PlaceholderRules::default().with_pattern(Some("^.*$"));
for payload in CR01_PAYLOADS {
assert!(
validate_path_placeholder("version", payload, &rules).is_err(),
"a permissive declared pattern must not relax the floor: {payload}"
);
}
}
#[test]
fn placeholder_declared_pattern_narrows() {
let rules = PlaceholderRules::default().with_pattern(Some("^C[0-9]+$"));
assert!(validate_path_placeholder("cui", "C0018787", &rules).is_ok());
let refusal = validate_path_placeholder("cui", "ABC", &rules)
.expect_err("the declared pattern must narrow");
assert_eq!(refusal.rule, "pattern");
}
#[test]
fn placeholder_refuses_a_declared_pattern_that_does_not_compile() {
let rules = PlaceholderRules::default().with_pattern(Some("^[A-Z"));
let refusal = validate_path_placeholder("cui", "C1", &rules)
.expect_err("a non-compiling declared pattern must refuse, never pass everything");
assert_eq!(refusal.rule, "pattern");
let rendered = refusal.to_string();
assert!(rendered.contains("cui"), "must name the param: {rendered}");
assert!(
!rendered.contains("C1"),
"must not echo the value: {rendered}"
);
}
#[test]
fn placeholder_declared_max_length_never_widens_the_module_cap() {
let rules = PlaceholderRules::default().with_max_length(Some(512));
let value = "a".repeat(300);
let refusal = validate_path_placeholder("v", &value, &rules)
.expect_err("the module constant is the HARD cap");
assert_eq!(refusal.rule, "maxLength");
}
#[test]
fn placeholder_declared_max_length_narrows_further() {
let rules = PlaceholderRules::default().with_max_length(Some(8));
assert!(validate_path_placeholder("v", "12345678", &rules).is_ok());
let refusal = validate_path_placeholder("v", "123456789", &rules)
.expect_err("the declared length narrows the cap");
assert_eq!(refusal.rule, "maxLength");
}
#[test]
fn placeholder_refusals_name_the_param_and_never_echo_the_value() {
let rules = PlaceholderRules::default().with_max_length(Some(4));
let values = [
"",
"2026AA?string=x",
"current/../../search/current",
"%252e",
"%zz",
"\\",
".",
"aaaaaaaaaa",
];
for value in values {
let refusal = validate_path_placeholder("version", value, &rules)
.expect_err("every one of these is refused");
let rendered = refusal.to_string();
assert!(
rendered.contains("version"),
"must name the declared param: {rendered}"
);
if !value.is_empty() {
assert!(
!rendered.contains(value),
"refusal echoed the rejected value {value:?}: {rendered}"
);
}
}
}
#[test]
fn placeholder_default_rules_are_floored_and_capped_never_permissive() {
let rules = PlaceholderRules::default();
assert!(rules.declared_pattern.is_none());
assert!(rules.declared_max_length.is_none());
assert!(!rules.allow_slash);
let cloned = rules.clone();
for payload in CR01_PAYLOADS {
assert!(
validate_path_placeholder("v", payload, &cloned).is_err(),
"`PlaceholderRules::default()` must be floored and capped: {payload}"
);
}
}
#[test]
fn cache_stops_storing_new_schemas_once_full() {
let compile = |max: u64| {
compile_input_2020_12(&serde_json::json!({ "type": "string", "maxLength": max }))
.map(Arc::new)
.map_err(|_| Arc::<str>::from("unexpected compile failure"))
};
let mut map = HashMap::new();
for n in 0..3_u64 {
remember_bounded(&mut map, 3, format!("k{n}"), compile(n)).expect("compiles");
}
assert_eq!(map.len(), 3, "below the bound every schema is stored");
let overflow = remember_bounded(&mut map, 3, "k-new".to_string(), compile(99))
.expect("an overflow schema still compiles and validates");
assert_eq!(map.len(), 3, "a full cache must not grow");
assert!(
!map.contains_key("k-new"),
"the overflow entry must not be stored"
);
assert!(overflow.is_valid(&Value::String("x".repeat(99))));
assert!(!overflow.is_valid(&Value::String("x".repeat(100))));
let stored = map["k1"].clone().expect("stored");
let again = remember_bounded(&mut map, 3, "k1".to_string(), compile(1)).expect("compiles");
assert!(
Arc::ptr_eq(&stored, &again),
"a key already present must resolve to the stored entry, not a fresh compile"
);
}
#[test]
fn cache_bound_still_reports_compile_failures() {
let mut map = HashMap::new();
map.insert("only".to_string(), Err(Arc::<str>::from("stored")));
let failed = remember_bounded(
&mut map,
1,
"other".to_string(),
Err(Arc::<str>::from("does not compile")),
);
assert!(failed.is_err(), "an overflow failure must still be an Err");
assert_eq!(map.len(), 1);
}
#[test]
fn placeholder_declared_pattern_compiles_once_per_pattern() {
let compiling = json!({ "type": "string", "pattern": "^C[0-9]+$" });
let first = cached_input_validator(&compiling, None).expect("compiles");
let second = cached_input_validator(&compiling, None).expect("compiles");
assert!(
Arc::ptr_eq(&first, &second),
"the second lookup recompiled instead of hitting the cache"
);
let broken = json!({ "type": "string", "pattern": "^[A-Z" });
let first_err = cached_input_validator(&broken, None).expect_err("does not compile");
let second_err = cached_input_validator(&broken, None).expect_err("does not compile");
assert!(
Arc::ptr_eq(&first_err, &second_err),
"a compile FAILURE must be cached too, or a broken declared pattern is \
recompiled on every request"
);
let rules = PlaceholderRules::default().with_pattern(Some("^[A-Z"));
let one = validate_path_placeholder("cui", "C1", &rules)
.expect_err("a broken pattern refuses")
.to_string();
let two = validate_path_placeholder("cui", "C1", &rules)
.expect_err("a broken pattern refuses")
.to_string();
assert_eq!(one, two, "refusals must be byte-identical across calls");
}
#[test]
fn placeholder_operates_on_the_rendered_string_not_a_json_value() {
let rules = PlaceholderRules::default();
for rendered in ["42", "true", "null", "1.5"] {
assert!(
validate_path_placeholder("v", rendered, &rules).is_ok(),
"a rendered scalar is an ordinary value: {rendered}"
);
}
}
#[test]
fn placeholder_is_a_pure_function_safe_under_concurrency() {
let handles: Vec<_> = (0..8)
.map(|worker| {
std::thread::spawn(move || {
let rules = PlaceholderRules::default().with_pattern(Some("^C[0-9]+$"));
let good = format!("C{worker}");
assert!(validate_path_placeholder("cui", &good, &rules).is_ok());
assert!(validate_path_placeholder("cui", "../etc", &rules).is_err());
})
})
.collect();
for handle in handles {
handle.join().expect("no worker panicked");
}
}
#[test]
fn resolved_path_refuses_a_segment_over_the_cap_composed_from_two_passing_values() {
let rules = PlaceholderRules::default();
let a = "a".repeat(180);
let b = "b".repeat(200);
assert!(validate_path_placeholder("a", &a, &rules).is_ok());
assert!(validate_path_placeholder("b", &b, &rules).is_ok());
let composed = format!("/search/{a}{b}");
let refusal = validate_resolved_path(&composed)
.expect_err("the composed segment is over the cap even though each value passed");
assert_eq!(refusal.rule, "segmentMaxLength");
}
#[test]
fn resolved_path_refuses_a_traversal_segment_composed_from_two_single_dots() {
let rules = PlaceholderRules::default();
assert!(
validate_path_placeholder("a", ".", &rules).is_err(),
"the single-dot floor closes this at the value layer"
);
let refusal = validate_resolved_path("/x/..")
.expect_err("the composed segment is the parent-directory sequence");
assert_eq!(refusal.rule, "pathSegment");
}
#[test]
fn resolved_path_refuses_a_residual_placeholder_brace() {
for path in ["/x/{unsubstituted}", "/x/}", "/x/{"] {
assert!(
validate_resolved_path(path).is_err(),
"an unsubstituted placeholder must never reach the wire: {path}"
);
}
}
#[test]
fn resolved_path_refuses_an_empty_interior_segment_and_accepts_a_clean_path() {
assert!(validate_resolved_path("/a//b").is_err(), "empty interior");
assert!(validate_resolved_path("/a/b/").is_err(), "empty trailing");
assert!(validate_resolved_path("/a/b").is_ok(), "a clean path");
assert!(validate_resolved_path("/content/current/CUI/C0018787").is_ok());
}
#[test]
fn resolved_path_accepts_the_absolute_root_and_still_refuses_doubled_and_trailing() {
for path in ["/", "%2F"] {
assert!(
validate_resolved_path(path).is_ok(),
"the absolute root is the shortest legal absolute path, and a `GET /` \
operation must be callable: {path:?} -> {:?}",
validate_resolved_path(path)
);
}
for path in ["//", "///", "/a/b/", "/a//b", "/a/", "/search/"] {
let refusal =
validate_resolved_path(path).expect_err(&format!("must stay refused: {path:?}"));
assert_eq!(
refusal.rule, "pathSegment",
"the empty-segment refusal keeps its rule token: {path:?}"
);
}
assert!(validate_resolved_path("/a").is_ok());
assert!(
validate_resolved_path("").is_err(),
"an empty composed path is not the root and has no endpoint"
);
}
#[test]
fn resolved_path_refuses_encoded_traversal_after_decode_once() {
for path in ["/a/%2e%2e/b", "/a/%2E%2E/b", "/a/%252e%252e/b", "/a/%zz/b"] {
assert!(
validate_resolved_path(path).is_err(),
"decode-once must reach this: {path}"
);
}
}
#[test]
fn resolved_path_refuses_query_and_fragment_markers_and_control_bytes() {
for path in ["/a?b=c", "/a#frag", "/a%3Fb", "/a\\b", "/a%00b", "/a\nb"] {
assert!(
validate_resolved_path(path).is_err(),
"must be refused anywhere in the composed path: {path:?}"
);
}
}
#[test]
fn resolved_path_refuses_a_single_dot_segment() {
assert!(validate_resolved_path("/a/./b").is_err());
assert!(validate_resolved_path("/a/%2e/b").is_err());
}
#[test]
fn resolved_path_refusal_names_the_position_not_a_caller_supplied_name() {
let refusal = validate_resolved_path("/x/../secret").expect_err("traversal");
let rendered = refusal.to_string();
assert!(
rendered.contains("path segment"),
"the composed check is param-agnostic: {rendered}"
);
assert!(
!rendered.contains("secret"),
"must not echo the composed path: {rendered}"
);
}
#[test]
fn resolved_target_accepts_exactly_one_author_written_separator() {
assert!(validate_resolved_target("/a/b").is_ok());
assert!(validate_resolved_target("/a?b=c").is_ok());
assert!(validate_resolved_target("/a?b=c&d=e").is_ok());
}
#[test]
fn resolved_target_refuses_a_second_separator_and_an_empty_query() {
assert!(validate_resolved_target("/a?b=c?d=e").is_err());
assert!(validate_resolved_target("/a?").is_err());
assert!(validate_resolved_target("/a/../secret?b=c").is_err());
assert!(validate_resolved_target("/a?b=%00").is_err());
assert!(validate_resolved_target("/a#frag?b=c").is_err());
}
#[test]
fn resolved_target_applies_path_segment_structure_to_the_query_too() {
assert!(validate_resolved_target("/a?redirect=https://example.com").is_err());
assert!(validate_resolved_target("/a?b=//x").is_err());
assert!(validate_resolved_target("/a?b=1&c=x/").is_err());
}
}