Skip to main content

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}