fix: accept only well-formed UTF-8 sequences, and validate before previewing

Addresses Copilot's round-18 review on #2035. Two suppressed comments, both
holes in my own recent fixes rather than in the original code.

1. Round 16's grouping keyed off the length-announcing lead byte alone, so
   it accepted sequences RFC 3629 forbids -- and the comment claiming they
   fell back to per-triplet counting was simply wrong. Measured on 05bbfe50:

     x{+v:1}  "%C0%80x"        =>  "x%C0%80"        overlong
              "%E0%80%80x"     =>  "x%E0%80%80"     overlong
              "%ED%A0%80x"     =>  "x%ED%A0%80"     UTF-16 surrogate
              "%F4%90%80%80x"  =>  "x%F4%90%80%80"  past U+10FFFF

   The lead-byte ranges are now the well-formed ones -- C2-DF, E0 only with
   A0-BF, ED only with 80-9F, F0 only with 90-BF, F4 only with 80-8F -- so
   each of the above falls back to one triplet, which is what the fallback
   was always documented to do. The legal boundary cases (%E0%A0%80,
   %ED%9F%BF, %F4%8F%BF%BF) are tested alongside, so tightening the ranges
   cannot quietly start splitting valid characters.

2. The preview never ran the read's template validation. It expands
   expression by expression, so `x://{a}}/{b}` with both fields filled
   previewed `x://1}/2` -- a URI whose stray `}` makes every read refuse.

   Validation moved into an exported `templateError` that both callers
   consult, so they cannot drift. It subsumes the narrower rule round 12
   added here ("leave the invalid part standing"), which still let the REST
   of such a template expand: the refusal is per template, not per
   expression, so the preview now shows the template verbatim -- literals
   unencoded included, since half-normalizing an unreadable template invents
   a string the server never published. Two existing tests asserted the
   weaker behavior and now assert this one.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 01:40:50 -04:00
commit 9caac43da4

View file

@ -7,6 +7,7 @@ import {
parseUriTemplate,
requiredGroups,
templateVariables,
templateError,
tryExpandUriTemplate,
unmetRequiredGroups,
valueLengthError,
@ -763,6 +764,17 @@ describe("prefix modifier truncation", () => {
["x{+v:1}", "%C3x", "x%C3"],
// A stray continuation byte is not a sequence start.
["x{+v:1}", "%A9%C3%A9", "x%A9"],
// RFC 3629 well-formedness, not just the length-announcing lead byte: an
// overlong encoding, a UTF-16 surrogate, and a code point past U+10FFFF
// are each refused the grouping and counted per triplet.
["x{+v:1}", "%C0%80x", "x%C0"],
["x{+v:1}", "%E0%80%80x", "x%E0"],
["x{+v:1}", "%ED%A0%80x", "x%ED"],
["x{+v:1}", "%F4%90%80%80x", "x%F4"],
// The boundary cases that ARE well-formed stay whole.
["x{+v:1}", "%E0%A0%80x", "x%E0%A0%80"],
["x{+v:1}", "%ED%9F%BFx", "x%ED%9F%BF"],
["x{+v:1}", "%F4%8F%BF%BFx", "x%F4%8F%BF%BF"],
])(
"keeps a multi-octet sequence whole (%s over %s)",
(template, value, expected) => {
@ -876,6 +888,32 @@ describe("the per-value length ceiling", () => {
});
});
describe("templateError", () => {
it.each([
["a stray closing brace", "x://{a}}"],
["an unclosed brace", "x://{oops"],
["an invalid varspec", "x://{a,}"],
["an out-of-grammar modifier", "x://{id:abc}"],
])("reports %s", (_label, template) => {
expect(templateError(template)).not.toBeNull();
});
it.each(["x://{a}", "x://a{?b,c}", "x://a{;d:3}", "café/{var}"])(
"passes the valid template %s",
(template) => {
expect(templateError(template)).toBeNull();
},
);
it("gives strict expansion its verdict, so the two cannot disagree", () => {
// Every template the error reports must also refuse to expand.
for (const template of ["x://{a}}", "x://{oops", "x://{a,}"]) {
expect(templateError(template)).not.toBeNull();
expect(() => expandUriTemplateStrict(template, { a: "1" })).toThrow();
}
});
});
describe("valueLengthError", () => {
it("names the first oversized value", () => {
expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch(