pub fn validate_resolved_path(path: &str) -> Result<(), PlaceholderRefusal>Expand description
The D4 path-placeholder floor, re-exported from core pmcp.
There is exactly ONE implementation of these rules and it lives in
pmcp::server::schema_validation. This is a pub use, never a second copy
(Phase 128, Q2). Two reasons the home is core rather than here:
- The toolkit’s curated
httpbuild has nopmcp-code-modeedge and must not gain one (SC-1), so the shared rule cannot live in this crate. - This repo has a documented three-way-drift incident from a security rule that existed in more than one copy, so a second denylist is a prohibited shape rather than a style preference.
The re-export exists because D-09 obliges the SDK to publish the helper under
the name a third-party HttpExecutor implementor would look for. An
implementor whose template syntax is not OpenAPI’s {key} can call
pmcp_code_mode::validate_path_placeholder on each value it substitutes and
pmcp_code_mode::validate_resolved_path on the composed result — or
pmcp_code_mode::validate_resolved_target, which is that rule widened by the
single author-written ? separator, and is what ResolvedPath::from_checked
itself calls. Either reaches the
same rule the SDK itself applies before calling
HttpExecutor::execute_request. (Plain backticks, not an intra-doc link:
executor is gated on js-runtime and the link would not resolve in a
default-feature doc build.)
Validate the COMPOSED path after substitution and before dispatch.
§Why a per-value check is insufficient by construction
This is a proof, not a caution. Two values that each pass
validate_path_placeholder independently can compose into a refused form
across adjacent placeholders:
/search/{a}{b}withaat 180 code points andbat 200 yields a 380-code-point segment — over the cap, with neither part over it./x/{a}{b}witha = "."andb = "."yields the segment..— traversal — from two values neither of which contains the sequence.
Adjacency is reachable rather than theoretical: on the Code Mode surface the
substitution is genuinely per-{key}, and the composed result is then parsed
as a URL, which is where a composed traversal becomes a different endpoint.
§Contract
The same decode-once normalization as the per-value floor runs over the WHOLE
path; then the path is split on / and each segment is refused when it is
longer than PLACEHOLDER_MAX_LENGTH code points, equal to the
parent-directory sequence, equal to a single dot, or empty other than the
leading segment a path starting with / produces. A residual { or } is
refused — an unsubstituted placeholder reaching the wire is its own defect —
and ?, #, a backslash and any control byte are refused anywhere.
§The three empty-segment cases, decided rather than derived
The segment split reports an empty slice in three different situations, and they get three different answers on purpose:
| Path | Verdict | Why |
|---|---|---|
/ | ACCEPTED | the absolute root has no segments; it is the shortest legal absolute path and the target of a GET / operation |
/a//b | refused | a doubled separator changes the endpoint shape |
/a/b/ | refused | this is what /search/{v} composes to when v is empty — the empty-placeholder-at-the-tail case |
The first was a defect until Phase 128 CR-01: it fell out of the index
arithmetic (only index 0 is exempt, and a bare / produces empty slices at
indices 0 AND 1) rather than out of a decision, so validate_resolved_path("/")
refused and every call to a root operation failed at runtime with the
param-agnostic param 'path segment' must not be empty.
The refusal is param-agnostic: it carries the fixed position path segment
rather than a caller-supplied name, because the composition belongs to no
single parameter.
§Errors
Err(PlaceholderRefusal) describing the position and the expectation, never
the path.