diff --git a/src/application/AppBundleExecutable.openStep.test.ts b/src/application/AppBundleExecutable.openStep.test.ts new file mode 100644 index 0000000000..4a5aae87f8 --- /dev/null +++ b/src/application/AppBundleExecutable.openStep.test.ts @@ -0,0 +1,31 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { parseOpenStep } from "plist"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; + +it.each([ + ["no comment", ""], + ["block comment", "/* App metadata */\n"], + ["line comment", "// App metadata\n"], + ["mixed comments", " \t/* header */\n// executable selection\n"], +])("resolves an OpenStep Info.plist with %s", async (_label, prefix) => { + const root = await createTestTempDirectory("rea-openstep-bundle-"); + const bundle = join(root, "Fixture.app"); + const programs = join(bundle, "Contents", "MacOS"); + const executable = join(programs, "Fixture"); + const infoPlist = join(bundle, "Contents", "Info.plist"); + const source = `${prefix}{ CFBundleExecutable = Fixture; }`; + expect(parseOpenStep(source)).toEqual({ CFBundleExecutable: "Fixture" }); + await mkdir(programs, { recursive: true }); + await writeFile(infoPlist, source); + await writeFile(executable, "fixture - never executed"); + + expect(await resolveAppBundleExecutable(bundle)).toEqual({ + ok: true, + value: { executable, infoPlist }, + }); +}); diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index eec69718cf..850bbf9f58 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -66,7 +66,7 @@ export const omitPrototypeKeys = ( export const parseXmlPropertyList = (text: string): ParsedPropertyList => { // Preserve the pinned decoder's existing OpenStep autodetection, also used // by app Info.plist resolution. - const first = text.trimStart()[0]; + const first = text[skipOpenStepTrivia(text, 0)]; if (first === "{" || first === "(") { // The pinned decoder assigns a `__proto__` key to the object prototype, so // the entry is gone before the decoded value can be counted. Count keys in @@ -210,12 +210,17 @@ const isOpenStepPrototypeKey = (token: string): boolean => { /** Skip grammar trivia without interpreting delimiter examples inside comments. */ const skipOpenStepTrivia = (text: string, cursor: number): number => { - if (/\s/u.test(text[cursor] ?? "")) return cursor + 1; - if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor)) - return cursor; - const line = text[cursor + 1] === "/"; - const end = text.indexOf(line ? "\n" : "*/", cursor + 2); - return end === -1 ? text.length : end + (line ? 1 : 2); + while (cursor < text.length) { + if (/\s/u.test(text[cursor] ?? "")) { + cursor += 1; + continue; + } + if (!text.startsWith("//", cursor) && !text.startsWith("/*", cursor)) break; + const line = text[cursor + 1] === "/"; + const end = text.indexOf(line ? "\n" : "*/", cursor + 2); + cursor = end === -1 ? text.length : end + (line ? 1 : 2); + } + return cursor; }; /** Read the extent of one opaque scalar; the pinned decoder still owns its value. */ diff --git a/src/domain/propertyListOpenStepComments.test.ts b/src/domain/propertyListOpenStepComments.test.ts new file mode 100644 index 0000000000..13f77cdbbc --- /dev/null +++ b/src/domain/propertyListOpenStepComments.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; + +import { plistNestingLimitMessage } from "./apple/plistValue.js"; +import { parseXmlPropertyList } from "./propertyListKeys.js"; + +it("preserves array values and comment-like string content after leading trivia", () => { + const parsed = parseXmlPropertyList( + '\uFEFF /* header */ // array\r\n ("https://example.test", "/*literal*/")', + ); + expect(parsed.value).toEqual(["https://example.test", "/*literal*/"]); + expect(parsed.numberLiterals.incomplete).toBe(true); +}); + +it("retains prototype-key omission through commented OpenStep dispatch", () => { + const parsed = parseXmlPropertyList( + '/* header */ { __proto__ = { secret = value; }; kept = "/*literal*/"; }', + ); + expect(parsed.value).toEqual({ kept: "/*literal*/" }); + expect(parsed.omittedPrototypeKeys).toBe(1); +}); + +it("retains the depth preflight after a leading comment", () => { + expect(() => + parseXmlPropertyList(`// header\n${"(".repeat(129)}x${")".repeat(129)}`), + ).toThrow(plistNestingLimitMessage()); +}); + +it.each(["// header only", "/* unterminated", "/* header */ not-a-container"])( + "does not admit missing or unsupported root values: %s", + (source) => { + expect(() => parseXmlPropertyList(source)).toThrow(); + }, +); + +it("keeps XML comments on the XML decoding path", () => { + expect( + parseXmlPropertyList( + " value", + ).value, + ).toBe("value"); +});