From 857aedb987e7af779f9f67d957acaae868801763 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Sun, 30 Aug 2026 20:54:05 +0000 Subject: [PATCH] feat(adapters): structural path-placeholder values are an INVALID_INPUT error (FWD-18) The routing half of FWD-18 was already in place (placeholder keys skip query routing before value-shape inspection); this pins it with a test and decides the structural-value outcome: an object/array value under a placeholder key now fails with INVALID_INPUT instead of splicing the minified JSON into the path segment. Scalars (string/number/bool/null) render as before. --- src/adapters/forward.rs | 109 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 103 insertions(+), 6 deletions(-) diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index 3fbea16..ae40755 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -420,11 +420,26 @@ fn scalar_value_to_string(value: &Value) -> String { /// Percent-encoding scheme used by [`render_path_template`]: a value /// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is /// encoded so that a rendered value stays one literal path segment -/// (FWD-01 traversal/ smuggled-segments gate). Path parameters are never -/// rejected: they are rendered safely instead. -pub(crate) fn value_to_path_segment(value: &Value) -> String { - let raw = scalar_value_to_string(value); - utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string() +/// (FWD-01 traversal/ smuggled-segments gate). Scalars are never +/// rejected: they are rendered safely instead. Object/array values +/// under a placeholder key are rejected with `INVALID_INPUT` (FWD-18): +/// a path placeholder designates exactly one literal segment, so a +/// structural value has no faithful rendering — the pre-decision +/// behavior spliced the minified JSON into the path *and* emitted the +/// key as a query parameter (double-routed, neither faithful). +pub(crate) fn value_to_path_segment(value: &Value) -> Result { + let raw = match value { + Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => { + scalar_value_to_string(value) + } + other => { + return Err(CallError::invalid_input(format!( + "path placeholder value must be a scalar (string, number, boolean, or null); got {}: structural values have no faithful single-segment rendering", + type_name_of(other) + ))) + } + }; + Ok(utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string()) } /// Raw form of a scalar input value for query emission: `&`/`=` @@ -469,7 +484,7 @@ pub(crate) fn render_path_template( continue; } }; - out.push_str(&value_to_path_segment(raw_value)); + out.push_str(&value_to_path_segment(raw_value)?); rest = &tail[end + 1..]; } out.push_str(rest); @@ -1511,6 +1526,88 @@ mod tests { assert!(q.contains("q=rust") && q.contains("debug=true"), "{q}"); } + /// FWD-18, routing half pin: a key that matched a placeholder never + /// also emits as a query parameter — the skip in `build_request` + /// precedes query routing and is value-shape-agnostic. The scalar + /// case renders into the path only; the object-value case is + /// rejected (see the structural-value test below), so here the + /// companion non-placeholder key proves query routing stays intact + /// around the placeholder skip. + #[test] + fn placeholder_keys_never_double_route_as_query_params() { + let url = request_url( + "https://api.example.com", + "/repos/{owner}", + json!({"owner": "octocat", "extra": "q-val"}), + ) + .expect("scalar placeholder builds"); + assert_eq!(url.path(), "/repos/octocat"); + assert_eq!( + url.query(), + Some("extra=q-val"), + "only the non-placeholder key routes to query" + ); + + let ctx = noop_context(); + let schema = json!({ + "type": "object", + "properties": {"id": {"type": "string"}}, + }); + let err = build_request( + "https://api.example.com", + "/items/{id}", + "GET", + &None, + &TestHashMap::new(), + "svc", + &schema, + &json!({"id": "x", "undeclared": "v"}), + &ctx, + ) + .expect_err("undeclared key still rejected with a placeholder present"); + assert_eq!(err.code, "INVALID_INPUT"); + } + + /// FWD-18, structural-value decision: an object/array value under a + /// placeholder key is an `INVALID_INPUT` error, not a spliced JSON + /// segment. A path placeholder designates exactly one literal + /// segment; the pre-decision behavior embedded the minified JSON + /// into the path (and, under an additionalProperties schema, also + /// emitted the key as a query param — the double-route). + #[test] + fn object_or_array_path_values_error_instead_of_splicing_json_into_the_path() { + for value in [json!({"a": 1}), json!([1, 2])] { + let err = request_url( + "https://api.example.com", + "/things/{id}", + json!({"id": value}), + ) + .expect_err("structural path values must be rejected"); + assert_eq!(err.code, "INVALID_INPUT", "value was: {value}"); + assert!( + err.message.contains("must be a scalar"), + "value was: {value}" + ); + assert!( + !err.message.contains("{\"a\":1}") && !err.message.contains("[1,2]"), + "error must not echo the structural value: {value}" + ); + } + + for value in [json!("text"), json!(42), json!(true)] { + let url = request_url( + "https://api.example.com", + "/things/{id}", + json!({ "id": value }), + ) + .expect("scalar path values still render"); + assert!( + url.path().starts_with("/things/"), + "scalar {value} renders into the path" + ); + } + } + fn ctx_with_capability(namespace: &str, value: String) -> OperationContext { let mut ctx = noop_context(); ctx.capabilities = Capabilities::new().with_http_token(namespace, value);