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>
109 lines
4.6 KiB
TypeScript
109 lines
4.6 KiB
TypeScript
/**
|
|
* The web Resources form's view of an RFC 6570 URI template (#1919).
|
|
*
|
|
* Parsing, variable classification, and expansion live in
|
|
* `@inspector/core/mcp/uriTemplate.js` so the web form and the TUI cannot
|
|
* disagree about what a template means -- they are re-exported here so the
|
|
* panel has a single import. (The CLI is not a consumer: it has no template
|
|
* form, and its `resources/read` passes an already-expanded `--uri` straight to
|
|
* `readResource`.) What this module adds is the one piece that is purely a
|
|
* display concern: the partially-expanded preview string.
|
|
*/
|
|
|
|
import {
|
|
definedValues,
|
|
encodeLiteral,
|
|
expandTemplateExpression,
|
|
parseUriTemplate,
|
|
templateError,
|
|
valueLengthError,
|
|
} from "@inspector/core/mcp/uriTemplate.js";
|
|
|
|
export {
|
|
definedValues,
|
|
encodeLiteral,
|
|
expandUriTemplate,
|
|
hasRequiredValues,
|
|
parseUriTemplate,
|
|
requiredGroups,
|
|
templateError,
|
|
templateVariables,
|
|
tryExpandUriTemplate,
|
|
unmetRequiredGroups,
|
|
valueLengthError,
|
|
} from "@inspector/core/mcp/uriTemplate.js";
|
|
export type {
|
|
TemplateExpansion,
|
|
TemplatePart,
|
|
TemplateVariable,
|
|
VarSpec,
|
|
} from "@inspector/core/mcp/uriTemplate.js";
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*
|
|
* Two guards keep that promise, both of them things the previous
|
|
* expand-the-whole-template implementation got for free from the lenient
|
|
* `expandUriTemplate` it called:
|
|
*
|
|
* - **A template the read would refuse is shown verbatim.** `{a,}` still
|
|
* parses `a` into its varspecs, and `x://{a}}/{b}` parses two perfectly good
|
|
* expressions around a stray `}`, so expanding either previewed a URI whose
|
|
* submission is refused outright. `templateError` is the read's own verdict,
|
|
* so consulting it first is what keeps the two from disagreeing -- and it
|
|
* subsumes the narrower "skip the invalid part" rule this had at first,
|
|
* which left the *rest* of such a template expanding.
|
|
* - **The whole thing is wrapped.** This runs during render, where a throw
|
|
* unmounts the panel instead of disabling its button, and encoding can throw
|
|
* for real input: `encodeURIComponent` raises `URIError` on an unpaired
|
|
* surrogate, which a paste can deliver. The panel already reports that case
|
|
* through `tryExpandUriTemplate`; here it degrades to the raw template.
|
|
*/
|
|
export function previewUriTemplate(
|
|
uriTemplate: string,
|
|
values: Record<string, string>,
|
|
): 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;
|
|
|
|
// The same verdict the read is gated on. Without it this expanded expression
|
|
// by expression regardless: `x://{a}}/{b}` with both fields filled previewed
|
|
// `x://1}/2`, a URI whose stray `}` makes every read refuse -- the preview
|
|
// promising something the form it sits in can never send.
|
|
if (templateError(uriTemplate) !== null) return uriTemplate;
|
|
|
|
try {
|
|
return parseUriTemplate(uriTemplate)
|
|
.map((part) => {
|
|
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.
|
|
return part.names.some((name) => Object.hasOwn(defined, name))
|
|
? expandTemplateExpression(part, defined)
|
|
: part.source;
|
|
})
|
|
.join("");
|
|
} catch {
|
|
return uriTemplate;
|
|
}
|
|
}
|