diff --git a/docs/go-binaries.md b/docs/go-binaries.md index 58b2679fd2..0a5ace4a20 100644 --- a/docs/go-binaries.md +++ b/docs/go-binaries.md @@ -133,9 +133,9 @@ sections retain priority, and alignment, file mapping and record validation remain required. When ELF section names are unreadable, limitations report that coverage gap; the fallback search policy is unchanged. -A named ELF build-info section selects the record's discovery range. For inline -encoding, the following version and module strings may continue past that -section into its file-backed load mapping; their bytes are still checked +A named ELF or Mach-O build-info section selects the record's discovery range. +For inline encoding, the following version and module strings may continue past +that section into its file-backed load mapping; their bytes are still checked against the mapping and the existing aggregate string limit. Historical moduleless Go binaries can store their empty module-string header in @@ -163,10 +163,12 @@ and `reported_limits.maximum_input_bytes`. Select a suitable input within that fixed allowance; changing an output-write or transport budget does not admit it. Resource guards bound the selected file and cumulative header searches to 256 MiB -each, aggregate embedded strings -to 1 MiB, structural table decoding to 16 MiB and the complete normalized metadata -report to 8 MiB. Identical search spans with the same virtual alignment are -searched once; partially overlapping spans consume the cumulative search budget +each, aggregate embedded string contents to 1 MiB (varint length prefixes are +framing and do not count toward this allowance), structural table decoding to +16 MiB and the complete normalized metadata report to 8 MiB. Inline strings must +also fit within their actual mapped source range. Identical search spans with +the same virtual alignment are searched once; partially overlapping spans +consume the cumulative search budget and fail with a CPU resource constraint when it is exhausted. Oversized results fail without partial success. MCP also applies its [response transport budget](mcp-contracts.md), which counts both text and diff --git a/docs/godot-pck.md b/docs/godot-pck.md index 659eb87b26..b81633adb2 100644 --- a/docs/godot-pck.md +++ b/docs/godot-pck.md @@ -34,6 +34,11 @@ The same CLI commands locate a PCK at the beginning of a file or through its payloads or discover packs behind additional trailing signatures. The root content SHA-256 covers the whole carrier; its content classification remains native, while the inventory records the PCK role and exact pack bounds. +An EOF marker is only a candidate: an invalid size or a pointed-to location +without a `GDPC` header leaves native classification in place. Once the +trailer points to a pack header, malformed or unsupported PCK content remains +an error. This recognition boundary follows the Godot loader at pinned source +revision [06d1e7a](https://github.com/godotengine/godot/blob/06d1e7a34ccde4029eed53d018cfde77dfe771d5/core/io/file_access_pack.cpp#L2099-L2146). Ordinary native `open_binary` detection remains unchanged. ## MCP and selected facts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index a65322a0a4..ff9a2ab4f6 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -449,6 +449,10 @@ complete observation. failures; explicit `` represents empty data. Each XML dictionary key must have a following value element; a final key without one is malformed. An explicit empty string remains a value. + Binary UID payloads may use nonminimal byte widths. REA normalizes them only + in a private decoder copy; archive digests still identify the original bytes. + UID values above JavaScript's exact integer range return an unsupported-target + outcome instead of rounded reference identities. UID 0 cannot distinguish conditional nil from ordinary nil. Limits are 64 MiB of input, 200,000 objects/references and 128 nesting levels. XML dictionary entries decoded as `__proto__` are omitted with an explicit diff --git a/src/application/AppBundleExecutable.test.ts b/src/application/AppBundleExecutable.test.ts index d7ee19830e..59885158bc 100644 --- a/src/application/AppBundleExecutable.test.ts +++ b/src/application/AppBundleExecutable.test.ts @@ -5,8 +5,36 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { binaryPlistObjectTable } from "../../tests/fixtures/binaryPlistObjectTable.js"; import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; +const binaryPlistExecutableWithThreeByteUid = (): Buffer => { + const ascii = (value: string): Buffer => { + const bytes = Buffer.from(value, "ascii"); + return bytes.length < 15 + ? Buffer.concat([Buffer.from([0x50 | bytes.length]), bytes]) + : Buffer.concat([Buffer.from([0x5f, 0x10, bytes.length]), bytes]); + }; + const objects = [ + Buffer.from([0xd2, 1, 3, 2, 4]), + ascii("CFBundleExecutable"), + ascii("Fixture"), + ascii("ignored"), + Buffer.from([0x82, 1, 2, 3]), + ]; + const tableOffset = + 8 + objects.reduce((sum, object) => sum + object.length, 0); + const bytes = Buffer.alloc(tableOffset + objects.length + 32); + let cursor = 8; + const offsets = objects.map((object) => { + const offset = cursor; + object.copy(bytes, cursor); + cursor += object.length; + return offset; + }); + return binaryPlistObjectTable(bytes, offsets, tableOffset); +}; + it.each([ ["macOS", ["Contents"], ["Contents", "MacOS"]], ["iOS", [], []], @@ -34,6 +62,21 @@ it.each([ ); describe("app bundle Info.plist magic", () => { + it("decodes an executable when an unrelated UID uses a three-byte width", async () => { + const root = await createTestTempDirectory("rea-app-three-byte-uid-"); + const bundle = join(root, "Fixture.app"); + const plistPath = join(bundle, "Contents", "Info.plist"); + const executable = join(bundle, "Contents", "MacOS", "Fixture"); + await mkdir(join(bundle, "Contents", "MacOS"), { recursive: true }); + await writeFile(plistPath, binaryPlistExecutableWithThreeByteUid()); + await writeFile(executable, "inert fixture; never executed"); + + expect(await resolveAppBundleExecutable(bundle)).toEqual({ + ok: true, + value: { executable, infoPlist: plistPath }, + }); + }); + it("does not decode a high-bit fold of bplist as a binary Info.plist", async () => { const root = await mkdtemp(join(tmpdir(), "rea-bplist-fold-")); try { diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index ca7dfba21e..2bbbe45cfe 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -5,6 +5,7 @@ import { parseBinary } from "plist"; import { z } from "zod"; import { preflightPropertyListDecode } from "../artifacts/apple/PropertyListDecodeBudget.js"; +import { decodeBinaryPlistUids } from "../artifacts/apple/PlistScalars.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import { isPathWithinRoot } from "../domain/localPath.js"; import { @@ -335,10 +336,14 @@ const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() }); /** Decode a binary plist in-process, so no host plist tool is required. */ const parseBinaryPlistExecutable = (plist: Buffer): string => { - preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES); + const plan = preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES); // `plist` assigns a `__proto__` key as the prototype; copying drops it, as // the XML path does. - return executableName(omitPrototypeKeys(parseBinary(plist)).value); + return executableName( + omitPrototypeKeys( + parseBinary(decodeBinaryPlistUids(plist, plan.uidOffsetsToNormalize)), + ).value, + ); }; const isSafeExecutableName = (name: string): boolean => diff --git a/src/application/binaryDiagnostics/ByteEntropyService.test.ts b/src/application/binaryDiagnostics/ByteEntropyService.test.ts index a9ef6f4922..15300d51ab 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.test.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { open, stat, rename, writeFile } from "node:fs/promises"; +import { open, stat, rename, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; @@ -69,6 +69,67 @@ it("rejects missing files, non-files and windows beyond EOF, but accepts empty w ).toContainEqual(expect.stringContaining("reports a size of 0")); }); +it.skipIf(process.platform !== "linux" && process.platform !== "darwin")( + "projects real path-resolution failures as actionable input errors", + async () => { + const root = await createTestTempDirectory("rea-entropy-path-resolution-"); + const loop = join(root, "loop"); + await symlink("loop", loop); + const overlong = join(root, "x".repeat(256)); + const cases = [ + [loop, "ELOOP"], + [overlong, "ENAMETOOLONG"], + ] as const; + const service = new ByteEntropyService(); + try { + for (const [path, code] of cases) { + let observedCode: unknown; + try { + await stat(path); + } catch (cause: unknown) { + observedCode = + cause instanceof Error && "code" in cause ? cause.code : undefined; + } + expect(observedCode).toBe(code); + + const result = await service.inspect({ path }); + if (result.ok) throw new Error(`Expected ${code} path failure`); + expect(result.error).toMatchObject({ + _tag: "AnalysisInputError", + cause: { code }, + issues: [ + { + path: ["path"], + reason: "invalid_value", + }, + ], + }); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "invalid_request", + recovery: { + kind: "inspect_input_contract", + operation: "inspect_byte_entropy", + }, + details: { + operation: "inspect_byte_entropy", + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining(code), + }, + ], + }, + }); + expect(JSON.stringify(projected.details)).toContain(path); + } + } finally { + await service.close(); + } + }, +); + it.each([ [{ offset: 2048 }, "offset"], [{ offset: 2048, length: 0 }, "offset"], diff --git a/src/application/binaryDiagnostics/ByteEntropyService.ts b/src/application/binaryDiagnostics/ByteEntropyService.ts index 2974b690eb..4ce07cb45c 100644 --- a/src/application/binaryDiagnostics/ByteEntropyService.ts +++ b/src/application/binaryDiagnostics/ByteEntropyService.ts @@ -32,6 +32,7 @@ import { artifactFileCompletion, assertArtifactReadActive, artifactFileCancellationCause, + artifactSelectedPathErrno, } from "../../artifacts/ArtifactFileOperation.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -149,7 +150,8 @@ export class ByteEntropyService { cause.code, { cause }, ); - if (cause.code === "ENOENT" || cause.code === "ENOTDIR") + const pathErrno = artifactSelectedPathErrno(cause); + if (pathErrno !== undefined) return admitted ? new AnalysisArtifactChangedError( OPERATION, @@ -157,7 +159,18 @@ export class ByteEntropyService { "Selected file path changed during entropy inspection.", { cause }, ) - : invalid("path", "Selected file could not be found."); + : new AnalysisInputError(OPERATION, { cause }, [ + { + path: ["path"], + reason: "invalid_value", + message: + pathErrno === "ENOENT" || pathErrno === "ENOTDIR" + ? "Selected file could not be found." + : pathErrno === "ELOOP" + ? `Selected file path could not be resolved because symbolic-link traversal looped or exceeded the host limit (ELOOP): ${input.path}` + : `Selected file path exceeds the host path or component length limit (ENAMETOOLONG): ${input.path}`, + }, + ]); } return new AnalysisOutputError( OPERATION, diff --git a/src/artifacts/GodotPckLocation.ts b/src/artifacts/GodotPckLocation.ts index 783e9bee1c..cc2191b6ae 100644 --- a/src/artifacts/GodotPckLocation.ts +++ b/src/artifacts/GodotPckLocation.ts @@ -69,19 +69,13 @@ export const locateGodotPck = async ( if (trailer.readUInt32LE(8) !== GODOT_PCK_MAGIC) return undefined; const end = size - 12; const bytes = trailer.readBigUInt64LE(0); - if (bytes < 4n || bytes > BigInt(end)) - throw new ArtifactReaderFailure( - "format", - "Godot PCK trailer declares an invalid pack range", - ); + // The terminal marker is only a candidate until its range resolves to a PCK header. + if (bytes < 4n || bytes > BigInt(end)) return undefined; const start = end - Number(bytes); if ( (await readPckBytes(handle, start, 4, end, signal)).readUInt32LE(0) !== GODOT_PCK_MAGIC ) - throw new ArtifactReaderFailure( - "format", - "Godot PCK trailer does not point at a pack header", - ); + return undefined; return { start, end, embedded: true }; }; diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index 702ae67475..eb55c6b08f 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -1,4 +1,5 @@ import { writeOrderedZip } from "../../tests/fixtures/artifactEntryOrder.js"; +import { zipSymlinkMetadataFixture } from "../../tests/fixtures/zipSymlinkMetadata.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -213,6 +214,29 @@ describe("ZipArtifactReader positional reads", () => { }); }); +it("refuses to open ZIP symlink entries even when unixMode is absent", async () => { + const directory = await createTestTempDirectory("rea-zip-symlink-open-"); + const path = join(directory, "symlinks.zip"); + await writeFile(path, await zipSymlinkMetadataFixture()); + const reader = new ZipArtifactReader(path, "zip"); + try { + const entries = []; + for await (const entry of reader.entries()) entries.push(entry); + for (const logicalPath of ["type-only-link", "permissions-link"]) { + const entry = entries.find((candidate) => candidate.path === logicalPath); + expect(entry).toMatchObject({ kind: "symlink" }); + if (entry === undefined) + throw new Error(`Missing ZIP symlink ${logicalPath}`); + await expect(reader.open(entry)).rejects.toMatchObject({ + reason: "format", + message: "ZIP entry is not a regular file", + }); + } + } finally { + await reader.close(); + } +}); + describe("ZipArtifactReader decompression budget", () => { it("refuses an archive whose entries declare more than its size can justify", async () => { const directory = await createTestTempDirectory("rea-zip-budget-bomb-"); diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 61f58b9ae2..76cf292deb 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -256,7 +256,7 @@ export class ZipArtifactReader implements ArtifactReader { ), ); const source = stored.source; - if (source.directory || isSymlink(source)) + if (source.directory || source.symlink) return Promise.reject( new ArtifactReaderFailure("format", "ZIP entry is not a regular file"), ); @@ -285,7 +285,7 @@ const projectZipEntry = ( entry: Entry, index: number, ): ArtifactEntry | undefined => { - const kind = isSymlink(entry) + const kind = entry.symlink ? "symlink" : entry.directory ? "directory" @@ -472,8 +472,5 @@ async function* typedZipFailures( } } -const isSymlink = (entry: Entry): boolean => - entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000; - const toError = (cause: unknown): Error => cause instanceof Error ? cause : new Error("ZIP extraction failed"); diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 69fdaf3b34..4afc97a8e7 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -46,6 +46,7 @@ import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; import { decodeBinaryPlistScalars, scanBinaryPlistScalars, + UnsupportedBinaryPlistUidError, type BinaryPlistScalarDecodePlan, } from "./PlistScalars.js"; import type { PlistNumberLiterals } from "../../domain/apple/plistNumbers.js"; @@ -132,6 +133,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize: [], }; let datePrecisionOverlapCount = 0; if (isNibArchive) { @@ -225,6 +227,12 @@ export const analyzeInterfaceBuilderBundle = async (input: { recordOmittedArchive(entry.path, cause.reason); continue; } + if (cause instanceof UnsupportedBinaryPlistUidError) { + decodeBudget.release(plistReservation); + omitted += 1; + recordOmittedArchive(entry.path, "unsupported_uid_exact_range"); + continue; + } invalid.push( `${entry.path}: ${cause instanceof Error ? cause.message : "archive decode failed"}`, ); diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index bbfee2a1c3..ac3e28d811 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -18,6 +18,7 @@ import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js" import { decodeBinaryPlistScalars, scanBinaryPlistScalars, + UnsupportedBinaryPlistUidError, } from "./PlistScalars.js"; import { omittedPrototypeKeysLimitation, @@ -260,6 +261,13 @@ export const inspectBundleKeyedArchive = async (input: { }); } catch (cause) { if (cause instanceof AnalysisInputError) throw cause; + if (cause instanceof UnsupportedBinaryPlistUidError) + throw new AnalysisUnsupportedTargetError( + "inspect_keyed_archive", + join(input.bundlePath, entry.path), + cause.message, + { cause }, + ); // An intact file of another kind is not a damaged archive. if (cause instanceof KeyedArchiveKindError) throw new AnalysisUnsupportedTargetError( diff --git a/src/artifacts/apple/PlistScalars.test.ts b/src/artifacts/apple/PlistScalars.test.ts index 75f0ee78a6..a48658e5b1 100644 --- a/src/artifacts/apple/PlistScalars.test.ts +++ b/src/artifacts/apple/PlistScalars.test.ts @@ -7,9 +7,13 @@ import { projectPlistValue } from "../../domain/apple/plistValue.js"; import { binaryDateRequiresExactSeconds, decodeBinaryPlistScalars, + decodeBinaryPlistUids, scanBinaryPlistScalars, } from "./PlistScalars.js"; -import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js"; +import { + estimateBinaryPlistExpansion, + PropertyListDecodeBudgetFailure, +} from "./PropertyListDecodeBudget.js"; const dataObjectWithUnusedOverlappingInteger = (): Buffer => { const bytes = Buffer.alloc(66); @@ -177,6 +181,7 @@ describe("binary plist scalar bounds", () => { reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize: [], }); expect( estimateBinaryPlistExpansion(bytes, plan.estimatedBytes).estimatedBytes, @@ -227,6 +232,78 @@ describe("binary plist scalar bounds", () => { ); }); +const binaryPlistWithUid = (width: number, inputValue: bigint): Buffer => { + let value = inputValue; + const record = Buffer.alloc(width + 1); + record[0] = 0x80 | (width - 1); + for (let byte = width - 1; byte >= 0; byte -= 1) { + record[byte + 1] = Number(value & 0xffn); + value >>= 8n; + } + const offsetTable = 8 + record.length; + const bytes = Buffer.alloc(offsetTable + 1 + 32); + record.copy(bytes, 8); + return binaryPlistObjectTable(bytes, [8], offsetTable); +}; + +describe("binary plist UID widths", () => { + it.each([ + { width: 3, value: 0xabcdefn, normalizedWidth: 4 }, + { width: 9, value: 1n, normalizedWidth: 1 }, + { width: 16, value: 0x1234n, normalizedWidth: 2 }, + { width: 4, value: 0x1234n, normalizedWidth: undefined }, + ])( + "preserves the full $width-byte UID value and only normalizes unsupported widths", + ({ width, value, normalizedWidth }) => { + const bytes = binaryPlistWithUid(width, value); + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024); + expect(plan.uidOffsetsToNormalize).toEqual( + normalizedWidth === undefined + ? [] + : [{ offset: 8, width: normalizedWidth }], + ); + const compatible = decodeBinaryPlistUids( + bytes, + plan.uidOffsetsToNormalize, + ); + if (normalizedWidth === undefined) expect(compatible).toBe(bytes); + else expect(compatible[8 + width + 1]).toBe(0x80 | (normalizedWidth - 1)); + expect(parseBinary(compatible)).toEqual({ + UID: Number(value), + }); + }, + ); + + it("accounts for appended UID records before admitting the decode copy", () => { + const bytes = binaryPlistWithUid(3, 0xabcdefn); + for (const options of [{}, { numberProjection: true }]) { + const plan = estimateBinaryPlistExpansion(bytes, 1024 * 1024, options); + expect(() => + estimateBinaryPlistExpansion(bytes, plan.estimatedBytes - 1, options), + ).toThrow(PropertyListDecodeBudgetFailure); + } + }); + + it("rejects a truncated UID payload and values outside exact numeric range", () => { + const truncated = Buffer.alloc(50); + truncated[8] = 0x82; + truncated[9] = 1; + binaryPlistObjectTable(truncated, [8], 10); + expect(() => estimateBinaryPlistExpansion(truncated, 1024)).toThrow( + "binary plist UID object is truncated", + ); + + const inexact = binaryPlistWithUid(8, BigInt(Number.MAX_SAFE_INTEGER) + 1n); + expect(() => estimateBinaryPlistExpansion(inexact, 1024)).toThrow( + "binary plist UID value 9007199254740992", + ); + const nonzeroHighByte = binaryPlistWithUid(9, 0x1_0000_0000_0000_0000n); + expect(() => estimateBinaryPlistExpansion(nonzeroHighByte, 1024)).toThrow( + "binary plist UID value 18446744073709551616", + ); + }); +}); + describe("binary plist number rewriting", () => { it("does not rewrite an integer marker inside another object's data", () => { const bytes = dataObjectWithUnusedOverlappingInteger(); @@ -241,6 +318,7 @@ describe("binary plist number rewriting", () => { reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize: [], }), ).toBe(bytes); expect(bytes.subarray(13, 30)).toEqual( diff --git a/src/artifacts/apple/PlistScalars.ts b/src/artifacts/apple/PlistScalars.ts index 1566608f4c..d670ca9f3e 100644 --- a/src/artifacts/apple/PlistScalars.ts +++ b/src/artifacts/apple/PlistScalars.ts @@ -38,8 +38,106 @@ export interface BinaryPlistScalarDecodePlan { readonly reachableWideIntegerOffsets: readonly number[]; readonly overlappingWideIntegerOffsets: readonly number[]; readonly reachableNumericOffsets: readonly number[]; + /** Reachable UID records whose encoded width plist@5 cannot read. */ + readonly uidOffsetsToNormalize: readonly { offset: number; width: number }[]; } +/** A binary-plist UID cannot be projected to plist@5's numeric UID value exactly. */ +export class UnsupportedBinaryPlistUidError extends TypeError { + readonly _tag = "UnsupportedBinaryPlistUidError"; + + constructor( + readonly decimalValue: string, + readonly sourceWidth: number, + ) { + super( + `binary plist UID value ${decimalValue} in ${sourceWidth}-byte encoding exceeds the exact supported range`, + ); + this.name = "UnsupportedBinaryPlistUidError"; + } +} + +const readBinaryPlistUidValue = ( + bytes: Buffer, + offset: number, + width: number, +): bigint => { + let value = 0n; + for (let index = 0; index < width; index += 1) + value = (value << 8n) | BigInt(bytes[offset + 1 + index] ?? 0); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) + throw new UnsupportedBinaryPlistUidError(value.toString(), width); + return value; +}; + +const normalizedUidWidth = (value: bigint): number => + value <= 0xffn ? 1 : value <= 0xffffn ? 2 : value <= 0xffffffffn ? 4 : 8; + +/** Return a supported parser width when the source UID uses another width. */ +export const binaryPlistUidNormalizationWidth = ( + bytes: Buffer, + offset: number, +): number | undefined => { + const marker = bytes[offset]; + if (marker === undefined || marker >> 4 !== 8) return undefined; + const sourceWidth = (marker & 0x0f) + 1; + const value = readBinaryPlistUidValue(bytes, offset, sourceWidth); + if ([1, 2, 4, 8].includes(sourceWidth)) return undefined; + const targetWidth = normalizedUidWidth(value); + return targetWidth === sourceWidth ? undefined : targetWidth; +}; + +const binaryPlistUidRecord = ( + bytes: Buffer, + offset: number, + targetWidth?: number, +): Buffer => { + const sourceWidth = (bytes[offset]! & 0x0f) + 1; + let value = readBinaryPlistUidValue(bytes, offset, sourceWidth); + const width = targetWidth ?? normalizedUidWidth(value); + const record = Buffer.alloc(width + 1); + record[0] = 0x80 | (width - 1); + for (let byte = width - 1; byte >= 0; byte -= 1) { + record[byte + 1] = Number(value & 0xffn); + value >>= 8n; + } + return record; +}; + +/** Exact added offset-table representation size, shared with preflight. */ +export const binaryPlistCloneOffsetWidth = ( + currentWidth: number, + firstCloneOffset: number, + cloneRecordLengths: readonly number[], +): number | undefined => { + if (cloneRecordLengths.length === 0) return currentWidth; + let lastCloneOffset = firstCloneOffset; + for (let index = 0; index + 1 < cloneRecordLengths.length; index += 1) + lastCloneOffset += cloneRecordLengths[index] ?? 0; + return [currentWidth, 2, 4, 8] + .filter((width) => width >= currentWidth) + .find((width) => + width === 8 + ? lastCloneOffset <= Number.MAX_SAFE_INTEGER + : lastCloneOffset < 2 ** (width * 8), + ); +}; + +/** Normalize unsupported binary-plist UID widths for the pinned parser. */ +export const decodeBinaryPlistUids = ( + bytes: Buffer, + uidOffsets: readonly { offset: number; width: number }[], +): Buffer => { + if (uidOffsets.length === 0) return bytes; + const replacements = new Map( + uidOffsets.map(({ offset, width }) => [ + offset, + binaryPlistUidRecord(bytes, offset, width), + ]), + ); + return cloneBinaryPlistObjects(bytes, replacements); +}; + const APPLE_EPOCH_MILLISECONDS = 978_307_200_000; /** Whether Date's millisecond projection would lose finite, in-range source seconds. */ @@ -264,20 +362,32 @@ export const decodeBinaryPlistScalars = ( const reachableWideIntegers = metadata.wideIntegers.filter(({ offset }) => reachableWideIntegerOffsets.has(offset), ); - const cloneOffsets = new Set([ - ...unrepresentableDates - .filter(({ offset }) => overlappingDateOffsets.has(offset)) - .map(({ offset }) => offset), - ...reachableWideIntegers - .filter(({ offset }) => overlappingWideIntegerOffsets.has(offset)) - .map(({ offset }) => offset), - ]); - if (reachableWideIntegers.length === 0 && unrepresentableDates.length === 0) + const replacements = new Map(); + for (const { offset, width } of scalarPlan.uidOffsetsToNormalize) + replacements.set(offset, binaryPlistUidRecord(bytes, offset, width)); + for (const { offset } of unrepresentableDates) + if (overlappingDateOffsets.has(offset)) { + const replacement = Buffer.from(bytes.subarray(offset, offset + 9)); + replacement.writeDoubleBE(Number.NaN, 1); + replacements.set(offset, replacement); + } + for (const { offset, value } of reachableWideIntegers) + if (overlappingWideIntegerOffsets.has(offset)) { + const replacement = Buffer.alloc(9); + replacement[0] = 0x23; + replacement.writeDoubleBE(Number(value), 1); + replacements.set(offset, replacement); + } + if ( + reachableWideIntegers.length === 0 && + unrepresentableDates.length === 0 && + replacements.size === 0 + ) return bytes; const compatible = - cloneOffsets.size === 0 + replacements.size === 0 ? Buffer.from(bytes) - : cloneOverlappingScalars(bytes, cloneOffsets, metadata); + : cloneBinaryPlistObjects(bytes, replacements); for (const { offset, value } of reachableWideIntegers) { if (overlappingWideIntegerOffsets.has(offset)) continue; // Convert only the decoder's input copy to a double. Exact integer metadata @@ -294,10 +404,9 @@ export const decodeBinaryPlistScalars = ( return compatible; }; -const cloneOverlappingScalars = ( +const cloneBinaryPlistObjects = ( bytes: Buffer, - scalarOffsets: ReadonlySet, - metadata: BinaryPlistScalarMetadata, + replacements: ReadonlyMap, ): Buffer => { const trailerOffset = bytes.length - 32; const offsetSize = bytes[trailerOffset + 6] ?? 0; @@ -312,24 +421,22 @@ const cloneOverlappingScalars = ( const offsets = Array.from({ length: objectCount }, (_, index) => readOffset(oldTableOffset + index * offsetSize), ); - const cloneEntries = [...scalarOffsets]; - const wideIntegersByOffset = new Map( - metadata.wideIntegers.map((entry) => [entry.offset, entry.value]), + const cloneEntries = [...replacements.entries()]; + const replacementLengths = cloneEntries.map(([, record]) => record.length); + const newOffsetSize = binaryPlistCloneOffsetWidth( + offsetSize, + oldTableOffset, + replacementLengths, ); - const newOffsetSize = [1, 2, 4, 8].find((width) => { - if (width < offsetSize) return false; - const maximum = - width === 8 ? Number.MAX_SAFE_INTEGER : 2 ** (width * 8) - 1; - return cloneEntries.every( - (_, index) => oldTableOffset + index * 9 <= maximum, - ); - }); if (newOffsetSize === undefined) throw new RangeError( "binary plist cloned scalar offset exceeds supported width", ); const cloneStart = oldTableOffset; - const cloneBytes = cloneEntries.length * 9; + const cloneBytes = replacementLengths.reduce( + (sum, length) => sum + length, + 0, + ); const newTableOffset = cloneStart + cloneBytes; const newLength = bytes.length + cloneBytes + objectCount * (newOffsetSize - offsetSize); @@ -340,17 +447,12 @@ const cloneOverlappingScalars = ( const compatible = Buffer.alloc(newLength); bytes.copy(compatible, 0, 0, oldTableOffset); const cloneOffsetBySource = new Map(); - cloneEntries.forEach((sourceOffset, index) => { - const cloneOffset = cloneStart + index * 9; + let cloneCursor = cloneStart; + cloneEntries.forEach(([sourceOffset, replacement]) => { + const cloneOffset = cloneCursor; cloneOffsetBySource.set(sourceOffset, BigInt(cloneOffset)); - const wideInteger = wideIntegersByOffset.get(sourceOffset); - if (wideInteger !== undefined) { - compatible[cloneOffset] = 0x23; - compatible.writeDoubleBE(Number(wideInteger), cloneOffset + 1); - } else { - bytes.copy(compatible, cloneOffset, sourceOffset, sourceOffset + 9); - compatible.writeDoubleBE(Number.NaN, cloneOffset + 1); - } + replacement.copy(compatible, cloneOffset); + cloneCursor += replacement.length; }); const tableStart = newTableOffset; const tableEnd = tableStart + objectCount * newOffsetSize; diff --git a/src/artifacts/apple/PropertyListDecodeBudget.ts b/src/artifacts/apple/PropertyListDecodeBudget.ts index e4b43c1b3c..30fc785a47 100644 --- a/src/artifacts/apple/PropertyListDecodeBudget.ts +++ b/src/artifacts/apple/PropertyListDecodeBudget.ts @@ -1,8 +1,10 @@ import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js"; import { hasBinaryPlistSignature } from "../../domain/apple/appleArchiveFormat.js"; import { + binaryPlistCloneOffsetWidth, binaryDateRequiresExactSeconds, binaryPlistObjectEnd, + binaryPlistUidNormalizationWidth, } from "./PlistScalars.js"; /** Preflight the representation created by the pinned plist decoder. */ @@ -42,6 +44,7 @@ export const preflightPropertyListDecode = ( reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize: [], }; if (expansion.estimatedBytes > maxBytes) throw fail("expansion", `plist representation exceeds ${budget}`); @@ -176,6 +179,8 @@ export interface PlistDecodePreflight { readonly overlappingWideIntegerOffsets: readonly number[]; /** Reachable integer and real source records for exact number association. */ readonly reachableNumericOffsets: readonly number[]; + /** Reachable UID records cloned to one of plist@5's supported widths. */ + readonly uidOffsetsToNormalize: readonly { offset: number; width: number }[]; } const PROTOTYPE_KEY = "__proto__"; @@ -209,21 +214,6 @@ const findOverlappingScalarOffsets = ( return overlappingOffsets; }; -const findExactScalarCloneOffsetWidth = ( - currentWidth: number, - firstCloneOffset: number, - cloneCount: number, -): number | undefined => { - const lastCloneOffset = firstCloneOffset + cloneCount * 9 - 9; - return [currentWidth, 2, 4, 8] - .filter((width) => width >= currentWidth) - .find((width) => - width === 8 - ? lastCloneOffset <= Number.MAX_SAFE_INTEGER - : lastCloneOffset < 2 ** (width * 8), - ); -}; - /** * Count every expanded binary-plist reference before calling plist.parseBinary, * which recurses on each reference and copies shared containers. Malformed @@ -293,6 +283,7 @@ export const estimateBinaryPlistExpansion = ( const reachableDateOffsets = new Set(); const reachableWideIntegerOffsets = new Set(); const reachableNumericOffsets = new Set(); + const uidWidthsToNormalize = new Map(); const reachableDateOccurrences: number[] = []; while (pending.length > 0) { const entry = pending.pop(); @@ -339,6 +330,15 @@ export const estimateBinaryPlistExpansion = ( } continue; } + if (type === 8) { + const uidWidth = (marker & 0x0f) + 1; + if (offset + 1 + uidWidth > objectsEnd) + throw new TypeError("binary plist UID object is truncated"); + const normalizedWidth = binaryPlistUidNormalizationWidth(bytes, offset); + if (normalizedWidth !== undefined) + uidWidthsToNormalize.set(offset, normalizedWidth); + continue; + } if (type === 1 || type === 2) { const info = marker & 0x0f; if (type === 1 && info > 4) @@ -435,7 +435,29 @@ export const estimateBinaryPlistExpansion = ( } pendingObjectCount += childCount; } - if (!numberProjection) + const uidOffsetsToNormalize = [...uidWidthsToNormalize] + .map(([offset, width]) => ({ offset, width })) + .sort((left, right) => left.offset - right.offset); + if (!numberProjection) { + const cloneLengths = uidOffsetsToNormalize.map(({ width }) => width + 1); + if (cloneLengths.length > 0) { + const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0); + const newOffsetSize = binaryPlistCloneOffsetWidth( + offsetSize, + offsetTable, + cloneLengths, + ); + if (newOffsetSize === undefined) + throw new TypeError( + "binary plist cloned UID offset exceeds supported width", + ); + estimate += cloneBytes + objectCount * (newOffsetSize - offsetSize); + if (estimate > maxBytes) + throw fail( + "expansion", + `binary plist UID-copy expansion exceeds ${budget}`, + ); + } return { estimatedBytes: estimate, binaryPrototypeKeyObserved, @@ -445,7 +467,9 @@ export const estimateBinaryPlistExpansion = ( reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize, }; + } // Distinct objects can legally overlap in binary plists. Rewriting a date or // wide integer in place could change another reachable value, so isolate @@ -482,12 +506,16 @@ export const estimateBinaryPlistExpansion = ( ...precisionUnknownDateOffsets, ...overlappingWideIntegerOffsets, ]); - if (cloneOffsets.size > 0) { - const cloneBytes = cloneOffsets.size * 9; - const newOffsetSize = findExactScalarCloneOffsetWidth( + const cloneLengths = [ + ...uidOffsetsToNormalize.map(({ width }) => width + 1), + ...Array.from(cloneOffsets, () => 9), + ]; + if (cloneLengths.length > 0) { + const cloneBytes = cloneLengths.reduce((sum, length) => sum + length, 0); + const newOffsetSize = binaryPlistCloneOffsetWidth( offsetSize, offsetTable, - cloneOffsets.size, + cloneLengths, ); if (newOffsetSize === undefined) throw new TypeError( @@ -521,5 +549,6 @@ export const estimateBinaryPlistExpansion = ( reachableNumericOffsets: [...reachableNumericOffsets].sort( (left, right) => left - right, ), + uidOffsetsToNormalize, }; }; diff --git a/src/go/GoBinaryImage.ts b/src/go/GoBinaryImage.ts index 2d34e983ba..563dbd9a57 100644 --- a/src/go/GoBinaryImage.ts +++ b/src/go/GoBinaryImage.ts @@ -8,7 +8,10 @@ import { type FileMapping, } from "../native/metadata/BinaryContainer.js"; import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js"; -import { goBuildInfoSearch } from "./GoBuildInfoSearch.js"; +import { + goBuildInfoSearch, + type GoBuildInfoSearchRange, +} from "./GoBuildInfoSearch.js"; const MAGIC = Buffer.from("ff20476f206275696c64696e663a", "hex"); const MODULE_START = Buffer.from("3077af0c9274080241e1c107e6d618e6", "hex"); @@ -49,7 +52,8 @@ export interface GoBinaryImage { export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { const container = readBinaryContainer(bytes); const { format, architecture, bits, byte_order } = container; - let selected: { search: FileMapping; header: number } | null = null; + let selected: { search: GoBuildInfoSearchRange; header: number } | null = + null; const searched = new Set(); let remainingSearchBytes = MAX_HEADER_SEARCH_BYTES; const sectionNamesLimitation = container.section_names_limitation; @@ -114,16 +118,38 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { "Go build-info byte order disagrees with the image", ); const encoding = (flags & 2) !== 0 ? "inline" : "pointer"; - const readInline = (offset: number, remaining: number) => - readInlineString(reader, offset, remaining); let version: LocatedString; let module: LocatedString; if (encoding === "inline") { const sourceEnd = inlineDataEnd(header, search, container); - version = readInline(header + 32, sourceEnd - header - 32); - const next = version.location.offset + version.location.bytes; - module = readInline(next, sourceEnd - next); - verifyMapping(module.location.offset + module.location.bytes - header); + const versionExtent = readInlineExtent( + reader, + header + 32, + sourceEnd - header - 32, + ); + const moduleExtent = readInlineExtent( + reader, + versionExtent.next, + sourceEnd - versionExtent.next, + ); + verifyMapping(moduleExtent.next - header); + assertAggregateContentLimit(versionExtent.location, moduleExtent.location); + version = { + location: versionExtent.location, + bytes: reader.range( + versionExtent.location.offset, + versionExtent.location.bytes, + "Go build-info string", + ), + }; + module = { + location: moduleExtent.location, + bytes: reader.range( + moduleExtent.location.offset, + moduleExtent.location.bytes, + "Go build-info string", + ), + }; } else { if (ptrSize !== 4 && ptrSize !== 8) throw new BinaryFormatFailure( @@ -145,13 +171,8 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { moduleField, pointerBits, ); + assertAggregateContentLimit(version.location, module.location); } - if (version.location.bytes + module.location.bytes > MAX_BUILD_INFO_BYTES) - throw new BinaryResourceFailure( - "build-info", - MAX_BUILD_INFO_BYTES, - "Go build-info strings exceed the aggregate 1 MiB limit", - ); if (version.bytes.length === 0) throw new BinaryFormatFailure( "malformed", @@ -193,17 +214,30 @@ export const readGoBinaryImage = (bytes: Buffer): GoBinaryImage => { }; }; -/** Inline strings follow the header in mapped Go build-info data, even when - * the named ELF discovery section ends immediately after the header. +/** Inline strings follow the header in its mapped Go data, even when the named + * ELF or Mach-O discovery section ends immediately after the header. */ const inlineDataEnd = ( header: number, - search: FileMapping, + search: GoBuildInfoSearchRange, container: BinaryContainer, ): number => { - if (container.format !== "elf") return search.offset + search.size; + if (container.format === "pe") return search.offset + search.size; const address = search.address + BigInt(header - search.offset); let end = search.offset + search.size; + if (search.inline_mapping !== undefined) { + const delta = address - search.inline_mapping.address; + if ( + delta >= 0n && + delta < BigInt(search.inline_mapping.size) && + search.inline_mapping.offset + Number(delta) === header + ) + return Math.max( + end, + search.inline_mapping.offset + search.inline_mapping.size, + ); + return end; + } for (const { mapping } of container.load_regions ?? []) { const delta = address - mapping.address; if ( @@ -213,7 +247,7 @@ const inlineDataEnd = ( ) end = Math.max(end, mapping.offset + mapping.size); } - return Math.min(end, header + 32 + MAX_BUILD_INFO_BYTES); + return end; }; const observedText = (bytes: Buffer): string | null => { @@ -230,6 +264,18 @@ interface LocatedString { readonly location: GoStringLocation; } +const assertAggregateContentLimit = ( + version: GoStringLocation, + module: GoStringLocation, +): void => { + if (version.bytes + module.bytes > MAX_BUILD_INFO_BYTES) + throw new BinaryResourceFailure( + "build-info", + MAX_BUILD_INFO_BYTES, + "Go build-info strings exceed the aggregate 1 MiB limit", + ); +}; + const findHeader = (bytes: Buffer, search: FileMapping): number | null => { if (search.size === 0) return null; const end = search.offset + search.size; @@ -253,11 +299,11 @@ const findHeader = (bytes: Buffer, search: FileMapping): number | null => { return null; }; -const readInlineString = ( +const readInlineExtent = ( reader: BinaryReader, offset: number, remaining: number, -): LocatedString => { +): { readonly location: GoStringLocation; readonly next: number } => { let length = 0n; for (let index = 0; index < 10; index++) { if (index >= remaining) @@ -287,8 +333,8 @@ const readInlineString = ( ); const start = offset + index + 1; return { - bytes: reader.range(start, size, "Go build-info string"), location: { offset: start, bytes: size }, + next: start + size, }; } } diff --git a/src/go/GoBuildInfoSearch.ts b/src/go/GoBuildInfoSearch.ts index 7227acb6f8..8f02bedf56 100644 --- a/src/go/GoBuildInfoSearch.ts +++ b/src/go/GoBuildInfoSearch.ts @@ -6,9 +6,14 @@ import { type FileMapping, } from "../native/metadata/BinaryContainer.js"; +export interface GoBuildInfoSearchRange extends FileMapping { + /** Native file mapping containing inline strings after a named Mach-O header. */ + readonly inline_mapping?: FileMapping; +} + const fallbackSearchRanges = ( container: BinaryContainer, -): readonly FileMapping[] => { +): readonly GoBuildInfoSearchRange[] => { if (container.format === "elf") return (container.load_regions ?? []) .filter((region) => region.write && !region.execute) @@ -46,9 +51,9 @@ const fallbackSearchRanges = ( export const goBuildInfoSearch = ( bytes: Buffer, container: BinaryContainer, -): readonly FileMapping[] => { +): readonly GoBuildInfoSearchRange[] => { const reader = new BinaryReader(bytes, container.byte_order === "little"); - const named: FileMapping[] = []; + const named: GoBuildInfoSearchRange[] = []; for (const section of container.sections) { if (section.format === "elf" && section.name === ".go.buildinfo") { if (named.length !== 0) @@ -113,6 +118,7 @@ export const goBuildInfoSearch = ( "Mach-O build-info section", ), address: section.address, + inline_mapping: section.segment_mapping, }; if ( mappedFileOffset( diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index a0357ccba8..3c4449c1e2 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -58,6 +58,7 @@ import { AnalysisInputError, AnalysisOutputError, AnalysisResourceConstraintError, + AnalysisUnsupportedTargetError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { @@ -137,6 +138,7 @@ import { import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js"; import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js"; import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js"; +import { UnsupportedBinaryPlistUidError } from "../artifacts/apple/PlistScalars.js"; type SignatureRuntimeRootFactory = () => Promise< Pick @@ -1024,6 +1026,15 @@ class NativeMacOSClient implements AnalysisClient { if (!source.ok) return source; const parsed = parsePlistBinary(source.value); if (!parsed.ok) { + if (parsed.error instanceof UnsupportedBinaryPlistUidError) + return err( + new AnalysisUnsupportedTargetError( + "inspect_plist", + fileInput.selection.path, + parsed.error.message, + { cause: parsed.error }, + ), + ); return parsed.error instanceof AnalysisOutputError ? err( unreadablePlist( @@ -1032,7 +1043,7 @@ class NativeMacOSClient implements AnalysisClient { { cause: parsed.error }, ), ) - : parsed; + : err(parsed.error); } return ok( nativePlistObservation({ diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 468131ef0c..819b1315c1 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -244,6 +244,7 @@ it("does not treat a malformed date marker as a valid date scalar", () => { reachableWideIntegerOffsets: [], overlappingWideIntegerOffsets: [], reachableNumericOffsets: [], + uidOffsetsToNormalize: [], }), ).toBe(bytes); }); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index a8d1a131bb..1e1d52b39d 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -32,6 +32,7 @@ import { import { decodeBinaryPlistScalars, scanBinaryPlistScalars, + UnsupportedBinaryPlistUidError, } from "../../artifacts/apple/PlistScalars.js"; import { estimateBinaryPlistExpansion, @@ -248,7 +249,9 @@ export const parsePlistBinary = ( bytes: Buffer, ): Result< ParsedPlist, - AnalysisOutputError | AnalysisResourceConstraintError + | AnalysisOutputError + | AnalysisResourceConstraintError + | UnsupportedBinaryPlistUidError > => { try { if (!hasBinaryPlistSignature(bytes)) @@ -293,6 +296,7 @@ export const parsePlistBinary = ( ], }); } catch (cause: unknown) { + if (cause instanceof UnsupportedBinaryPlistUidError) return err(cause); if (cause instanceof PropertyListDecodeBudgetFailure) return err( new AnalysisResourceConstraintError( diff --git a/tests/acceptance/artifactZipSymlinkMetadata.test.ts b/tests/acceptance/artifactZipSymlinkMetadata.test.ts new file mode 100644 index 0000000000..445023e3d9 --- /dev/null +++ b/tests/acceptance/artifactZipSymlinkMetadata.test.ts @@ -0,0 +1,48 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; + +import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js"; +import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { zipSymlinkMetadataFixture } from "../fixtures/zipSymlinkMetadata.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest( + "reports ZIP symlinks from type bits when Unix permission bits are absent", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-zip-symlink-metadata-"); + const archivePath = join(root, "symlinks.zip"); + await writeFile(archivePath, await zipSymlinkMetadataFixture()); + + const inspected = await cli.run({ + arguments: ["inspect-artifact", archivePath, "--json"], + }); + expect(inspected.exitCode, inspected.stdout + inspected.stderr).toBe(0); + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(inspected.json).normalized_result, + ); + const inventoryEvidence = inspection.substeps[0]?.evidence; + if (inventoryEvidence === undefined) + throw new Error("CLI inspection did not retain ZIP inventory Evidence"); + const inventory = artifactInventoryResultSchema.parse( + inventoryEvidence.normalized_result, + ); + const entriesByPath = new Map( + inventory.occurrences.map((entry) => [entry.logical_path, entry]), + ); + + expect(entriesByPath.get("target.txt")).toMatchObject({ + entry_kind: "file", + }); + for (const path of ["type-only-link", "permissions-link"]) + expect(entriesByPath.get(path)).toMatchObject({ + entry_kind: "symlink", + limitations: expect.arrayContaining([ + "Archive symlink target was not read.", + ]), + }); + }, +); diff --git a/tests/acceptance/goBinaryCli.test.ts b/tests/acceptance/goBinaryCli.test.ts index a5dfdf0bb8..d52a729242 100644 --- a/tests/acceptance/goBinaryCli.test.ts +++ b/tests/acceptance/goBinaryCli.test.ts @@ -12,7 +12,11 @@ import { createGoSplitElfFixture, createGoOverlappingElfFixture, createGoBinaryPathFailureFixture, + createGoInlineAggregateFixture, setGoPeVirtualSize, + setGoElfLoadFileSize, + setGoMachOSectionSize, + setGoMachOSegmentFileSize, GO_MODULE_TEXT, } from "../fixtures/go/image.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -21,6 +25,156 @@ import { parseEvidence } from "../../src/domain/evidence.js"; import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; +cliTest( + "reads Mach-O inline Go strings beyond a header-only discovery section through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-go-macho-inline-range-"); + const { client, call } = await connectLocalToolsMcp(); + const complete = createGoBinaryFixture({ format: "macho" }); + setGoMachOSectionSize(complete.bytes, 32); + const truncated = createGoBinaryFixture({ format: "macho" }); + setGoMachOSectionSize(truncated.bytes, 32); + setGoMachOSegmentFileSize( + truncated.bytes, + truncated.moduleOffset + truncated.moduleBytes.length - 1, + ); + + for (const item of [ + { name: "header-only", bytes: complete.bytes, expected: "success" }, + { name: "truncated", bytes: truncated.bytes, expected: "malformed" }, + ] as const) { + const path = join(root, `${item.name}.macho`); + await writeFile(path, item.bytes); + const result = await runFileCli(cli, { + arguments: ["inspect-go-binary", path, "--json"], + }); + const response = await call("inspect_go_binary", { path }); + if (item.expected === "success") { + expect(result.exitCode, result.stderr).toBe(0); + const evidence = evidenceResultOf(goBinarySchema).parse(result.json); + expect(evidence.normalized_result).toMatchObject({ + format: "macho", + build_info: { + header_offset: complete.headerOffset, + go_version: "go1.26.0", + module: { path: "example.com/tool/cmd/tool" }, + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + } else { + const expected = { + code: "invalid_request", + details: { + operation: "inspect_go_binary", + issues: [{ path: ["path"], reason: "invalid_format" }], + }, + }; + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject(expected); + expect(parseMcpToolError(response).error).toMatchObject(expected); + } + } + await client.ping(); + }, + 60000, +); + +cliTest( + "counts inline Go string contents separately from varint framing through CLI and MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-go-inline-budget-"); + const { client, call } = await connectLocalToolsMcp(); + const maximum = 1024 * 1024; + const cases = [ + { + name: "exact", + fixture: createGoInlineAggregateFixture(maximum), + outcome: "success", + }, + { + name: "over", + fixture: createGoInlineAggregateFixture(maximum + 1), + outcome: "resource", + }, + { + name: "truncated", + fixture: createGoInlineAggregateFixture(512), + outcome: "malformed", + }, + ] as const; + + for (const item of cases) { + if (item.name === "truncated") + setGoElfLoadFileSize( + item.fixture.bytes, + item.fixture.moduleOffset + item.fixture.moduleBytes.length - 1, + ); + const path = join(root, `${item.name}.elf`); + await writeFile(path, item.fixture.bytes); + const result = await runFileCli(cli, { + arguments: ["inspect-go-binary", path, "--json"], + }); + const response = await call("inspect_go_binary", { path }); + + if (item.outcome === "success") { + expect(result.exitCode, result.stderr).toBe(0); + const evidence = evidenceResultOf(goBinarySchema).parse(result.json); + const buildInfo = evidence.normalized_result.build_info; + expect(buildInfo).not.toBeNull(); + expect( + buildInfo!.version_location.bytes + buildInfo!.module_location.bytes, + ).toBe(maximum); + expect( + item.fixture.moduleOffset + + item.fixture.moduleBytes.length - + item.fixture.headerOffset, + ).toBeGreaterThan(maximum + 32); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + } else { + const code = + item.outcome === "resource" + ? "resource_constraint" + : "invalid_request"; + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code, + details: { + operation: "inspect_go_binary", + ...(item.outcome === "resource" + ? { + resource: "memory", + reported_limits: { + boundary: "build-info", + maximum_bytes: maximum, + }, + } + : { issues: [{ path: ["path"], reason: "invalid_format" }] }), + }, + }); + expect(parseMcpToolError(response).error).toMatchObject({ + code, + details: { + operation: "inspect_go_binary", + ...(item.outcome === "resource" + ? { + resource: "memory", + reported_limits: { + boundary: "build-info", + maximum_bytes: maximum, + }, + } + : { issues: [{ path: ["path"], reason: "invalid_format" }] }), + }, + }); + } + } + await client.ping(); + }, + 60000, +); + cliTest( "delivers a Go CLI summary with its unchanged complete source bundle", async ({ cli }) => { diff --git a/tests/acceptance/godotPck.test.ts b/tests/acceptance/godotPck.test.ts index 4da902a18d..45cc36e35f 100644 --- a/tests/acceptance/godotPck.test.ts +++ b/tests/acceptance/godotPck.test.ts @@ -123,6 +123,72 @@ cliTest( }, ); +cliTest( + "keeps native inventory when an incidental GDPC trailer does not point at a pack header", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pck-marker-candidate-"); + const carrier = elf(2, 1, 62); + const candidates = [ + Buffer.concat([carrier, Buffer.from("12345678ABCD", "ascii")]), + Buffer.concat([carrier, Buffer.from("12345678GDPC", "ascii")]), + Buffer.concat([ + carrier, + Buffer.from("notpack!", "ascii"), + pckU64(8), + Buffer.from("GDPC", "ascii"), + ]), + ]; + + for (const [index, bytes] of candidates.entries()) { + const source = join(root, `candidate-${String(index)}.x86_64`); + await writeFile(source, bytes); + const reply = await cli.run({ + arguments: ["inspect-artifact", source, "--json"], + }); + expect(reply.exitCode, reply.stdout + reply.stderr).toBe(0); + const graph = graphFrom(reply.json); + expect(graph.manifest.root_format).toBe("elf"); + expect( + graph.nodes.find( + (row) => row.artifact_id === graph.manifest.root_artifact_id, + )?.format, + ).toBe("elf"); + expect(graph.occurrences).toHaveLength(1); + } + }, +); + +cliTest( + "refuses an unsupported version after an embedded trailer points at a PCK header", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-pck-confirmed-malformed-"); + const source = join(root, "unsupported.x86_64"); + const pack = buildGodotPck(4, members); + pack.writeUInt32LE(1, 4); + await writeFile( + source, + Buffer.concat([ + elf(2, 1, 62), + pack, + pckU64(pack.length), + Buffer.from("GDPC", "ascii"), + ]), + ); + + const reply = await cli.run({ + arguments: ["inspect-artifact", source, "--json"], + }); + expect(reply.exitCode).toBe(1); + expect(reply.json).toMatchObject({ + code: "artifact_operation_failed", + details: { + reason: "unavailable", + detail: expect.stringContaining("Godot PCK format 1 is unsupported"), + }, + }); + }, +); + cliTest( "applies strict and record-and-continue MD5 integrity to inspection and extraction", async ({ cli }) => { diff --git a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts index 1f1ba969ad..8160b5b841 100644 --- a/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts +++ b/tests/boundary/filesystem/keyedArchiveScalarIdentity.test.ts @@ -1,11 +1,12 @@ import { createHash } from "node:crypto"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { build } from "plist"; import { expect, it } from "vitest"; import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { keyedArchiveUidBytes } from "../../fixtures/keyedArchiveThreeByteUid.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -136,3 +137,56 @@ cliTest.for(cases)( ); }, ); + +cliTest.for(["three-byte-uid"] as const)( + "preserves three-byte UID identity through the keyed-archive CLI", + async (_kind, { cli }) => { + const root = await createTestTempDirectory("rea-keyed-three-byte-uid-"); + const path = join(root, "archive.plist"); + const bytes = keyedArchiveUidBytes(); + const digest = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode).toBe(0); + const result = keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.archive_sha256).toBe(digest); + expect(result.roots).toEqual({ root: { UID: 66051 } }); + expect(result.references).toContainEqual({ + source: null, + path: ["root"], + target: 66051, + status: "unresolved", + raw: { UID: 66051 }, + }); + }, +); + +cliTest( + "reports an inexact binary UID as unsupported without changing the artifact", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-inexact-uid-"); + const path = join(root, "archive.plist"); + const bytes = keyedArchiveUidBytes(8, BigInt(Number.MAX_SAFE_INTEGER) + 1n); + const originalHash = createHash("sha256").update(bytes).digest("hex"); + await writeFile(path, bytes); + + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + + expect(output.exitCode).toBe(1); + expect(output.json).toMatchObject({ + code: "unsupported_target", + message: expect.stringContaining("9007199254740992"), + }); + const after = await readFile(path); + expect(after).toEqual(bytes); + expect(createHash("sha256").update(after).digest("hex")).toBe(originalHash); + }, +); diff --git a/tests/fixtures/go/image.ts b/tests/fixtures/go/image.ts index fccc8b63a3..2dda4dea26 100644 --- a/tests/fixtures/go/image.ts +++ b/tests/fixtures/go/image.ts @@ -141,6 +141,60 @@ export const createGoBinaryFixture = ( return { bytes, headerOffset, versionOffset, moduleOffset, moduleBytes }; }; +/** Build an ELF inline record with a selected aggregate string-content size. */ +export const createGoInlineAggregateFixture = (contentBytes: number) => { + const fixture = createGoBinaryFixture({ moduleText: "" }); + const version = Buffer.from("v"); + const moduleBytes = Buffer.alloc(contentBytes - version.length, 0x78); + if (moduleBytes.length < 33) + throw new Error("Go module string must include its framing markers"); + MODULE_START.copy(moduleBytes); + MODULE_END.copy(moduleBytes, moduleBytes.length - MODULE_END.length); + moduleBytes[moduleBytes.length - MODULE_END.length - 1] = 0x0a; + const versionLength = encodeVarint(version.length); + const moduleLength = encodeVarint(moduleBytes.length); + const versionOffset = fixture.headerOffset + 32 + versionLength.length; + const modulePrefixOffset = versionOffset + version.length; + const moduleOffset = modulePrefixOffset + moduleLength.length; + const end = moduleOffset + moduleBytes.length; + const bytes = Buffer.alloc(Math.ceil(Math.max(4096, end) / 16) * 16); + fixture.bytes.copy(bytes); + // Discovery names the header only; inline strings continue in PT_LOAD. + bytes.writeBigUInt64LE(32n, 416); + versionLength.copy(bytes, fixture.headerOffset + 32); + version.copy(bytes, versionOffset); + moduleLength.copy(bytes, modulePrefixOffset); + moduleBytes.copy(bytes, moduleOffset); + // ELF64 PT_LOAD p_filesz/p_memsz, matching this fixture's native identity. + bytes.writeBigUInt64LE(BigInt(bytes.length), 160); + bytes.writeBigUInt64LE(BigInt(bytes.length), 168); + return { + bytes, + headerOffset: fixture.headerOffset, + versionOffset, + moduleOffset, + moduleBytes, + }; +}; + +/** Shorten the ELF file-backed load mapping while retaining the file bytes. */ +export const setGoElfLoadFileSize = (bytes: Buffer, size: number): void => { + bytes.writeBigUInt64LE(BigInt(size), 160); +}; + +/** Set the Go Mach-O discovery section while leaving its segment unchanged. */ +export const setGoMachOSectionSize = (bytes: Buffer, size: number): void => { + bytes.writeBigUInt64LE(BigInt(size), 144); +}; + +/** Shorten a Mach-O segment's file-backed range while retaining the file bytes. */ +export const setGoMachOSegmentFileSize = ( + bytes: Buffer, + size: number, +): void => { + bytes.writeBigUInt64LE(BigInt(size), 80); +}; + /** Set the first PE section's declared virtual content size in an authored fixture. */ export const setGoPeVirtualSize = ( bytes: Buffer, diff --git a/tests/fixtures/keyedArchiveThreeByteUid.ts b/tests/fixtures/keyedArchiveThreeByteUid.ts new file mode 100644 index 0000000000..f4e92e6c0b --- /dev/null +++ b/tests/fixtures/keyedArchiveThreeByteUid.ts @@ -0,0 +1,46 @@ +const ascii = (value: string): Buffer => { + const bytes = Buffer.from(value, "ascii"); + return bytes.length < 15 + ? Buffer.concat([Buffer.from([0x50 | bytes.length]), bytes]) + : Buffer.concat([Buffer.from([0x5f, 0x10, bytes.length]), bytes]); +}; + +/** NSKeyedArchiver with a caller-sized UID for `$top.root`. */ +export const keyedArchiveUidBytes = ( + width = 3, + uidValue = 0x010203n, +): Buffer => { + const uid = Buffer.alloc(width + 1); + uid[0] = 0x80 | (width - 1); + for (let byte = width - 1; byte >= 0; byte -= 1) { + uid[byte + 1] = Number(uidValue & 0xffn); + uidValue >>= 8n; + } + const objects = [ + Buffer.from([0xd3, 1, 3, 4, 2, 8, 5]), + ascii("$archiver"), + ascii("NSKeyedArchiver"), + ascii("$objects"), + ascii("$top"), + Buffer.from([0xd1, 6, 7]), + ascii("root"), + uid, + Buffer.from([0xa2, 9, 10]), + ascii("$null"), + ascii("payload"), + ]; + const offsets: number[] = []; + let position = Buffer.byteLength("bplist00", "ascii"); + for (const object of objects) { + offsets.push(position); + position += object.length; + } + const header = Buffer.from("bplist00", "ascii"); + const trailer = Buffer.alloc(32); + trailer[6] = 1; + trailer[7] = 1; + trailer.writeBigUInt64BE(BigInt(objects.length), 8); + trailer.writeBigUInt64BE(0n, 16); + trailer.writeBigUInt64BE(BigInt(position), 24); + return Buffer.concat([header, ...objects, Buffer.from(offsets), trailer]); +}; diff --git a/tests/fixtures/zipSymlinkMetadata.ts b/tests/fixtures/zipSymlinkMetadata.ts new file mode 100644 index 0000000000..b3946c0dbc --- /dev/null +++ b/tests/fixtures/zipSymlinkMetadata.ts @@ -0,0 +1,17 @@ +import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js"; + +/** Real ZIP entries whose Unix external attributes declare symlinks. */ +export const zipSymlinkMetadataFixture = async (): Promise => { + const writer = new ZipWriter(new Uint8ArrayWriter(), { + versionMadeBy: 3 << 8, + }); + await writer.add("target.txt", new TextReader("harmless target text")); + for (const [name, mode] of [ + ["type-only-link", 0o120000], + ["permissions-link", 0o120777], + ] as const) + await writer.add(name, new TextReader("target.txt"), { + externalFileAttributes: (mode << 16) >>> 0, + }); + return Buffer.from(await writer.close()); +};