fix(expr): own-property-only lookup and Int32-bound array index in TemplateEngine port

This commit is contained in:
Claude
2026-08-30 10:49:14 +00:00
parent 3fde792c53
commit 72e31e8ea5
2 changed files with 31 additions and 4 deletions

View File

@@ -72,4 +72,21 @@ describe("templateEngine.render — parity with VolcanicCore.Core.TemplateEngine
it("bracketed string key lookup", () => {
expect(render(`{input["weird.key"]}`, J(`{"weird.key":"v"}`))).toBe("v");
});
it("prototype-chain properties are not reachable (own-property lookup only)", () => {
// Backend `TryGetProperty` only sees own keys. JS `in` walks the
// prototype chain, which would silently expose Object.prototype members
// like __proto__, constructor, toString. Must render empty (missing path).
expect(render("{input.__proto__}", { a: 1 })).toBe("");
expect(render("{input.constructor}", { a: 1 })).toBe("");
expect(render(`{input["__proto__"]}`, { a: 1 })).toBe("");
});
it("bracket-form array index outside Int32 range passes through as literal", () => {
// C# int.TryParse rejects; TryEvaluate returns false; caller emits verbatim.
expect(render("{input.tags[9999999999]}", { tags: ["x"] }))
.toBe("{input.tags[9999999999]}");
expect(render("{input.tags[-9999999999]}", { tags: ["x"] }))
.toBe("{input.tags[-9999999999]}");
});
});

View File

@@ -1,8 +1,14 @@
// Client-side port of VolcanicCore.Core.TemplateEngine.Render. MUST stay
// byte-exact with the backend — the dialog preview shows the user what the
// executor will produce. Divergence is a bug. Backend tests live in
// behaviorally aligned with the backend — the dialog preview shows the user
// what the executor will produce. Divergence is a bug. Backend tests live in
// tests/VolcanicCore.Tests/TemplateEngineTests.cs; add a matching case here
// when adding one there.
//
// Known parity caveat: numeric leaves render via String(v) after JSON.parse,
// so over-precision or non-normalized JSON numbers (e.g. `1.50`, `1e21`,
// `9007199254740993` above Number.MAX_SAFE_INTEGER) may drift by one
// representation from the backend's raw-text rendering. Real webhook payloads
// don't hit this in practice.
export function render(template: string, input: unknown): string {
if (!template || template.indexOf("{") < 0) return template;
@@ -68,7 +74,7 @@ function tryEvaluate(expr: string, input: unknown): string | undefined {
while (j < rest.length && isIdentChar(rest[j])) j++;
if (j === 1) return undefined;
const name = rest.slice(1, j);
if (have && isPlainObject(walker) && name in walker) walker = walker[name];
if (have && isPlainObject(walker) && Object.prototype.hasOwnProperty.call(walker, name)) walker = walker[name];
else have = false;
rest = rest.slice(j);
continue;
@@ -79,10 +85,14 @@ function tryEvaluate(expr: string, input: unknown): string | undefined {
const inside = rest.slice(1, close).trim();
if (inside.length >= 2 && inside[0] === `"` && inside[inside.length - 1] === `"`) {
const key = inside.slice(1, -1);
if (have && isPlainObject(walker) && key in walker) walker = walker[key];
if (have && isPlainObject(walker) && Object.prototype.hasOwnProperty.call(walker, key)) walker = walker[key];
else have = false;
} else if (/^-?\d+$/.test(inside)) {
const idx = Number(inside);
// Match C# int.TryParse — anything outside Int32 signed range makes
// the whole placeholder malformed (pass through verbatim), not an
// out-of-range array index.
if (idx < -2147483648 || idx > 2147483647) return undefined;
if (have && Array.isArray(walker) && idx >= 0 && idx < walker.length) walker = walker[idx];
else have = false;
} else {