From 48c352b6bfa34c820a530272cd36600158c37b1e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:36:20 +0800 Subject: [PATCH] fix(cli): reuse firmware and web module input constraints Derive extraction limits and web module fields from the shared contracts. Reject empty or NUL-containing local paths before CLI path resolution, preserve firmware relative-path handling, and apply the advertised extraction defaults. Validate advertised JSON Schemas with Ajv and check the registered parser defaults; prune redundant schema-shape and description assertions. --- src/cli/firmwareCommands.ts | 54 +++++--------- src/cli/webModuleCommands.ts | 29 ++++---- .../cli/cliInputSchemaRequired.test.ts | 70 +++++++++++++++++++ 3 files changed, 102 insertions(+), 51 deletions(-) diff --git a/src/cli/firmwareCommands.ts b/src/cli/firmwareCommands.ts index 9c59711e21..3921d85218 100644 --- a/src/cli/firmwareCommands.ts +++ b/src/cli/firmwareCommands.ts @@ -4,6 +4,8 @@ import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysi import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; +import { firmwareInputSchemas } from "../domain/firmware/firmwareAnalysis.js"; +import { localPathStringSchema } from "../domain/localPath.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { FirmwareOperation } from "../domain/firmware/firmwareAnalysis.js"; import type { Logger } from "pino"; @@ -19,6 +21,8 @@ export const registerFirmwareCommands = ( const service = new FirmwareAnalysisService( createFirmwareAnalysisProvider(environment), ); + const extractInput = firmwareInputSchemas.extract_firmware.shape; + const range = extractInput.range.unwrap().shape; const execute = ( name: string, operation: FirmwareOperation, @@ -34,7 +38,9 @@ export const registerFirmwareCommands = ( } }), ); - const path = z.string().describe("Local firmware path; never executed"); + const path = localPathStringSchema.describe( + "Local firmware path; never executed", + ); cli.command(CLI_COMMANDS.inspectFirmwareRegions, { description: "Inspect firmware signature candidates without extracting them", @@ -52,38 +58,20 @@ export const registerFirmwareCommands = ( "Extract selected firmware bytes and return verified files and provenance", args: z.object({ path, - outputDirectory: z - .string() - .describe("Absolute absent output directory; parent must exist"), + outputDirectory: localPathStringSchema.describe( + "Absent output directory; parent must exist", + ), }), options: z.object({ - offset: z - .number() - .int() - .nonnegative() + offset: range.offset .optional() .describe("Selected byte offset; requires length"), - length: z - .number() - .int() - .positive() + length: range.length .optional() .describe("Selected byte length; requires offset"), - maxDepth: z - .number() - .int() - .optional() - .describe("Extraction depth, 1–10; default 3"), - maxOutputBytes: z - .number() - .int() - .optional() - .describe("Staging byte budget; default 256 MiB, maximum 512 MiB"), - maxOutputFiles: z - .number() - .int() - .optional() - .describe("Staging entry budget; default 10000"), + maxDepth: extractInput.max_depth, + maxOutputBytes: extractInput.max_output_bytes, + maxOutputFiles: extractInput.max_output_files, }), run: ({ args, options }) => execute(CLI_COMMANDS.extractFirmware, "extract_firmware", { @@ -92,15 +80,9 @@ export const registerFirmwareCommands = ( ...(options.offset === undefined && options.length === undefined ? {} : { range: { offset: options.offset, length: options.length } }), - ...(options.maxDepth === undefined - ? {} - : { max_depth: options.maxDepth }), - ...(options.maxOutputBytes === undefined - ? {} - : { max_output_bytes: options.maxOutputBytes }), - ...(options.maxOutputFiles === undefined - ? {} - : { max_output_files: options.maxOutputFiles }), + max_depth: options.maxDepth, + max_output_bytes: options.maxOutputBytes, + max_output_files: options.maxOutputFiles, }), }); }; diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index 3297f3454c..b14f23da5a 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -3,10 +3,14 @@ import { createWebModuleTraceService } from "../composition/webModules.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; +const importMapInputSchema = + webModuleTraceInputSchema.shape.import_map.unwrap(); + /** Expose the same selected-context module trace through the CLI. */ export const registerWebModuleCommands = ( cli: CliInstance, @@ -18,9 +22,7 @@ export const registerWebModuleCommands = ( description: "Trace one captured script's native imports to URLs and captured source candidates", args: z.object({ - manifestPath: z - .string() - .describe("Absolute export_web_scripts manifest path"), + manifestPath: webModuleTraceInputSchema.shape.manifest_path, scriptIndex: z.coerce .number() .int() @@ -28,18 +30,15 @@ export const registerWebModuleCommands = ( .describe("Zero-based manifest script index"), }), options: z.object({ - importerUrl: z - .string() - .optional() - .describe("Explicit HTTP(S) importer URL"), - importMapPath: z - .string() - .optional() - .describe("Absolute local import-map JSON"), - importMapBaseUrl: z - .string() - .optional() - .describe("HTTP(S) import-map base URL"), + importerUrl: webModuleTraceInputSchema.shape.importer_url, + importMapPath: importMapInputSchema.shape.path + .describe( + "Absolute local import-map JSON path; requires --import-map-base-url", + ) + .optional(), + importMapBaseUrl: importMapInputSchema.shape.base_url + .describe("HTTP(S) import-map base URL; requires --import-map-path") + .optional(), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 023b7dc4ab..82f72af10c 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -131,3 +131,73 @@ describe("CLI advertised input schemas", () => { ); }); }); + +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); + }); +}); +import { resolve } from "node:path";