import { Ajv2020 } from "ajv/dist/2020.js"; import { Cli, z } from "incur"; import { describe, expect, it } from "vitest"; import { createCli } from "../../../src/cli.js"; import { createCliInventory } from "../../../scripts/lib/catalog-cli.mjs"; import { apktoolInputSchemas, MAX_SELECTED_LOCALES, } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; type CliInstance = ReturnType; type CommandMap = NonNullable>; const INPUT_GROUPS = ["args", "options"] as const; const registeredCommands: WeakMap = Cli.toCommands; const advertisedGroupSchema = z .object({ properties: z.record(z.string(), z.unknown()), required: z.array(z.string()).optional(), }) .loose(); const advertisedInputSchema = z.object({ args: advertisedGroupSchema.optional(), options: advertisedGroupSchema.optional(), }); const serve = async ( cli: CliInstance, argv: readonly string[], ): Promise<{ readonly stdout: string; readonly exitCode: number }> => { let stdout = ""; let exitCode = 0; await cli.serve([...argv], { env: {}, exit: (code) => { exitCode = code; }, stdout: (text) => { stdout += text; }, }); return { stdout, exitCode }; }; const advertisedSchema = async (cli: CliInstance, command: string) => { const result = await serve(cli, [command, "--schema", "--json"]); expect(result.exitCode, command).toBe(0); return advertisedInputSchema.parse(JSON.parse(result.stdout)); }; const parserSchema = ( cli: CliInstance, command: string, group: (typeof INPUT_GROUPS)[number], ): z.ZodObject | undefined => { const entry = registeredCommands.get(cli)?.get(command); if (entry === undefined || !(group in entry)) return undefined; const schema: unknown = Reflect.get(entry, group); return schema instanceof z.ZodObject ? schema : undefined; }; /** * Reject a key only when an object parse that omits it fails. * `exactOptional()` rejects explicit `undefined` and still accepts omission. */ const omissionRejected = (schema: z.ZodObject | undefined): string[] => Object.entries(schema?.shape ?? {}) .filter(([key, field]) => !acceptsOmittedKey(key, field)) .map(([key]) => key) .sort(); const acceptsOmittedKey = (key: string, field: z.ZodType): boolean => z.object({ [key]: field }).safeParse({}).success; describe("CLI advertised input schemas", () => { it("require exactly the inputs whose omission the parser rejects", async () => { const cli = createCli({}); const mismatches: string[] = []; for (const command of createCliInventory(cli).primary) { const advertised = await advertisedSchema(cli, command); for (const group of INPUT_GROUPS) { const parser = parserSchema(cli, command, group); const advertisedGroup = advertised[group]; if (parser === undefined) { if (advertisedGroup !== undefined) mismatches.push(`${command} ${group}: advertised without parser`); continue; } if (advertisedGroup === undefined) { mismatches.push(`${command} ${group}: parser group not advertised`); continue; } const parserProperties = Object.keys(parser.shape).toSorted(); const advertisedProperties = Object.keys( advertisedGroup.properties, ).toSorted(); if (advertisedProperties.join() !== parserProperties.join()) mismatches.push( `${command} ${group}: advertised properties [${advertisedProperties.join()}], parser properties [${parserProperties.join()}]`, ); const required = (advertisedGroup.required ?? []).toSorted(); const rejected = omissionRejected(parser); if (required.join() !== rejected.join()) mismatches.push( `${command} ${group}: advertised [${required.join()}], parser rejects [${rejected.join()}]`, ); } } expect(mismatches).toEqual([]); }); it("allows an omitted WASM timeout while validating explicitly supplied values", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "inspect-wasm-artifact"); const options = parserSchema(cli, "inspect-wasm-artifact", "options"); expect(options).toBeDefined(); const validate = new Ajv2020({ strict: false }).compile( advertised.options ?? {}, ); expect(options?.safeParse({}).success).toBe(true); expect(validate({})).toBe(true); expect(options?.safeParse({ commandTimeoutMs: undefined }).success).toBe( false, ); for (const commandTimeoutMs of [1, 30_000, 2_147_483_647]) { expect(options?.safeParse({ commandTimeoutMs }).success).toBe(true); expect(validate({ commandTimeoutMs })).toBe(true); } for (const commandTimeoutMs of [0, 1.5, 2_147_483_648]) { expect(options?.safeParse({ commandTimeoutMs }).success).toBe(false); expect(validate({ commandTimeoutMs })).toBe(false); } }); it("accepts the one-argument keyed-archive call and keeps its constraints", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "inspect-keyed-archive"); const ajv = new Ajv2020({ strict: false }); const args = ajv.compile(advertised.args ?? {}); const options = ajv.compile(advertised.options ?? {}); expect(args({ path: "./keyed.plist" })).toBe(true); expect(args({})).toBe(false); expect(options({})).toBe(true); expect(options({ limit: 0 })).toBe(false); expect(advertised.args?.properties?.["archive"]).toMatchObject({ default: ".", }); expect(advertised.options?.properties?.["limit"]).toMatchObject({ default: 20_000, minimum: 1, }); expect( parserSchema(cli, "inspect-keyed-archive", "args")?.parse({ path: "x" }), ).toEqual({ path: "x", archive: "." }); const help = await serve(cli, ["inspect-keyed-archive", "--help"]); expect(help.stdout).toContain( "Usage: rea inspect-keyed-archive [archive] [options]", ); expect(help.stdout).toContain( "archive Relative archive path when the target is a bundle", ); }); }); describe("firmware CLI advertised input schema", () => { it("keeps limits and defaults from its shared input contract", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "extract-firmware"); const ajv = new Ajv2020({ strict: false }); const args = ajv.compile(advertised.args ?? {}); const options = ajv.compile(advertised.options ?? {}); expect(args({ path: "firmware.bin", outputDirectory: "output" })).toBe( true, ); expect(args({ path: "", outputDirectory: "output" })).toBe(false); expect(args({ path: "firmware.bin", outputDirectory: "" })).toBe(false); expect(options({})).toBe(true); expect(options({ maxDepth: 0 })).toBe(false); expect(options({ maxDepth: 11 })).toBe(false); expect(options({ maxOutputBytes: 0 })).toBe(false); expect(options({ maxOutputBytes: 512 * 1024 * 1024 + 1 })).toBe(false); expect(options({ maxOutputFiles: 100_001 })).toBe(false); expect(advertised.options?.properties?.["maxDepth"]).toMatchObject({ default: 3, }); expect(advertised.options?.properties?.["maxOutputBytes"]).toMatchObject({ default: 256 * 1024 * 1024, }); expect(advertised.options?.properties?.["maxOutputFiles"]).toMatchObject({ default: 10_000, }); const maxOffset = Number.MAX_SAFE_INTEGER; expect(options({ offset: maxOffset + 1 })).toBe(false); expect(options({ length: maxOffset + 1 })).toBe(false); expect( parserSchema(cli, "extract-firmware", "options")?.parse({}), ).toMatchObject({ maxDepth: 3, maxOutputBytes: 256 * 1024 * 1024, maxOutputFiles: 10_000, }); }); }); describe("web module CLI advertised input schema", () => { it("advertises the shared path and URL constraints", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "trace-web-module-imports"); const ajv = new Ajv2020({ strict: false }); const args = ajv.compile(advertised.args ?? {}); const options = ajv.compile(advertised.options ?? {}); expect( args({ manifestPath: resolve("manifest.json"), scriptIndex: 0 }), ).toBe(true); expect(args({ manifestPath: "", scriptIndex: 0 })).toBe(false); expect(args({ manifestPath: "manifest\u0000.json", scriptIndex: 0 })).toBe( false, ); expect(args({ manifestPath: "./manifest.json", scriptIndex: -1 })).toBe( false, ); expect(options({})).toBe(true); expect(options({ importerUrl: "" })).toBe(false); expect(options({ importMapPath: "" })).toBe(false); expect(options({ importMapPath: "map\u0000.json" })).toBe(false); expect(options({ importMapBaseUrl: "" })).toBe(false); }); }); describe("Apktool locale selection input schema", () => { it("preserves the owner's array bounds through CLI advertisement and parsing", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "decode-android-resources"); expect(advertised.options?.properties?.locales).toMatchObject({ type: "array", minItems: 1, maxItems: MAX_SELECTED_LOCALES, }); const parser = parserSchema(cli, "decode-android-resources", "options"); const ajv = new Ajv2020({ strict: false }); const validate = ajv.compile({ ...advertised.options, $schema: "https://json-schema.org/draft/2020-12/schema", }); for (const locales of [ undefined, [], ["de"], Array(MAX_SELECTED_LOCALES).fill("de"), Array(MAX_SELECTED_LOCALES + 1).fill("de"), ["not a locale"], ]) { const input = locales === undefined ? {} : { locales }; const expected = apktoolInputSchemas.decode_android_resources.safeParse({ path: "/targets/app.apk", ...input, }).success; expect(parser?.safeParse(input).success).toBe(expected); expect(validate(input)).toBe(expected); } }); }); describe("reference-source outline-depth input schema", () => { it("aligns advertised bounds with the parser's accepted values", async () => { const cli = createCli({}); const advertised = await advertisedSchema(cli, "import-reference-source"); const outlineDepth = advertised.options?.properties?.outlineDepth; expect(outlineDepth).toMatchObject({ type: "integer", minimum: 0 }); const parser = parserSchema(cli, "import-reference-source", "options"); expect(parser?.safeParse({ outlineDepth: -1 }).success).toBe(false); expect(parser?.safeParse({ outlineDepth: 0 }).success).toBe(true); expect(parser?.safeParse({ outlineDepth: 2 }).success).toBe(true); const ajv = new Ajv2020({ strict: false }); const validate = ajv.compile({ ...advertised.options, $schema: "https://json-schema.org/draft/2020-12/schema", }); expect(validate({ outlineDepth: -1 })).toBe(false); expect(validate({ outlineDepth: 0 })).toBe(true); expect(validate({ outlineDepth: 2 })).toBe(true); }); }); import { resolve } from "node:path";