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:
cliffhall 2026-08-16 23:44:45 -04:00
commit 2e06b369f4

View file

@ -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