Skip to content

Commit 94ac40d

Browse files
committed
fix(workflow): parse metadata as pure literals
1 parent a23be1c commit 94ac40d

2 files changed

Lines changed: 224 additions & 95 deletions

File tree

src/workflow-script.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,38 @@ return { ok: true, name: meta.name }
3737
);
3838
}
3939

40+
{
41+
assert.throws(
42+
() =>
43+
parseWorkflowScript(`
44+
export const meta = {
45+
name: 'bracket-call',
46+
description: ({})['constructor']['constructor']('return process')(),
47+
}
48+
`),
49+
(error: unknown) =>
50+
error instanceof WorkflowScriptError &&
51+
error.kind === "meta" &&
52+
/literal value/.test(error.message),
53+
);
54+
}
55+
56+
{
57+
assert.throws(
58+
() =>
59+
parseWorkflowScript(`
60+
export const meta = {
61+
name: 'computed-key',
62+
['description']: 'd',
63+
}
64+
`),
65+
(error: unknown) =>
66+
error instanceof WorkflowScriptError &&
67+
error.kind === "meta" &&
68+
/static property name/.test(error.message),
69+
);
70+
}
71+
4072
{
4173
const parsed = parseWorkflowScript(`
4274
export const meta = {

src/workflow-script.ts

Lines changed: 192 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,6 @@ function extractMetaLiteral(source: string): { metaLiteral: string; metaEndIndex
106106
const end = scanBalancedObject(source, objectStart);
107107
const metaLiteral = source.slice(objectStart, end + 1);
108108

109-
assertPureMetaLiteral(metaLiteral);
110-
111109
return { metaLiteral, metaEndIndex: end + 1 };
112110
}
113111

@@ -166,115 +164,214 @@ function scanBalancedObject(source: string, start: number): number {
166164
throw new WorkflowScriptError("meta", "Unclosed meta object literal");
167165
}
168166

169-
function assertPureMetaLiteral(literal: string): void {
170-
for (let i = 0; i < literal.length; i += 1) {
171-
const ch = literal[i]!;
172-
const next = literal[i + 1];
173-
if (ch === '"' || ch === "'") {
174-
i = skipQuoted(literal, i, ch);
175-
continue;
176-
}
177-
if (ch === "/" && next === "/") {
178-
i = skipLineComment(literal, i + 2);
179-
continue;
180-
}
181-
if (ch === "/" && next === "*") {
182-
i = skipBlockComment(literal, i + 2);
183-
continue;
184-
}
185-
if (ch === "`") {
186-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no templates)");
187-
}
188-
if (literal.startsWith("...", i)) {
189-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no spreads)");
190-
}
191-
if (literal.startsWith("=>", i)) {
192-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no functions)");
193-
}
194-
if (!/[A-Za-z_$]/.test(ch)) continue;
195-
196-
const identifierStart = i;
197-
i += 1;
198-
while (i < literal.length && /[\w$]/.test(literal[i]!)) i += 1;
199-
const identifier = literal.slice(identifierStart, i);
200-
if (identifier === "function" || identifier === "class" || identifier === "new") {
201-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no executable values)");
167+
function isOnlyPreamble(text: string): boolean {
168+
// strip block comments, line comments, whitespace
169+
const stripped = text
170+
.replace(/\/\*[\s\S]*?\*\//g, "")
171+
.replace(/\/\/.*$/gm, "")
172+
.trim();
173+
return stripped.length === 0;
174+
}
175+
176+
function evaluateMetaLiteral(literal: string, _filename: string): unknown {
177+
try {
178+
return new PureMetaLiteralParser(literal).parse();
179+
} catch (error) {
180+
const message = error instanceof Error ? error.message : String(error);
181+
throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`);
182+
}
183+
}
184+
185+
class PureMetaLiteralParser {
186+
private index = 0;
187+
188+
constructor(private readonly source: string) {}
189+
190+
parse(): unknown {
191+
this.skipTrivia();
192+
const value = this.parseValue();
193+
this.skipTrivia();
194+
if (this.index !== this.source.length) {
195+
this.fail(`unexpected token ${JSON.stringify(this.source[this.index])}`);
202196
}
203-
i = skipTrivia(literal, i) - 1;
204-
if (literal[i + 1] === "(") {
205-
throw new WorkflowScriptError("meta", "meta must be a pure literal (no function calls)");
197+
return value;
198+
}
199+
200+
private parseValue(): unknown {
201+
this.skipTrivia();
202+
const ch = this.source[this.index];
203+
if (ch === "{") return this.parseObject();
204+
if (ch === "[") return this.parseArray();
205+
if (ch === '"' || ch === "'") return this.parseString();
206+
if (ch === "-" || (ch !== undefined && /\d/.test(ch))) return this.parseNumber();
207+
if (ch !== undefined && /[A-Za-z_$]/.test(ch)) {
208+
const identifier = this.parseIdentifier();
209+
if (identifier === "true") return true;
210+
if (identifier === "false") return false;
211+
if (identifier === "null") return null;
212+
this.fail(`identifier ${identifier} is not a literal value`);
206213
}
214+
this.fail(`expected a literal value, got ${JSON.stringify(ch)}`);
207215
}
208-
}
209216

210-
function skipQuoted(source: string, start: number, quote: '"' | "'"): number {
211-
let escape = false;
212-
for (let i = start + 1; i < source.length; i += 1) {
213-
const ch = source[i]!;
214-
if (escape) {
215-
escape = false;
216-
continue;
217+
private parseObject(): Record<string, unknown> {
218+
this.expect("{");
219+
const value: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
220+
this.skipTrivia();
221+
if (this.consume("}")) return value;
222+
223+
for (;;) {
224+
this.skipTrivia();
225+
const ch = this.source[this.index];
226+
const key = ch === '"' || ch === "'" ? this.parseString() : this.parseIdentifier();
227+
this.skipTrivia();
228+
this.expect(":");
229+
value[key] = this.parseValue();
230+
this.skipTrivia();
231+
if (this.consume("}")) return value;
232+
this.expect(",");
233+
this.skipTrivia();
234+
if (this.consume("}")) return value;
217235
}
218-
if (ch === "\\") {
219-
escape = true;
220-
continue;
236+
}
237+
238+
private parseArray(): unknown[] {
239+
this.expect("[");
240+
const value: unknown[] = [];
241+
this.skipTrivia();
242+
if (this.consume("]")) return value;
243+
244+
for (;;) {
245+
value.push(this.parseValue());
246+
this.skipTrivia();
247+
if (this.consume("]")) return value;
248+
this.expect(",");
249+
this.skipTrivia();
250+
if (this.consume("]")) return value;
221251
}
222-
if (ch === quote) return i;
223252
}
224-
return source.length - 1;
225-
}
226253

227-
function skipLineComment(source: string, start: number): number {
228-
const newline = source.indexOf("\n", start);
229-
return newline < 0 ? source.length - 1 : newline;
230-
}
254+
private parseString(): string {
255+
const quote = this.source[this.index];
256+
if (quote !== '"' && quote !== "'") this.fail("expected a quoted string");
257+
this.index += 1;
258+
let value = "";
231259

232-
function skipBlockComment(source: string, start: number): number {
233-
const end = source.indexOf("*/", start);
234-
return end < 0 ? source.length - 1 : end + 1;
235-
}
260+
while (this.index < this.source.length) {
261+
const ch = this.source[this.index++]!;
262+
if (ch === quote) return value;
263+
if (ch === "\n" || ch === "\r") this.fail("unterminated string literal");
264+
if (ch !== "\\") {
265+
value += ch;
266+
continue;
267+
}
236268

237-
function skipTrivia(source: string, start: number): number {
238-
let i = start;
239-
while (i < source.length) {
240-
if (/\s/.test(source[i]!)) {
241-
i += 1;
242-
continue;
269+
if (this.index >= this.source.length) this.fail("unterminated string escape");
270+
const escaped = this.source[this.index++]!;
271+
const simpleEscapes: Record<string, string> = {
272+
"\\": "\\",
273+
"\"": "\"",
274+
"'": "'",
275+
n: "\n",
276+
r: "\r",
277+
t: "\t",
278+
b: "\b",
279+
f: "\f",
280+
v: "\v",
281+
"0": "\0",
282+
};
283+
if (escaped in simpleEscapes) {
284+
value += simpleEscapes[escaped];
285+
continue;
286+
}
287+
if (escaped === "x") {
288+
value += String.fromCodePoint(this.parseHexDigits(2));
289+
continue;
290+
}
291+
if (escaped === "u") {
292+
if (this.consume("{")) {
293+
const end = this.source.indexOf("}", this.index);
294+
if (end < 0) this.fail("unterminated Unicode escape");
295+
const digits = this.source.slice(this.index, end);
296+
if (!/^[0-9a-fA-F]{1,6}$/.test(digits)) this.fail("invalid Unicode escape");
297+
this.index = end + 1;
298+
const codePoint = Number.parseInt(digits, 16);
299+
if (codePoint > 0x10ffff) this.fail("Unicode escape is out of range");
300+
value += String.fromCodePoint(codePoint);
301+
} else {
302+
value += String.fromCodePoint(this.parseHexDigits(4));
303+
}
304+
continue;
305+
}
306+
if (escaped === "\n") continue;
307+
if (escaped === "\r") {
308+
this.consume("\n");
309+
continue;
310+
}
311+
value += escaped;
243312
}
244-
if (source.startsWith("//", i)) {
245-
i = skipLineComment(source, i + 2) + 1;
246-
continue;
313+
this.fail("unterminated string literal");
314+
}
315+
316+
private parseNumber(): number {
317+
const match = this.source
318+
.slice(this.index)
319+
.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
320+
if (!match) this.fail("invalid number literal");
321+
this.index += match[0].length;
322+
const value = Number(match[0]);
323+
if (!Number.isFinite(value)) this.fail("number literal must be finite");
324+
return value;
325+
}
326+
327+
private parseIdentifier(): string {
328+
const match = this.source.slice(this.index).match(/^[A-Za-z_$][\w$]*/);
329+
if (!match) this.fail("expected a static property name");
330+
this.index += match[0].length;
331+
return match[0];
332+
}
333+
334+
private parseHexDigits(length: number): number {
335+
const digits = this.source.slice(this.index, this.index + length);
336+
if (digits.length !== length || !/^[0-9a-fA-F]+$/.test(digits)) {
337+
this.fail("invalid hexadecimal escape");
247338
}
248-
if (source.startsWith("/*", i)) {
249-
i = skipBlockComment(source, i + 2) + 1;
250-
continue;
339+
this.index += length;
340+
return Number.parseInt(digits, 16);
341+
}
342+
343+
private skipTrivia(): void {
344+
for (;;) {
345+
while (this.index < this.source.length && /\s/.test(this.source[this.index]!)) {
346+
this.index += 1;
347+
}
348+
if (this.source.startsWith("//", this.index)) {
349+
const end = this.source.indexOf("\n", this.index + 2);
350+
this.index = end < 0 ? this.source.length : end + 1;
351+
continue;
352+
}
353+
if (this.source.startsWith("/*", this.index)) {
354+
const end = this.source.indexOf("*/", this.index + 2);
355+
if (end < 0) this.fail("unterminated block comment");
356+
this.index = end + 2;
357+
continue;
358+
}
359+
return;
251360
}
252-
break;
253361
}
254-
return i;
255-
}
256362

257-
function isOnlyPreamble(text: string): boolean {
258-
// strip block comments, line comments, whitespace
259-
const stripped = text
260-
.replace(/\/\*[\s\S]*?\*\//g, "")
261-
.replace(/\/\/.*$/gm, "")
262-
.trim();
263-
return stripped.length === 0;
264-
}
363+
private expect(token: string): void {
364+
if (!this.consume(token)) this.fail(`expected ${JSON.stringify(token)}`);
365+
}
265366

266-
function evaluateMetaLiteral(literal: string, filename: string): unknown {
267-
try {
268-
const value = vm.runInNewContext(`(${literal})`, Object.create(null), {
269-
filename: `${filename}:meta`,
270-
timeout: 1000,
271-
});
272-
// Rehydrate into the host realm — vm values keep context prototypes which
273-
// break assert.deepEqual and other host identity checks.
274-
return JSON.parse(JSON.stringify(value));
275-
} catch (error) {
276-
const message = error instanceof Error ? error.message : String(error);
277-
throw new WorkflowScriptError("meta", `Invalid meta literal: ${message}`);
367+
private consume(token: string): boolean {
368+
if (!this.source.startsWith(token, this.index)) return false;
369+
this.index += token.length;
370+
return true;
371+
}
372+
373+
private fail(message: string): never {
374+
throw new Error(`${message} at offset ${this.index}`);
278375
}
279376
}
280377

0 commit comments

Comments
 (0)