diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index b1ea22624..d2619f8bd 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -108,7 +108,37 @@ export const inspectNativeMacho = async ( else if (optional.error._tag === "AnalysisCapabilityUnavailableError") limitations.push(VTOOL_UNAVAILABLE_LIMITATION); else return err(optional.error); - const normalized = normalizeMacho(captures, context.invocation, limitations); + const otool = captures.find(({ tool }) => tool === "otool"); + if (otool === undefined) throw new TypeError("Missing otool capture"); + let load: ReturnType; + try { + load = parseOtoolLoadCommands(sliceOutput(otool)); + } catch (cause: unknown) { + return err( + new AnalysisOutputError( + "inspect_macho", + "Malformed otool load-command output", + { + cause, + capturedOutput: { + stdout: otool.stdout, + stderr: otool.stderr, + truncated: false, + stdout_bytes: otool.stdoutBytes, + stderr_bytes: otool.stderrBytes, + exit_code: otool.exitCode, + signal: otool.signal, + }, + }, + ), + ); + } + const normalized = normalizeMacho( + captures, + context.invocation, + limitations, + load, + ); const segmentOffset = segmentContainerOffset( normalized, selectedArchitecture, @@ -135,10 +165,10 @@ const normalizeMacho = ( captures: readonly NativeCommandCapture[], toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation, additionalLimitations: readonly string[], + load: ReturnType, ) => { const byTool = captureLookup(captures); const architectures = parseLipoArchitectures(byTool("lipo").stdout); - const load = parseOtoolLoadCommands(sliceOutput(byTool("otool"))); const imports = parseDyldSymbols( sliceOutput(byTool("dyld_info", 0)), "imports", diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index cd0fb9024..1321a8696 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -43,6 +43,15 @@ interface ParsedLoadCommands { readonly flags: Set; } +const DYLIB_COMMANDS = new Set([ + "LC_ID_DYLIB", + "LC_LOAD_DYLIB", + "LC_LOAD_WEAK_DYLIB", + "LC_REEXPORT_DYLIB", + "LC_LAZY_LOAD_DYLIB", + "LC_LOAD_UPWARD_DYLIB", +]); + /** * otool emits LF, but a capture replayed from another host or toolchain can * carry CRLF. The section/command boundaries below match a bare `\n`, so a @@ -57,11 +66,14 @@ const normalizeLineEndings = (output: string): string => export const parseOtoolLoadCommands = (raw: string) => { const output = normalizeLineEndings(raw); const header = parseHeader(output); - const state = createLoadCommandState(header?.flags); + if (header === undefined) + throw new TypeError("otool output is missing the Mach header"); + const state = createLoadCommandState(header.flags); for (const block of output.split(/(?=^Load command \d+$)/mu)) parseLoadCommand(block, state); + validateLoadCommandFraming(header, state.commands); return { - fileType: header?.fileType ?? null, + fileType: header.fileType, flags: [...state.flags].sort(), uuid: state.uuid, commands: state.commands, @@ -74,7 +86,14 @@ export const parseOtoolLoadCommands = (raw: string) => { const parseHeader = ( output: string, -): { fileType: string | null; flags: string[] } | undefined => { +): + | { + fileType: string | null; + flags: string[]; + commandCount: number; + commandBytes: number; + } + | undefined => { const lines = output.split(/\r?\n/u).map((line) => line.trim()); const columnsLine = lines.findIndex((line) => /(?:^|\s)magic\s/u.test(line)); if (columnsLine < 0) return undefined; @@ -82,6 +101,10 @@ const parseHeader = ( const dataLine = lines.slice(columnsLine + 1).find((line) => line.length > 0); const values = dataLine?.split(/\s+/u); if (columns === undefined || values === undefined) return undefined; + const commandCount = headerValue(columns, values, "ncmds"); + const commandBytes = headerValue(columns, values, "sizeofcmds"); + if (commandCount === null || commandBytes === null) + throw new TypeError("otool Mach header is missing valid command framing"); const fileTypeIndex = columns.indexOf("filetype"); const flagsIndex = columns.indexOf("flags"); const complete = values.length >= columns.length; @@ -92,6 +115,8 @@ const parseHeader = ( const flagsEnd = flagsIndex >= 0 ? values.length - (columns.length - flagsIndex - 1) : 0; return { + commandCount, + commandBytes, fileType: complete && fileTypeValueIndex >= 0 && fileTypeValueIndex < values.length ? (values[fileTypeValueIndex] ?? null) @@ -105,6 +130,48 @@ const parseHeader = ( }; }; +const headerValue = ( + columns: readonly string[], + values: readonly string[], + name: string, +): number | null => { + const index = columns.indexOf(name); + if (index < 0) return null; + const flagsIndex = columns.indexOf("flags"); + const valueIndex = + flagsIndex >= 0 && index > flagsIndex + ? values.length - (columns.length - index) + : index; + const value = values[valueIndex]; + return value === undefined ? null : numeric(value); +}; + +const validateLoadCommandFraming = ( + header: { readonly commandCount: number; readonly commandBytes: number }, + commands: readonly { + readonly index: number; + readonly fields: Readonly>; + }[], +): void => { + if (commands.length !== header.commandCount) + throw new TypeError( + "otool load-command count does not match the Mach header", + ); + let totalBytes = 0; + for (const [expectedIndex, command] of commands.entries()) { + const size = numberField(command.fields, "cmdsize"); + if (command.index !== expectedIndex || size === null || size < 8) + throw new TypeError("otool load-command sequence or size is invalid"); + totalBytes += size; + if (!Number.isSafeInteger(totalBytes)) + throw new TypeError("otool load-command byte count is unsafe"); + } + if (totalBytes !== header.commandBytes) + throw new TypeError( + "otool load-command sizes do not match the Mach header", + ); +}; + const createLoadCommandState = ( headerFlags: readonly string[] | undefined, ): ParsedLoadCommands => ({ @@ -120,11 +187,17 @@ const createLoadCommandState = ( const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { const indexText = /^Load command (\d+)/u.exec(block)?.[1]; if (indexText === undefined) return; + const index = Number(indexText); + if (!Number.isSafeInteger(index)) + throw new TypeError("otool load-command index is unsafe"); const header = block.split(/\n\s*Section\n/u)[0] ?? block; const fields = parseFields(header); - const kind = stringField(fields, "cmd") ?? "unknown"; + const kind = stringField(fields, "cmd"); + if (kind === null) throw new TypeError("otool load command is missing cmd"); + for (const key of scalarFields(kind)) requireUniqueField(header, key); + if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(header); state.commands.push({ - index: Number.parseInt(indexText, 10), + index, kind, fields, }); @@ -135,6 +208,59 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { collectSegment(kind, block, fields, state); }; +const validateDylibCommandLines = (block: string): void => { + const allowed = new Set([ + "cmd", + "cmdsize", + "name", + "time stamp", + "current version", + "compatibility version", + ]); + for (const rawLine of block.split("\n").slice(1)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + const key = /^(time stamp|\S+(?:\s+version)?)\s+/u.exec(line)?.[1]; + if (key === undefined || !allowed.has(key)) + throw new TypeError( + "otool dylib command contains ambiguous multiline text", + ); + } +}; + +const scalarFields = (kind: string): readonly string[] => { + const common = ["cmd", "cmdsize"]; + if (DYLIB_COMMANDS.has(kind)) + return [ + ...common, + "name", + "time stamp", + "current version", + "compatibility version", + ]; + if (kind === "LC_LOAD_DYLINKER" || kind === "LC_ID_DYLINKER") + return [...common, "name"]; + if (kind === "LC_UUID") return [...common, "uuid"]; + if (kind === "LC_MAIN") return [...common, "entryoff"]; + if (kind === "LC_BUILD_VERSION") + return [...common, "platform", "minos", "sdk", "ntools"]; + if (kind.startsWith("LC_VERSION_MIN_")) return [...common, "version", "sdk"]; + if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64") + return [ + ...common, + "segname", + "vmaddr", + "vmsize", + "fileoff", + "filesize", + "maxprot", + "initprot", + "nsects", + "flags", + ]; + return common; +}; + const collectUuid = ( kind: string, fields: Readonly>, @@ -161,11 +287,14 @@ const collectBuild = ( state: ParsedLoadCommands, ): void => { if (kind === "LC_BUILD_VERSION") { + const tools = parseBuildTools(block); + if (numberField(fields, "ntools") !== tools.length) + throw new TypeError("otool build-tool count does not match ntools"); state.builds.push({ platform: stringField(fields, "platform"), minimum_os: stringField(fields, "minos"), sdk: stringField(fields, "sdk"), - tools: parseBuildTools(block), + tools, }); return; } @@ -183,13 +312,7 @@ const collectDependency = ( fields: Readonly>, state: ParsedLoadCommands, ): void => { - if ( - !kind.startsWith("LC_LOAD_") && - kind !== "LC_ID_DYLIB" && - kind !== "LC_REEXPORT_DYLIB" && - kind !== "LC_LAZY_LOAD_DYLIB" - ) - return; + if (!DYLIB_COMMANDS.has(kind)) return; state.dependencies.push({ path: stripOffsetSuffix(stringField(fields, "name")), kind, @@ -204,8 +327,13 @@ const collectSegment = ( fields: Readonly>, state: ParsedLoadCommands, ): void => { - if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64") - state.segments.push(parseSegment(block, fields)); + if (kind !== "LC_SEGMENT" && kind !== "LC_SEGMENT_64") return; + const segment = parseSegment(block, fields); + if (numberField(fields, "nsects") !== segment.sections.length) + throw new TypeError( + "otool segment section count does not match its sections", + ); + state.segments.push(segment); }; const parseFields = (block: string): Record => { @@ -227,6 +355,15 @@ const parseFields = (block: string): Record => { return fields; }; +const requireUniqueField = (block: string, key: string): void => { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const pattern = new RegExp(`^\\s*${escaped}\\s+`, "mu"); + let count = 0; + for (const line of block.split("\n")) if (pattern.test(line)) count += 1; + if (count !== 1) + throw new TypeError(`otool field ${key} is missing or repeated`); +}; + const parseSegment = ( block: string, fields: Readonly>, @@ -247,6 +384,16 @@ const parseSections = (block: string): ParsedSection[] => .slice(1) .map((sectionBlock) => { const fields = parseFields(`Section${sectionBlock}`); + for (const key of [ + "sectname", + "segname", + "addr", + "size", + "offset", + "align", + "flags", + ]) + requireUniqueField(sectionBlock, key); return { segment: stringField(fields, "segname") ?? "unknown", name: stringField(fields, "sectname") ?? "unknown", diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index cb84d0060..5ac80e778 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -21,6 +21,7 @@ import { nativeFixture as fixture, nativeMachoTarget as machoTarget, nativeMachoTargetForFile, + withOtoolMachHeader, } from "../../../fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -143,6 +144,7 @@ it("retains native inventory facts from captured Apple tool output", async () => const outputs: Record = {}; for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"]) outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); + outputs.otool = withOtoolMachHeader(outputs.otool ?? ""); outputs["dyld_info:-imports"] = await fixture("dyld-inventory/imports.txt"); const exportsOutput = await fixture("dyld-inventory/exports.txt"); outputs["dyld_info:-exports"] = exportsOutput; @@ -275,7 +277,8 @@ it("retains symbol names containing spaces in dyld inventory rows", () => { }); it("preserves numeric-looking segment and section identifiers from otool", () => { - const parsed = parseOtoolLoadCommands(`Load command 1 + const parsed = parseOtoolLoadCommands( + withOtoolMachHeader(`Load command 0 cmd LC_SEGMENT_64 cmdsize 152 segname 0001 @@ -299,7 +302,8 @@ Section flags 0x00000000 reserved1 0 reserved2 0 -`); +`), + ); expect(parsed.segments[0]).toMatchObject({ name: "0001", file_offset: 16384, @@ -367,7 +371,8 @@ it.each([ ])( "keeps load-command-looking text inside native install names: %s", (installName) => { - const parsed = parseOtoolLoadCommands(`Load command 0 + const parsed = parseOtoolLoadCommands( + withOtoolMachHeader(`Load command 0 cmd LC_ID_DYLIB cmdsize 80 name ${installName} (offset 24) @@ -378,7 +383,8 @@ Load command 1 cmd LC_UUID cmdsize 24 uuid 01234567-89AB-CDEF-0123-456789ABCDEF -`); +`), + ); expect(parsed.commands.map(({ index, kind }) => ({ index, kind }))).toEqual( [ { index: 0, kind: "LC_ID_DYLIB" }, diff --git a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts index de2c23232..057083c84 100644 --- a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts @@ -4,20 +4,25 @@ import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider. import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js"; import { NativeFixtureRunner, + nativeFixture, nativeMachoTarget, + withOtoolMachHeader, } from "../../../fixtures/nativeCommands.js"; it("retains lazily loaded library dependencies", () => { const load = parseOtoolLoadCommands( - [ - "Load command 9", - " cmd LC_LAZY_LOAD_DYLIB", - " name /usr/lib/libLazy.dylib (offset 24)", - " time stamp 2 Thu Jan 1 00:00:02 1970", - " current version 1.0.0", - "compatibility version 1.0.0", - "", - ].join("\n"), + withOtoolMachHeader( + [ + "Load command 0", + " cmd LC_LAZY_LOAD_DYLIB", + " cmdsize 48", + " name /usr/lib/libLazy.dylib (offset 24)", + " time stamp 2 Thu Jan 1 00:00:02 1970", + " current version 1.0.0", + "compatibility version 1.0.0", + "", + ].join("\n"), + ), ); expect(load.dependencies).toContainEqual({ path: "/usr/lib/libLazy.dylib", @@ -29,15 +34,18 @@ it("retains lazily loaded library dependencies", () => { it("preserves dependency install names that begin with the word stamp", () => { const load = parseOtoolLoadCommands( - [ - "Load command 2", - " cmd LC_LOAD_DYLIB", - " name stamp plugin.dylib (offset 24)", - " time stamp 2 Thu Jan 1 00:00:02 1970", - " current version 1.0.0", - "compatibility version 1.0.0", - "", - ].join("\n"), + withOtoolMachHeader( + [ + "Load command 0", + " cmd LC_LOAD_DYLIB", + " cmdsize 48", + " name stamp plugin.dylib (offset 24)", + " time stamp 2 Thu Jan 1 00:00:02 1970", + " current version 1.0.0", + "compatibility version 1.0.0", + "", + ].join("\n"), + ), ); expect(load.dependencies).toContainEqual({ path: "stamp plugin.dylib", @@ -54,20 +62,25 @@ it("preserves dependency install names that begin with the word stamp", () => { it("normalizes legacy version-minimum commands into build metadata", () => { const load = parseOtoolLoadCommands( - [ - "Load command 3", - " cmd LC_VERSION_MIN_MACOSX", - " version 10.13", - " sdk 10.15", - "Load command 4", - " cmd LC_VERSION_MIN_IPHONEOS", - " version 12.0", - " sdk 12.1", - "Load command 5", - " cmd LC_SOURCE_VERSION", - " version 1.2.3", - "", - ].join("\n"), + withOtoolMachHeader( + [ + "Load command 0", + " cmd LC_VERSION_MIN_MACOSX", + " cmdsize 16", + " version 10.13", + " sdk 10.15", + "Load command 1", + " cmd LC_VERSION_MIN_IPHONEOS", + " cmdsize 16", + " version 12.0", + " sdk 12.1", + "Load command 2", + " cmd LC_SOURCE_VERSION", + " cmdsize 16", + " version 1.2.3", + "", + ].join("\n"), + ), ); expect(load.builds).toEqual([ { @@ -85,15 +98,30 @@ it("normalizes legacy version-minimum commands into build metadata", () => { ]); }); +it("does not classify LC_LOAD_DYLINKER as a dylib dependency", async () => { + const load = parseOtoolLoadCommands( + await nativeFixture("native-macho-header/otool-header-load.txt"), + ); + expect(load.commands.some(({ kind }) => kind === "LC_LOAD_DYLINKER")).toBe( + true, + ); + expect(load.dependencies.some(({ path }) => path === "/usr/lib/dyld")).toBe( + false, + ); +}); + it("limits thread-state entrypoints instead of reporting none silently", async () => { const runner = new NativeFixtureRunner({ - "otool:-h": [ - "Load command 0", - " cmd LC_UNIXTHREAD", - " flavor 1", - " count 1", - "", - ].join("\n"), + "otool:-h": withOtoolMachHeader( + [ + "Load command 0", + " cmd LC_UNIXTHREAD", + " cmdsize 16", + " flavor 1", + " count 1", + "", + ].join("\n"), + ), }); const client = new NativeMacOSProvider({}, runner, "darwin").createClient( nativeMachoTarget("/owned/legacy-fixture"), diff --git a/tests/boundary/providers/native/nativeLoadText.golden.test.ts b/tests/boundary/providers/native/nativeLoadText.golden.test.ts index a5ed5b023..62e5ebad3 100644 --- a/tests/boundary/providers/native/nativeLoadText.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadText.golden.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js"; -import { nativeFixture } from "../../../fixtures/nativeCommands.js"; +import { + nativeFixture, + withOtoolMachHeader, +} from "../../../fixtures/nativeCommands.js"; it.each([ ["decimal-space", "123 library.dylib"], ["hex-space", "0x123 library.dylib"], @@ -9,7 +12,9 @@ it.each([ ["ordinary", "ordinary.dylib"], ])("preserves native install-name text from %s capture", async (slug, name) => { const load = parseOtoolLoadCommands( - await nativeFixture(`load-command-text-values/${slug}.txt`), + withOtoolMachHeader( + await nativeFixture(`load-command-text-values/${slug}.txt`), + ), ); expect(load.dependencies).toContainEqual({ path: name, diff --git a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts index e7cd23db2..49d80a601 100644 --- a/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts +++ b/tests/boundary/providers/native/nativeMachoHeader.golden.test.ts @@ -6,6 +6,7 @@ import { NativeFixtureRunner, nativeFixture, nativeMachoTarget, + withOtoolMachHeader, } from "../../../fixtures/nativeCommands.js"; it("collects the actual Mach header alongside the native load commands", async () => { @@ -32,6 +33,36 @@ it("collects the actual Mach header alongside the native load commands", async ( }); }); +it("rejects line breaks in a dylib name that manufacture a command and retains otool output", async () => { + const output = [ + "/owned/fixture:", + "Mach header", + " magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + " 0xfeedfacf 16777228 0 0x00 2 1 120 0x00000000", + "Load command 0", + " cmd LC_LOAD_DYLIB", + " cmdsize 120", + " name @rpath/a", + "Load command 77", + " cmd LC_REEXPORT_DYLIB", + " name /evil.dylib (offset 24)", + " time stamp 2 Thu Jan 1 00:00:02 1970", + " current version 1.0.0", + "compatibility version 1.0.0", + "", + ].join("\n"); + const runner = new NativeFixtureRunner({ "otool:-h": output }); + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + nativeMachoTarget("/owned/fixture"), + ); + const result = await client.execute("inspect_macho", {}); + if (result.ok) throw new Error("expected malformed otool framing to fail"); + expect(result.error).toMatchObject({ + _tag: "AnalysisOutputError", + capturedOutput: { stdout: output, stderr: "", truncated: false }, + }); +}); + it("does not reinterpret segment and section flags as Mach header flags", async () => { const load = parseOtoolLoadCommands( await nativeFixture("native-macho-header/otool-header-load.txt"), @@ -56,11 +87,8 @@ it("decodes Mach header fields by their column names", () => { const reordered = parseOtoolLoadCommands( [ "Mach header", - " flags filetype magic cputype", - " NOUNDEFS 2 0xfeedfacf 16777228", - "Load command 0", - " cmd LC_UUID", - " uuid 01234567-89ab-cdef-0123-456789abcdef", + " flags filetype magic cputype ncmds sizeofcmds", + " NOUNDEFS 2 0xfeedfacf 16777228 0 0", ].join("\n"), ); expect(reordered.fileType).toBe("2"); @@ -69,19 +97,83 @@ it("decodes Mach header fields by their column names", () => { [ "Mach header", " filetype magic flags ncmds sizeofcmds", - " 2 0xfeedfacf NOUNDEFS DYLDLINK 4 512", + " 2 0xfeedfacf NOUNDEFS DYLDLINK 0 0", ].join("\n"), ); expect(reorderedMultiwordFlags.fileType).toBe("2"); expect(reorderedMultiwordFlags.flags).toEqual(["DYLDLINK", "NOUNDEFS"]); - const missing = parseOtoolLoadCommands( - "Mach header\n magic cputype flags\n 0xfeedfacf 1 NOUNDEFS\n", + expect(() => + parseOtoolLoadCommands( + "Mach header\n magic cputype flags\n 0xfeedfacf 1 NOUNDEFS\n", + ), + ).toThrow(/command framing/u); + expect(() => + parseOtoolLoadCommands( + "Mach header\n filetype magic flags ncmds sizeofcmds\n 2 0xfeedfacf NOUNDEFS\n", + ), + ).toThrow(/command framing/u); +}); + +it("rejects duplicate semantic fields, injected sections, and truncated framing", () => { + const duplicateUuid = withOtoolMachHeader(`Load command 0 + cmd LC_UUID + cmdsize 24 + uuid 01234567-89ab-cdef-0123-456789abcdef + uuid fedcba98-7654-3210-fedc-ba9876543210 +`); + expect(() => parseOtoolLoadCommands(duplicateUuid)).toThrow(/uuid/u); + + const injectedSection = withOtoolMachHeader(`Load command 0 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __TEXT +Section + sectname __fake + segname __TEXT + addr 0x1000 + size 0x10 + offset 4096 + align 2^0 (1) + flags 0x0 +`); + expect(() => parseOtoolLoadCommands(injectedSection)).toThrow(/vmaddr/u); + + const incorrectSectionCount = withOtoolMachHeader(`Load command 0 + cmd LC_SEGMENT_64 + cmdsize 152 + segname __TEXT + vmaddr 0x100000000 + vmsize 0x2000 + fileoff 0 + filesize 8192 + maxprot 0x5 + initprot 0x5 + nsects 0 + flags 0x0 +Section + sectname __text + segname __TEXT + addr 0x100001000 + size 0x100 + offset 4096 + align 4 + flags 0x0 +`); + expect(() => parseOtoolLoadCommands(incorrectSectionCount)).toThrow( + /section count/u, ); - expect(missing.fileType).toBeNull(); - expect(missing.flags).toEqual(["NOUNDEFS"]); - const incomplete = parseOtoolLoadCommands( - "Mach header\n filetype magic flags ncmds sizeofcmds\n 2 0xfeedfacf NOUNDEFS\n", - ); - expect(incomplete.fileType).toBeNull(); - expect(incomplete.flags).toEqual([]); + + const truncatedCount = [ + "Mach header", + " magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + " 0xfeedfacf 16777228 0 0x00 2 2 48 0x00000000", + "Load command 0", + " cmd LC_UUID", + " cmdsize 24", + " uuid 01234567-89ab-cdef-0123-456789abcdef", + ].join("\n"); + expect(() => parseOtoolLoadCommands(truncatedCount)).toThrow(/count/u); + + const truncatedBytes = truncatedCount.replace("2 2 48", "2 1 32"); + expect(() => parseOtoolLoadCommands(truncatedBytes)).toThrow(/sizes/u); }); diff --git a/tests/boundary/providers/native/nativeReexport.golden.test.ts b/tests/boundary/providers/native/nativeReexport.golden.test.ts index 468968a69..024715b59 100644 --- a/tests/boundary/providers/native/nativeReexport.golden.test.ts +++ b/tests/boundary/providers/native/nativeReexport.golden.test.ts @@ -5,11 +5,14 @@ import { NativeFixtureRunner, nativeFixture, nativeMachoTarget, + withOtoolMachHeader, } from "../../../fixtures/nativeCommands.js"; it("retains native re-export dependencies alongside ordinary and identity commands", async () => { const runner = new NativeFixtureRunner({ - otool: await nativeFixture("native-reexport/otool-load.txt"), + otool: withOtoolMachHeader( + await nativeFixture("native-reexport/otool-load.txt"), + ), }); const client = new NativeMacOSProvider({}, runner, "darwin").createClient( nativeMachoTarget("/owned/libOuter.dylib"), diff --git a/tests/fixtures/native-macos/otool-load.md b/tests/fixtures/native-macos/otool-load.md new file mode 100644 index 000000000..3e985d2c8 --- /dev/null +++ b/tests/fixtures/native-macos/otool-load.md @@ -0,0 +1,7 @@ +# Representative native load-command fixture + +`otool-load.txt` is a compact, handcrafted representative used by the native +fixture runner. Its Mach header is made internally consistent with the five +listed commands (296 command bytes); it is not a capture from a real binary. +Use fixtures under `native-macho-header/` or `native-reexport/` for producer +captures. diff --git a/tests/fixtures/native-macos/otool-load.txt b/tests/fixtures/native-macos/otool-load.txt index 2889de738..31498817e 100644 --- a/tests/fixtures/native-macos/otool-load.txt +++ b/tests/fixtures/native-macos/otool-load.txt @@ -1,7 +1,7 @@ fixture: Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags - 0xfeedfacf 16777228 0 0x00 EXECUTE 4 512 NOUNDEFS DYLDLINK + 0xfeedfacf 16777228 0 0x00 EXECUTE 5 296 NOUNDEFS DYLDLINK Load command 0 cmd LC_SEGMENT_64 cmdsize 152 diff --git a/tests/fixtures/nativeCommands.ts b/tests/fixtures/nativeCommands.ts index 5f57ee7c0..5945d79ad 100644 --- a/tests/fixtures/nativeCommands.ts +++ b/tests/fixtures/nativeCommands.ts @@ -124,3 +124,20 @@ export const nativeMachoTargetForFile = async ( /** Load one captured native tool output. */ export const nativeFixture = (name: string): Promise => readFile(new URL(`./native-macos/${name}`, import.meta.url), "utf8"); + +/** Add consistent Mach command-count metadata to a command-only test capture. */ +export const withOtoolMachHeader = (output: string): string => { + const count = [...output.matchAll(/^Load command (\d+)$/gmu)].length; + const sizes = [...output.matchAll(/^\s*cmdsize\s+(\d+)\s*$/gmu)].map( + ([, size]) => Number(size), + ); + if (sizes.length !== count) + throw new TypeError("command-only fixture must provide every cmdsize"); + const bytes = sizes.reduce((sum, size) => sum + size, 0); + return [ + "Mach header", + " magic cputype cpusubtype caps filetype ncmds sizeofcmds flags", + ` 0xfeedfacf 16777228 0 0x00 2 ${count} ${bytes} 0x00000000`, + output, + ].join("\n"); +};