pmcp/server/schema_validation.rs
1//! Runtime enforcement of a tool's declared `inputSchema` (Phase 128, D-01).
2//!
3//! This is the ONE **JSON-Schema** input-validation entry point in the SDK. It
4//! lives in core `pmcp` rather than in a consumer crate so that a tool's
5//! arguments and its `structuredContent` can never be checked under different
6//! dialects, and so the compiled-validator cache, the draft pin and the
7//! value-free refusal renderer exist exactly once.
8//!
9//! The qualifier is load-bearing: `pmcp_server_toolkit::workbook::input::validate_input`
10//! is a SECOND input validator over a declared tool surface. It is not a second
11//! copy of this one and does not belong here — it checks a `CalculateInput`
12//! against a workbook `Manifest` + `CellMap` (dtype, closed-enum membership,
13//! strict-constant overrides), which is a DTO/tier rule set with no JSON Schema
14//! anywhere in it. Anything that IS a JSON Schema check on tool arguments belongs
15//! in this module.
16//!
17//! # Why this is not `super::output_validation`
18//!
19//! - **Inputs REFUSE; outputs only warn.** A `tools/call` whose arguments violate
20//! the declared schema must never reach a backend, so `validate_input`
21//! returns an error rather than emitting a `tracing::warn!`.
22//! - **Inputs compile under Draft 2020-12 on BOTH eras (D-02).** `Era` is
23//! deliberately NOT a parameter here, and inputs do NOT route through
24//! `output_validation::compile_for_era`. Outputs froze v1 at `$schema`
25//! auto-detect because there was shipped behaviour to freeze; inputs have none,
26//! and a config- or `schemars`-declared `$schema` must not be able to change
27//! input enforcement semantics.
28//! - **Inputs assert `format`; outputs do not (Q1).** `format` is annotative by
29//! default in `jsonschema` 0.49, so a declared `format` would silently enforce
30//! nothing. Inputs therefore compile through a format-asserting builder
31//! (`compile_input_2020_12`). Because that changes compile semantics, inputs
32//! keep their own compile entry point and their own validator cache
33//! (`cached_input_validator`, Q6) — a format-asserting and a non-asserting
34//! validator must never collide on one cache key.
35//! - **Refusals are rendered value-free (SC-7).** A `ValidationError`'s `Display`
36//! echoes the rejected value for every keyword, and
37//! `ValidationErrorKind::AdditionalProperties::unexpected` is the caller's own
38//! key list. Neither ever reaches a rendered refusal: see `expectation` and
39//! `render_refusal`. Callers of this module handle PHI.
40//!
41//! No `jsonschema` type appears in any public signature in this module, so a
42//! future `jsonschema` major bump is not a breaking `pmcp` change.
43
44use serde_json::Value;
45use std::borrow::Cow;
46use std::collections::HashMap;
47use std::sync::{Arc, OnceLock, PoisonError, RwLock};
48
49/// Refusal detail for a `tools/call` argument-schema violation.
50///
51/// Deliberately carries DECLARED data only: never the rejected value, and never
52/// a caller-supplied argument key (SC-7).
53#[non_exhaustive]
54#[derive(Debug, Clone)]
55pub struct InputViolation {
56 /// JSON pointer into the arguments.
57 ///
58 /// Always a DECLARED property name, and EMPTY for `additionalProperties`
59 /// (measured: `jsonschema` 0.49.2 reports the instance root for that
60 /// keyword, so there is no name to give). [`render_refusal`] echoes a pointer
61 /// only when its first segment is in the caller-supplied `declared`
62 /// allow-list, so an undeclared name can never reach a client message.
63 pub pointer: String,
64 /// The violated JSON Schema keyword, e.g. `"maxLength"`, `"required"`.
65 ///
66 /// `"schema"` means the tool's own declared `inputSchema` did not compile,
67 /// or the violated keyword has no value-free rendering yet.
68 pub keyword: &'static str,
69 /// The DECLARED expectation, rendered value-free.
70 ///
71 /// For `additionalProperties` this is a COUNT of unknown arguments — never
72 /// the keys themselves.
73 pub expected: String,
74}
75
76/// Check `arguments` against a tool's declared `inputSchema`.
77///
78/// - `arguments` of `None` or [`Value::Null`] is treated as `{}` (the MCP
79/// "missing arguments" semantics), so a zero-parameter tool ACCEPTS while a
80/// tool declaring `required` parameters is refused by the `required` keyword
81/// rather than by `type` — the latter's message would echo `null`.
82/// - Compiled under Draft 2020-12 regardless of protocol era (D-02).
83/// - `additionalProperties` is honoured exactly as the schema declares it, and is
84/// never re-added by this function.
85///
86/// `schema_key` is an optional pre-computed cache key for `schema` — normally
87/// `schema.to_string()`, computed ONCE when a long-lived handler is built so the
88/// hot `tools/call` path does not re-serialize the whole `inputSchema` per
89/// request. `None` computes it internally, which is what a one-shot caller wants.
90/// It is a `&str` and not a `jsonschema` type, so it does not widen this module's
91/// public API onto `jsonschema`.
92///
93/// # Errors
94///
95/// `Err(Vec<InputViolation>)` on violation. A declared schema that does not
96/// compile yields a single violation with `keyword: "schema"` — a drifted
97/// declaration must refuse, never pass everything.
98pub fn validate_input(
99 schema: &Value,
100 arguments: Option<&Value>,
101 schema_key: Option<&str>,
102) -> Result<(), Vec<InputViolation>> {
103 let validator = match cached_input_validator(schema, schema_key) {
104 Ok(v) => v,
105 Err(detail) => {
106 // The DETAIL is a schema-compilation message (author-supplied schema
107 // text, never caller arguments), so it is safe to log server-side —
108 // and it is deliberately NOT what the client is told.
109 tracing::warn!(
110 detail = %detail,
111 "declared inputSchema is not a valid JSON Schema; refusing every call to this tool"
112 );
113 return Err(vec![InputViolation {
114 pointer: String::new(),
115 keyword: "schema",
116 expected: UNCOMPILABLE_SCHEMA.to_string(),
117 }]);
118 },
119 };
120
121 let arguments = effective_arguments(arguments);
122 // Fast path: the conforming case is the common one and `is_valid` short-circuits
123 // without building any error values.
124 if validator.is_valid(arguments) {
125 return Ok(());
126 }
127
128 let violations: Vec<InputViolation> = validator
129 .iter_errors(arguments)
130 .map(|e| violation(&e, schema))
131 .collect();
132 if violations.is_empty() {
133 // `is_valid` disagreed with `iter_errors`. Refuse rather than fall through
134 // to the backend: an enforcement that cannot describe a failure must still
135 // enforce it.
136 return Err(vec![InputViolation {
137 pointer: String::new(),
138 keyword: "schema",
139 expected: GENERIC_MISMATCH.to_string(),
140 }]);
141 }
142 Err(violations)
143}
144
145/// What a client is told when the tool's own declared `inputSchema` does not
146/// compile. Deliberately detail-free — the detail is logged server-side.
147const UNCOMPILABLE_SCHEMA: &str = "the tool's declared inputSchema is not a valid JSON Schema";
148
149/// The fallback expectation for a keyword with no value-free rendering yet.
150const GENERIC_MISMATCH: &str = "does not match the declared schema";
151
152/// Substitute a missing or `null` `arguments` with `{}` BEFORE the validator sees
153/// it.
154///
155/// Measured necessary: `jsonschema` 0.49.2 refuses `null` against `type: object`
156/// with a `Type` error whose message echoes `null`, whereas an empty object is
157/// refused by `Required { property }` — a DECLARED name. This substitution is what
158/// makes the two easily-conflated acceptance rows come out right: a zero-parameter
159/// tool ACCEPTS, and a tool declaring `required` parameters is refused by
160/// `required`, never by `type`.
161fn effective_arguments(arguments: Option<&Value>) -> &Value {
162 static EMPTY: OnceLock<Value> = OnceLock::new();
163 match arguments {
164 Some(v) if !v.is_null() => v,
165 _ => EMPTY.get_or_init(|| Value::Object(serde_json::Map::new())),
166 }
167}
168
169/// Project one `jsonschema` error into an [`InputViolation`], value-free.
170///
171/// `schema` is the DECLARED schema the error came from; it is what lets
172/// `safe_pointer` tell a declared property name apart from a caller-chosen one.
173fn violation(e: &jsonschema::ValidationError<'_>, schema: &Value) -> InputViolation {
174 let (keyword, expected) =
175 expectation(e).unwrap_or_else(|| ("schema", GENERIC_MISMATCH.to_string()));
176 InputViolation {
177 pointer: safe_pointer(e, schema),
178 keyword,
179 expected,
180 }
181}
182
183/// The fixed token a non-declared pointer segment is replaced by.
184///
185/// Carries no length and no hash of the redacted key: a length is a side channel
186/// on a value that may itself be PHI (T-128-08a).
187///
188/// `pub(crate)` so the E3 path (`super::typed_tool`'s `garde` mapping) redacts a
189/// caller-chosen `garde::Path` segment with the SAME token rather than a second
190/// literal that could drift — a D1 and an E3 refusal must read alike (T-128-17b).
191pub(crate) const REDACTED_SEGMENT: &str = "<redacted>";
192
193/// Project `e`'s instance pointer onto the DECLARED schema, redacting every
194/// segment whose name came from the instance rather than from the declaration.
195///
196/// # Why this is a projection and not a copy
197///
198/// RESEARCH Finding 1c records that `instance_path()` "is safe for declared
199/// properties" — and that qualifier is load-bearing. [`validate_input`] is public
200/// and accepts arbitrary schemas, so the qualifier does not hold in general.
201/// Measured counterexample: under
202/// `{"type":"object","additionalProperties":{"type":"integer"}}` — or under any
203/// schema using `patternProperties` — the property name is chosen by the CALLER
204/// and appears verbatim in the pointer, so
205/// `{"Jane Doe DOB 1970-01-01": "x"}` yields the pointer
206/// `/Jane Doe DOB 1970-01-01`. Copying that into a refusal violates SC-7 even
207/// though no `ValidationError` was ever `Display`-formatted, which is precisely
208/// the leak this module exists to prevent.
209///
210/// A segment is emitted VERBATIM only when it is
211///
212/// - a key of the current schema node's `properties` map (a DECLARED name), or
213/// - a base-10 integer, i.e. an array index, which carries no caller-chosen text.
214///
215/// Every other segment becomes [`REDACTED_SEGMENT`]. An `additionalProperties:
216/// false` violation keeps the empty pointer `jsonschema` already reports for it,
217/// so it never names a key at all.
218fn safe_pointer(e: &jsonschema::ValidationError<'_>, schema: &Value) -> String {
219 let raw = e.instance_path().as_str();
220 if raw.is_empty() {
221 return String::new();
222 }
223 let mut node = Some(schema);
224 let mut out = String::new();
225 for token in raw.trim_start_matches('/').split('/') {
226 let (rendered, next) = project_pointer_segment(node, token);
227 out.push('/');
228 out.push_str(rendered);
229 node = next;
230 }
231 out
232}
233
234/// One step of [`safe_pointer`]'s walk: what to emit, and the schema node the
235/// next segment is resolved against.
236fn project_pointer_segment<'a>(
237 node: Option<&'a Value>,
238 token: &'a str,
239) -> (&'a str, Option<&'a Value>) {
240 if !token.is_empty() && token.bytes().all(|byte| byte.is_ascii_digit()) {
241 // An array index. `items` is the 2020-12 object form; array-form `items`
242 // does not compile under this module's pin (RESEARCH Finding 1h), so a
243 // single subschema is the only shape reachable here.
244 return (token, node.and_then(|n| n.get("items")));
245 }
246 let decoded = unescape_pointer_token(token);
247 match node
248 .and_then(|n| n.get("properties"))
249 .and_then(|properties| properties.get(decoded.as_ref()))
250 {
251 // Declared: emit the ORIGINAL token, so the pointer stays valid RFC 6901.
252 Some(child) => (token, Some(child)),
253 None => (REDACTED_SEGMENT, None),
254 }
255}
256
257/// Decode one RFC 6901 pointer token (`~1` -> `/`, `~0` -> `~`), in that order.
258///
259/// Only needed for the `properties` lookup — the emitted text is always the
260/// original, still-escaped token.
261fn unescape_pointer_token(token: &str) -> Cow<'_, str> {
262 if token.contains('~') {
263 Cow::Owned(token.replace("~1", "/").replace("~0", "~"))
264 } else {
265 Cow::Borrowed(token)
266 }
267}
268
269/// Render a SCHEMA-COMPILATION error's own text.
270///
271/// This is the ONLY place in this module where a `jsonschema` error is
272/// `Display`-formatted, and it is deliberately reachable from exactly two
273/// callers: the server-side `tracing::warn!` in [`validate_input`] and
274/// [`check_input_schema_compiles`], whose audience is a CONFIG AUTHOR.
275///
276/// The distinction is not cosmetic. A *validation* error's `Display` echoes the
277/// rejected caller value for every keyword (RESEARCH Finding 1b) and must never
278/// be rendered — that is what `expectation` exists for. A *compilation* error
279/// describes author-supplied schema text and contains no caller data at all, so
280/// rendering it is safe in both of those positions and in neither is it sent to
281/// an MCP client on a `tools/call` path.
282fn compile_error_detail(error: &jsonschema::ValidationError<'_>) -> String {
283 format!("{error}")
284}
285
286/// The DECLARED expectation behind a validation error, or `None` when this keyword
287/// has no value-free rendering yet.
288///
289/// Every arm reads ONLY declared data. `AdditionalProperties.unexpected` is the
290/// caller's own key list — which may itself be sensitive — and is therefore read
291/// exclusively through `.len()`. A `ValidationError`'s `Display` is never used: it
292/// echoes the rejected value for every keyword.
293fn expectation(e: &jsonschema::ValidationError<'_>) -> Option<(&'static str, String)> {
294 use jsonschema::error::ValidationErrorKind as K;
295
296 Some(match e.kind() {
297 // `unexpected` is the CALLER-SUPPLIED key list — it is the attacker's own
298 // text and may itself be PHI. It is read EXCLUSIVELY through `.len()`;
299 // never iterate it, never index it, never format it.
300 K::AdditionalProperties { unexpected } => (
301 "additionalProperties",
302 format!("unknown argument(s): {}", unexpected.len()),
303 ),
304 K::Required { property } => (
305 "required",
306 format!(
307 "`{}` is required",
308 property.as_str().unwrap_or(UNNAMED_PROPERTY)
309 ),
310 ),
311 K::MaxLength { limit } => ("maxLength", format!("at most {limit} characters")),
312 K::MinLength { limit } => ("minLength", format!("at least {limit} characters")),
313 K::Pattern { pattern } => ("pattern", format!("must match {pattern}")),
314 K::Maximum { limit } => ("maximum", format!("at most {limit}")),
315 K::Minimum { limit } => ("minimum", format!("at least {limit}")),
316 K::Enum { options } => ("enum", format!("one of {options}")),
317 K::MaxItems { limit } => ("maxItems", format!("at most {limit} items")),
318 K::Type { kind } => ("type", format!("must be {}", type_expectation(kind))),
319 // Q1 turns format ASSERTION on for inputs, so this is a kind this module
320 // actively produces rather than one it merely tolerates. The `format`
321 // name is declared config content and is safe to echo; without this arm a
322 // `format` refusal would be indistinguishable from an unknown failure and
323 // would quietly undercut the reason Q1 chose to enforce it at all.
324 K::Format { format } => ("format", format!("must be a valid {format}")),
325 // The regex engine's OWN ReDoS guard firing on caller input against a
326 // config-declared `pattern`. This is simultaneously a refusal and a
327 // signal about the declaration, so an operator needs it in the logs; the
328 // log line names the DECLARED schema position only (`schema_path`, e.g.
329 // `/properties/cui/pattern`) and never the value. That log line is the
330 // instrument for T-128-10's accepted assumption-A2 residual, and
331 // `fuzz_placeholder_pattern_redos` (plan 10) is what looks for it
332 // deliberately.
333 K::BacktrackLimitExceeded { .. } => {
334 tracing::warn!(
335 schema_path = %e.schema_path(),
336 "declared `pattern` hit the regex backtracking limit on caller input; refusing"
337 );
338 ("pattern", BACKTRACK_LIMIT.to_string())
339 },
340 _ => return None,
341 })
342}
343
344/// Render a `Type` violation's DECLARED type (or type union), value-free.
345fn type_expectation(kind: &jsonschema::error::TypeKind) -> String {
346 use jsonschema::error::TypeKind;
347 match kind {
348 TypeKind::Single(declared) => declared.as_str().to_owned(),
349 TypeKind::Multiple(declared) => declared
350 .iter()
351 .map(jsonschema::JsonType::as_str)
352 .collect::<Vec<&str>>()
353 .join(" or "),
354 }
355}
356
357/// What a client is told when the engine's backtracking limit fires.
358///
359/// Names neither the pattern nor the value: which of the two is at fault is not
360/// decidable from here, and the pair is exactly what an attacker probing a
361/// pathological declared pattern would want confirmed.
362const BACKTRACK_LIMIT: &str = "could not be evaluated against the declared pattern";
363
364/// Stand-in for a `Required { property }` payload that is not a JSON string. Not
365/// reachable from a well-formed schema; present so this path cannot panic.
366const UNNAMED_PROPERTY: &str = "<unnamed>";
367
368/// Compile `schema` under an explicitly-pinned Draft 2020-12 with `format`
369/// ASSERTION enabled (Q1).
370///
371/// `format` is annotative by default in `jsonschema` 0.49 — a declared
372/// `format: "uri"` accepts `"!!!not-a-uri!!!"` — and a config keyword that
373/// silently enforces nothing is the defect class this phase exists to remove. The
374/// opt-in changes compile semantics, which is exactly why inputs have their own
375/// compile entry point instead of sharing `output_validation::compile_2020_12`.
376///
377/// `normalize_schema_dialect` is REUSED from `output_validation` rather than
378/// copied, so a declared legacy `$schema` is normalized identically for inputs and
379/// outputs. That module's `normalize_schema_dialect` / `compile_2020_12` /
380/// `cached_validator` split exists to stay under the CI cognitive-complexity gate:
381/// this function is a FOURTH sibling of it, never a fifth branch inside it.
382fn compile_input_2020_12(
383 schema: &Value,
384) -> Result<jsonschema::Validator, jsonschema::ValidationError<'static>> {
385 let normalized = super::output_validation::normalize_schema_dialect(schema);
386 jsonschema::options()
387 .with_draft(jsonschema::Draft::Draft202012)
388 .should_validate_formats(true)
389 .build(&normalized)
390}
391
392type InputValidatorCache = RwLock<HashMap<String, Result<Arc<jsonschema::Validator>, Arc<str>>>>;
393
394/// The process-global memo behind [`cached_input_validator`]. Module-scope so the
395/// bound below is observable from a test.
396static INPUT_VALIDATOR_CACHE: OnceLock<InputValidatorCache> = OnceLock::new();
397
398/// Most distinct schemas [`cached_input_validator`] will remember.
399///
400/// The memo is keyed on schema TEXT and never evicts, so without a bound its size
401/// is a function of how many DISTINCT schemas the process ever sees. For a
402/// config-driven server that is the number of declared tools and patterns, which
403/// is small and fixed. But `validate_input` is `pub`, and
404/// `fuzz_placeholder_pattern_redos` drives arbitrary declared patterns through
405/// `declared_pattern_check`: measured, that target reached libFuzzer's 2048 MB RSS
406/// limit in about 130k executions (~8 KB retained per distinct pattern), both in CI
407/// and locally.
408///
409/// Once the map holds this many entries a NEW schema is compiled and returned
410/// WITHOUT being stored. That is the same shape as the toolkit's
411/// `MAX_REMEMBERED` memo in `code_mode.rs`, and for the same reason: enforcement
412/// never depends on the memo. A full cache costs a recompile per call for schemas
413/// beyond the bound; it never changes a verdict. Schemas already stored keep
414/// hitting.
415///
416/// 4096 is deliberately far above any realistic server (a 500-tool server with a
417/// patterned parameter each is ~1,500 schemas), so the ordinary case never sees the
418/// bound, while worst-case retained memory stays in the tens of megabytes.
419const MAX_CACHED_VALIDATORS: usize = 4096;
420
421/// Fetch (or compile and cache) the input validator for `schema`.
422///
423/// Keyed on the canonical schema TEXT alone, and deliberately SEPARATE from
424/// `output_validation::cached_validator`:
425///
426/// - a format-asserting validator (this one) and a non-asserting one (outputs)
427/// must never collide on the same key; and
428/// - `Era` is not part of the key because inputs are era-free (D-02) — an
429/// `Era`-keyed input cache would encode a distinction that does not exist.
430///
431/// Compilation errors are cached too, as the error string, so a drifted schema
432/// does not recompile on every call.
433///
434/// `schema_key`, when supplied, is the caller's pre-computed `schema.to_string()`;
435/// a cache HIT then costs no serialization at all.
436fn cached_input_validator(
437 schema: &Value,
438 schema_key: Option<&str>,
439) -> Result<Arc<jsonschema::Validator>, Arc<str>> {
440 let cache = INPUT_VALIDATOR_CACHE.get_or_init(InputValidatorCache::default);
441
442 // An `RwLock` rather than a `Mutex` because the steady state is ALL reads: the
443 // map is written once per distinct schema and then hit on every `tools/call`
444 // forever. Under a `Mutex` every validation in the process serializes against
445 // every other one, across all tools, for a lookup that mutates nothing.
446 //
447 // Why the poison recovery: a poisoned lock here only means another thread
448 // panicked while inserting; the map itself is still usable — recover rather
449 // than propagate a panic out of a request-path guard.
450 //
451 // The key is resolved BEFORE the lookup so that BOTH callers reach the read
452 // path. Gating the read probe on `schema_key.is_some()` instead would leave
453 // `declared_pattern_check` — which passes `None` and runs once per declared
454 // `pattern` per placeholder per request — taking the EXCLUSIVE write lock on
455 // every request, excluding exactly the readers an `RwLock` exists to admit.
456 // A pre-computed key additionally lets a hit avoid re-serializing the schema.
457 let key: Cow<'_, str> = match schema_key {
458 Some(k) => Cow::Borrowed(k),
459 None => Cow::Owned(schema.to_string()),
460 };
461
462 {
463 let map = cache.read().unwrap_or_else(PoisonError::into_inner);
464 if let Some(hit) = map.get(key.as_ref()) {
465 return hit.clone();
466 }
467 }
468
469 // Compiled OUTSIDE the write lock. Compilation builds the regex set for every
470 // declared `pattern`, and holding the exclusive lock across it stalls every
471 // reader in the process. Losing a race costs one redundant compile, which
472 // `or_insert` then discards — cheaper than serializing all readers behind it.
473 let compiled = compile_input_2020_12(schema)
474 .map(Arc::new)
475 // `compile_error_detail` is the ONE audited Display-render site; this is a
476 // COMPILATION error, so it carries author-supplied schema text and no
477 // caller data.
478 .map_err(|error| Arc::from(compile_error_detail(&error).as_str()));
479
480 let mut map = cache.write().unwrap_or_else(PoisonError::into_inner);
481 remember_bounded(&mut map, MAX_CACHED_VALIDATORS, key.into_owned(), compiled)
482}
483
484/// Store `compiled` under `key` unless the map already holds `cap` entries, and
485/// return the entry the caller should use.
486///
487/// Split out of [`cached_input_validator`] so the bound is testable on a LOCAL map
488/// with a tiny cap. Testing it through the process-global cache would fill that
489/// cache for every other test in the binary and break the ones that assert a
490/// cache hit.
491///
492/// When full, a NEW key is returned uncached. A key already present — we lost a
493/// compile race to another thread — still resolves to the stored entry, so two
494/// racing callers never disagree about which `Arc` is canonical.
495fn remember_bounded(
496 map: &mut HashMap<String, Result<Arc<jsonschema::Validator>, Arc<str>>>,
497 cap: usize,
498 key: String,
499 compiled: Result<Arc<jsonschema::Validator>, Arc<str>>,
500) -> Result<Arc<jsonschema::Validator>, Arc<str>> {
501 if map.len() >= cap && !map.contains_key(&key) {
502 return compiled;
503 }
504 map.entry(key).or_insert(compiled).clone()
505}
506
507/// Check that `schema` compiles as a Draft 2020-12 input schema — the
508/// CONFIG-TIME gate (SC-2).
509///
510/// This exists as a separate entry point from [`validate_input`] because its
511/// audience is different. Here the schema is AUTHOR-supplied (a server's own
512/// config, or a bundled `OpenAPI` spec) and there is no caller data anywhere in
513/// scope, so echoing the compile error's own detail is exactly what the author
514/// needs. The SC-7 no-echo rule governs the client-facing `tools/call` path, and
515/// on that path a non-compiling declared schema still yields the detail-free
516/// refusal [`validate_input`] returns.
517///
518/// The returned `pointer` comes from the compile error's `schema_path`, which
519/// points straight at the offending declaration — measured as
520/// `/properties/<param>/pattern` for a nested non-compiling `pattern`
521/// (RESEARCH Finding 1e). No projection through `safe_pointer` is needed or
522/// wanted: a schema path is entirely declaration-derived.
523///
524/// # `jsonschema::meta::is_valid` is NOT the check
525///
526/// It returns `true` for a schema whose nested `pattern` does not compile
527/// (measured, RESEARCH Finding 1e), so a meta-schema validation would pass over
528/// the single most common authoring mistake this gate exists to catch. The check
529/// has to be a real Draft 2020-12 compile of the synthesized `inputSchema`.
530///
531/// # Errors
532///
533/// `Err(InputViolation)` with `keyword: "schema"` when `schema` does not compile.
534pub fn check_input_schema_compiles(schema: &Value) -> Result<(), InputViolation> {
535 match compile_input_2020_12(schema) {
536 Ok(_) => Ok(()),
537 Err(error) => Err(InputViolation {
538 pointer: error.schema_path().as_str().to_string(),
539 keyword: "schema",
540 expected: compile_error_detail(&error),
541 }),
542 }
543}
544
545/// Render `violations` as ONE client-facing refusal message.
546///
547/// `declared` is the tool's declared parameter names, in declaration order; it is
548/// the ONLY name source this function will echo.
549///
550/// Shape (locked by SC-7): `unknown argument(s): 2; allowed: cui, version`.
551#[must_use]
552pub fn render_refusal(violations: &[InputViolation], declared: &[&str]) -> String {
553 let allowed = if declared.is_empty() {
554 "(this tool declares no parameters)".to_string()
555 } else {
556 declared.join(", ")
557 };
558 if violations.is_empty() {
559 return format!("arguments do not match the declared schema; allowed: {allowed}");
560 }
561 violations
562 .iter()
563 .map(|v| render_one(v, declared, &allowed))
564 .collect::<Vec<String>>()
565 .join("; ")
566}
567
568/// Render ONE violation.
569///
570/// An `additionalProperties` refusal is the locked SC-7 shape: a COUNT plus the
571/// declared allow-list, because the keyword reports the instance root and there is
572/// no safe name to give. Every other keyword may name its location — but ONLY when
573/// the pointer's first segment is a DECLARED parameter, so a pointer segment that
574/// came from the caller can never reach a client message.
575fn render_one(v: &InputViolation, declared: &[&str], allowed: &str) -> String {
576 if v.keyword == "additionalProperties" {
577 return format!("{}; allowed: {allowed}", v.expected);
578 }
579 let first = v.pointer.trim_start_matches('/').split('/').next();
580 match first {
581 Some(name) if !name.is_empty() && declared.contains(&name) => {
582 format!("{}: {}", v.pointer, v.expected)
583 },
584 _ => v.expected.clone(),
585 }
586}
587
588// ===========================================================================
589// D4 — the ONE copy of the path-placeholder character floor.
590// ===========================================================================
591
592/// The hard upper bound on a path-placeholder value, in Unicode code points.
593///
594/// Deliberately a module CONSTANT and not a configuration value. D-08 requires
595/// the length half of the CR-01 fix to hold regardless of how D3 is configured —
596/// in particular when `default_max_length` is set to `0`, which would otherwise
597/// silently disable the "Length via two placeholders" acceptance row along with
598/// the free-text policy. A validation rule that a configuration value can switch
599/// off is the silent-hole class this phase exists to remove.
600///
601/// Counted in code points, not bytes and not grapheme clusters, so it agrees
602/// with `jsonschema`'s own `maxLength` semantics (RESEARCH Finding 1d).
603pub const PLACEHOLDER_MAX_LENGTH: usize = 256;
604
605/// The per-parameter narrowing a caller may declare on top of the floor.
606///
607/// `Default` means FLOOR-PLUS-CAP WITH NO NARROWING — all-`None` plus
608/// `allow_slash: false`. It emphatically does NOT mean "no checks": the
609/// unconditional floor and [`PLACEHOLDER_MAX_LENGTH`] still apply, which is why
610/// [`PlaceholderRules::default()`] is a safe value for a trait's default method
611/// body to return.
612///
613/// This struct is `#[non_exhaustive]`, so another crate cannot build it with a
614/// struct literal. Use [`PlaceholderRules::default()`] and the `with_*` builders:
615///
616/// ```ignore
617/// let rules = PlaceholderRules::default()
618/// .with_pattern(Some("^C[0-9]+$"))
619/// .with_max_length(Some(32));
620/// ```
621#[non_exhaustive]
622#[derive(Debug, Clone, Default)]
623pub struct PlaceholderRules<'a> {
624 /// A `pattern` declared for this parameter, which NARROWS the floor.
625 ///
626 /// Never replaces it: a spec pattern of `^.*$` accepts every CR-01 payload
627 /// (measured, RESEARCH Finding 5b), so pattern-supersedes-denylist would be a
628 /// no-op (D-10).
629 pub declared_pattern: Option<&'a str>,
630 /// A `maxLength` declared for this parameter.
631 ///
632 /// Narrows [`PLACEHOLDER_MAX_LENGTH`] further; a declared length LARGER than
633 /// the constant never widens it.
634 pub declared_max_length: Option<usize>,
635 /// Whether `/` is permitted inside this parameter's value.
636 ///
637 /// Its ONLY legitimate source is an explicit per-parameter entry in the
638 /// server's own config (D-11). An `OpenAPI` spec's `allowReserved` must never
639 /// be wired to it: a spec is third-party content baked into the package and
640 /// that keyword is widely copy-pasted without intent. Even when this is
641 /// `true`, the parent-directory sequence is refused with no escape.
642 pub allow_slash: bool,
643}
644
645impl<'a> PlaceholderRules<'a> {
646 /// Declare a narrowing `pattern`.
647 #[must_use]
648 pub fn with_pattern(mut self, pattern: Option<&'a str>) -> Self {
649 self.declared_pattern = pattern;
650 self
651 }
652
653 /// Declare a narrowing `maxLength`.
654 #[must_use]
655 pub fn with_max_length(mut self, max_length: Option<usize>) -> Self {
656 self.declared_max_length = max_length;
657 self
658 }
659
660 /// Opt this parameter in to `/` (D-11 — config only, never spec-derived).
661 #[must_use]
662 pub fn allowing_slash(mut self, allow_slash: bool) -> Self {
663 self.allow_slash = allow_slash;
664 self
665 }
666}
667
668/// Why a path-placeholder value was refused.
669///
670/// Carries the DECLARED parameter name and the DECLARED expectation only — never
671/// a byte of the rejected value, and never which character tripped the floor
672/// (naming the character would itself echo a byte of the value and turn the
673/// refusal into a one-byte oracle).
674#[non_exhaustive]
675#[derive(Debug, Clone)]
676pub struct PlaceholderRefusal {
677 /// The DECLARED parameter name, safe to echo.
678 pub param: String,
679 /// Which rule refused: `"nonEmpty"`, `"percentEncoding"`, `"characterFloor"`,
680 /// `"maxLength"`, `"pattern"`, `"segmentMaxLength"` or `"pathSegment"`.
681 pub rule: &'static str,
682 /// The DECLARED expectation, rendered value-free.
683 pub expected: String,
684}
685
686impl std::fmt::Display for PlaceholderRefusal {
687 /// Renders in the `render_scalar` house style (PATTERNS SP-1): name the
688 /// parameter, state the declared expectation, never the value.
689 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
690 write!(f, "param '{}' {}", self.param, self.expected)
691 }
692}
693
694impl std::error::Error for PlaceholderRefusal {}
695
696/// The expectation a floor refusal states when `/` is denied.
697const FLOOR_EXPECTATION: &str = "must not contain a path separator, a \
698 parent-directory sequence, a query or fragment marker, a backslash or any \
699 control character — in literal or percent-encoded form — and must not be a \
700 single dot";
701
702/// The same, for a parameter that opted in to `/` (D-11).
703const FLOOR_EXPECTATION_SLASH_ALLOWED: &str = "must not contain a \
704 parent-directory sequence, a query or fragment marker, a backslash or any \
705 control character — in literal or percent-encoded form — and must not be a \
706 single dot";
707
708/// The expectation a percent-handling refusal states.
709const PERCENT_EXPECTATION: &str = "must not contain an encoded percent sign \
710 (`%25`) or a malformed percent escape";
711
712/// The fixed `param` a composed-path refusal carries.
713///
714/// [`validate_resolved_path`] is param-agnostic by construction: the composition
715/// it checks belongs to no single parameter, so there is no declared name to give
716/// and a caller-supplied one must never be substituted.
717const COMPOSED_POSITION: &str = "path segment";
718
719/// Validate ONE path-placeholder value before substitution (D4).
720///
721/// This is the single copy of the D4 character floor in the SDK. It lives in core
722/// `pmcp` — not in `pmcp-code-mode` — because BOTH HTTP surfaces must reach it:
723/// the curated single-call build resolves to the toolkit's `http` feature, whose
724/// dependency list carries no `pmcp-code-mode` edge (RESEARCH Finding 6), so a
725/// helper exported only from there would force either a new dependency edge that
726/// widens the curated graph or two copies of one security rule — the drift class
727/// this repo has already been bitten by. D-09's published-helper obligation is met
728/// by a `pub use` re-export from `pmcp-code-mode`.
729///
730/// # The four steps, and why the order is load-bearing
731///
732/// 1. **Unconditional floor**, which no declared pattern can relax (D-10). It is
733/// implemented as DECODE ONCE, THEN DENY — never as an enumerated denylist of
734/// literal and pre-encoded spellings, because an enumeration is incomplete by
735/// construction: a mixed form such as `.%2E` or `%2E.` decodes to the
736/// parent-directory sequence while matching neither the literal nor the
737/// fully-encoded spelling. Enumerating more spellings does not converge;
738/// decoding does.
739/// 2. **Always-on cap** at [`PLACEHOLDER_MAX_LENGTH`] code points (D-08).
740/// 3. **Declared pattern narrows** — evaluated through `cached_input_validator`,
741/// so a placeholder pattern and an `inputSchema` pattern resolve `\s` through
742/// the identical engine AND a repeated pattern compiles once.
743/// 4. **Declared length narrows further**; a declared length larger than the
744/// module constant never widens it.
745///
746/// Running the floor FIRST is empirically justified, not stylistic: a spec pattern
747/// of `^.*$` accepts every CR-01 payload (measured, RESEARCH Finding 5b), so
748/// pattern-supersedes-floor would have silently disabled the check.
749///
750/// This is a pure function holding no shared mutable state of its own, so two
751/// concurrent Code Mode calls on one executor cannot interleave placeholder state.
752///
753/// `value` is the value ALREADY RENDERED to a string by the caller (the toolkit's
754/// `render_scalar`), so the floor and the cap see the rendered text rather than a
755/// JSON number or bool.
756///
757/// # Not sufficient on its own
758///
759/// Per-value checks cannot establish final-path safety. Every caller MUST also run
760/// [`validate_resolved_path`] on the composed path before dispatch.
761///
762/// # Errors
763///
764/// `Err(PlaceholderRefusal)` naming the DECLARED parameter and the DECLARED
765/// expectation, never the value.
766pub fn validate_path_placeholder(
767 param: &str,
768 value: &str,
769 rules: &PlaceholderRules<'_>,
770) -> Result<(), PlaceholderRefusal> {
771 // 1. UNCONDITIONAL FLOOR — before any declared narrowing (D-10).
772 placeholder_floor(param, value, rules.allow_slash)?;
773
774 // 2. ALWAYS-ON CAP (D-08), in code points to agree with `maxLength`.
775 let length = value.chars().count();
776 if length > PLACEHOLDER_MAX_LENGTH {
777 return Err(refusal(
778 param,
779 "maxLength",
780 format!("must be at most {PLACEHOLDER_MAX_LENGTH} characters"),
781 ));
782 }
783
784 // 3. DECLARED PATTERN NARROWS (never replaces).
785 if let Some(pattern) = rules.declared_pattern {
786 declared_pattern_check(param, value, pattern)?;
787 }
788
789 // 4. DECLARED LENGTH NARROWS FURTHER. A declared length larger than the
790 // module constant cannot widen it, because step 2 already ran.
791 if let Some(declared) = rules.declared_max_length {
792 if length > declared {
793 return Err(refusal(
794 param,
795 "maxLength",
796 format!("must be at most {declared} characters"),
797 ));
798 }
799 }
800 Ok(())
801}
802
803/// Validate the COMPOSED path after substitution and before dispatch.
804///
805/// # Why a per-value check is insufficient by construction
806///
807/// This is a proof, not a caution. Two values that each pass
808/// [`validate_path_placeholder`] independently can compose into a refused form
809/// across adjacent placeholders:
810///
811/// - `/search/{a}{b}` with `a` at 180 code points and `b` at 200 yields a
812/// 380-code-point segment — over the cap, with neither part over it.
813/// - `/x/{a}{b}` with `a = "."` and `b = "."` yields the segment `..` — traversal
814/// — from two values neither of which contains the sequence.
815///
816/// Adjacency is reachable rather than theoretical: on the Code Mode surface the
817/// substitution is genuinely per-`{key}`, and the composed result is then parsed
818/// as a URL, which is where a composed traversal becomes a different endpoint.
819///
820/// # Contract
821///
822/// The same decode-once normalization as the per-value floor runs over the WHOLE
823/// path; then the path is split on `/` and each segment is refused when it is
824/// longer than [`PLACEHOLDER_MAX_LENGTH`] code points, equal to the
825/// parent-directory sequence, equal to a single dot, or empty other than the
826/// leading segment a path starting with `/` produces. A residual `{` or `}` is
827/// refused — an unsubstituted placeholder reaching the wire is its own defect —
828/// and `?`, `#`, a backslash and any control byte are refused anywhere.
829///
830/// # The three empty-segment cases, decided rather than derived
831///
832/// The segment split reports an empty slice in three different situations, and
833/// they get three different answers on purpose:
834///
835/// | Path | Verdict | Why |
836/// |---|---|---|
837/// | `/` | ACCEPTED | the absolute root has no segments; it is the shortest legal absolute path and the target of a `GET /` operation |
838/// | `/a//b` | refused | a doubled separator changes the endpoint shape |
839/// | `/a/b/` | refused | this is what `/search/{v}` composes to when `v` is empty — the empty-placeholder-at-the-tail case |
840///
841/// The first was a defect until Phase 128 CR-01: it fell out of the index
842/// arithmetic (only index 0 is exempt, and a bare `/` produces empty slices at
843/// indices 0 AND 1) rather than out of a decision, so `validate_resolved_path("/")`
844/// refused and every call to a root operation failed at runtime with the
845/// param-agnostic `param 'path segment' must not be empty`.
846///
847/// The refusal is param-agnostic: it carries the fixed position `path segment`
848/// rather than a caller-supplied name, because the composition belongs to no
849/// single parameter.
850///
851/// # Errors
852///
853/// `Err(PlaceholderRefusal)` describing the position and the expectation, never
854/// the path.
855pub fn validate_resolved_path(path: &str) -> Result<(), PlaceholderRefusal> {
856 let decoded = decode_once(COMPOSED_POSITION, path)?;
857 if decoded
858 .iter()
859 .any(|byte| denied_byte(*byte, /* allow_slash */ true) || matches!(byte, b'{' | b'}'))
860 {
861 return Err(refusal(
862 COMPOSED_POSITION,
863 "pathSegment",
864 format!(
865 "{FLOOR_EXPECTATION_SLASH_ALLOWED}, and must carry no unsubstituted placeholder"
866 ),
867 ));
868 }
869 check_resolved_segments(&decoded)
870}
871
872/// [`validate_resolved_path`] widened by the single author-written query
873/// separator, for a composed path that may carry a `?`.
874///
875/// Both HTTP surfaces compose a path template with resolved placeholder values
876/// and then check the result. An operator may write a literal `?` in the
877/// template, so the composed string is a path AND a query — while
878/// [`validate_resolved_path`] denies `?` anywhere, deliberately, because a `?`
879/// arriving from a *value* silently changes the endpoint.
880///
881/// The narrowing is therefore: split at the FIRST `?` and apply the unmodified
882/// rule to each half. It lives HERE, beside the rule it widens, rather than at
883/// either call site — the curated surface (`HttpClient::check_composed_path`)
884/// and the Code Mode surface (`ResolvedPath::from_checked`) previously held one
885/// copy each, which made this the one part of the floor that could drift
886/// between them. One more sibling, never a second copy.
887///
888/// What the split does NOT relax:
889///
890/// - A SECOND `?` is still refused: only the first is split off, so the query
891/// portion faces the unmodified rule, which denies `?`.
892/// - An empty query portion is still refused — a dangling `/x?` is a trailing
893/// separator, the same class as a trailing `/`.
894/// - A `?` reaching the composed string from a placeholder VALUE never gets
895/// here; the per-value floor has already refused it.
896///
897/// # Two inherited conservatisms, stated so they are not a surprise
898///
899/// Both come from applying the unmodified rule to the query half, and both are
900/// what the two former call-site copies already did — neither is new here.
901///
902/// 1. `%25` is refused outright (it is what bounds the decode to a single pass),
903/// so a query carrying a percent-encoded percent sign is refused.
904/// 2. The query half also faces the rules about path SHAPE — no `//`, no trailing
905/// `/`, no empty segment — because `validate_resolved_path` checks bytes AND
906/// segment structure together. So `/a?redirect=https://example.com`,
907/// `/a?b=//x` and `/a?b=1&c=x/` are all refused. This is the one an operator
908/// actually hits: a query value holding a URL does not compose. Asserted by
909/// `resolved_target_applies_path_segment_structure_to_the_query_too` so it
910/// cannot change silently. Relaxing it means splitting the byte floor from the
911/// segment-structure rules and applying only the former to the query half —
912/// deliberately NOT done here, because widening a security floor is a decision
913/// for its own change, not a side effect of de-duplicating two copies.
914///
915/// # Errors
916///
917/// The [`PlaceholderRefusal`] from [`validate_resolved_path`]. It is value-free:
918/// it names the rule and the declared expectation, never a byte of the path.
919pub fn validate_resolved_target(path: &str) -> Result<(), PlaceholderRefusal> {
920 match path.split_once('?') {
921 // No author-written separator: the whole string is a path.
922 None => validate_resolved_path(path),
923 // Exactly one author-written `?`. The separator itself is permitted;
924 // both sides still face the full, unmodified rule set.
925 Some((path_part, query_part)) => {
926 validate_resolved_path(path_part).and_then(|()| validate_resolved_path(query_part))
927 },
928 }
929}
930
931/// Per-segment half of [`validate_resolved_path`], split out to keep both
932/// functions inside the cognitive-complexity budget.
933fn check_resolved_segments(decoded: &[u8]) -> Result<(), PlaceholderRefusal> {
934 // The absolute root is a legal path with NO segments at all — the shortest
935 // legal absolute path, and the target of the `GET /` health/index operation
936 // every second `OpenAPI` document declares. `[b'/'].split(b'/')` reports it as
937 // TWO empty slices (before and after the separator), and only index 0 is
938 // exempt, so without this the root path is refused with the param-agnostic
939 // `must not be empty` on every single request.
940 //
941 // Deliberately an equality test on the WHOLE decoded path rather than a
942 // relaxation of the empty-segment rule: `//` and a trailing `/` stay refused,
943 // both by decision. `/a/b/` is what `/search/{v}` composes to when `v` is
944 // empty, which is the case the non-leading-empty rule exists to close, and a
945 // doubled separator changes the endpoint shape. Asserted in both directions by
946 // `resolved_path_accepts_the_absolute_root_and_still_refuses_doubled_and_trailing`.
947 if decoded == b"/" {
948 return Ok(());
949 }
950 let leading_slash = decoded.first() == Some(&b'/');
951 for (index, segment) in decoded.split(|byte| *byte == b'/').enumerate() {
952 check_one_resolved_segment(segment, index == 0 && leading_slash)?;
953 }
954 Ok(())
955}
956
957/// One composed path segment. Split out from [`check_resolved_segments`] because
958/// the two together measured cognitive complexity 28 against the blocking CI cap
959/// of 25 — the same reason `output_validation.rs` carries its three-function
960/// split.
961///
962/// `leading` marks the one legitimate empty segment: the one an absolute path
963/// produces before its first `/`.
964fn check_one_resolved_segment(segment: &[u8], leading: bool) -> Result<(), PlaceholderRefusal> {
965 if segment.is_empty() {
966 // Any OTHER empty segment means a doubled or trailing `/`, which changes
967 // the endpoint shape — and is exactly what an empty placeholder value
968 // substituted at the tail of a template produces.
969 if leading {
970 return Ok(());
971 }
972 return Err(refusal(
973 COMPOSED_POSITION,
974 "pathSegment",
975 "must not be empty".to_string(),
976 ));
977 }
978 if segment == b".." || segment == b"." {
979 return Err(refusal(
980 COMPOSED_POSITION,
981 "pathSegment",
982 "must not be a relative path reference".to_string(),
983 ));
984 }
985 if String::from_utf8_lossy(segment).chars().count() > PLACEHOLDER_MAX_LENGTH {
986 return Err(refusal(
987 COMPOSED_POSITION,
988 "segmentMaxLength",
989 format!("must be at most {PLACEHOLDER_MAX_LENGTH} characters"),
990 ));
991 }
992 Ok(())
993}
994
995/// Build a refusal. Kept as one helper so no call site can forget that the
996/// rejected value is never a field.
997fn refusal(param: &str, rule: &'static str, expected: String) -> PlaceholderRefusal {
998 PlaceholderRefusal {
999 param: param.to_owned(),
1000 rule,
1001 expected,
1002 }
1003}
1004
1005/// Step 1 — the unconditional floor (D-10), as decode-once-then-deny.
1006fn placeholder_floor(
1007 param: &str,
1008 value: &str,
1009 allow_slash: bool,
1010) -> Result<(), PlaceholderRefusal> {
1011 if value.is_empty() {
1012 return Err(refusal(param, "nonEmpty", "must not be empty".to_string()));
1013 }
1014 let decoded = decode_once(param, value)?;
1015 let floor_expectation = if allow_slash {
1016 FLOOR_EXPECTATION_SLASH_ALLOWED
1017 } else {
1018 FLOOR_EXPECTATION
1019 };
1020 let denied = decoded.iter().any(|byte| denied_byte(*byte, allow_slash))
1021 // The parent-directory sequence has NO escape, even with `allow_slash`
1022 // (D-11).
1023 || decoded.windows(2).any(|pair| pair == b"..")
1024 // A single dot is a meaningful path segment (`a/./b` normalizes to
1025 // `a/b`), so two adjacent placeholders each holding `.` compose to `..`.
1026 // Refusing it closes that composition at the value layer as well as in
1027 // `validate_resolved_path`.
1028 // `&decoded[..]` and not `decoded.as_ref()`: the latter picks its target
1029 // type by inference, so widening `decode_once` to another `Cow` target
1030 // would silently re-resolve this comparison rather than fail to compile.
1031 || &decoded[..] == b".";
1032 if denied {
1033 return Err(refusal(
1034 param,
1035 "characterFloor",
1036 floor_expectation.to_string(),
1037 ));
1038 }
1039 Ok(())
1040}
1041
1042/// Steps 1a-1c — refuse `%25` outright, refuse a malformed escape, then
1043/// percent-decode exactly ONCE.
1044///
1045/// Refusing `%25` in any hex case BEFORE decoding is what makes one pass
1046/// sufficient rather than the first round of an unbounded regress: with `%25`
1047/// refused, no surviving input can encode a further `%`, so a single decode
1048/// reaches ground truth. A malformed escape is refused because leaving it
1049/// undecided would mean every downstream layer deciding for itself whether to
1050/// treat it as a literal `%` or as an error.
1051fn decode_once<'v>(param: &str, value: &'v str) -> Result<Cow<'v, [u8]>, PlaceholderRefusal> {
1052 let bytes = value.as_bytes();
1053
1054 // Fast path: a value with no `%` at all has nothing to decode and nothing that
1055 // could be a `%25`, so it needs neither the pre-scan nor a copy. This is the
1056 // overwhelmingly common case, and both callers only READ the result — the
1057 // composed-path check and the per-value floor each scan it and hand it to
1058 // `check_resolved_segments`. Borrow instead of allocating.
1059 if !bytes.contains(&b'%') {
1060 return Ok(Cow::Borrowed(bytes));
1061 }
1062
1063 if contains_ascii_case_insensitive(value, "%25") {
1064 return Err(refusal(
1065 param,
1066 "percentEncoding",
1067 PERCENT_EXPECTATION.to_string(),
1068 ));
1069 }
1070 let mut out = Vec::with_capacity(bytes.len());
1071 let mut index = 0;
1072 while index < bytes.len() {
1073 if bytes[index] == b'%' {
1074 let decoded = bytes
1075 .get(index + 1)
1076 .zip(bytes.get(index + 2))
1077 .and_then(|(high, low)| Some(hex_nibble(*high)? * 16 + hex_nibble(*low)?));
1078 let Some(byte) = decoded else {
1079 return Err(refusal(
1080 param,
1081 "percentEncoding",
1082 PERCENT_EXPECTATION.to_string(),
1083 ));
1084 };
1085 out.push(byte);
1086 index += 3;
1087 } else {
1088 out.push(bytes[index]);
1089 index += 1;
1090 }
1091 }
1092 Ok(Cow::Owned(out))
1093}
1094
1095/// One ASCII hex digit's value, case-insensitively; `None` for a non-hex byte.
1096fn hex_nibble(byte: u8) -> Option<u8> {
1097 match byte {
1098 b'0'..=b'9' => Some(byte - b'0'),
1099 b'a'..=b'f' => Some(byte - b'a' + 10),
1100 b'A'..=b'F' => Some(byte - b'A' + 10),
1101 _ => None,
1102 }
1103}
1104
1105/// Step 1d's denylist, over ONE decoded byte.
1106///
1107/// `\` is denied because IIS, some nginx rewrite configurations and AWS API
1108/// Gateway normalize it toward `/` and `..\` toward traversal, so a floor that
1109/// stops `../` and passes `..\` is deployment-dependent rather than sound. CR, LF
1110/// and the remaining ASCII control characters are denied because an unencoded
1111/// newline in a path reaching a logging or transport layer is response-splitting
1112/// and request-smuggling surface — and because admitting some control characters
1113/// and not others invites exactly the enumeration gap the decode-once design
1114/// exists to avoid.
1115const fn denied_byte(byte: u8, allow_slash: bool) -> bool {
1116 match byte {
1117 b'?' | b'#' | b'\\' => true,
1118 b'/' => !allow_slash,
1119 0x00..=0x1F | 0x7F => true,
1120 _ => false,
1121 }
1122}
1123
1124/// ASCII-case-insensitive substring search.
1125///
1126/// `%2E` and `%2e` decode identically, so a case-SENSITIVE `contains` is a bypass
1127/// (RESEARCH Finding 5b).
1128fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
1129 let (haystack, needle) = (haystack.as_bytes(), needle.as_bytes());
1130 needle.len() <= haystack.len()
1131 && haystack
1132 .windows(needle.len())
1133 .any(|window| window.eq_ignore_ascii_case(needle))
1134}
1135
1136/// Step 3 — the declared `pattern`, routed through the CACHED input validator.
1137///
1138/// `cached_input_validator` and not `compile_input_2020_12`: the uncached entry
1139/// point would compile a fresh regex on every placeholder check on every request,
1140/// which is both a per-request cost and a direct amplifier of the `ReDoS` residual
1141/// T-128-10 accepts. It also means a placeholder `pattern` and an `inputSchema`
1142/// `pattern` resolve `\s` through the identical engine, which matters because
1143/// `\s` is not a single rule in this engine (RESEARCH Finding 1f).
1144///
1145/// A pattern that does not compile is itself a refusal; the cache stores that
1146/// failure too, so a broken declared pattern is not recompiled per request either.
1147fn declared_pattern_check(
1148 param: &str,
1149 value: &str,
1150 pattern: &str,
1151) -> Result<(), PlaceholderRefusal> {
1152 let schema = serde_json::json!({ "type": "string", "pattern": pattern });
1153 match cached_input_validator(&schema, None) {
1154 Ok(validator) => {
1155 if validator.is_valid(&Value::String(value.to_owned())) {
1156 Ok(())
1157 } else {
1158 Err(refusal(param, "pattern", format!("must match {pattern}")))
1159 }
1160 },
1161 // The compile DETAIL is author-supplied schema text, but this refusal is
1162 // client-facing, so it stays detail-free — matching `validate_input`'s
1163 // treatment of a non-compiling declared `inputSchema`.
1164 Err(_) => Err(refusal(
1165 param,
1166 "pattern",
1167 "has a declared pattern that is not a valid regular expression".to_string(),
1168 )),
1169 }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use serde_json::json;
1176
1177 /// A two-parameter tool's schema, shaped exactly as
1178 /// `pmcp-server-toolkit`'s `build_input_schema` emits it.
1179 fn two_param_schema() -> Value {
1180 json!({
1181 "type": "object",
1182 "properties": {
1183 "cui": { "type": "string" },
1184 "version": { "type": "string" },
1185 },
1186 "required": ["cui"],
1187 "additionalProperties": false,
1188 })
1189 }
1190
1191 fn zero_param_schema() -> Value {
1192 json!({
1193 "type": "object",
1194 "properties": {},
1195 "required": [],
1196 "additionalProperties": false,
1197 })
1198 }
1199
1200 #[test]
1201 fn schema_validation_refuses_undeclared_argument_with_a_count_only_message() {
1202 let schema = two_param_schema();
1203 let violations = validate_input(
1204 &schema,
1205 Some(&json!({ "cui": "C0018787", "apiKey": "secret" })),
1206 None,
1207 )
1208 .expect_err("an undeclared argument must be refused");
1209 assert_eq!(violations.len(), 1, "one additionalProperties violation");
1210 assert_eq!(violations[0].keyword, "additionalProperties");
1211 assert_eq!(
1212 violations[0].pointer, "",
1213 "0.49.2 reports the instance root for additionalProperties"
1214 );
1215
1216 let msg = render_refusal(&violations, &["cui", "version"]);
1217 assert!(
1218 msg.contains('1'),
1219 "must carry the unknown-argument count: {msg}"
1220 );
1221 assert!(msg.contains("cui"), "must name the declared params: {msg}");
1222 assert!(
1223 msg.contains("version"),
1224 "must name the declared params: {msg}"
1225 );
1226 assert!(!msg.contains("apiKey"), "must not echo the key: {msg}");
1227 assert!(!msg.contains("secret"), "must not echo the value: {msg}");
1228 }
1229
1230 #[test]
1231 fn schema_validation_accepts_absent_arguments_on_a_zero_parameter_tool() {
1232 let schema = zero_param_schema();
1233 assert!(validate_input(&schema, None, None).is_ok());
1234 assert!(validate_input(&schema, Some(&Value::Null), None).is_ok());
1235 }
1236
1237 #[test]
1238 fn schema_validation_refuses_absent_arguments_by_required_never_by_type() {
1239 let schema = two_param_schema();
1240 let violations = validate_input(&schema, None, None)
1241 .expect_err("a required parameter must make absent arguments a refusal");
1242 assert!(
1243 violations.iter().any(|v| v.keyword == "required"),
1244 "expected a `required` violation, got {violations:?}"
1245 );
1246 assert!(
1247 violations.iter().all(|v| v.keyword != "type"),
1248 "a `type` violation would mean `null` reached the validator: {violations:?}"
1249 );
1250 let msg = render_refusal(&violations, &["cui", "version"]);
1251 assert!(msg.contains("cui"), "must name the required param: {msg}");
1252 assert!(!msg.contains("null"), "must never echo `null`: {msg}");
1253 }
1254
1255 #[test]
1256 fn schema_validation_renders_byte_identical_refusals_across_repeat_calls() {
1257 let schema = two_param_schema();
1258 let declared = ["cui", "version"];
1259 let args = json!({ "cui": "C0018787", "apiKey": "secret" });
1260
1261 let refuse = || {
1262 let violations = validate_input(&schema, Some(&args), None)
1263 .expect_err("the pair must actually have been refused");
1264 render_refusal(&violations, &declared)
1265 };
1266 let first = refuse();
1267 let second = refuse();
1268 assert_eq!(
1269 first, second,
1270 "0.49.2 error iteration order is deterministic, so refusals must be stable"
1271 );
1272 assert!(!first.is_empty(), "a refusal must never render empty");
1273 }
1274
1275 // ===================================================================
1276 // Plan 02 Task 1 — every `ValidationErrorKind` arm this phase can emit.
1277 // ===================================================================
1278
1279 /// A one-property object schema, so each arm can be exercised in isolation.
1280 fn one_prop(property: &Value) -> Value {
1281 json!({
1282 "type": "object",
1283 "properties": { "p": property },
1284 "additionalProperties": false,
1285 })
1286 }
1287
1288 /// Refuse `args` against `schema` and render the client-facing message.
1289 fn refusal_for(schema: &Value, args: &Value, declared: &[&str]) -> String {
1290 let violations =
1291 validate_input(schema, Some(args), None).expect_err("the pair must be refused");
1292 render_refusal(&violations, declared)
1293 }
1294
1295 #[test]
1296 fn schema_validation_max_length_refusal_names_the_limit_not_the_value() {
1297 let schema = one_prop(&json!({ "type": "string", "maxLength": 8 }));
1298 let value = "x".repeat(5000);
1299 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1300 assert!(msg.contains('8'), "must carry the declared limit: {msg}");
1301 assert!(
1302 !msg.contains(&"x".repeat(10)),
1303 "must not echo the rejected value: {msg}"
1304 );
1305 assert!(!msg.contains(&value), "must not echo the value: {msg}");
1306 }
1307
1308 #[test]
1309 fn schema_validation_enum_refusal_lists_declared_options_only() {
1310 let schema = one_prop(&json!({ "enum": ["exact", "words"] }));
1311 let value = "Jane Doe DOB 1970-01-01";
1312 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1313 assert!(msg.contains("exact"), "must list declared options: {msg}");
1314 assert!(msg.contains("words"), "must list declared options: {msg}");
1315 assert!(!msg.contains(value), "must not echo the value: {msg}");
1316 }
1317
1318 #[test]
1319 fn schema_validation_pattern_refusal_names_the_declared_pattern() {
1320 let schema = one_prop(&json!({ "type": "string", "pattern": "^C[0-9]+$" }));
1321 let value = "Jane Doe DOB 1970-01-01";
1322 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1323 assert!(
1324 msg.contains("^C[0-9]+$"),
1325 "must name the declared pattern: {msg}"
1326 );
1327 assert!(!msg.contains(value), "must not echo the value: {msg}");
1328 }
1329
1330 #[test]
1331 fn schema_validation_bound_refusals_name_the_declared_bound_only() {
1332 let cases: &[(Value, Value, &str)] = &[
1333 (
1334 json!({ "type": "integer", "maximum": 10 }),
1335 json!(4242),
1336 "10",
1337 ),
1338 (json!({ "type": "integer", "minimum": 10 }), json!(-7), "10"),
1339 (
1340 json!({ "type": "string", "minLength": 4 }),
1341 json!("ab"),
1342 "4",
1343 ),
1344 (
1345 json!({ "type": "array", "maxItems": 2 }),
1346 json!(["a", "b", "c"]),
1347 "2",
1348 ),
1349 ];
1350 for (property, value, declared_bound) in cases {
1351 let schema = one_prop(property);
1352 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1353 assert!(
1354 msg.contains(declared_bound),
1355 "must name the declared bound {declared_bound}: {msg}"
1356 );
1357 let rendered_value = format!("{value}");
1358 assert!(
1359 !msg.contains(&rendered_value),
1360 "must not echo the rejected value: {msg}"
1361 );
1362 }
1363 }
1364
1365 #[test]
1366 fn schema_validation_type_refusal_names_the_declared_type_only() {
1367 let schema = one_prop(&json!({ "type": "integer" }));
1368 let msg = refusal_for(&schema, &json!({ "p": "Jane Doe" }), &["p"]);
1369 assert!(
1370 msg.contains("integer"),
1371 "must name the declared type: {msg}"
1372 );
1373 assert!(!msg.contains("Jane Doe"), "must not echo the value: {msg}");
1374 }
1375
1376 #[test]
1377 fn schema_validation_format_refusal_names_the_declared_format_only() {
1378 // Q1: inputs compile through a format-ASSERTING builder, so `format` is a
1379 // kind this phase actively produces and must render value-free.
1380 let schema = one_prop(&json!({ "type": "string", "format": "uri" }));
1381 let value = "!!!not-a-uri!!!";
1382 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1383 assert!(msg.contains("uri"), "must name the declared format: {msg}");
1384 assert!(!msg.contains(value), "must not echo the value: {msg}");
1385 }
1386
1387 #[test]
1388 fn schema_validation_required_refusal_names_the_declared_property() {
1389 let schema = two_param_schema();
1390 let msg = refusal_for(
1391 &schema,
1392 &json!({ "version": "2026AA" }),
1393 &["cui", "version"],
1394 );
1395 assert!(
1396 msg.contains("cui"),
1397 "must name the declared required property: {msg}"
1398 );
1399 }
1400
1401 #[test]
1402 fn schema_validation_additional_properties_refusal_carries_a_count_and_no_keys() {
1403 let schema = two_param_schema();
1404 let msg = refusal_for(
1405 &schema,
1406 &json!({ "cui": "C1", "apiKey": "secret", "Jane Doe DOB 1970-01-01": "x" }),
1407 &["cui", "version"],
1408 );
1409 assert!(msg.contains('2'), "must carry the count 2: {msg}");
1410 assert!(msg.contains("cui"), "must name the allow-list: {msg}");
1411 assert!(msg.contains("version"), "must name the allow-list: {msg}");
1412 assert!(!msg.contains("apiKey"), "must not echo a key: {msg}");
1413 assert!(
1414 !msg.contains("Jane Doe"),
1415 "must not echo a caller key: {msg}"
1416 );
1417 }
1418
1419 #[test]
1420 fn schema_validation_empty_rejected_value_refusal_is_value_free() {
1421 let schema = one_prop(&json!({ "type": "string", "minLength": 3 }));
1422 let msg = refusal_for(&schema, &json!({ "p": "" }), &["p"]);
1423 assert!(msg.contains('3'), "must name the declared minimum: {msg}");
1424 assert!(
1425 msg.contains("at least"),
1426 "must state the declared expectation: {msg}"
1427 );
1428 }
1429
1430 #[test]
1431 fn schema_validation_astral_and_combining_value_never_reaches_the_refusal() {
1432 // `maxLength` counts code points (RESEARCH Finding 1d): 4 emoji + a
1433 // decomposed `é` is over a cap of 3.
1434 let schema = one_prop(&json!({ "type": "string", "maxLength": 3 }));
1435 // Deliberately ALL non-ASCII, so a per-code-point absence assertion is
1436 // meaningful: an ASCII code point from the value would also occur in the
1437 // declared expectation's own English prose, which would make the
1438 // assertion vacuous rather than strict.
1439 let value = "\u{1F600}\u{1F600}\u{1F600}\u{1F600}\u{00E9}\u{0301}";
1440 let msg = refusal_for(&schema, &json!({ "p": value }), &["p"]);
1441 for ch in value.chars() {
1442 assert!(
1443 !msg.contains(ch),
1444 "code point {ch:?} from the rejected value reached the refusal: {msg}"
1445 );
1446 }
1447 assert!(!msg.contains(value), "must not echo the value: {msg}");
1448 }
1449
1450 #[test]
1451 fn schema_validation_array_form_items_schema_refuses_with_a_schema_keyword() {
1452 // Draft-07 array-form `items` does not compile under the 2020-12 pin
1453 // (RESEARCH Finding 1h): a drifted declaration must REFUSE, value-free.
1454 let schema = json!({
1455 "$schema": "http://json-schema.org/draft-07/schema#",
1456 "type": "object",
1457 "properties": { "tags": { "type": "array", "items": [ { "type": "string" } ] } },
1458 });
1459 let violations = validate_input(&schema, Some(&json!({ "tags": ["a"] })), None)
1460 .expect_err("a non-compiling declared schema must refuse every call");
1461 assert_eq!(violations.len(), 1, "exactly one schema violation");
1462 assert_eq!(violations[0].keyword, "schema");
1463 let msg = render_refusal(&violations, &["tags"]);
1464 assert!(
1465 !msg.contains("type"),
1466 "the compile detail is logged server-side, never rendered: {msg}"
1467 );
1468 }
1469
1470 #[test]
1471 fn schema_validation_pattern_properties_key_never_reaches_the_refusal() {
1472 // `patternProperties` names the CALLER's key in the instance pointer, so a
1473 // raw `instance_path()` copy would leak it (Codex HIGH / T-128-08a).
1474 let schema = json!({
1475 "type": "object",
1476 "patternProperties": { "^.*$": { "type": "integer" } },
1477 });
1478 let args = json!({ "Jane Doe DOB 1970-01-01": "x" });
1479 let violations =
1480 validate_input(&schema, Some(&args), None).expect_err("a string is not an integer");
1481 // The POINTER itself must be sanitized, not merely suppressed by
1482 // `render_refusal`: `InputViolation` is public and its `pointer` reaches
1483 // logs, execution records and third-party renderers.
1484 assert!(
1485 !violations[0].pointer.contains("Jane Doe"),
1486 "a caller-chosen property name reached `InputViolation::pointer`: {}",
1487 violations[0].pointer
1488 );
1489 let msg = render_refusal(&violations, &["p"]);
1490 assert!(
1491 !msg.contains("Jane Doe"),
1492 "a caller-chosen property name reached the refusal: {msg}"
1493 );
1494 assert!(
1495 !msg.contains("1970-01-01"),
1496 "a caller-chosen property name reached the refusal: {msg}"
1497 );
1498 }
1499
1500 #[test]
1501 fn schema_validation_additional_properties_subschema_key_never_reaches_the_refusal() {
1502 // `additionalProperties` as a SUBSCHEMA (not `false`) also puts the
1503 // caller's key in the pointer.
1504 let schema = json!({
1505 "type": "object",
1506 "properties": { "cui": { "type": "string" } },
1507 "additionalProperties": { "type": "integer" },
1508 });
1509 let args = json!({ "Jane Doe DOB 1970-01-01": "x" });
1510 let violations =
1511 validate_input(&schema, Some(&args), None).expect_err("a string is not an integer");
1512 assert!(
1513 !violations[0].pointer.contains("Jane Doe"),
1514 "a caller-chosen property name reached `InputViolation::pointer`: {}",
1515 violations[0].pointer
1516 );
1517 let msg = render_refusal(&violations, &["cui"]);
1518 assert!(
1519 !msg.contains("Jane Doe"),
1520 "a caller-chosen property name reached the refusal: {msg}"
1521 );
1522 }
1523
1524 #[test]
1525 fn schema_validation_declared_property_pointer_survives_the_projection() {
1526 // The redaction must not be a blanket suppression: a DECLARED name is
1527 // still what makes a refusal actionable for the legitimate caller.
1528 let schema = one_prop(&json!({ "type": "string", "maxLength": 2 }));
1529 let violations = validate_input(&schema, Some(&json!({ "p": "abc" })), None)
1530 .expect_err("over the declared maxLength");
1531 assert_eq!(violations[0].pointer, "/p", "declared names stay verbatim");
1532 }
1533
1534 #[test]
1535 fn schema_validation_declared_array_index_pointer_survives_the_projection() {
1536 let schema = json!({
1537 "type": "object",
1538 "properties": {
1539 "tags": { "type": "array", "items": { "type": "string", "maxLength": 2 } },
1540 },
1541 });
1542 let violations = validate_input(&schema, Some(&json!({ "tags": ["ok", "toolong"] })), None)
1543 .expect_err("the second item is over the declared maxLength");
1544 assert_eq!(
1545 violations[0].pointer, "/tags/1",
1546 "a base-10 array index carries no caller-chosen text"
1547 );
1548 }
1549
1550 #[test]
1551 fn schema_validation_check_input_schema_compiles_names_the_offending_property_path() {
1552 let schema = json!({
1553 "type": "object",
1554 "properties": { "bad": { "type": "string", "pattern": "^[A-Z" } },
1555 });
1556 let violation = check_input_schema_compiles(&schema)
1557 .expect_err("a nested non-compiling `pattern` must be caught at config time");
1558 assert_eq!(violation.keyword, "schema");
1559 assert!(
1560 violation.pointer.contains("bad"),
1561 "the pointer must name the offending property path: {}",
1562 violation.pointer
1563 );
1564 }
1565
1566 #[test]
1567 fn schema_validation_check_input_schema_compiles_accepts_a_well_formed_schema() {
1568 assert!(check_input_schema_compiles(&two_param_schema()).is_ok());
1569 }
1570
1571 // ===================================================================
1572 // Plan 02 Task 2 — the D4 character floor and the composed-path check.
1573 // ===================================================================
1574
1575 /// The CR-01 probe payloads, verbatim from `128-CHANGE-REQUEST.md` and from
1576 /// RESEARCH Finding 5b's measured `^.*$` probe.
1577 const CR01_PAYLOADS: &[&str] = &[
1578 "2026AA?string=x",
1579 "current/../../search/current",
1580 "current/../../search/current?string=x",
1581 "%2e%2e%2f",
1582 "%3Fstring%3Dx",
1583 "a%00b",
1584 "a#frag",
1585 ];
1586
1587 #[test]
1588 fn placeholder_accepts_the_cap_and_refuses_one_more() {
1589 let rules = PlaceholderRules::default();
1590 let at_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH);
1591 assert!(validate_path_placeholder("v", &at_cap, &rules).is_ok());
1592
1593 let over_cap = "a".repeat(PLACEHOLDER_MAX_LENGTH + 1);
1594 let refusal = validate_path_placeholder("v", &over_cap, &rules)
1595 .expect_err("one code point over the cap must be refused");
1596 assert_eq!(refusal.rule, "maxLength");
1597 }
1598
1599 #[test]
1600 fn placeholder_counts_code_points_not_bytes() {
1601 // A 256-emoji value is 1024 bytes and exactly at the cap.
1602 let rules = PlaceholderRules::default();
1603 let at_cap = "\u{1F600}".repeat(PLACEHOLDER_MAX_LENGTH);
1604 assert_eq!(at_cap.len(), PLACEHOLDER_MAX_LENGTH * 4, "1024 bytes");
1605 assert!(validate_path_placeholder("v", &at_cap, &rules).is_ok());
1606 }
1607
1608 #[test]
1609 fn placeholder_refuses_an_empty_value() {
1610 let refusal = validate_path_placeholder("v", "", &PlaceholderRules::default())
1611 .expect_err("an empty path segment changes the URL shape");
1612 assert_eq!(refusal.rule, "nonEmpty");
1613 }
1614
1615 #[test]
1616 fn placeholder_refuses_a_bare_slash_unless_the_parameter_opts_in() {
1617 assert!(
1618 validate_path_placeholder("v", "/", &PlaceholderRules::default()).is_err(),
1619 "`/` is denied by default"
1620 );
1621 let opted_in = PlaceholderRules::default().allowing_slash(true);
1622 assert!(
1623 validate_path_placeholder("v", "/", &opted_in).is_ok(),
1624 "D-11: `/` is liftable by per-parameter CONFIG opt-in"
1625 );
1626 }
1627
1628 #[test]
1629 fn placeholder_refuses_the_parent_directory_sequence_with_and_without_slash_opt_in() {
1630 for allow_slash in [false, true] {
1631 let rules = PlaceholderRules::default().allowing_slash(allow_slash);
1632 for value in ["..", "a/../b", "%2e%2e", "%2E%2E"] {
1633 assert!(
1634 validate_path_placeholder("v", value, &rules).is_err(),
1635 "traversal has NO escape even with allow_slash={allow_slash}: {value}"
1636 );
1637 }
1638 }
1639 }
1640
1641 #[test]
1642 fn placeholder_refuses_every_cr01_payload() {
1643 let rules = PlaceholderRules::default();
1644 for payload in CR01_PAYLOADS {
1645 assert!(
1646 validate_path_placeholder("version", payload, &rules).is_err(),
1647 "CR-01 payload must be refused: {payload}"
1648 );
1649 }
1650 }
1651
1652 #[test]
1653 fn placeholder_percent_scan_is_case_insensitive_on_hex() {
1654 let rules = PlaceholderRules::default();
1655 for value in ["%2e%2e%2f", "%2E%2E%2F", "%2f", "%2F", "%3f", "%3F"] {
1656 assert!(
1657 validate_path_placeholder("v", value, &rules).is_err(),
1658 "the hex scan must be ASCII-case-insensitive: {value}"
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn placeholder_refuses_double_encoded_percent() {
1665 let rules = PlaceholderRules::default();
1666 for value in ["%252e", "%252E", "%25", "a%2525b"] {
1667 let refusal = validate_path_placeholder("v", value, &rules)
1668 .expect_err("`%25` must be refused outright, in any hex case");
1669 assert_eq!(
1670 refusal.rule, "percentEncoding",
1671 "refusing `%25` up front is what BOUNDS the decode to one pass: {value}"
1672 );
1673 }
1674 }
1675
1676 #[test]
1677 fn placeholder_refuses_a_malformed_percent_escape() {
1678 let rules = PlaceholderRules::default();
1679 for value in ["%zz", "%2", "%", "a%g0b"] {
1680 assert!(
1681 validate_path_placeholder("v", value, &rules).is_err(),
1682 "a malformed escape has no legitimate use in a path value: {value}"
1683 );
1684 }
1685 }
1686
1687 #[test]
1688 fn placeholder_refuses_mixed_literal_and_encoded_traversal() {
1689 // THE decode-once row. An enumerated literal-plus-encoded denylist admits
1690 // both of these; decoding once does not.
1691 let rules = PlaceholderRules::default();
1692 for value in [".%2E", "%2E.", ".%2e", "%2e."] {
1693 assert!(
1694 validate_path_placeholder("v", value, &rules).is_err(),
1695 "step 1 was implemented as an enumeration, not as decode-once: {value}"
1696 );
1697 }
1698 }
1699
1700 #[test]
1701 fn placeholder_refuses_backslash_in_literal_and_encoded_form() {
1702 let rules = PlaceholderRules::default();
1703 for value in ["\\", "..\\", "a\\b", "%5c", "%5C"] {
1704 assert!(
1705 validate_path_placeholder("v", value, &rules).is_err(),
1706 "reverse proxies normalize `\\` toward `/`: {value}"
1707 );
1708 }
1709 }
1710
1711 #[test]
1712 fn placeholder_refuses_carriage_return_and_line_feed_in_both_forms() {
1713 let rules = PlaceholderRules::default();
1714 for value in ["a\rb", "a\nb", "a%0db", "a%0Db", "a%0ab", "a%0Ab", "a\tb"] {
1715 assert!(
1716 validate_path_placeholder("v", value, &rules).is_err(),
1717 "a control character in a path is response-splitting surface: {value:?}"
1718 );
1719 }
1720 }
1721
1722 #[test]
1723 fn placeholder_refuses_a_bare_single_dot() {
1724 let rules = PlaceholderRules::default();
1725 for value in [".", "%2e", "%2E"] {
1726 assert!(
1727 validate_path_placeholder("v", value, &rules).is_err(),
1728 "two adjacent single dots compose to traversal: {value}"
1729 );
1730 }
1731 }
1732
1733 #[test]
1734 fn placeholder_floor_runs_before_a_permissive_declared_pattern() {
1735 // D-10, empirically justified: `^.*$` accepts every CR-01 payload
1736 // (RESEARCH Finding 5b), so pattern-supersedes-floor would be a no-op.
1737 let rules = PlaceholderRules::default().with_pattern(Some("^.*$"));
1738 for payload in CR01_PAYLOADS {
1739 assert!(
1740 validate_path_placeholder("version", payload, &rules).is_err(),
1741 "a permissive declared pattern must not relax the floor: {payload}"
1742 );
1743 }
1744 }
1745
1746 #[test]
1747 fn placeholder_declared_pattern_narrows() {
1748 let rules = PlaceholderRules::default().with_pattern(Some("^C[0-9]+$"));
1749 assert!(validate_path_placeholder("cui", "C0018787", &rules).is_ok());
1750 let refusal = validate_path_placeholder("cui", "ABC", &rules)
1751 .expect_err("the declared pattern must narrow");
1752 assert_eq!(refusal.rule, "pattern");
1753 }
1754
1755 #[test]
1756 fn placeholder_refuses_a_declared_pattern_that_does_not_compile() {
1757 let rules = PlaceholderRules::default().with_pattern(Some("^[A-Z"));
1758 let refusal = validate_path_placeholder("cui", "C1", &rules)
1759 .expect_err("a non-compiling declared pattern must refuse, never pass everything");
1760 assert_eq!(refusal.rule, "pattern");
1761 let rendered = refusal.to_string();
1762 assert!(rendered.contains("cui"), "must name the param: {rendered}");
1763 assert!(
1764 !rendered.contains("C1"),
1765 "must not echo the value: {rendered}"
1766 );
1767 }
1768
1769 #[test]
1770 fn placeholder_declared_max_length_never_widens_the_module_cap() {
1771 let rules = PlaceholderRules::default().with_max_length(Some(512));
1772 let value = "a".repeat(300);
1773 let refusal = validate_path_placeholder("v", &value, &rules)
1774 .expect_err("the module constant is the HARD cap");
1775 assert_eq!(refusal.rule, "maxLength");
1776 }
1777
1778 #[test]
1779 fn placeholder_declared_max_length_narrows_further() {
1780 let rules = PlaceholderRules::default().with_max_length(Some(8));
1781 assert!(validate_path_placeholder("v", "12345678", &rules).is_ok());
1782 let refusal = validate_path_placeholder("v", "123456789", &rules)
1783 .expect_err("the declared length narrows the cap");
1784 assert_eq!(refusal.rule, "maxLength");
1785 }
1786
1787 #[test]
1788 fn placeholder_refusals_name_the_param_and_never_echo_the_value() {
1789 let rules = PlaceholderRules::default().with_max_length(Some(4));
1790 let values = [
1791 "",
1792 "2026AA?string=x",
1793 "current/../../search/current",
1794 "%252e",
1795 "%zz",
1796 "\\",
1797 ".",
1798 "aaaaaaaaaa",
1799 ];
1800 for value in values {
1801 let refusal = validate_path_placeholder("version", value, &rules)
1802 .expect_err("every one of these is refused");
1803 let rendered = refusal.to_string();
1804 assert!(
1805 rendered.contains("version"),
1806 "must name the declared param: {rendered}"
1807 );
1808 if !value.is_empty() {
1809 assert!(
1810 !rendered.contains(value),
1811 "refusal echoed the rejected value {value:?}: {rendered}"
1812 );
1813 }
1814 }
1815 }
1816
1817 #[test]
1818 fn placeholder_default_rules_are_floored_and_capped_never_permissive() {
1819 let rules = PlaceholderRules::default();
1820 assert!(rules.declared_pattern.is_none());
1821 assert!(rules.declared_max_length.is_none());
1822 assert!(!rules.allow_slash);
1823 // `Clone` is required by plan 08's owned-rules construction.
1824 let cloned = rules.clone();
1825 for payload in CR01_PAYLOADS {
1826 assert!(
1827 validate_path_placeholder("v", payload, &cloned).is_err(),
1828 "`PlaceholderRules::default()` must be floored and capped: {payload}"
1829 );
1830 }
1831 }
1832
1833 /// The memo must not grow without bound. `fuzz_placeholder_pattern_redos` drove
1834 /// the process to libFuzzer's 2 GB RSS limit through exactly this map, in CI
1835 /// and locally, because every distinct generated pattern was stored forever.
1836 ///
1837 /// Run on a LOCAL map with a tiny cap: the process-global cache would be left
1838 /// full for every other test in the binary.
1839 #[test]
1840 fn cache_stops_storing_new_schemas_once_full() {
1841 let compile = |max: u64| {
1842 compile_input_2020_12(&serde_json::json!({ "type": "string", "maxLength": max }))
1843 .map(Arc::new)
1844 .map_err(|_| Arc::<str>::from("unexpected compile failure"))
1845 };
1846 let mut map = HashMap::new();
1847 for n in 0..3_u64 {
1848 remember_bounded(&mut map, 3, format!("k{n}"), compile(n)).expect("compiles");
1849 }
1850 assert_eq!(map.len(), 3, "below the bound every schema is stored");
1851
1852 // Beyond the cap: still returns a working validator, stores nothing.
1853 let overflow = remember_bounded(&mut map, 3, "k-new".to_string(), compile(99))
1854 .expect("an overflow schema still compiles and validates");
1855 assert_eq!(map.len(), 3, "a full cache must not grow");
1856 assert!(
1857 !map.contains_key("k-new"),
1858 "the overflow entry must not be stored"
1859 );
1860 assert!(overflow.is_valid(&Value::String("x".repeat(99))));
1861 assert!(!overflow.is_valid(&Value::String("x".repeat(100))));
1862
1863 // Entries stored before the cap keep hitting: the SAME Arc comes back.
1864 let stored = map["k1"].clone().expect("stored");
1865 let again = remember_bounded(&mut map, 3, "k1".to_string(), compile(1)).expect("compiles");
1866 assert!(
1867 Arc::ptr_eq(&stored, &again),
1868 "a key already present must resolve to the stored entry, not a fresh compile"
1869 );
1870 }
1871
1872 /// The bound must not turn a compile FAILURE into a success or drop it: a
1873 /// broken declared pattern past the cap is still refused.
1874 #[test]
1875 fn cache_bound_still_reports_compile_failures() {
1876 let mut map = HashMap::new();
1877 map.insert("only".to_string(), Err(Arc::<str>::from("stored")));
1878 let failed = remember_bounded(
1879 &mut map,
1880 1,
1881 "other".to_string(),
1882 Err(Arc::<str>::from("does not compile")),
1883 );
1884 assert!(failed.is_err(), "an overflow failure must still be an Err");
1885 assert_eq!(map.len(), 1);
1886 }
1887
1888 #[test]
1889 fn placeholder_declared_pattern_compiles_once_per_pattern() {
1890 // Observe the MEMO, not the wall clock: a second lookup of the same schema
1891 // text must hand back the SAME `Arc`, which is only true on a cache hit.
1892 // This is what makes T-128-10's memoization claim checkable.
1893 let compiling = json!({ "type": "string", "pattern": "^C[0-9]+$" });
1894 let first = cached_input_validator(&compiling, None).expect("compiles");
1895 let second = cached_input_validator(&compiling, None).expect("compiles");
1896 assert!(
1897 Arc::ptr_eq(&first, &second),
1898 "the second lookup recompiled instead of hitting the cache"
1899 );
1900
1901 let broken = json!({ "type": "string", "pattern": "^[A-Z" });
1902 let first_err = cached_input_validator(&broken, None).expect_err("does not compile");
1903 let second_err = cached_input_validator(&broken, None).expect_err("does not compile");
1904 assert!(
1905 Arc::ptr_eq(&first_err, &second_err),
1906 "a compile FAILURE must be cached too, or a broken declared pattern is \
1907 recompiled on every request"
1908 );
1909
1910 let rules = PlaceholderRules::default().with_pattern(Some("^[A-Z"));
1911 let one = validate_path_placeholder("cui", "C1", &rules)
1912 .expect_err("a broken pattern refuses")
1913 .to_string();
1914 let two = validate_path_placeholder("cui", "C1", &rules)
1915 .expect_err("a broken pattern refuses")
1916 .to_string();
1917 assert_eq!(one, two, "refusals must be byte-identical across calls");
1918 }
1919
1920 #[test]
1921 fn placeholder_operates_on_the_rendered_string_not_a_json_value() {
1922 // A non-string argument is rendered to a string by the CALLER
1923 // (`render_scalar`) and the RENDERED string is what the floor and the cap
1924 // see — which is why this primitive takes `&str` and never a `Value`.
1925 let rules = PlaceholderRules::default();
1926 for rendered in ["42", "true", "null", "1.5"] {
1927 assert!(
1928 validate_path_placeholder("v", rendered, &rules).is_ok(),
1929 "a rendered scalar is an ordinary value: {rendered}"
1930 );
1931 }
1932 }
1933
1934 #[test]
1935 fn placeholder_is_a_pure_function_safe_under_concurrency() {
1936 // D4 concurrency edge: no shared mutable state of its own, so two Code
1937 // Mode calls on one executor cannot interleave placeholder state. The
1938 // validator cache behind the declared-pattern step is the only shared
1939 // state and it is an `RwLock<HashMap<…>>`, read-probed before any write.
1940 let handles: Vec<_> = (0..8)
1941 .map(|worker| {
1942 std::thread::spawn(move || {
1943 let rules = PlaceholderRules::default().with_pattern(Some("^C[0-9]+$"));
1944 let good = format!("C{worker}");
1945 assert!(validate_path_placeholder("cui", &good, &rules).is_ok());
1946 assert!(validate_path_placeholder("cui", "../etc", &rules).is_err());
1947 })
1948 })
1949 .collect();
1950 for handle in handles {
1951 handle.join().expect("no worker panicked");
1952 }
1953 }
1954
1955 #[test]
1956 fn resolved_path_refuses_a_segment_over_the_cap_composed_from_two_passing_values() {
1957 // The adjacency proof: 180 + 200 code points compose to a 380-code-point
1958 // segment, and NEITHER part alone exceeds the cap.
1959 let rules = PlaceholderRules::default();
1960 let a = "a".repeat(180);
1961 let b = "b".repeat(200);
1962 assert!(validate_path_placeholder("a", &a, &rules).is_ok());
1963 assert!(validate_path_placeholder("b", &b, &rules).is_ok());
1964
1965 let composed = format!("/search/{a}{b}");
1966 let refusal = validate_resolved_path(&composed)
1967 .expect_err("the composed segment is over the cap even though each value passed");
1968 assert_eq!(refusal.rule, "segmentMaxLength");
1969 }
1970
1971 #[test]
1972 fn resolved_path_refuses_a_traversal_segment_composed_from_two_single_dots() {
1973 // `.` + `.` composes to `..` — traversal from two values neither of which
1974 // contains the sequence. Closed at BOTH layers.
1975 let rules = PlaceholderRules::default();
1976 assert!(
1977 validate_path_placeholder("a", ".", &rules).is_err(),
1978 "the single-dot floor closes this at the value layer"
1979 );
1980 let refusal = validate_resolved_path("/x/..")
1981 .expect_err("the composed segment is the parent-directory sequence");
1982 assert_eq!(refusal.rule, "pathSegment");
1983 }
1984
1985 #[test]
1986 fn resolved_path_refuses_a_residual_placeholder_brace() {
1987 for path in ["/x/{unsubstituted}", "/x/}", "/x/{"] {
1988 assert!(
1989 validate_resolved_path(path).is_err(),
1990 "an unsubstituted placeholder must never reach the wire: {path}"
1991 );
1992 }
1993 }
1994
1995 #[test]
1996 fn resolved_path_refuses_an_empty_interior_segment_and_accepts_a_clean_path() {
1997 assert!(validate_resolved_path("/a//b").is_err(), "empty interior");
1998 assert!(validate_resolved_path("/a/b/").is_err(), "empty trailing");
1999 assert!(validate_resolved_path("/a/b").is_ok(), "a clean path");
2000 assert!(validate_resolved_path("/content/current/CUI/C0018787").is_ok());
2001 }
2002
2003 /// Phase 128 CR-01 — the absolute root is a legal path, and exempting it must
2004 /// not re-admit either of the two empty-segment shapes that are refused BY
2005 /// DECISION.
2006 ///
2007 /// The accept half and the two refuse halves are one test on purpose: the
2008 /// accept alone is satisfiable by relaxing the non-leading-empty rule, which
2009 /// would silently re-open `/a/b/` — the composed form of `/search/{v}` with an
2010 /// empty `v`, i.e. the exact case that rule was written to close.
2011 ///
2012 /// Fails on removal: delete the `decoded == b"/"` exemption in
2013 /// `check_resolved_segments` and the first assertion reports
2014 /// `param 'path segment' must not be empty`.
2015 #[test]
2016 fn resolved_path_accepts_the_absolute_root_and_still_refuses_doubled_and_trailing() {
2017 // ACCEPT: the root, in the two spellings that decode to a bare `/`.
2018 for path in ["/", "%2F"] {
2019 assert!(
2020 validate_resolved_path(path).is_ok(),
2021 "the absolute root is the shortest legal absolute path, and a `GET /` \
2022 operation must be callable: {path:?} -> {:?}",
2023 validate_resolved_path(path)
2024 );
2025 }
2026
2027 // STILL REFUSED, by decision — a doubled separator changes the endpoint
2028 // shape, and a trailing `/` is what an empty tail placeholder composes to.
2029 for path in ["//", "///", "/a/b/", "/a//b", "/a/", "/search/"] {
2030 let refusal =
2031 validate_resolved_path(path).expect_err(&format!("must stay refused: {path:?}"));
2032 assert_eq!(
2033 refusal.rule, "pathSegment",
2034 "the empty-segment refusal keeps its rule token: {path:?}"
2035 );
2036 }
2037
2038 // The exemption is an EQUALITY test on the whole decoded path, so it cannot
2039 // be reached by a path that merely starts or ends with the root.
2040 assert!(validate_resolved_path("/a").is_ok());
2041 assert!(
2042 validate_resolved_path("").is_err(),
2043 "an empty composed path is not the root and has no endpoint"
2044 );
2045 }
2046
2047 #[test]
2048 fn resolved_path_refuses_encoded_traversal_after_decode_once() {
2049 for path in ["/a/%2e%2e/b", "/a/%2E%2E/b", "/a/%252e%252e/b", "/a/%zz/b"] {
2050 assert!(
2051 validate_resolved_path(path).is_err(),
2052 "decode-once must reach this: {path}"
2053 );
2054 }
2055 }
2056
2057 #[test]
2058 fn resolved_path_refuses_query_and_fragment_markers_and_control_bytes() {
2059 for path in ["/a?b=c", "/a#frag", "/a%3Fb", "/a\\b", "/a%00b", "/a\nb"] {
2060 assert!(
2061 validate_resolved_path(path).is_err(),
2062 "must be refused anywhere in the composed path: {path:?}"
2063 );
2064 }
2065 }
2066
2067 #[test]
2068 fn resolved_path_refuses_a_single_dot_segment() {
2069 assert!(validate_resolved_path("/a/./b").is_err());
2070 assert!(validate_resolved_path("/a/%2e/b").is_err());
2071 }
2072
2073 #[test]
2074 fn resolved_path_refusal_names_the_position_not_a_caller_supplied_name() {
2075 let refusal = validate_resolved_path("/x/../secret").expect_err("traversal");
2076 let rendered = refusal.to_string();
2077 assert!(
2078 rendered.contains("path segment"),
2079 "the composed check is param-agnostic: {rendered}"
2080 );
2081 assert!(
2082 !rendered.contains("secret"),
2083 "must not echo the composed path: {rendered}"
2084 );
2085 }
2086
2087 // `validate_resolved_target`'s guarantees are exercised end-to-end by
2088 // `pmcp_server_toolkit::http::client::query_separator` and by
2089 // `pmcp_code_mode::executor::query_separator` (11 rows each). These rows live
2090 // HERE as well, beside the rule, so `cargo test -p pmcp` alone cannot be green
2091 // against an edit to this security floor — the reason the narrowing was moved
2092 // into this module in the first place.
2093
2094 #[test]
2095 fn resolved_target_accepts_exactly_one_author_written_separator() {
2096 assert!(validate_resolved_target("/a/b").is_ok());
2097 assert!(validate_resolved_target("/a?b=c").is_ok());
2098 assert!(validate_resolved_target("/a?b=c&d=e").is_ok());
2099 }
2100
2101 #[test]
2102 fn resolved_target_refuses_a_second_separator_and_an_empty_query() {
2103 // Only the FIRST `?` is split off, so the query portion faces the
2104 // unmodified rule, which denies `?`.
2105 assert!(validate_resolved_target("/a?b=c?d=e").is_err());
2106 // A dangling `?` is a trailing separator, the same class as a trailing `/`.
2107 assert!(validate_resolved_target("/a?").is_err());
2108 // And the path portion keeps every rule it had.
2109 assert!(validate_resolved_target("/a/../secret?b=c").is_err());
2110 assert!(validate_resolved_target("/a?b=%00").is_err());
2111 assert!(validate_resolved_target("/a#frag?b=c").is_err());
2112 }
2113
2114 #[test]
2115 fn resolved_target_applies_path_segment_structure_to_the_query_too() {
2116 // DOCUMENTED CONSEQUENCE, asserted so it cannot change silently: the query
2117 // half faces `validate_resolved_path` whole, including the rules about path
2118 // SHAPE (`//`, a trailing `/`, an empty segment). So a query value carrying
2119 // a URL or a trailing slash is refused. That is inherited conservatism, not
2120 // a new rule — both former call-site copies did exactly this — but it is the
2121 // most surprising thing about the narrowing and the one an operator hits.
2122 assert!(validate_resolved_target("/a?redirect=https://example.com").is_err());
2123 assert!(validate_resolved_target("/a?b=//x").is_err());
2124 assert!(validate_resolved_target("/a?b=1&c=x/").is_err());
2125 }
2126}