Skip to main content

validate_resolved_path

Function validate_resolved_path 

Source
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:

  1. The toolkit’s curated http build has no pmcp-code-mode edge and must not gain one (SC-1), so the shared rule cannot live in this crate.
  2. 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} with a at 180 code points and b at 200 yields a 380-code-point segment — over the cap, with neither part over it.
  • /x/{a}{b} with a = "." and b = "." 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:

PathVerdictWhy
/ACCEPTEDthe absolute root has no segments; it is the shortest legal absolute path and the target of a GET / operation
/a//brefuseda doubled separator changes the endpoint shape
/a/b/refusedthis 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.