fix: encode literals per RFC 6570 3.1, and label the varname tolerance
Addresses Copilot's round-10 review on #2035. One inline comment, one
suppressed; both real.
1. Literals were copied through verbatim, but RFC 6570 3.1 requires them
pct-encoded on expansion -- the conformance case `café/{var}` expands to
`caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw
UTF-8 in the path; since this module replaced the SDK's expander for both
clients, nothing else was going to encode it. (The pinned SDK does not
either -- it returns `café/value` too -- so this is a gap we inherited
rather than introduced.)
`encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#`
are structure) and existing pct-triplets, and encodes the rest. A BRACE
passes through: it can only reach a literal from a malformed template,
where an unclosed `{` becomes trailing text, and `%7B` would leave a
string resembling neither the template nor anything the server can match.
This is also what removed the preview's placeholder machinery. No token
can survive a round trip now that literals are encoded, so the preview
assembles part by part instead -- `encodeLiteral` for literal runs,
`expandTemplateExpression` for filled expressions, `part.source` for the
rest. Both halves still come from the shared expander, so the preview
cannot promise a URI that submitting would not send, and the question of
what token could never collide with a server-supplied template is gone.
2. On the varname set I took the structural point rather than the
conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a
separately named `TOLERATED_VARCHAR` tier, and a name needing it parses
with `conforming: false`, surfaced on `TemplateVariable`.
So the grammar in the code says what the RFC says and the tolerance is
explicit and separately tested -- but the Inspector still expands
`{default-graph-uri}`, because real servers publish hyphenated names and
the SDK's matcher round-trips them. Refusing to read a resource that
demonstrably works is a worse failure for a debugging tool than expanding
a name the conformance suite would reject. A caller wanting RFC-exact
behavior refuses on the flag rather than re-deriving the grammar.
npm run ci passes.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
ef3f5e74d8
commit
2e06b369f4
3 changed files with 225 additions and 98 deletions
|
|
@ -54,13 +54,13 @@ describe("parseUriTemplate", () => {
|
|||
describe("templateVariables", () => {
|
||||
it("finds a simple variable and marks it required", () => {
|
||||
expect(templateVariables("foobar://events/{topic}")).toEqual([
|
||||
{ name: "topic", operator: "", required: true },
|
||||
{ name: "topic", operator: "", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => {
|
||||
expect(templateVariables("foobar://events{?topic}")).toEqual([
|
||||
{ name: "topic", operator: "?", required: false },
|
||||
{ name: "topic", operator: "?", required: false, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ describe("templateVariables", () => {
|
|||
|
||||
it("deduplicates a repeated name and keeps it required if any use is", () => {
|
||||
expect(templateVariables("x://{?id}/{id}")).toEqual([
|
||||
{ name: "id", operator: "?", required: true },
|
||||
{ name: "id", operator: "?", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ describe("varspec modifiers", () => {
|
|||
// user cannot usefully fill.
|
||||
it("parses a prefix modifier off the variable name", () => {
|
||||
expect(templateVariables("x://a/{id:3}")).toEqual([
|
||||
{ name: "id", operator: "", required: true },
|
||||
{ name: "id", operator: "", required: true, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -266,7 +266,7 @@ describe("the ; (path-parameter) operator", () => {
|
|||
// variable named ";id" and expands to "".
|
||||
it("is recognised as an operator, not part of the name", () => {
|
||||
expect(templateVariables("x://a{;id}")).toEqual([
|
||||
{ name: "id", operator: ";", required: false },
|
||||
{ name: "id", operator: ";", required: false, conforming: true },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -641,6 +641,64 @@ describe("unmetRequiredGroups", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("literal normalization", () => {
|
||||
// RFC 6570 3.1: a literal may contain non-ASCII, but expansion emits it
|
||||
// pct-encoded -- the conformance case `café/{var}` expands to
|
||||
// `caf%C3%A9/value`. Literals used to pass through verbatim, and since this
|
||||
// module replaced the SDK's expander for both clients, nothing else was
|
||||
// going to encode them. (Measured: the pinned SDK returns `café/value` too.)
|
||||
it("percent-encodes a non-ASCII literal", () => {
|
||||
expect(expandUriTemplate("café/{var}", { var: "value" })).toBe(
|
||||
"caf%C3%A9/value",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes a space in a literal", () => {
|
||||
expect(expandUriTemplate("a b/{v}", { v: "x" })).toBe("a%20b/x");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["URI delimiters", "x://a/b?c=d#e{?q}", { q: "1" }, "x://a/b?c=d#e?q=1"],
|
||||
["an existing pct-triplet", "a%20b/{v}", { v: "x" }, "a%20b/x"],
|
||||
])("preserves %s", (_label, template, values, expected) => {
|
||||
expect(expandUriTemplate(template, values)).toBe(expected);
|
||||
});
|
||||
|
||||
it("passes a brace through, so a malformed template stays legible", () => {
|
||||
// A brace reaches a literal only from a malformed template (an unclosed
|
||||
// `{` becomes trailing text). Encoding it to %7B would leave a string
|
||||
// resembling neither the template nor anything the server could match.
|
||||
expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops");
|
||||
});
|
||||
});
|
||||
|
||||
describe("names outside RFC 6570 varchar", () => {
|
||||
// The conformance suite rejects `{~thing}` and `{default-graph-uri}`. The
|
||||
// Inspector expands them anyway -- real servers publish hyphenated names and
|
||||
// the SDK's matcher round-trips them -- but the tolerance is labelled rather
|
||||
// than folded into the grammar, so a caller wanting RFC-exact behavior can
|
||||
// refuse on the flag.
|
||||
it.each(["user-id", "a~b"])("expands the tolerated name %s", (name) => {
|
||||
expect(expandUriTemplate(`x://{${name}}`, { [name]: "7" })).toBe("x://7");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["default-graph-uri", false],
|
||||
["~thing", false],
|
||||
["plain_name", true],
|
||||
["a.b", true],
|
||||
["%61%62", true],
|
||||
])("marks %s conforming=%s", (name, conforming) => {
|
||||
expect(templateVariables(`x://{${name}}`)[0].conforming).toBe(conforming);
|
||||
});
|
||||
|
||||
it("marks a name non-conforming if any occurrence needed the tolerance", () => {
|
||||
expect(templateVariables("x://{user-id}{?user-id}")[0].conforming).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prefix modifier truncation", () => {
|
||||
// RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as
|
||||
// ONE. Truncating by code point cut the sixth triplet in half -- measured
|
||||
|
|
|
|||
|
|
@ -79,3 +79,23 @@ describe("preview with Object.prototype-colliding names", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewUriTemplate - literals", () => {
|
||||
// The preview promises the URI that submitting would send, so it applies the
|
||||
// same RFC 6570 3.1 literal encoding the wire does.
|
||||
it("percent-encodes a non-ASCII literal", () => {
|
||||
expect(previewUriTemplate("café/{var}", { var: "value" })).toBe(
|
||||
"caf%C3%A9/value",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the literal even while an expression is still unfilled", () => {
|
||||
expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}");
|
||||
});
|
||||
|
||||
it("keeps a malformed template legible rather than encoding its brace", () => {
|
||||
expect(previewUriTemplate("x://café/{oops", {})).toBe(
|
||||
"x://caf%C3%A9/{oops",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,12 +12,14 @@
|
|||
|
||||
import {
|
||||
definedValues,
|
||||
expandUriTemplate,
|
||||
encodeLiteral,
|
||||
expandTemplateExpression,
|
||||
parseUriTemplate,
|
||||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
export {
|
||||
definedValues,
|
||||
encodeLiteral,
|
||||
expandUriTemplate,
|
||||
hasRequiredValues,
|
||||
parseUriTemplate,
|
||||
|
|
@ -33,56 +35,37 @@ export type {
|
|||
} from "@inspector/core/mcp/uriTemplate.js";
|
||||
|
||||
/**
|
||||
* A placeholder standing in for an expression the user has not filled in yet.
|
||||
* A partially-expanded template for display: an expression with at least one
|
||||
* value is expanded exactly as the wire would render it, and the rest are left
|
||||
* standing as written so the user can see what is still needed.
|
||||
*
|
||||
* `U+0000` cannot appear in a URI template, so a token built from it can never
|
||||
* collide with real template text; and because it is emitted as *literal* text
|
||||
* rather than as a variable value, expansion passes it through unencoded.
|
||||
*/
|
||||
const deferredToken = (index: number) => `\u0000${index}\u0000`;
|
||||
|
||||
/**
|
||||
* A partially-expanded template for display: expressions with at least one
|
||||
* value are expanded exactly as `expandUriTemplate` would, and the rest are
|
||||
* left standing as written so the user can see what is still needed.
|
||||
* Assembled part by part rather than by rewriting the template and re-parsing
|
||||
* it. The rewrite approach needed a placeholder to stand in for each unexpanded
|
||||
* expression, and a placeholder is exactly what cannot survive the round trip
|
||||
* now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever
|
||||
* token stood in for `{?topic}` would come back encoded and no longer match.
|
||||
* Going part by part removes the token, and with it the question of what text
|
||||
* could never collide with a server-supplied template.
|
||||
*
|
||||
* Unfilled expressions are swapped for an inert token and restored afterwards,
|
||||
* and the rewritten template is expanded by `expandUriTemplate` itself. Routing
|
||||
* it back through the real expander is what keeps the preview honest: it cannot
|
||||
* promise a URI that submitting would not send. (Expansion carries no
|
||||
* cross-expression state — see `expandParts` — so this is a per-expression
|
||||
* substitution, not a whole-template rewrite that some later pass depends on.)
|
||||
* Each half still comes from the shared expander -- `encodeLiteral` for literal
|
||||
* runs, `expandTemplateExpression` for filled ones -- so the preview cannot
|
||||
* promise a URI that submitting would not send.
|
||||
*/
|
||||
export function previewUriTemplate(
|
||||
uriTemplate: string,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const parts = parseUriTemplate(uriTemplate);
|
||||
const deferred: string[] = [];
|
||||
const defined = definedValues(values);
|
||||
|
||||
const rewritten = parts
|
||||
return parseUriTemplate(uriTemplate)
|
||||
.map((part) => {
|
||||
if (part.kind === "literal") return part.text;
|
||||
if (part.kind === "literal") return encodeLiteral(part.text);
|
||||
// `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are
|
||||
// valid RFC 6570 variable names, and a plain lookup would find
|
||||
// `Object.prototype`'s member and treat a blank field as filled.
|
||||
if (part.names.some((name) => Object.hasOwn(defined, name))) {
|
||||
return part.source;
|
||||
}
|
||||
deferred.push(part.source);
|
||||
return deferredToken(deferred.length - 1);
|
||||
return part.names.some((name) => Object.hasOwn(defined, name))
|
||||
? expandTemplateExpression(part, defined)
|
||||
: part.source;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const expanded = expandUriTemplate(rewritten, defined);
|
||||
|
||||
// Restored by exact-string replacement rather than by a pattern: a regex
|
||||
// matching the token would have to embed U+0000 literally, which `eslint`
|
||||
// rejects (`no-control-regex`) -- and each token's text is already known
|
||||
// here, so there is nothing to match on.
|
||||
return deferred.reduce(
|
||||
(uri, source, index) => uri.replaceAll(deferredToken(index), source),
|
||||
expanded,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue