fix: read template variables as own properties only

Addresses Copilot's round-6 review on #2035.

`toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC
6570 variable names -- `varname` allows ALPHA / DIGIT / `_` / pct-encoded
-- and every lookup here was a bare `values[name]`, which finds
`Object.prototype`'s member for each of them. Measured: `({})["toString"]
!== undefined` is true with typeof "function", and `({}["constructor"] ??
"").length` is 1.

So a *blank* `{?toString}` field read as supplied and expanded a function
body into the URI instead of omitting the expression, and
`hasRequiredValues([["constructor"]], {})` returned true because
`Object.length` is 1 -- a required group satisfied by nothing.

All reads now go through an own-property `readValue`, and the web
preview's filled-check uses `Object.hasOwn`. `expandExpression` carries
the value through its filter rather than re-reading it afterwards, so the
"is it defined" test and the read cannot disagree and nothing needs a
non-null assertion.

The remaining six findings were documentation drifting behind the code,
which matters here because the comments are the argument for why this
module exists:

- The web helper still described the SDK's second-`?`-to-`&` rewrite as
  something the preview relies on. That was removed last round.
- Three places still named the CLI as a consumer. It has no template form
  and passes an already-expanded `--uri` to `readResource`.
- The PR description still described the superseded architecture
  (expansion delegated to the SDK), i.e. the opposite of what now ships.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 22:02:39 -04:00
commit 1939136eff

View file

@ -468,3 +468,41 @@ describe("prefix-modifier grammar", () => {
);
});
});
describe("variable names that collide with Object.prototype", () => {
// `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570
// varnames. A bare `values[name]` lookup finds the prototype's member for
// every one of them, so a *blank* field read as supplied: measured,
// `({})["toString"] !== undefined` is true and its typeof is "function".
it.each(["toString", "constructor", "valueOf", "hasOwnProperty"])(
"omits a blank {?%s} instead of expanding a prototype member",
(name) => {
expect(expandUriTemplate(`x://a{?${name}}`, { [name]: "" })).toBe(
"x://a",
);
},
);
it("omits the expression when the key is absent entirely", () => {
expect(expandUriTemplate("x://a{?toString}", {})).toBe("x://a");
});
it("still expands such a variable when it really has a value", () => {
expect(expandUriTemplate("x://a{?toString}", { toString: "v" })).toBe(
"x://a?toString=v",
);
});
it("does not treat an inherited member as satisfying a required group", () => {
// `Object` (the inherited constructor) has length 1, so the old
// `(values[name] ?? "").length > 0` test reported this as satisfied.
expect(hasRequiredValues([["constructor"]], {})).toBe(false);
expect(hasRequiredValues([["constructor"]], { constructor: "c" })).toBe(
true,
);
});
it("handles __proto__ as an ordinary variable name", () => {
expect(expandUriTemplate("x://a{?__proto__}", {})).toBe("x://a");
});
});