The advertised-schema check parsed undefined on each field. exactOptional rejects that value and still accepts a missing key, so inspect-wasm-artifact looked required for commandTimeoutMs. Judge omission with an object parse that leaves the key out. The advertised required list already matched that parse for every other primary command. Co-authored-by: morluto <morluto@users.noreply.github.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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<typeof createCli>;
|
|
type CommandMap = NonNullable<ReturnType<typeof Cli.toCommands.get>>;
|
|
|
|
const INPUT_GROUPS = ["args", "options"] as const;
|
|
const registeredCommands: WeakMap<object, CommandMap> = 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 <path> [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<string>(MAX_SELECTED_LOCALES).fill("de"),
|
|
Array<string>(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";
|