fix: keep a pct-encoded UTF-8 sequence whole, and bound the preview too

Addresses Copilot's round-16 review on #2035. Two suppressed comments, both
real, and both refinements of fixes from the previous two rounds rather than
defects in the original code.

1. Round 11 settled on "a pct-encoded triplet counts as one character", but
   a multi-octet character spans several triplets -- `%C3%A9` is one `é`.
   Measured on a3dd8563:

     x{+v:1}  v = "%C3%A9x"  =>  "x%C3"   a lone lead byte, decoding to nothing

   `prefixUnits` now recognizes a well-formed pct-encoded UTF-8 sequence from
   its lead byte (RFC 3629: C2-DF two octets, E0-EF three, F0-F4 four;
   continuations 80-BF), longest alternative first so a four-octet sequence
   is not read as a two-octet one followed by loose triplets.

   A malformed sequence -- a lead byte with too few continuations, or a stray
   continuation -- matches no sequence alternative and falls back to
   per-triplet counting. That is the conservative direction: it counts as
   MORE units, so truncation keeps less rather than emitting octets that
   were never in the value.

2. Round 14's per-value ceiling only covered the read. The panel calls
   `previewUriTemplate` on every keystroke, and that path expands expression
   by expression, so it reached the allocation-heavy encoders with the same
   oversized value -- submission refused while the UI froze on the paste
   anyway. Both paths now share one exported `valueLengthError`, checked
   before any encoding happens.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-17 01:13:17 -04:00
commit 7d9410cbe0
3 changed files with 102 additions and 13 deletions

View file

@ -9,6 +9,7 @@ import {
templateVariables,
tryExpandUriTemplate,
unmetRequiredGroups,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
describe("parseUriTemplate", () => {
@ -749,6 +750,26 @@ describe("prefix modifier truncation", () => {
},
);
it.each([
// A pct-encoded UTF-8 sequence is ONE character: `%C3%A9` is `é`. Counting
// per triplet returned `%C3`, a lone lead byte decoding to nothing.
["x{+v:1}", "%C3%A9x", "x%C3%A9"],
["x{+v:2}", "%C3%A9x", "x%C3%A9x"],
// Three-octet (`€`) and four-octet (an emoji) sequences, same rule.
["x{+v:1}", "%E2%82%ACy", "x%E2%82%AC"],
["x{+v:1}", "%F0%9F%98%80z", "x%F0%9F%98%80"],
// A malformed sequence -- a lead byte with too few continuations -- falls
// back to per-triplet counting rather than swallowing what follows.
["x{+v:1}", "%C3x", "x%C3"],
// A stray continuation byte is not a sequence start.
["x{+v:1}", "%A9%C3%A9", "x%A9"],
])(
"keeps a multi-octet sequence whole (%s over %s)",
(template, value, expected) => {
expect(expandUriTemplate(template, { v: value })).toBe(expected);
},
);
it("counts a triplet as one character under a named operator too", () => {
expect(expandUriTemplate("x{?v:1}", { v: "%61%62" })).toBe("x?v=%2561");
});
@ -855,6 +876,18 @@ describe("the per-value length ceiling", () => {
});
});
describe("valueLengthError", () => {
it("names the first oversized value", () => {
expect(valueLengthError({ a: "1", b: "b".repeat(1_000_001) })).toMatch(
/"b" exceeds the 1000000-character limit/,
);
});
it("passes values at or under the limit", () => {
expect(valueLengthError({ a: "a".repeat(1_000_000) })).toBeNull();
});
});
describe("tryExpandUriTemplate", () => {
it("returns the URI and no error when the template expands", () => {
expect(

View file

@ -126,3 +126,19 @@ describe("previewUriTemplate - it must never throw or over-promise", () => {
);
});
});
describe("previewUriTemplate - the value ceiling", () => {
it("falls back before encoding an oversized value", () => {
// Without this the guard was half a guard: the read was refused while the
// preview still encoded the same value on every keystroke, so a large
// paste froze the UI anyway.
expect(previewUriTemplate("x://{v}", { v: "a".repeat(1_000_001) })).toBe(
"x://{v}",
);
});
it("still previews a value at the limit", () => {
const value = "a".repeat(1_000_000);
expect(previewUriTemplate("x://{v}", { v: value })).toBe(`x://${value}`);
});
});

View file

@ -15,6 +15,7 @@ import {
encodeLiteral,
expandTemplateExpression,
parseUriTemplate,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
export {
@ -27,6 +28,7 @@ export {
templateVariables,
tryExpandUriTemplate,
unmetRequiredGroups,
valueLengthError,
} from "@inspector/core/mcp/uriTemplate.js";
export type {
TemplateExpansion,
@ -71,6 +73,12 @@ export function previewUriTemplate(
): string {
const defined = definedValues(values);
// The same ceiling `tryExpandUriTemplate` enforces for the read. Without it
// the guard was half a guard: submission was refused while this path still
// handed the oversized value to the encoders on every keystroke, so a large
// paste froze the UI anyway. Checked before any encoding happens.
if (valueLengthError(defined) !== null) return uriTemplate;
try {
return parseUriTemplate(uriTemplate)
.map((part) => {