From b4db35f092d7166aebdf4387b48f7652b1d1c3b6 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:47 +0800 Subject: [PATCH] fix: bind identity and coverage to the producing rule (#1513) * fix: bind identity and coverage to the producing rule Logical paths, container families, examined bytes, path-segment conventions, destination collisions, file-id leases, Mach-O CPU selection, and firmware cleanup now follow the observation that owns each rule. * fix: fail unmatched Mach-O slices and keep firmware roots independent A fat target whose architecture lipo did not list now stops before slice tools run. Chained fixups still count unread LC_DYLD_INFO rebase bytes, and a firmware cleanup failure no longer blocks the next workspace. * test: cover path identity and runtime families at the projection boundary NFC joins and segment conventions are now asserted by the path resolver, the Apple projection, and a real APK inventory instead of helper unit tests. * test: expect unread dyld rebase bytes in the Apple dispatch fixture The legacy LC_DYLD_INFO fixture keeps a rebase range this reader does not decode, so the public CLI and MCP check now requires that partial coverage beside the bind opcodes. --------- Co-authored-by: Cursor Agent --- scripts/verify/apple/dispatch.mjs | 16 +- .../ReferenceSourceImportEntries.ts | 8 +- .../JavaScriptArtifactPathResolution.test.ts | 30 ++++ .../JavaScriptArtifactPathResolution.ts | 12 +- src/application/native/NativeUiActionTrace.ts | 22 ++- src/artifacts/ArtifactPaths.ts | 3 +- src/artifacts/inventory/classify.ts | 64 +++++--- src/artifacts/inventory/reader.ts | 11 +- .../CdpCaptureContextRetention.test.ts | 35 +++++ .../CdpCaptureDocuments.baseUrl.test.ts | 17 ++ src/browser/CdpCaptureDocuments.ts | 3 +- src/browser/CdpCaptureEventHandlers.ts | 12 +- src/browser/CdpCaptureEventTypes.ts | 2 + src/browser/CdpCaptureEvents.ts | 6 +- src/browser/PlaywrightScenarioBrowser.test.ts | 10 ++ src/browser/PlaywrightScenarioBrowser.ts | 12 +- src/browser/assets/PublishWebScripts.ts | 80 ++++++++-- src/domain/analysisErrorBase.ts | 4 +- src/domain/android/androidApplication.ts | 27 ++-- src/domain/apple/appleApplication.test.ts | 77 +++++++++ src/domain/apple/appleApplication.ts | 87 +++++----- src/domain/apple/appleBundleAnatomy.ts | 148 +++++++++++++----- src/domain/artifactComparison.ts | 39 +++++ src/domain/artifactIdentity.ts | 25 +++ src/domain/artifactPathSyntax.ts | 2 +- src/domain/firmware/firmwareAnalysis.ts | 8 +- .../javascriptRuntimeStaticCandidates.ts | 22 +-- src/domain/nativeConvention.ts | 38 +++++ src/domain/webScriptExportPlan.test.ts | 20 ++- src/domain/webScriptExportPlan.ts | 27 +--- src/domain/zipPackageFormat.ts | 4 + src/firmware/FirmwareProvider.test.ts | 98 ++++++++++++ src/firmware/FirmwareProvider.ts | 88 +++++++---- src/native/AppleDispatchCoverage.ts | 46 ++++-- src/native/AppleDispatchMetadata.test.ts | 2 +- src/native/AppleMachoFixups.test.ts | 35 ++++- src/native/AppleMachoFixups.ts | 26 ++- src/native/AppleMachoSelection.test.ts | 21 +++ src/native/AppleMachoSelection.ts | 28 +++- src/native/AppleSwiftVtables.test.ts | 4 + src/native/NativeMachoInspection.test.ts | 73 +++++++++ src/native/NativeMachoInspection.ts | 36 +++-- .../ProcessScenarioRuntimeValidation.test.ts | 41 ++++- .../ProcessScenarioRuntimeValidation.ts | 6 +- src/windows/WindowsPrivateRuntime.ts | 24 ++- .../boundary/filesystem/zipDetection.test.ts | 74 +++++++++ .../firmware/firmwareIntegration.test.ts | 12 +- tests/composition/android/application.test.ts | 32 +++- 48 files changed, 1251 insertions(+), 266 deletions(-) create mode 100644 src/domain/nativeConvention.ts create mode 100644 src/firmware/FirmwareProvider.test.ts create mode 100644 src/native/NativeMachoInspection.test.ts diff --git a/scripts/verify/apple/dispatch.mjs b/scripts/verify/apple/dispatch.mjs index 411e3e32e8..4ac861b299 100644 --- a/scripts/verify/apple/dispatch.mjs +++ b/scripts/verify/apple/dispatch.mjs @@ -29,7 +29,7 @@ const LINK_MODES = [ { mode: "dyld-info", flags: ["-Wl,-no_fixup_chains"], - fixups: "LC_DYLD_INFO bind opcodes", + fixups: "LC_DYLD_INFO rebase opcodes were not decoded", swift: true, }, { mode: "chained", flags: [], fixups: "chained fixups:", swift: true }, @@ -135,7 +135,12 @@ const checkObjc = (metadata, mode) => { item.selector === "reaFixtureLength", ) || objcCoverage?.status !== "complete" || - fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true + fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true || + (mode.mode === "dyld-info") !== + (fixupCoverage(metadata)?.reason?.includes( + "LC_DYLD_INFO bind opcodes", + ) === + true) ) throw new Error( `Apple metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, @@ -165,7 +170,12 @@ const checkSwift = (metadata, mode) => { item.owner === "ReaWitnessFixture: ReaWitnessFixtureProtocol" && item.implementation_address !== null, ) || - fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true + fixupCoverage(metadata)?.reason?.startsWith(mode.fixups) !== true || + (mode.mode === "dyld-info") !== + (fixupCoverage(metadata)?.reason?.includes( + "LC_DYLD_INFO bind opcodes", + ) === + true) ) throw new Error( `Swift metadata fixture drifted (${mode.mode}): ${JSON.stringify(metadata)}`, diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 6a3e704513..4b3f2689f8 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -1,6 +1,10 @@ import { createHash } from "node:crypto"; import { posix } from "node:path"; +import { + logicalPathEscapesRoot, + normalizeJoinedLogicalPath, +} from "../domain/artifactIdentity.js"; import { classifyReferenceSourcePath, detectReferenceSourceLanguage, @@ -83,10 +87,10 @@ const resolveInternalSpecifier = ( ): { to: string; resolution: "internal" | "unresolved" } => { if (posix.isAbsolute(specifier)) return { to: specifier, resolution: "unresolved" }; - const normalized = posix.normalize( + const normalized = normalizeJoinedLogicalPath( posix.join(posix.dirname(fromPath), specifier), ); - if (normalized.startsWith("../") || normalized === "..") + if (logicalPathEscapesRoot(normalized)) return { to: normalized, resolution: "unresolved" }; const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; const candidates = suffixes.flatMap((suffix) => [ diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 1b5bd33c4c..97715243d7 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -67,6 +67,36 @@ describe("artifact-local graph identity", () => { }); }); +describe("logical path identity", () => { + it("resolves an NFD relative import to the NFC inventory path and rejects a root escape", () => { + const nfc = "caf\u00e9".normalize("NFC"); + const nfd = nfc.normalize("NFD"); + const files = fileMap([ + file("dir/index.js", "root"), + file(`dir/${nfc}.js`, "root"), + ]); + expect( + resolve({ + declaredPath: `./${nfd}.js`, + sourcePath: "dir/index.js", + context: "module-specifier", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: `dir/${nfc}.js`, + }); + expect( + resolve({ + declaredPath: "../escape.js", + sourcePath: "index.js", + context: "module-specifier", + files, + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }); +}); + describe("contextual JavaScript artifact path resolution", () => { it.each([ ["main.js", "app/main.js"], diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index f87cc24558..418b9ac862 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1,6 +1,10 @@ import { isBuiltin } from "node:module"; import { posix } from "node:path"; +import { + normalizeJoinedLogicalPath, + logicalPathEscapesRoot, +} from "../../domain/artifactIdentity.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { admitsCanonicalPathSyntax, @@ -326,12 +330,8 @@ const confineCandidate = ( input: ResolveArtifactPathInput, candidate: string, ): string | ArtifactPathResolution => { - const normalized = posix.normalize(candidate); - if ( - normalized === ".." || - normalized.startsWith("../") || - normalized.startsWith("/") - ) + const normalized = normalizeJoinedLogicalPath(candidate); + if (logicalPathEscapesRoot(normalized)) return unresolvedOutcome(input, "rejected", [ "The resolved candidate escapes the canonical artifact root.", ]); diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 125a11567c..36e7ec67b8 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -73,15 +73,27 @@ export const traceNativeUiAction = async ( ), ); graph = joinInterfaceBuilderDispatch(decoded.data.graph, metadata.result); + const archiveDecode = decoded.data.graph.coverage.find( + (facet) => facet.facet === "archive_decode", + ); + const documentsFound = decoded.data.documents.length > 0; coverage.push({ facet: "interface_builder_archives", - status: decoded.data.documents.length > 0 ? "complete" : "unsupported", + status: + archiveDecode?.status === "partial" || + archiveDecode?.status === "complete" || + archiveDecode?.status === "unsupported" + ? archiveDecode.status + : documentsFound + ? "complete" + : "unsupported", reason: - decoded.data.documents.length > 0 + archiveDecode?.reason ?? + (documentsFound ? null - : "no_compiled_interface_builder_archives_found", - examined: decoded.data.documents.length, - omitted: 0, + : "no_compiled_interface_builder_archives_found"), + examined: archiveDecode?.examined ?? decoded.data.documents.length, + omitted: archiveDecode?.omitted ?? 0, }); const bounded = boundUiGraph(graph, input.max_nodes, input.max_edges); graph = bounded.graph; diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index cabcb60cf7..3950b4c6e2 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -1,5 +1,6 @@ import { posix } from "node:path"; +import { logicalPathNfc } from "../domain/artifactIdentity.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; import { ArtifactReaderFailure } from "./ArtifactReader.js"; @@ -31,7 +32,7 @@ export const normalizeArtifactPath = (input: string): string => { "path", `Artifact path is absolute or unsafe: ${JSON.stringify(input)}`, ); - const normalized = input.normalize("NFC").replace(/\/+$/u, ""); + const normalized = logicalPathNfc(input).replace(/\/+$/u, ""); const parts = normalized.split("/"); if ( normalized.length === 0 || diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 18c6759210..d0c8dfef80 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -6,7 +6,10 @@ import { import type { FileHandle } from "node:fs/promises"; import type { Stats } from "node:fs"; -import { classifyArtifactContent } from "./ArtifactGraphConstruction.js"; +import { + classifyArtifactBytes, + classifyArtifactContent, +} from "./ArtifactGraphConstruction.js"; import { ARTIFACT_CLASSIFICATION_PREFIX_BYTES } from "../ArtifactHash.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; import { @@ -17,17 +20,6 @@ import { ArtifactReaderFailure } from "../ArtifactReader.js"; import type { HashResult } from "../ArtifactHash.js"; import { hashStableRootArtifactHandle } from "./hashStableRootArtifact.js"; -const classifyContainerExtension = ( - path: string, -): ArtifactOccurrence["artifact_format"] | undefined => { - const lower = path.toLowerCase(); - const zipPackage = zipPackageFormatForPath(lower); - if (zipPackage !== undefined) return zipPackage; - for (const format of ["asar", "dmg", "pkg"] as const) - if (lower.endsWith(`.${format}`)) return format; - return undefined; -}; - /** Classify and hash one file root through the same stable open descriptor. */ export const classifyAndHashRoot = async ( path: string, @@ -47,9 +39,7 @@ export const classifyAndHashRoot = async ( "integrity", `Root artifact changed before inventory: ${path}`, ); - const format = - classifyContainerExtension(path) ?? - (await classifyOpenedRoot(path, handle)); + const format = await classifyRootFormat(path, handle); const digest = await hashStableRootArtifactHandle( path, handle, @@ -79,14 +69,50 @@ const openRootFile = async ( } }; -const classifyOpenedRoot = async ( +const classifyRootFormat = async ( path: string, handle: FileHandle, ): Promise => { const magic = Buffer.alloc(ARTIFACT_CLASSIFICATION_PREFIX_BYTES); const observed = await handle.read(magic, 0, magic.length, 0); const prefix = magic.subarray(0, observed.bytesRead); - if (hasZipSignature(prefix)) return "zip"; - return classifyArtifactContent(path, prefix, (await handle.stat()).size) - .format; + const lower = path.toLowerCase(); + if (hasZipSignature(prefix)) return zipPackageFormatForPath(lower) ?? "zip"; + if (lower.endsWith(".asar") && hasAsarHeader(prefix)) return "asar"; + if ( + lower.endsWith(".pkg") && + prefix.subarray(0, 4).toString("ascii") === "xar!" + ) + return "pkg"; + if (lower.endsWith(".dmg") && (await hasKolyTrailer(handle))) return "dmg"; + const size = (await handle.stat()).size; + const format = classifyArtifactContent(path, prefix, size).format; + // Path suffixes stay role hints for nested members. A root whose container + // magic did not match is classified from the bytes that were read. + if (format === "asar" || format === "pkg" || format === "dmg") + return classifyArtifactBytes(prefix, size); + return format; +}; + +/** + * Electron ASAR starts with a size pickle whose payload is one uint32, then + * a header pickle whose payload begins with a JSON object. + */ +const hasAsarHeader = (prefix: Buffer): boolean => { + if (prefix.length < 16 || prefix.readUInt32LE(0) !== 4) return false; + const headerSize = prefix.readUInt32LE(4); + const payloadSize = prefix.readUInt32LE(8); + if (headerSize < 16 || payloadSize !== headerSize - 4) return false; + const stringLength = prefix.readUInt32LE(12); + return ( + stringLength >= 2 && stringLength <= payloadSize - 4 && prefix[16] === 0x7b + ); +}; + +const hasKolyTrailer = async (handle: FileHandle): Promise => { + const size = (await handle.stat()).size; + if (size < 512) return false; + const trailer = Buffer.alloc(4); + const read = await handle.read(trailer, 0, trailer.length, size - 512); + return read.bytesRead === 4 && trailer.toString("ascii") === "koly"; }; diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index cc0a3593b8..692431a00e 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -5,6 +5,7 @@ import { MachOSliceArtifactReader } from "../MachOSliceArtifactReader.js"; import { NativeDmgArtifactReader } from "../NativeDmgArtifactReader.js"; import { ZipArtifactReader } from "../ZipArtifactReader.js"; import type { ArtifactOccurrence } from "../../domain/artifactGraph.js"; +import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "../../domain/zipPackageFormat.js"; export const createReader = async ( path: string, @@ -39,7 +40,15 @@ export const inventoryLimitations = ( format: ArtifactOccurrence["artifact_format"], reader: ArtifactReader | undefined, ): string[] => { - if (reader !== undefined) return []; + if (reader !== undefined) + return reader.format === "zip" || + reader.format === "ipa" || + reader.format === "apk" || + reader.format === "msix" || + reader.format === "appx" + ? [ZIP_NON_ENTRY_TAIL_LIMITATION] + : []; + // dmg and pkg reach here only after classifyRootFormat matched their bytes. if (format === "dmg" || format === "pkg") return [ `${format.toUpperCase()} root hash is observed; child inventory requires a native macOS adapter.`, diff --git a/src/browser/CdpCaptureContextRetention.test.ts b/src/browser/CdpCaptureContextRetention.test.ts index 81e3722979..a75a63bf57 100644 --- a/src/browser/CdpCaptureContextRetention.test.ts +++ b/src/browser/CdpCaptureContextRetention.test.ts @@ -56,4 +56,39 @@ describe("CDP execution-context retention", () => { }); expect(electronFrames.size).toBe(0); }); + + it("keeps a script on the frame stored when it was parsed", () => { + const request = inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: ["https://app.example.test"], + target_id: "page-1", + }); + const browser = new CdpCaptureEvents( + request, + new Set(["https://app.example.test"]), + ); + browser.beginAuthorizedFrame("frame-old"); + browser.beginAuthorizedFrame("frame-new"); + browser.ingest({ + method: "Runtime.executionContextCreated", + params: { context: { id: 7, auxData: { frameId: "frame-old" } } }, + }); + browser.ingest({ + method: "Debugger.scriptParsed", + params: { + scriptId: "script-1", + url: "https://app.example.test/app.js", + executionContextId: 7, + }, + }); + browser.ingest({ + method: "Runtime.executionContextCreated", + params: { context: { id: 7, auxData: { frameId: "frame-new" } } }, + }); + const script = browser.scripts.get("script-1"); + if (script === undefined) throw new TypeError("Expected captured script"); + expect( + browser.frameForScript(script, new Set(["frame-old", "frame-new"])), + ).toBe("frame-old"); + }); }); diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index 94b04dc65b..29492706e6 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -198,6 +198,23 @@ describe("empty form destinations with a document base URL", () => { }, ); + it("keeps the first value when an attribute name repeats", () => { + const value = snapshot(); + const later = `${origin}/later`; + value.strings.push(later); + const href = value.strings.indexOf("href"); + const guide = value.strings.indexOf("guide"); + const nodes = value.documents[0]?.nodes; + if (nodes === undefined) throw new TypeError("Expected a DOM snapshot"); + nodes.attributes[2] = [href, guide, href, value.strings.length - 1]; + const result = capture(value); + expect(result.nodes[2]?.attribute_names).toEqual(["href", "href"]); + expect(result.urls.map(({ url }) => url)).toContain( + `${origin}/screens/guide`, + ); + expect(result.urls.map(({ url }) => url)).not.toContain(later); + }); + it("does not apply form semantics to an action attribute on another element", () => { const baseUrl = `${origin}/assets/`; const value = snapshot(baseUrl); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 4fd86caf81..38276d4801 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -387,7 +387,8 @@ const domMetadata = ( for (let index = 0; index + 1 < attributes.length; index += 2) { const name = indexedString(strings, attributes[index]).toLowerCase(); const value = indexedString(strings, attributes[index + 1]); - pairs.set(name, value); + // HTML keeps the first declaration. A later repeat must not move dom_urls. + if (!pairs.has(name)) pairs.set(name, value); } const urls: WebPageInspection["metadata"]["dom_urls"] = []; for (const attribute of domUrlAttributes) { diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index 2b6d0ef4bf..3e4c1290f0 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -47,6 +47,7 @@ export const handleExecutionContextCreated = ( ) return; const key = String(identifier); + // New contexts may reuse an id. Scripts already parsed keep the frame stored on them. state.executionContextFrames.set(key, frameId); }; @@ -80,6 +81,7 @@ export const handleScriptParsed = ( state.completeness.exclude("scripts", exclusionReasonForUrl(rawUrl)); return; } + const executionContext = executionContextKey(params.executionContextId); const baseScript = { scriptId, rawUrl, @@ -87,7 +89,11 @@ export const handleScriptParsed = ( origin: sanitized.origin, ...scriptMetadataValues(params), language: cdpStringValue(params.scriptLanguage) ?? null, - executionContextKey: executionContextKey(params.executionContextId), + executionContextKey: executionContext, + frameId: + executionContext === null + ? null + : (state.executionContextFrames.get(executionContext) ?? null), }; const baseBytes = scriptMetadataStringBytes(baseScript); const sourceMapDeclared = @@ -131,6 +137,7 @@ const scriptMetadataStringBytes = (script: { readonly hash: string | null; readonly language: string | null; readonly executionContextKey: string | null; + readonly frameId: string | null; }): number => stringByteLength(script.scriptId) + stringByteLength(script.rawUrl) + @@ -138,7 +145,8 @@ const scriptMetadataStringBytes = (script: { stringByteLength(script.origin) + stringByteLength(script.hash) + stringByteLength(script.language) + - stringByteLength(script.executionContextKey); + stringByteLength(script.executionContextKey) + + stringByteLength(script.frameId); const stringByteLength = (value: string | null): number => value === null ? 0 : Buffer.byteLength(value, "utf8"); diff --git a/src/browser/CdpCaptureEventTypes.ts b/src/browser/CdpCaptureEventTypes.ts index 6095e571b6..dbd16b5d51 100644 --- a/src/browser/CdpCaptureEventTypes.ts +++ b/src/browser/CdpCaptureEventTypes.ts @@ -12,6 +12,8 @@ export interface CapturedScript { readonly sourceMapUrl: string | null; readonly sourceMapRawUrl: string | null; readonly executionContextKey: string | null; + /** Frame observed when this script was parsed. Later context reuse does not move it. */ + readonly frameId: string | null; } export type NetworkState = WebPageInspection["network"]["requests"][number]; diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 3312a97491..b5dcc72186 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -159,10 +159,8 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { script: CapturedScript, allowedFrameIds: ReadonlySet, ): string | null { - if (script.executionContextKey === null) return null; - const frameId = this.executionContextFrames.get(script.executionContextKey); - return frameId !== undefined && allowedFrameIds.has(frameId) - ? frameId + return script.frameId !== null && allowedFrameIds.has(script.frameId) + ? script.frameId : null; } diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts index 4ee0a3f1fd..cb3c0e8620 100644 --- a/src/browser/PlaywrightScenarioBrowser.test.ts +++ b/src/browser/PlaywrightScenarioBrowser.test.ts @@ -3,12 +3,22 @@ import type { Browser } from "playwright-core"; import { browserScenarioSchema } from "../domain/browserScenario.js"; import { + attachedScenarioLeaseKey, openPlaywrightScenarioBrowser, PlaywrightScenarioBrowserCleanupOwner, } from "./PlaywrightScenarioBrowser.js"; afterEach(() => vi.unstubAllEnvs()); +it("locks one attached browser for spellings that discover the same endpoint", () => { + expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).toBe( + attachedScenarioLeaseKey("http://127.0.0.1:9222/", "page-1"), + ); + expect(attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-1")).not.toBe( + attachedScenarioLeaseKey("http://127.0.0.1:9222", "page-2"), + ); +}); + const deferred = () => { let resolve: (value: Value | PromiseLike) => void = () => undefined; const promise = new Promise((resolvePromise) => { diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts index cd2d5a65ac..b82b56864c 100644 --- a/src/browser/PlaywrightScenarioBrowser.ts +++ b/src/browser/PlaywrightScenarioBrowser.ts @@ -182,6 +182,13 @@ const createAttachedScenarioRelease = ( }; }; +/** One browser admission. The key is the discovery URL this endpoint already parses. */ +export const attachedScenarioLeaseKey = ( + endpoint: string, + targetId: string, +): string => + JSON.stringify([new URL("/json/version", endpoint).href, targetId]); + const acquireAttachedScenario = ( scenario: Extract, signal: AbortSignal | undefined, @@ -189,7 +196,10 @@ const acquireAttachedScenario = ( if (signal?.aborted === true) return Promise.reject(new BrowserObservationError(OPERATION, "cancelled")); - const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]); + const key = attachedScenarioLeaseKey( + scenario.cdp_endpoint, + scenario.target_id, + ); const admission = attachedScenarioAdmissions.get(key) ?? ({ diff --git a/src/browser/assets/PublishWebScripts.ts b/src/browser/assets/PublishWebScripts.ts index 04bf10893b..f3af5b8b66 100644 --- a/src/browser/assets/PublishWebScripts.ts +++ b/src/browser/assets/PublishWebScripts.ts @@ -2,20 +2,26 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { Readable } from "node:stream"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js"; import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js"; import { webScriptExportManifestSchema, webScriptExportResultSchema, type ExportWebScriptsInput, + type ExportedWebScript, type WebScriptExportResult, } from "../../domain/webScriptExport.js"; -import { planWebScriptExport } from "../../domain/webScriptExportPlan.js"; +import { + planWebScriptExport, + type PlannedWebScript, +} from "../../domain/webScriptExportPlan.js"; import { WebScriptExportError } from "../../domain/webScriptExportError.js"; import type { SelectedScriptCapture } from "./ScriptCaptureAdapters.js"; const LIMITATIONS = [ "Only script bytes retained in this capture are exported. Sources are not refetched or executed, and a response or Debugger source does not prove execution.", - "Ordinary unambiguous URL paths preserve relative module layouts by origin. Query variants, collisions, inline sources, and unrepresentable paths are isolated; their browser module relationships remain unknown.", + "Ordinary unambiguous URL paths preserve relative module layouts by origin, including case-distinct spellings. Query variants, exact path collisions, file/directory prefixes, inline sources, and unrepresentable paths are isolated. A case-distinct pair is isolated only when the destination filesystem cannot store both spellings; the record names that pair. Browser module relationships of isolated paths remain unknown.", "Local JavaScript analysis does not implement browser import maps, root-relative URL resolution, remote URL loading, or browser runtime behavior. It may strip import queries and fragments; isolated variants are not canonical targets.", "Source-map declarations are retained as metadata. No source maps or missing dependencies are fetched or reconstructed.", "Captured response bytes are browser-decoded bytes; Debugger sources are the exposed text encoded as UTF-8. Redacted bytes are exported exactly as retained, and may no longer parse as JavaScript.", @@ -32,7 +38,11 @@ export const publishWebScripts = async ( const tree = await SafeOutputTree.create(input.output_directory); try { signal?.throwIfAborted(); - const records = planned.map(({ record }) => record); + const records: ExportedWebScript[] = []; + for (const [index, item] of planned.entries()) { + signal?.throwIfAborted(); + records.push(await publishPlannedScript(tree, item, index, signal)); + } const manifest = webScriptExportManifestSchema.parse({ capture_path: input.capture_path, capture_sha256: captureSha256, @@ -66,19 +76,6 @@ export const publishWebScripts = async ( bytes: bytes.length, }, }); - for (const { script, record } of planned) { - signal?.throwIfAborted(); - if ( - script.content.state === "captured" && - record.content.state === "exported" - ) - await tree.write( - `files/${record.content.relative_path}`, - Readable.from([script.content.bytes]), - script.content.sha256, - signal, - ); - } await tree.write("manifest.json", Readable.from([bytes]), digest, signal); signal?.throwIfAborted(); await tree.commit(); @@ -98,5 +95,56 @@ export const publishWebScripts = async ( } }; +const publishPlannedScript = async ( + tree: SafeOutputTree, + item: PlannedWebScript, + index: number, + signal: AbortSignal | undefined, +): Promise => { + const { script, record } = item; + const content = script.content; + if (content.state !== "captured" || record.content.state !== "exported") + return record; + const write = (relativePath: string): Promise => + tree.write( + `files/${relativePath}`, + Readable.from([content.bytes]), + content.sha256, + signal, + ); + try { + await write(record.content.relative_path); + return record; + } catch (cause: unknown) { + const collision = destinationCollisionReason(cause); + if (collision === undefined || record.content.layout !== "url-path") + throw cause; + const relativePath = isolatedScriptPath(script, index); + await write(relativePath); + return { + ...record, + content: { + ...record.content, + relative_path: relativePath, + layout: "isolated", + layout_reason: collision, + }, + }; + } +}; + +const isolatedScriptPath = ( + script: PlannedWebScript["script"], + index: number, +): string => + `isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`; + +const destinationCollisionReason = (cause: unknown): string | undefined => + cause instanceof ArtifactReaderFailure && + cause.reason === "path" && + cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX) + ? cause.message + : undefined; + const message = (cause: unknown): string => cause instanceof Error ? cause.message : "Unknown publication failure"; diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index a8449146e0..90056c3b20 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -98,6 +98,7 @@ import type { ArtifactInventoryPartialObservation } from "./artifactPartialObser import type { BrowserScenarioPartialObservation } from "./browserScenarioCapture.js"; import type { Evidence } from "./evidence.js"; import type { AndroidPartialObservation } from "./android/androidPartialObservation.js"; +import type { FirmwarePartialObservation } from "./firmware/firmwareAnalysis.js"; /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = @@ -109,4 +110,5 @@ export type AnalysisPartialObservation = | JavaScriptRuntimeObservation | ArtifactInventoryPartialObservation | Evidence - | AndroidPartialObservation; + | AndroidPartialObservation + | FirmwarePartialObservation; diff --git a/src/domain/android/androidApplication.ts b/src/domain/android/androidApplication.ts index 6744257490..67b58499f7 100644 --- a/src/domain/android/androidApplication.ts +++ b/src/domain/android/androidApplication.ts @@ -11,6 +11,7 @@ import { bridgeCandidateCoverageSchema, projectCartesianCandidates, } from "../bridgeCandidateProjection.js"; +import { nativeRuntimeConvention } from "../nativeConvention.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const pathSchema = z.string().min(1); @@ -176,7 +177,6 @@ const classify = (all: readonly Component[]) => ({ }); const runtimeFamilies = (all: readonly Component[]) => { - const paths = all.map(({ path }) => path.toLowerCase()); const families = new Set< AndroidApplicationProjectionResult["runtime_families"][number] >(); @@ -187,15 +187,10 @@ const runtimeFamilies = (all: readonly Component[]) => { if (all.some(({ format }) => format === "elf")) families.add("native"); if (all.some(({ format }) => format === "javascript-bundle")) families.add("javascript"); - if ( - paths.some( - (path) => path.includes("reactnative") || path.includes("hermes"), - ) - ) - families.add("react-native"); - if (paths.some((path) => path.includes("libflutter.so"))) - families.add("flutter"); - if (paths.some((path) => path.includes("libunity.so"))) families.add("unity"); + for (const { path } of all) { + const convention = nativeRuntimeConvention(path); + if (convention !== null) families.add(convention); + } return [...families].sort(compare); }; @@ -216,12 +211,12 @@ const bridgeCandidates = ( const bridgeBasis = ( path: string, ): AndroidApplicationProjectionResult["bridge_candidates"][number]["basis"] => { - const lower = path.toLowerCase(); - if (lower.includes("react") || lower.includes("hermes")) - return "react-native-convention"; - if (lower.includes("flutter")) return "flutter-convention"; - if (lower.includes("unity")) return "unity-convention"; - if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(lower)) return "jni-library-convention"; + const convention = nativeRuntimeConvention(path); + if (convention === "react-native") return "react-native-convention"; + if (convention === "flutter") return "flutter-convention"; + if (convention === "unity") return "unity-convention"; + if (/lib\/[^/]+\/lib[^/]+\.so$/u.test(path.toLowerCase())) + return "jni-library-convention"; return "managed-and-native-content"; }; diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index 5c846611a1..173334f2dc 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -572,3 +572,80 @@ describe("Apple application bridge candidates", () => { }); }); }); + +describe("Apple path conventions", () => { + it("recognizes anatomy names by English case and reports the archive spelling", () => { + const result = project( + inventoryEvidence("directory", "Fixture.app", [ + { path: "contents/info.plist" }, + macho("contents/macos/Fixture"), + { path: "contents/_codesignature/coderesources" }, + ]), + ); + expect(result.bundles[0]).toMatchObject({ + layout: "macos-deep", + info_plist_path: "contents/info.plist", + executable_candidates: ["contents/macos/Fixture"], + signing_paths: ["contents/_codesignature/coderesources"], + }); + expect(result.components.bundle_metadata.map(({ path }) => path)).toEqual([ + "contents/info.plist", + ]); + expect(result.components.signing.map(({ path }) => path)).toEqual([ + "contents/_codesignature/coderesources", + ]); + }); + + it("limits runtime families and bridge bases to path segments", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + macho("Payload/Fixture.app/Frameworks/libfoo.dylib"), + macho("SwiftSupport/libswiftCore.dylib"), + macho("Payload/Fixture.app/Frameworks/MyApp.framework/MyApp"), + macho("Payload/Fixture.app/community/libcommunity.so"), + { + path: "Payload/Fixture.app/main.js", + format: "javascript-bundle", + }, + ]), + ); + expect(result.runtime_families).toEqual( + expect.arrayContaining(["javascript", "native", "swift-objective-c"]), + ); + expect(result.runtime_families).not.toContain("flutter"); + expect(result.runtime_families).not.toContain("unity"); + expect(result.runtime_families).not.toContain("react-native"); + expect( + result.bridge_candidates.every( + ({ basis }) => basis === "javascript-and-native-content", + ), + ).toBe(true); + expect(result.limitations).toEqual( + expect.arrayContaining([ + "Runtime families are inferred from inventory formats and paths.", + "A bridge basis is inferred from the native path and is repeated for every managed component.", + ]), + ); + }); + + it("recognizes framework segments and does not treat a bare dylib as Swift", () => { + const result = project( + inventoryEvidence("ipa", "Fixture.ipa", [ + { path: "Payload/Fixture.app/Info.plist" }, + macho("Payload/Fixture.app/Fixture"), + macho("Payload/Fixture.app/Frameworks/Flutter.framework/Flutter"), + macho( + "Payload/Fixture.app/Frameworks/UnityFramework.framework/UnityFramework", + ), + macho("Payload/Fixture.app/Frameworks/hermes.framework/hermes"), + macho("Payload/Fixture.app/Frameworks/libfoo.dylib"), + ]), + ); + expect(result.runtime_families).toEqual( + expect.arrayContaining(["flutter", "unity", "react-native", "native"]), + ); + expect(result.runtime_families).not.toContain("swift-objective-c"); + }); +}); diff --git a/src/domain/apple/appleApplication.ts b/src/domain/apple/appleApplication.ts index 232ce2a364..79dff1c633 100644 --- a/src/domain/apple/appleApplication.ts +++ b/src/domain/apple/appleApplication.ts @@ -4,6 +4,8 @@ import canonicalize from "canonicalize"; import { z } from "zod"; import { + anatomyNameIs, + anatomyTailMatches, appleBundleSchema, appleComponentSchema, applicationRoots, @@ -14,6 +16,10 @@ import { platformsOf, type AppleInventoryEntry, } from "./appleBundleAnatomy.js"; +import { + isSwiftRuntimeLibrary, + nativeRuntimeConvention, +} from "../nativeConvention.js"; import { parseArtifactInventoryEvidence } from "../artifactInventoryEvidence.js"; import { evidenceSchema } from "../evidence.js"; import { digestSchema } from "../digests.js"; @@ -239,19 +245,20 @@ const classifyComponents = ( ) => { const withinApp = (path: string): boolean => roots.some((root) => isWithin(path, root)); - const anatomy = (pattern: RegExp): Component[] => + const matching = (predicate: (path: string) => boolean): Component[] => entries.flatMap(({ path, kind, component }) => kind === "file" && component !== undefined && !isSidecar(path) && withinApp(path) && - pattern.test(path) + predicate(path) ? [component] : [], ); + const lastSegment = (path: string): string => path.split("/").at(-1) ?? ""; return { bundle_metadata: all.filter(({ path }) => - /(?:^|\/)Info\.plist$/u.test(path), + anatomyNameIs(lastSegment(path), "Info.plist"), ), executables: all.filter( ({ path, format }) => withinApp(path) && isMachOFormat(format), @@ -259,23 +266,41 @@ const classifyComponents = ( frameworks: all.filter(({ path }) => /\.framework\//u.test(path)), native_libraries: all.filter(({ path }) => /\.(?:dylib|so)$/iu.test(path)), javascript: all.filter(({ format }) => format === "javascript-bundle"), - signing: all.filter(({ path }) => - /(?:^|\/)(?:embedded\.(?:mobileprovision|provisionprofile)|_CodeSignature\/CodeResources)$/u.test( - path, - ), + signing: all.filter( + ({ path }) => + anatomyTailMatches(path, ["_CodeSignature", "CodeResources"]) || + anatomyNameIs(lastSegment(path), "embedded.mobileprovision") || + anatomyNameIs(lastSegment(path), "embedded.provisionprofile"), ), - privileged_helpers: anatomy( - /(?:^|\/)Contents\/Library\/LaunchServices\/[^/]+$/u, + privileged_helpers: matching((path) => + anatomyTailMatches(path, ["Contents", "Library", "LaunchServices", "*"]), ), - launchd_plists: anatomy( - /(?:^|\/)Contents\/Library\/Launch(?:Agents|Daemons)\/[^/]+\.plist$/u, + launchd_plists: matching( + (path) => + path.endsWith(".plist") && + (anatomyTailMatches(path, [ + "Contents", + "Library", + "LaunchAgents", + "*", + ]) || + anatomyTailMatches(path, [ + "Contents", + "Library", + "LaunchDaemons", + "*", + ])), ).map((component) => ({ ...component, - domain: component.path.includes("/LaunchDaemons/") + domain: component.path + .split("/") + .some((segment) => anatomyNameIs(segment, "LaunchDaemons")) ? ("daemon" as const) : ("agent" as const), })), - helpers: anatomy(/(?:^|\/)Contents\/Helpers\/[^/]+$/u), + helpers: matching((path) => + anatomyTailMatches(path, ["Contents", "Helpers", "*"]), + ), }; }; @@ -285,7 +310,6 @@ const deduplicateComponents = (values: readonly Component[]): Component[] => ); const identifyRuntimeFamilies = (all: readonly Component[]) => { - const paths = all.map(({ path }) => path.toLowerCase()); const families = new Set< z.infer< typeof appleApplicationProjectionResultSchema @@ -293,28 +317,14 @@ const identifyRuntimeFamilies = (all: readonly Component[]) => { >(); if (all.some(({ format }) => ["mach-o", "mach-o-universal"].includes(format))) families.add("native"); - if (paths.some((path) => /\.(?:dylib|framework\/[^/]+)$/u.test(path))) + if (all.some(({ path }) => isSwiftRuntimeLibrary(path))) families.add("swift-objective-c"); if (all.some(({ format }) => format === "javascript-bundle")) families.add("javascript"); - if ( - paths.some( - (path) => - path.includes("reactnative") || - path.includes("react.framework") || - path.includes("hermes"), - ) - ) - families.add("react-native"); - if ( - paths.some( - (path) => - path.includes("flutter.framework") || path.includes("app.framework"), - ) - ) - families.add("flutter"); - if (paths.some((path) => path.includes("unityframework.framework"))) - families.add("unity"); + for (const { path } of all) { + const convention = nativeRuntimeConvention(path); + if (convention !== null) families.add(convention); + } return [...families].sort(compare); }; @@ -361,11 +371,10 @@ const bridgeBasis = ( | "react-native-convention" | "flutter-convention" | "unity-convention" => { - const lower = path.toLowerCase(); - if (lower.includes("react")) return "react-native-convention"; - if (lower.includes("flutter") || lower.includes("app.framework")) - return "flutter-convention"; - if (lower.includes("unity")) return "unity-convention"; + const convention = nativeRuntimeConvention(path); + if (convention === "react-native") return "react-native-convention"; + if (convention === "flutter") return "flutter-convention"; + if (convention === "unity") return "unity-convention"; return "javascript-and-native-content"; }; @@ -422,7 +431,9 @@ const projectionLimitations = (facts: { : []), "Bundle roles follow path conventions. Read each info_plist_path with inspect_plist for CFBundleExecutable, identifiers, and declared services.", "Bundle identifiers and signing claims require dedicated plist and CMS parsing; this projection reports only exact paths and hashes.", + "Runtime families are inferred from inventory formats and paths.", "Bridge candidates are path-based hypotheses, not observed runtime calls.", + "A bridge basis is inferred from the native path and is repeated for every managed component.", ]; const compare = (left: string, right: string): number => diff --git a/src/domain/apple/appleBundleAnatomy.ts b/src/domain/apple/appleBundleAnatomy.ts index 224c5ff49e..05eb9d4546 100644 --- a/src/domain/apple/appleBundleAnatomy.ts +++ b/src/domain/apple/appleBundleAnatomy.ts @@ -67,6 +67,50 @@ export const isSidecar = (path: string): boolean => { export const isWithin = (path: string, root: string): boolean => root === "." || path === root || path.startsWith(`${root}/`); +/** + * English (en-US) case fold used only for Apple's fixed anatomy vocabulary. + * Reported paths keep the archive spelling. Inventory identity stays case-sensitive. + */ +export const anatomyNameIs = (actual: string, expected: string): boolean => + actual.toLocaleLowerCase("en-US") === expected.toLocaleLowerCase("en-US"); + +/** Directory entry that folds to one vocabulary word. The exact spelling wins. */ +export const anatomyChild = ( + names: ReadonlySet | undefined, + expected: string, +): string | null => { + if (names === undefined) return null; + let exact: string | undefined; + let folded: string | undefined; + for (const name of names) { + if (name === expected) exact = name; + else if ( + anatomyNameIs(name, expected) && + (folded === undefined || compare(name, folded) < 0) + ) + folded = name; + } + return exact ?? folded ?? null; +}; + +/** + * Whether `path`'s tail matches anatomy vocabulary. `*` matches one segment + * that is not itself a vocabulary comparison. + */ +export const anatomyTailMatches = ( + path: string, + expected: readonly string[], +): boolean => { + const actual = path.split("/"); + if (actual.length < expected.length) return false; + const tail = actual.slice(-expected.length); + return expected.every((name, index) => { + const segment = tail[index]; + if (segment === undefined || segment.length === 0) return false; + return name === "*" || anatomyNameIs(segment, name); + }); +}; + /** * Outermost iOS (`Payload/X.app`) and macOS (`X.app/Contents/…`) applications. * An inventoried `.app` directory is its own root, `.`. @@ -82,15 +126,20 @@ export const applicationRoots = ( const found = new Set(); for (const { path } of entries) { if (isSidecar(path)) continue; - const ios = /^(Payload\/[^/]+\.app)(?:\/|$)/u.exec(path); - if (ios?.[1] !== undefined) { - found.add(ios[1]); + const segments = path.split("/"); + const app = segments[1]; + if ( + anatomyNameIs(segments[0] ?? "", "Payload") && + app !== undefined && + /\.app$/iu.test(app) + ) { + found.add(`${segments[0]}/${app}`); continue; } - const segments = path.split("/"); const index = segments.findIndex( (segment, position) => - /\.app$/iu.test(segment) && segments[position + 1] === "Contents", + /\.app$/iu.test(segment) && + anatomyNameIs(segments[position + 1] ?? "", "Contents"), ); if (index >= 0) found.add(segments.slice(0, index + 1).join("/")); } @@ -186,8 +235,8 @@ const enclosingBundle = ( const bundleLayout = (bundle: string, tree: Tree): Bundle["layout"] => { const names = tree.children.get(directoryOf(bundle)); - if (names?.has("Contents") === true) return "macos-deep"; - if (names?.has("Versions") === true) return "versioned-framework"; + if (anatomyChild(names, "Contents") !== null) return "macos-deep"; + if (anatomyChild(names, "Versions") !== null) return "versioned-framework"; return "shallow"; }; @@ -201,13 +250,18 @@ const bundleRole = (bundle: string): Bundle["role"] => { if (name.endsWith(".systemextension")) return "system-extension"; if (name.endsWith(".dext")) return "driver-extension"; if (name.endsWith(".bundle")) { - if (container === "PlugIns") return "plug-in"; - return container === "Resources" ? "resource-bundle" : "bundle"; + if (anatomyNameIs(container ?? "", "PlugIns")) return "plug-in"; + return anatomyNameIs(container ?? "", "Resources") + ? "resource-bundle" + : "bundle"; } if (!name.endsWith(".app")) return "plug-in"; - if (container === "LoginItems" && segments.at(-3) === "Library") + if ( + anatomyNameIs(container ?? "", "LoginItems") && + anatomyNameIs(segments.at(-3) ?? "", "Library") + ) return "login-item"; - if (container === "Helpers") return "helper-application"; + if (anatomyNameIs(container ?? "", "Helpers")) return "helper-application"; return "nested-application"; }; @@ -225,18 +279,32 @@ const contentDirectories = ( tree: Tree, ): string[] => { const directory = directoryOf(bundle); - if (layout === "macos-deep") return [joinPath(directory, "Contents")]; + if (layout === "macos-deep") { + const contents = namedChild(tree, directory, "Contents"); + return contents === null ? [] : [contents]; + } if (layout === "shallow") return [directory]; - const versions = joinPath(directory, "Versions"); + const versions = namedChild(tree, directory, "Versions"); + if (versions === null) return []; // Only real version directories count: Current is a symlink, and files // such as .DS_Store are not versions. return [...(tree.children.get(versions) ?? [])] - .filter((version) => version !== "Current") + .filter((version) => !anatomyNameIs(version, "Current")) .map((version) => joinPath(versions, version)) .filter((version) => tree.directories.has(version)) .sort(compare); }; +/** Child path using the archive's spelling of one anatomy name. */ +const namedChild = ( + tree: Tree, + directory: string, + expected: string, +): string | null => { + const name = anatomyChild(tree.children.get(directory), expected); + return name === null ? null : joinPath(directory, name); +}; + const infoPlistPath = ( bundle: string, layout: Bundle["layout"], @@ -246,17 +314,15 @@ const infoPlistPath = ( // With several real versions, Versions/Current (a symlink outside the // inventory) decides which plist applies, so the plist stays unknown. if (directories.length !== 1) return null; - const candidates = directories - .map((directory) => - joinPath( - directory, - layout === "versioned-framework" - ? "Resources/Info.plist" - : "Info.plist", - ), - ) - .filter((path) => tree.files.has(path)); - return candidates.length === 1 ? (candidates[0] ?? null) : null; + const directory = directories[0]; + if (directory === undefined) return null; + const parent = + layout === "versioned-framework" + ? namedChild(tree, directory, "Resources") + : directory; + if (parent === null) return null; + const info = namedChild(tree, parent, "Info.plist"); + return info !== null && tree.files.has(info) ? info : null; }; const executableCandidates = ( @@ -265,12 +331,15 @@ const executableCandidates = ( tree: Tree, ): string[] => contentDirectories(bundle, layout, tree) - .flatMap((directory) => - directFiles( - layout === "macos-deep" ? joinPath(directory, "MacOS") : directory, - tree, - ), - ) + .flatMap((directory) => { + const executableDirectory = + layout === "macos-deep" + ? namedChild(tree, directory, "MacOS") + : directory; + return executableDirectory === null + ? [] + : directFiles(executableDirectory, tree); + }) .filter((path) => MACH_O_FORMATS.includes(tree.files.get(path)?.format ?? ""), ); @@ -281,12 +350,17 @@ const signingPaths = ( tree: Tree, ): string[] => contentDirectories(bundle, layout, tree) - .flatMap((directory) => [ - joinPath(directory, "_CodeSignature/CodeResources"), - joinPath(directory, "embedded.provisionprofile"), - joinPath(directory, "embedded.mobileprovision"), - ]) - .filter((path) => tree.files.has(path)) + .flatMap((directory) => { + const signature = namedChild(tree, directory, "_CodeSignature"); + return [ + signature === null + ? null + : namedChild(tree, signature, "CodeResources"), + namedChild(tree, directory, "embedded.provisionprofile"), + namedChild(tree, directory, "embedded.mobileprovision"), + ]; + }) + .filter((path): path is string => path !== null && tree.files.has(path)) .sort(compare); /** Platforms of application roots whose layout was observed, not assumed. */ diff --git a/src/domain/artifactComparison.ts b/src/domain/artifactComparison.ts index 04bc33ec9f..7cdd419d29 100644 --- a/src/domain/artifactComparison.ts +++ b/src/domain/artifactComparison.ts @@ -13,6 +13,7 @@ import { } from "./artifactInventoryEvidence.js"; import { prefixedDigestSchema } from "./../domain/digests.js"; import { canonicalJson } from "./comparisonSemantics.js"; +import { ZIP_NON_ENTRY_TAIL_LIMITATION } from "./zipPackageFormat.js"; const evidenceIdSchema = prefixedDigestSchema("ev"); const comparisonStatusSchema = z.enum([ @@ -122,6 +123,11 @@ export const compareArtifacts = ( ], leftCovered && rightCovered, ); + const rootChange = rootHashChange(left.inventory, right.inventory, changes, [ + ...left.evidence.map(({ evidence_id: id }) => id), + ...right.evidence.map(({ evidence_id: id }) => id), + ]); + if (rootChange !== null) changes.push(rootChange); const summary = summarize( left.inventory, right.inventory, @@ -431,5 +437,38 @@ const summarize = ( const hasCoverageLimitation = (limitations: readonly string[]): boolean => limitations.some( (limitation) => + limitation !== ZIP_NON_ENTRY_TAIL_LIMITATION && !/integrity contradiction\(s\) were recorded/u.test(limitation), ); + +const rootHashChange = ( + left: InventorySet, + right: InventorySet, + changes: readonly ArtifactChange[], + evidenceLinks: readonly string[], +): ArtifactChange | null => { + if (left.manifest.root_sha256 === right.manifest.root_sha256) return null; + if ( + changes.some( + (change) => + change.logical_path === "." && change.dimensions.includes("content"), + ) + ) + return null; + const leftRoot = left.occurrences.find( + (occurrence) => occurrence.logical_path === ".", + ); + const rightRoot = right.occurrences.find( + (occurrence) => occurrence.logical_path === ".", + ); + return { + classification: "changed", + logical_path: ".", + dimensions: ["content"], + left_occurrence_id: leftRoot?.occurrence_id ?? null, + right_occurrence_id: rightRoot?.occurrence_id ?? null, + left_artifact_id: leftRoot?.artifact_id ?? null, + right_artifact_id: rightRoot?.artifact_id ?? null, + evidence_links: [...evidenceLinks], + }; +}; diff --git a/src/domain/artifactIdentity.ts b/src/domain/artifactIdentity.ts index 3c46a3b975..3107d9af0e 100644 --- a/src/domain/artifactIdentity.ts +++ b/src/domain/artifactIdentity.ts @@ -1,3 +1,5 @@ +import { posix } from "node:path"; + import { digestCanonicalValue } from "./canonicalDigest.js"; import type { ArtifactEdge, @@ -6,6 +8,29 @@ import type { IntegrityContradiction, } from "./artifactGraph.js"; +/** NFC spelling of a logical archive path. Inventory and path joins share it. */ +export const logicalPathNfc = (value: string): string => value.normalize("NFC"); + +/** + * Lexical join against an inventoried logical path. The result is NFC. + * `"."` is the archive root. A path that escapes the root, or that contains + * NUL, is left in a form the caller can report; `logicalPathEscapesRoot` + * is then true. + */ +export const normalizeJoinedLogicalPath = (value: string): string => { + if (value.includes("\0")) return value; + return logicalPathNfc( + posix.normalize(value.replaceAll("\\", "/")).replace(/^\.\//u, ""), + ); +}; + +/** Whether a joined logical path leaves the archive root. */ +export const logicalPathEscapesRoot = (path: string): boolean => + path.includes("\0") || + path === ".." || + path.startsWith("../") || + path.startsWith("/"); + /** Content-address an artifact independently of where it was observed. */ export const artifactIdForContent = (sha256: string): string => `art_${digestCanonicalValue({ sha256 }, "Artifact")}`; diff --git a/src/domain/artifactPathSyntax.ts b/src/domain/artifactPathSyntax.ts index 5641da1b7f..4884bd646c 100644 --- a/src/domain/artifactPathSyntax.ts +++ b/src/domain/artifactPathSyntax.ts @@ -6,7 +6,7 @@ * Policy divergence (deliberate, not yet unified): * - Local artifact paths resolve against an inventoried container root and * report rejected | external | not-found. Confinement is lexical - * (posix.normalize must stay inside the root). + * (normalizeJoinedLogicalPath must stay inside the root). * - Live DOM destinations resolve against the document base URL and report * approved | outside_policy | unsupported against allowedOrigins * (see CdpCaptureDocuments.domDestination). diff --git a/src/domain/firmware/firmwareAnalysis.ts b/src/domain/firmware/firmwareAnalysis.ts index 00f6675d00..a7cdb35506 100644 --- a/src/domain/firmware/firmwareAnalysis.ts +++ b/src/domain/firmware/firmwareAnalysis.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { isAbsolute } from "node:path"; import { digestSchema } from "../digests.js"; -import { jsonValueSchema } from "../jsonValue.js"; +import { jsonValueSchema, type JsonValue } from "../jsonValue.js"; import { localPathStringSchema } from "../localPath.js"; const bytes = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); @@ -95,6 +95,12 @@ const chunk = z.strictObject({ root_file_range: range.nullable(), }); +/** Completed firmware result retained when private-runtime cleanup cannot be confirmed. */ +export interface FirmwarePartialObservation { + readonly kind: "firmware"; + readonly result: JsonValue; +} + /** Portable results retain reported boundaries, unknowns and extraction lineage. */ export const firmwareResultSchemas = { inspect_firmware_regions: z.strictObject({ diff --git a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts index 64a47d0504..1f02c820dc 100644 --- a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts +++ b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts @@ -2,6 +2,10 @@ import { posix, relative, win32 } from "node:path"; import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js"; +import { + logicalPathEscapesRoot, + normalizeJoinedLogicalPath, +} from "../artifactIdentity.js"; import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { ParsedStaticLayer } from "./javascriptRuntimeReconciliationParsing.js"; @@ -174,7 +178,7 @@ const nodePaths = (node: ApplicationNode): string[] => { paths.push(observation.evidence.location.value.path); } return paths - .map(normalizeArtifactPath) + .map(relativeLogicalPath) .filter( (path): path is string => path !== null && path !== "artifact-root", ); @@ -221,7 +225,7 @@ const pathBelowRoot = (root: string, value: string): string | null => { portableRemainder.startsWith("../") ) return null; - return normalizeArtifactPath(portableRemainder); + return relativeLogicalPath(portableRemainder); }; const absoluteFilePathStyle = (path: string): "posix" | "windows" | null => { @@ -250,7 +254,7 @@ const pathBelowUrlPrefix = (prefix: string, value: string): string | null => { ) return null; try { - return normalizeArtifactPath( + return relativeLogicalPath( decodeURIComponent(candidate.pathname.slice(base.pathname.length)), ); } catch (cause: unknown) { @@ -292,17 +296,13 @@ const pathWithinPrefix = (path: string, prefix: string): boolean => prefix === "" || path === prefix || path.startsWith(`${prefix}/`); const safeArtifactPath = (prefix: string, suffix: string): string | null => - normalizeArtifactPath(prefix === "" ? suffix : posix.join(prefix, suffix)); + relativeLogicalPath(prefix === "" ? suffix : posix.join(prefix, suffix)); -const normalizeArtifactPath = (value: string): string | null => { - const normalized = posix - .normalize(value.replaceAll("\\", "/")) - .replace(/^\.\//u, ""); +const relativeLogicalPath = (value: string): string | null => { + const normalized = normalizeJoinedLogicalPath(value); return normalized !== "" && normalized !== "." && - normalized !== ".." && - !normalized.startsWith("/") && - !normalized.startsWith("../") + !logicalPathEscapesRoot(normalized) ? normalized : null; }; diff --git a/src/domain/nativeConvention.ts b/src/domain/nativeConvention.ts new file mode 100644 index 0000000000..448dcef003 --- /dev/null +++ b/src/domain/nativeConvention.ts @@ -0,0 +1,38 @@ +/** Path-segment conventions. These are inferences, not observations of runtime behavior. */ + +export type NativeRuntimeConvention = "react-native" | "flutter" | "unity"; + +const foldSegment = (segment: string): string => + segment.toLocaleLowerCase("en-US"); + +const REACT_NATIVE_SEGMENTS = new Set([ + "react.framework", + "libhermes.so", + "hermes.framework", +]); +const FLUTTER_SEGMENTS = new Set(["libflutter.so", "flutter.framework"]); +const UNITY_SEGMENTS = new Set(["libunity.so", "unityframework.framework"]); + +/** + * A runtime family named by one filename or directory segment. + * `reactnative` may occur inside a segment (`libreactnativejni.so`). + * The other names match the whole segment. + */ +export const nativeRuntimeConvention = ( + path: string, +): NativeRuntimeConvention | null => { + for (const segment of path.split("/")) { + const folded = foldSegment(segment); + if (folded.includes("reactnative") || REACT_NATIVE_SEGMENTS.has(folded)) + return "react-native"; + if (FLUTTER_SEGMENTS.has(folded)) return "flutter"; + if (UNITY_SEGMENTS.has(folded)) return "unity"; + } + return null; +}; + +/** Swift runtime libraries are `libswift*.dylib` segments, not every dynamic library. */ +export const isSwiftRuntimeLibrary = (path: string): boolean => + path + .split("/") + .some((segment) => /^libswift.+\.dylib$/u.test(foldSegment(segment))); diff --git a/src/domain/webScriptExportPlan.test.ts b/src/domain/webScriptExportPlan.test.ts index edfb2b0fcb..f0af5cddd0 100644 --- a/src/domain/webScriptExportPlan.test.ts +++ b/src/domain/webScriptExportPlan.test.ts @@ -46,7 +46,6 @@ describe("captured script path projection", () => { it.each([ ["https://a.test/main.js", "https://a.test/main.js"], ["https://a.test/main.js", "https://a.test/main.js?v=2"], - ["https://a.test/A/main.js", "https://a.test/a/other.js"], ["https://a.test/main.js", "https://a.test/main.js/child.js"], ])( "isolates every version in a conflicting layout (%s, %s)", @@ -99,6 +98,25 @@ describe("captured script path projection", () => { }, ); + it("keeps case-distinct URL paths until the destination collides", () => { + const planned = planWebScriptExport([ + source("https://a.test/A/main.js"), + source("https://a.test/a/other.js", 2), + ]); + expect( + planned.every( + ({ record }) => + record.content.state === "exported" && + record.content.layout === "url-path", + ), + ).toBe(true); + const paths = planned.map(({ record }) => + record.content.state === "exported" ? record.content.relative_path : "", + ); + expect(paths[0]).toMatch(/\/A\/main\.js$/u); + expect(paths[1]).toMatch(/\/a\/other\.js$/u); + }); + it("does not choose a canonical version when a competing source is unavailable", () => { const missing: CapturedWebScript = { ...source("https://a.test/main.js?v=2", 2), diff --git a/src/domain/webScriptExportPlan.ts b/src/domain/webScriptExportPlan.ts index 72ba2ebb8b..e4c0aed4b0 100644 --- a/src/domain/webScriptExportPlan.ts +++ b/src/domain/webScriptExportPlan.ts @@ -34,7 +34,7 @@ export const planWebScriptExport = ( }; const candidate = candidates[index]; const reason = conflicts.has(index) - ? "Captured paths collide by content version, case, or file/directory prefix." + ? "Captured paths collide by content version or file/directory prefix." : (candidate?.reason ?? null); const path = reason === null ? candidate?.path : undefined; return { @@ -103,40 +103,27 @@ const candidatePath = (script: CapturedWebScript): CandidatePath => { }; }; -// Register every prefix so case collisions in parent directories and a file -// conflicting with a directory isolate all affected sources, not just the last. +// Register every exact prefix so a repeated path, or a file that is also a +// directory prefix, isolates every affected source. Case-distinct spellings +// stay distinct until the destination filesystem reports a collision. const conflictingPaths = ( candidates: readonly CandidatePath[], ): Set => { - const prefixes = new Map< - string, - { - spellings: Set; - indices: number[]; - files: number[]; - } - >(); + const prefixes = new Map(); candidates.forEach(({ path }, index) => { if (path === undefined) return; const parts = path.split("/"); parts.forEach((_, position) => { const spelling = parts.slice(0, position + 1).join("/"); - const key = spelling.toLowerCase(); - const entry = prefixes.get(key) ?? { - spellings: new Set(), - indices: [], - files: [], - }; - entry.spellings.add(spelling); + const entry = prefixes.get(spelling) ?? { indices: [], files: [] }; entry.indices.push(index); if (position === parts.length - 1) entry.files.push(index); - prefixes.set(key, entry); + prefixes.set(spelling, entry); }); }); const conflicts = new Set(); for (const entry of prefixes.values()) if ( - entry.spellings.size > 1 || entry.files.length > 1 || (entry.files.length > 0 && entry.indices.length > entry.files.length) ) diff --git a/src/domain/zipPackageFormat.ts b/src/domain/zipPackageFormat.ts index 39a8f86a58..1544084e9f 100644 --- a/src/domain/zipPackageFormat.ts +++ b/src/domain/zipPackageFormat.ts @@ -28,3 +28,7 @@ export const zipPackageFormatForPath = ( if (lower.endsWith(".appx") || lower.endsWith(".appxbundle")) return "appx"; return undefined; }; + +/** Inventory cannot see bytes that are not central-directory entries. */ +export const ZIP_NON_ENTRY_TAIL_LIMITATION = + "ZIP inventory lists central-directory entries only. Bytes after the central directory, including an APK Signing Block or a ZIP comment, are not occurrences."; diff --git a/src/firmware/FirmwareProvider.test.ts b/src/firmware/FirmwareProvider.test.ts new file mode 100644 index 0000000000..0f3bb0b61f --- /dev/null +++ b/src/firmware/FirmwareProvider.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; + +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { err, ok } from "../domain/result.js"; +import { firmwareRequestSchema } from "../domain/firmware/firmwareAnalysis.js"; +import { finishFirmwareWorkspace } from "./FirmwareProvider.js"; + +const request = firmwareRequestSchema.parse({ + operation: "extract_firmware", + input: { + path: "/tmp/firmware.bin", + output_directory: "/tmp/extracted", + }, +}); + +const execution = ok({ + result: { files: ["kept"] }, + rawResult: null, + provider: { id: "unblob", name: "unblob", version: "1" }, + limitations: [], + locations: [], + subject: null, +}); + +describe("firmware workspace cleanup", () => { + it("returns the completed extraction beside a cleanup failure and does not delete the leaked root", async () => { + const close = vi.fn(async () => { + throw new Error("cleanup failed"); + }); + const finished = await finishFirmwareWorkspace( + { path: "/tmp/runtime", close }, + execution, + "unblob", + request, + ); + expect(close).toHaveBeenCalledOnce(); + if (finished.ok || !(finished.error instanceof ProviderAdapterError)) + throw new Error("Expected cleanup failure"); + expect(finished.error.cleanupResources).toEqual([ + "/tmp/runtime", + "/tmp/extracted", + ]); + expect(finished.error.partialObservation).toEqual({ + kind: "firmware", + result: { files: ["kept"] }, + }); + expect(finished.error.diagnostics).toEqual({ reason: "cleanup failed" }); + expect(projectAnalysisError(finished.error).details).toMatchObject({ + partial_observation: { + kind: "firmware", + result: { files: ["kept"] }, + }, + }); + }); + + it("does not replay a cleanup failure for a later workspace", async () => { + const failed = await finishFirmwareWorkspace( + { + path: "/tmp/runtime", + close: async () => { + throw new Error("cleanup failed"); + }, + }, + execution, + "unblob", + request, + ); + expect(failed.ok).toBe(false); + const next = await finishFirmwareWorkspace( + { path: "/tmp/next", close: async () => undefined }, + execution, + "unblob", + request, + ); + expect(next).toBe(execution); + }); + + it("leaves an uncertain workspace recorded and does not remove it", async () => { + const close = vi.fn(async () => undefined); + const cleanup = new ProviderCleanupError( + "unblob", + ["/tmp/runtime"], + { reason: "ownership uncertain" }, + { operation: "extract_firmware" }, + ); + const outcome = err(cleanup); + const finished = await finishFirmwareWorkspace( + { path: "/tmp/runtime", close }, + outcome, + "unblob", + request, + ); + expect(close).not.toHaveBeenCalled(); + expect(finished).toBe(outcome); + }); +}); diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index a0d7dfa505..a8b3bf560f 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -65,7 +65,6 @@ const limitations = [ /** Queued bring-your-own firmware adapters with private snapshots and owned cleanup. */ export class FirmwareProvider implements FirmwareAnalysisPort { #tail: Promise = Promise.resolve(); - #cleanupFailure: AnalysisError | undefined; constructor( readonly environment: Readonly< Record @@ -115,7 +114,6 @@ export class FirmwareProvider implements FirmwareAnalysisPort { : AbortSignal.any([options.signal, deadline]); let outcome: Outcome; try { - if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure); if (signal.aborted) throw new AnalysisCancelledError(request.operation); const command = await resolveFirmwareCommand( this.environment, @@ -316,29 +314,49 @@ export class FirmwareProvider implements FirmwareAnalysisPort { }, }), ); - if (!outcome.ok && outcome.error.cleanupIncomplete) { - this.#cleanupFailure = - root === undefined || - outcome.error.cleanupResources.includes(root.path) - ? outcome.error - : new ProviderCleanupError( - engineName, - [...outcome.error.cleanupResources, root.path], - outcome.error instanceof ProviderAdapterError - ? (outcome.error.diagnostics ?? { - reason: outcome.error.message, - }) - : { reason: outcome.error.message }, - { cause: outcome.error }, - ); - } } // Never remove a workspace while process ownership remains uncertain. - if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure); - try { - await root?.close(); - } catch (cause: unknown) { - this.#cleanupFailure = new ProviderCleanupError( + // A leaked root stays recorded on this result and is not deleted. The next + // operation may create a new root; this failure is not replayed for it. + return finishFirmwareWorkspace(root, outcome, engineName, request); + } +} + +/** Keep a completed extraction beside cleanup failure, or skip removal when ownership is uncertain. */ +export const finishFirmwareWorkspace = async ( + root: { readonly path: string; close(): Promise } | undefined, + outcome: Outcome, + engineName: string, + request: FirmwareRequest, +): Promise => { + if (!outcome.ok && outcome.error.cleanupIncomplete) { + if ( + root === undefined || + outcome.error.cleanupResources.includes(root.path) + ) + return outcome; + return err( + new ProviderCleanupError( + engineName, + [...outcome.error.cleanupResources, root.path], + outcome.error instanceof ProviderAdapterError + ? (outcome.error.diagnostics ?? { reason: outcome.error.message }) + : { reason: outcome.error.message }, + { + cause: outcome.error, + operation: request.operation, + ...(outcome.error.partialObservation === undefined + ? {} + : { partialObservation: outcome.error.partialObservation }), + }, + ), + ); + } + try { + await root?.close(); + } catch (cause: unknown) { + return err( + new ProviderCleanupError( engineName, [ root?.path ?? "unknown", @@ -346,13 +364,23 @@ export class FirmwareProvider implements FirmwareAnalysisPort { ? [request.input.output_directory] : []), ], + { reason: cause instanceof Error ? cause.message : String(cause) }, { - reason: cause instanceof Error ? cause.message : String(cause), - previous_result: outcome.ok ? outcome.value.result : null, + cause, + operation: request.operation, + ...(outcome.ok + ? { + partialObservation: { + kind: "firmware", + result: outcome.value.result, + }, + } + : outcome.error.partialObservation === undefined + ? {} + : { partialObservation: outcome.error.partialObservation }), }, - ); - return err(this.#cleanupFailure); - } - return outcome; + ), + ); } -} + return outcome; +}; diff --git a/src/native/AppleDispatchCoverage.ts b/src/native/AppleDispatchCoverage.ts index 746e099be0..c49e35acc8 100644 --- a/src/native/AppleDispatchCoverage.ts +++ b/src/native/AppleDispatchCoverage.ts @@ -4,11 +4,19 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; import type { FacetDecodeFacts } from "./AppleDispatchDecodeFacts.js"; const describeFixups = (fixups: PointerFixups): string => { - if (fixups.kind === "chained") - return `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}`; - return fixups.kind === "dyld-info" - ? "LC_DYLD_INFO bind opcodes" - : "no fixup load commands"; + const unread = + fixups.unreadRebaseBytes > 0 + ? `LC_DYLD_INFO rebase opcodes were not decoded (${fixups.unreadRebaseBytes} bytes)` + : undefined; + const kind = + fixups.kind === "chained" + ? `chained fixups: ${fixups.formats.join(", ") || "no fixup segments"}` + : fixups.kind === "dyld-info" + ? "LC_DYLD_INFO bind opcodes" + : unread === undefined + ? "no fixup load commands" + : undefined; + return [unread, kind].filter((line) => line !== undefined).join("; "); }; /** Append the per-facet coverage of one Apple dispatch metadata decode. */ @@ -83,18 +91,26 @@ export const pushDispatchCoverage = (input: { ({ decode }) => decode.status === "decoded", ).length, }); + const fixupReason = [ + describeFixups(fixups), + ...fixups.issues.map( + ({ code, location, message }) => + `${code}${location === undefined ? "" : ` at ${location}`}: ${message}`, + ), + ] + .filter((line) => line.length > 0) + .join("; "); result.coverage.push({ facet: "pointer_fixups", - status: fixups.issues.length > 0 ? "partial" : "complete", - reason: [ - describeFixups(fixups), - ...fixups.issues.map( - ({ code, location, message }) => - `${code}${location === undefined ? "" : ` at ${location}`}: ${message}`, - ), - ].join("; "), - examined: 0, - decoded: 0, + status: + fixups.issues.length > 0 || fixups.unreadRebaseBytes > 0 + ? "partial" + : fixups.examined === 0 + ? "unsupported" + : "complete", + reason: fixupReason.length > 0 ? fixupReason : null, + examined: fixups.examined, + decoded: fixups.issues.length > 0 ? 0 : fixups.examined, }); result.coverage.push({ facet: "swift_generic_resilient_witnesses_overrides_async_coroutines", diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index b0758748e7..927d984950 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -354,7 +354,7 @@ describe("byte-swapped universal Apple dispatch metadata", () => { const bytes = wrapped(false); bytes.writeUInt32BE(0xfeedface, 256); expect(() => decodeAppleDispatchMetadata(bytes, 100, provenance)).toThrow( - "Only little-endian 64-bit Mach-O metadata is supported", + "Unsupported 32-bit Mach-O metadata for requested CPU type arm64 (FAT header byte order: little-endian)", ); }); diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index d6c56e7b91..0260d9eab2 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -149,13 +149,37 @@ describe("pointer fixup tables", () => { data.writeUInt16LE(99, 46); const fixups = parsePointerFixups( data, - { chained: { offset: 0, size: data.length }, binds: [], dylibs: [] }, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [], + dylibs: [], + }, SEGMENTS, BASE, ); expect(fixups.failures).toContain("Unsupported chained pointer format 99"); }); + it("keeps unread LC_DYLD_INFO rebase bytes when chained fixups are present", () => { + const data = chainedData(1); + const fixups = parsePointerFixups( + data, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [{ offset: 8, size: 12 }], + dylibs: [], + }, + SEGMENTS, + BASE, + ); + expect(fixups).toMatchObject({ + kind: "chained", + unreadRebaseBytes: 12, + }); + }); + it("does not decode unreferenced imports while constructing fixup facts", () => { const data = chainedData(1); // The second import is outside the table bytes; only the first is needed @@ -164,7 +188,12 @@ describe("pointer fixup tables", () => { expect(() => parsePointerFixups( data, - { chained: { offset: 0, size: data.length }, binds: [], dylibs: [] }, + { + chained: { offset: 0, size: data.length }, + binds: [], + rebases: [], + dylibs: [], + }, SEGMENTS, BASE, ), @@ -179,6 +208,7 @@ describe("pointer fixup tables", () => { { chained: { offset: 0, size: data.length }, binds: [], + rebases: [], dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"], }, SEGMENTS, @@ -241,6 +271,7 @@ describe("pointer fixup tables", () => { { offset: 0, size: opcodes.length, stream: "bind" }, { offset: opcodes.length, size: lazy.length, stream: "lazy" }, ], + rebases: [], dylibs: ["/usr/lib/libobjc.A.dylib", "/usr/lib/libSystem.B.dylib"], }, SEGMENTS, diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 4c7686790b..1422ff8c72 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -6,6 +6,11 @@ import type { DecodeIssue } from "./AppleDispatchDecodeFacts.js"; export interface FixupCommands { /** Absolute file range of `LC_DYLD_CHAINED_FIXUPS` data. */ readonly chained: { readonly offset: number; readonly size: number } | null; + /** Absolute file ranges of `LC_DYLD_INFO(_ONLY)` rebase streams. */ + readonly rebases: readonly { + readonly offset: number; + readonly size: number; + }[]; /** Absolute file ranges of `LC_DYLD_INFO(_ONLY)` bind streams. */ readonly binds: readonly { readonly offset: number; @@ -35,6 +40,10 @@ export interface PointerFixups { readonly formats: readonly string[]; readonly failures: readonly string[]; readonly issues: readonly DecodeIssue[]; + /** Fixup segments or bind streams this reader walked. */ + readonly examined: number; + /** Rebase opcode bytes present in the load command and not decoded. */ + readonly unreadRebaseBytes: number; /** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */ decode(address: bigint, raw: bigint): DecodedPointer; } @@ -250,6 +259,8 @@ const chainedFixups = ( formats, failures: issues.map(({ message }) => message), issues, + examined: Math.max(ranges.length, 1), + unreadRebaseBytes: 0, decode(address, raw) { const segment = ranges.find( ({ from, to }) => address >= from && address < to, @@ -420,13 +431,24 @@ export const parsePointerFixups = ( baseAddress: bigint, ): PointerFixups => { const image = { bytes, segments, dylibs: commands.dylibs, baseAddress }; - if (commands.chained !== null) return chainedFixups(image, commands.chained); + const unreadRebaseBytes = commands.rebases.reduce( + (total, rebase) => total + rebase.size, + 0, + ); + if (commands.chained !== null) { + const chained = chainedFixups(image, commands.chained); + return unreadRebaseBytes === 0 + ? chained + : { ...chained, unreadRebaseBytes }; + } if (commands.binds.length === 0) return { kind: "none", formats: [], failures: [], issues: [], + examined: 0, + unreadRebaseBytes, decode: (_address, raw) => ({ kind: "rebase", target: raw }), }; const binds = new Map(); @@ -446,6 +468,8 @@ export const parsePointerFixups = ( issues: failures.map((message) => issue("pointer_fixups", "bind_stream_decode_failed", message), ), + examined: commands.binds.length, + unreadRebaseBytes, decode(address, raw) { const bound = binds.get(address); return bound === undefined diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 5c4da8d006..7344b1b6ad 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -16,6 +16,27 @@ describe("native Mach-O slice selection", () => { expect(parseMachoLayout(Buffer.from(fat), "arm64").segments).toEqual([]); }); + it("selects only the admitted CPU type and rejects a 32-bit slice", () => { + const sixtyFour = fatImage([ + { cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) }, + ]); + expect(() => parseMachoLayout(Buffer.from(sixtyFour), "x86")).toThrow( + /Requested FAT architecture is absent/u, + ); + expect(() => parseMachoLayout(Buffer.from(sixtyFour), "arm")).toThrow( + /Requested FAT architecture is absent/u, + ); + const thirtyTwo = fatImage([ + { + cpu: CPU.i386, + bytes: machoImage({ cpu: CPU.i386, wide: false }), + }, + ]); + expect(() => parseMachoLayout(Buffer.from(thirtyTwo), "x86")).toThrow( + /Unsupported 32-bit Mach-O metadata for requested CPU type x86/u, + ); + }); + it("rejects FAT subtype disagreement and unaligned slice offsets", () => { const original = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]); const mismatched = original.slice(); diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index ced43f164d..bfc7a37d6c 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -90,7 +90,8 @@ export const selectMachoSlice = ( const headerEnd = 8 + count * stride; if (count > 128 || headerEnd > bytes.length) throw malformed("Malformed FAT architecture table"); - const cpu = architecture === "arm64" ? 0x0100000c : 0x01000007; + const cpu = MACHO_CPU_TYPE[architecture]; + if (cpu === undefined) throw invalid("Requested FAT architecture is absent"); const declarations = readFatSliceDeclarations(bytes.subarray(8, headerEnd), { count, wide: fat64, @@ -115,9 +116,26 @@ export const selectMachoSlice = ( } if (selected === undefined) throw invalid("Requested FAT architecture is absent"); + if (selected.slice + 4 > bytes.length) + throw malformed("Truncated Mach-O header"); + const sliceMagic = bytes.readUInt32LE(selected.slice); + if (sliceMagic === MH_MAGIC || sliceMagic === MH_CIGAM) + throw invalid( + `Unsupported 32-bit Mach-O metadata for requested CPU type ${architecture}`, + ); return selected; }; +/** CPU types admitted by `machArchitecture` in the domain binary target parser. */ +const MACHO_CPU_TYPE: Readonly> = { + x86: 7, + x86_64: 0x01000007, + arm: 12, + arm64: 0x0100000c, +}; +const MH_MAGIC = 0xfeedface; +const MH_CIGAM = 0xcefaedfe; + /** Parse segments/sections for a little-endian 64-bit Mach-O architecture slice. */ export const parseMachoLayout = ( bytes: Buffer, @@ -151,6 +169,7 @@ export const parseMachoLayout = ( sections: [], dylibs: [], binds: [], + rebases: [], chained: null, baseAddress: undefined, }; @@ -168,7 +187,7 @@ export const parseMachoLayout = ( else collectFixupCommand(bytes, command, state); cursor += command.size; } - const { segments, sections, chained, binds, dylibs } = state; + const { segments, sections, chained, binds, rebases, dylibs } = state; const offset = (address: bigint, size = 1): number => { const matches = segments.filter( (segment) => @@ -187,7 +206,7 @@ export const parseMachoLayout = ( segments, sections, offset, - fixups: { chained, binds, dylibs }, + fixups: { chained, binds, rebases, dylibs }, baseAddress: state.baseAddress ?? 0n, }; }; @@ -200,6 +219,7 @@ interface LayoutState { readonly sections: Section[]; readonly dylibs: string[]; readonly binds: FixupCommands["binds"][number][]; + readonly rebases: FixupCommands["rebases"][number][]; chained: FixupCommands["chained"]; baseAddress: bigint | undefined; } @@ -273,6 +293,8 @@ const collectFixupCommand = ( state.chained = range(cursor + 8); } else if (kind === LC_DYLD_INFO || kind === LC_DYLD_INFO_ONLY) { if (size < 48) throw new RangeError("Truncated dyld-info command"); + const rebase = range(cursor + 8); + if (rebase.size > 0) state.rebases.push(rebase); for (const [at, stream] of [ [cursor + 16, "bind"], [cursor + 24, "weak"], diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 782e093499..dbb1c12b51 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -66,6 +66,8 @@ describe("Swift vtable coverage facts: unresolved Swift dispatch slots", () => { failures: [], issues: [], decode: (_address, raw) => ({ kind: "rebase", target: raw }), + examined: 0, + unreadRebaseBytes: 0, }, swift: { facet: "swift_conformances_static_witness_slots", @@ -178,6 +180,8 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () = kind: "unsupported", reason: "Unsupported chained pointer format 99", }), + examined: 0, + unreadRebaseBytes: 0, }, swift: { facet: "swift_conformances_static_witness_slots", diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts new file mode 100644 index 0000000000..939f610995 --- /dev/null +++ b/src/native/NativeMachoInspection.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { err, ok } from "../domain/result.js"; +import type { NativeCommandCapture } from "./CommandRunner.js"; +import { inspectNativeMacho } from "./NativeMachoInspection.js"; + +const capture = (tool: string, stdout: string): NativeCommandCapture => ({ + tool, + executable: tool, + executableSha256: "a".repeat(64), + toolVersion: null, + versionReason: null, + arguments: [], + stdout, + stderr: "", + stdoutBytes: stdout.length, + stderrBytes: 0, + exitCode: 0, + signal: null, +}); + +const inspect = async (lipo: string, architecture: "x86" | "arm64") => { + const commands: { + readonly tool: string; + readonly args: readonly string[]; + }[] = []; + const result = await inspectNativeMacho({ + target: { + kind: "executable", + format: "mach-o", + path: "/fixture/app", + sha256: "b".repeat(64), + architecture, + availableArchitectures: ["x86", "arm64"], + }, + run: (tool, args) => { + commands.push({ tool, args }); + if (tool === "lipo") return Promise.resolve(ok(capture(tool, lipo))); + if (tool === "file") return Promise.resolve(ok(capture(tool, "Mach-O"))); + return Promise.resolve(err(new AnalysisCancelledError("inspect_native"))); + }, + invocation: () => { + throw new Error("invocation is not reached"); + }, + }); + return { result, commands }; +}; + +describe("Mach-O tool architecture selection", () => { + it("rejects a CPU lipo did not list instead of reading another slice", async () => { + const { result, commands } = await inspect("architecture arm64\n", "x86"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ + _tag: "AnalysisOutputError", + reason: "lipo did not list i386; refusing to read another slice", + }); + expect(commands.map(({ tool }) => tool)).toEqual(["file", "lipo"]); + }); + + it("passes the arm64e slice lipo listed for an arm64 target", async () => { + const { commands } = await inspect("architecture arm64e\n", "arm64"); + const otool = commands.find(({ tool }) => tool === "otool"); + expect(otool?.args).toEqual([ + "-h", + "-l", + "-arch", + "arm64e", + "/fixture/app", + ]); + }); +}); diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 2ef2c95ae9..d8fee75721 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -1,6 +1,7 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import type { EvidenceLocation } from "../domain/evidence.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisOutputError } from "../domain/analysisErrorCore.js"; import { inspectMachoSchema, type NativeCommandInvocation, @@ -65,10 +66,19 @@ export const inspectNativeMacho = async ( } const lipo = captures.find(({ tool }) => tool === "lipo"); if (lipo === undefined) throw new TypeError("Missing lipo capture"); - const selectedArchitecture = selectArchitecture( - context.target, - parseLipoArchitectures(lipo.stdout).map(({ name }) => name), + const availableArchitectures = parseLipoArchitectures(lipo.stdout).map( + ({ name }) => name, ); + const selection = selectArchitecture(context.target, availableArchitectures); + if (selection.status === "absent") + return err( + new AnalysisOutputError( + "inspect_macho", + `lipo did not list ${selection.architecture}; refusing to read another slice`, + ), + ); + const selectedArchitecture = + selection.status === "selected" ? selection.architecture : null; for (const [tool, prefix] of REQUIRED_SLICE_COMMANDS) { const captured = await context.run( tool, @@ -339,24 +349,32 @@ const selectedArchitectureArguments = ( : ["-arch", toolArchitecture]; }; +type ArchitectureSelection = + | { readonly status: "unfiltered" } + | { readonly status: "selected"; readonly architecture: string } + | { readonly status: "absent"; readonly architecture: string }; + const selectArchitecture = ( target: BinaryTarget, available: readonly string[], -): string | null => { +): ArchitectureSelection => { if (target.kind !== "executable" || target.availableArchitectures.length < 2) - return null; + return { status: "unfiltered" }; const normalized = target.architecture === "x86" ? "i386" : target.architecture; - if (available.includes(normalized)) return normalized; + if (available.includes(normalized)) + return { status: "selected", architecture: normalized }; // BinaryTarget intentionally normalizes ARM64 CPU subtypes. Recover the // concrete arm64e slice name from lipo before invoking slice-aware tools. if (normalized === "arm64") { const arm64e = available.find((architecture) => architecture === "arm64e"); - if (arm64e !== undefined) return arm64e; + if (arm64e !== undefined) + return { status: "selected", architecture: arm64e }; const variant = available.find((architecture) => /^arm64e\.[A-Za-z0-9_]+$/u.test(architecture), ); - if (variant !== undefined) return variant; + if (variant !== undefined) + return { status: "selected", architecture: variant }; } - return normalized; + return { status: "absent", architecture: normalized }; }; diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index 9a3bb923fb..fcf23e6ef6 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -1,4 +1,11 @@ -import { mkdtemp, rm, symlink } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it } from "vitest"; @@ -21,6 +28,38 @@ it("preserves the caller-selected executable symlink for process invocation", as } }); +it("resolves a Windows executable through the canonical PATH spelling", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-process-path-")); + try { + const selected = join(root, "selected"); + const ambient = join(root, "ambient"); + await mkdir(selected); + await mkdir(ambient); + await writeFile(join(selected, "tool.exe"), ""); + await chmod(join(selected, "tool.exe"), 0o644); + const scenario = parseProcessScenario({ + executable: "tool.exe", + working_directory: root, + environment: { Path: selected }, + }); + const candidate = join(selected, "tool.exe"); + try { + const resolved = await resolveProcessScenarioRuntimePaths( + scenario, + { PATH: ambient }, + "win32", + ); + expect(resolved.executable).toBe(candidate); + } catch (cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + expect(message).toContain(candidate); + expect(message).not.toContain(ambient); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + it("retains the selected missing working directory and OS reason", async () => { const selected = join(tmpdir(), "rea-process-missing-cwd-unique"); const scenario = parseProcessScenario({ diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index 23444b1337..5196005b2e 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import type { ProcessScenario } from "../../domain/process/processScenario.js"; +import { snapshotEnvironment } from "../snapshotEnvironment.js"; import { ProcessCaptureError, processCaptureCancelled, @@ -12,6 +13,7 @@ import { export const resolveProcessScenarioRuntimePaths = async ( scenario: ProcessScenario, environment: Readonly> = process.env, + platform: NodeJS.Platform = process.platform, ): Promise => { const selectedWorkingDirectory = resolve(scenario.working_directory); let workingDirectory: string; @@ -39,7 +41,9 @@ export const resolveProcessScenarioRuntimePaths = async ( const executable = await resolveExecutable( scenario.executable, workingDirectory, - scenario.environment.PATH ?? environment.PATH ?? "", + snapshotEnvironment(scenario.environment, platform).PATH ?? + snapshotEnvironment(environment, platform).PATH ?? + "", ); const observationPaths = scenario.filesystem_observation_paths.map((path) => resolve(workingDirectory, path), diff --git a/src/windows/WindowsPrivateRuntime.ts b/src/windows/WindowsPrivateRuntime.ts index 24f8767466..e17544a499 100644 --- a/src/windows/WindowsPrivateRuntime.ts +++ b/src/windows/WindowsPrivateRuntime.ts @@ -44,7 +44,7 @@ export class WindowsPrivateRuntime { authority.call("runtime_create", [parent, prefix]), ); const runtime = new WindowsPrivateRuntime(observation, authority); - runtimes.set(observation.path.toLowerCase(), runtime); + runtimes.set(windowsRuntimeIdentityKey(observation), runtime); return runtime; } @@ -142,16 +142,30 @@ export class WindowsPrivateRuntime { await this.#pending?.catch(() => undefined); this.authority.call("runtime_close", [this.observation.handle]); this.#closed = true; - runtimes.delete(this.observation.path.toLowerCase()); + runtimes.delete(windowsRuntimeIdentityKey(this.observation)); } } -/** Select an already-owned runtime; an arbitrary pathname cannot grant authority. */ +/** File id returned by the OS. Path spelling is not part of the lease key. */ +const windowsRuntimeIdentityKey = (identity: { + readonly volumeSerial: string; + readonly fileId: string; +}): string => `${identity.volumeSerial}:${identity.fileId}`; + +/** + * Select an already-owned runtime. Only the observed `path` or `finalPath` + * matches, character for character. A case variant fails closed. + */ export const windowsPrivateRuntime = ( rootPath: string, ): WindowsPrivateRuntime => { - const runtime = runtimes.get(rootPath.toLowerCase()); - if (runtime === undefined) + const matches = [...runtimes.values()].filter( + (runtime) => + runtime.observation.finalPath === rootPath || + runtime.observation.path === rootPath, + ); + const runtime = matches[0]; + if (matches.length !== 1 || runtime === undefined) throw new Error(`No native private runtime owns ${rootPath}`); return runtime; }; diff --git a/tests/boundary/filesystem/zipDetection.test.ts b/tests/boundary/filesystem/zipDetection.test.ts index 3e1878db4d..07336e61a3 100644 --- a/tests/boundary/filesystem/zipDetection.test.ts +++ b/tests/boundary/filesystem/zipDetection.test.ts @@ -33,6 +33,80 @@ describe("ZIP signature detection", () => { }, ); + it("uses ZIP magic before the filename suffix", async () => { + const root = await createTestTempDirectory("rea-zip-suffix-"); + const apk = join(root, "renamed.apk"); + const program = join(root, "program.apk"); + await writeFile(apk, Buffer.from([0x50, 0x4b, 0x03, 0x04])); + await writeFile(program, Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + expect( + (await classifyAndHashRoot(apk, false, await lstat(apk))).format, + ).toBe("apk"); + expect( + (await classifyAndHashRoot(program, false, await lstat(program))).format, + ).toBe("elf"); + }); + + it("requires container magic for asar, pkg, and dmg suffixes", async () => { + const root = await createTestTempDirectory("rea-container-magic-"); + const asar = join(root, "app.asar"); + const textAsar = join(root, "notes.asar"); + const pkg = join(root, "install.pkg"); + const textPkg = join(root, "notes.pkg"); + const dmg = join(root, "image.dmg"); + const textDmg = join(root, "notes.dmg"); + const headerSize = 20; + await writeFile( + asar, + Buffer.from([ + 4, + 0, + 0, + 0, + headerSize, + 0, + 0, + 0, + headerSize - 4, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0x7b, + 0x7d, + ]), + ); + await writeFile(textAsar, Buffer.from("not an asar")); + await writeFile(pkg, Buffer.from("xar!")); + await writeFile(textPkg, Buffer.from("not a pkg")); + const image = Buffer.alloc(512); + image.write("koly", 0, "ascii"); + await writeFile(dmg, image); + await writeFile(textDmg, Buffer.from("not a dmg")); + expect( + (await classifyAndHashRoot(asar, false, await lstat(asar))).format, + ).toBe("asar"); + expect( + (await classifyAndHashRoot(textAsar, false, await lstat(textAsar))) + .format, + ).toBe("file"); + expect( + (await classifyAndHashRoot(pkg, false, await lstat(pkg))).format, + ).toBe("pkg"); + expect( + (await classifyAndHashRoot(textPkg, false, await lstat(textPkg))).format, + ).toBe("file"); + expect( + (await classifyAndHashRoot(dmg, false, await lstat(dmg))).format, + ).toBe("dmg"); + expect( + (await classifyAndHashRoot(textDmg, false, await lstat(textDmg))).format, + ).toBe("file"); + }); + it.each([[], [0x50], [0x50, 0x4b], [0x50, 0x4b, 0x03]])( "rejects an incomplete signature %j", async (...bytes) => { diff --git a/tests/boundary/firmware/firmwareIntegration.test.ts b/tests/boundary/firmware/firmwareIntegration.test.ts index 2a2e9dff88..3924f37aba 100644 --- a/tests/boundary/firmware/firmwareIntegration.test.ts +++ b/tests/boundary/firmware/firmwareIntegration.test.ts @@ -324,7 +324,7 @@ it("cancels a waiting request promptly while keeping later launches behind the a await assertFirmwareCleanup(fixture.launches); }); -it("retains an uncertain workspace and blocks new launches after cleanup failure", async () => { +it("retains an uncertain workspace and lets the next operation create a new root", async () => { const fixture = await firmwareFixture("cleanup-failure"); const first = await fixture.service.execute("inspect_firmware_regions", { path: fixture.path, @@ -335,12 +335,18 @@ it("retains an uncertain workspace and blocks new launches after cleanup failure }); const workspace = fixture.launches[0]?.cwd; expect(workspace).toBeDefined(); - if (workspace !== undefined) await access(workspace); + if (workspace === undefined) return; + await access(workspace); const next = await fixture.service.execute("inspect_firmware_regions", { path: fixture.path, }); expect(next).toMatchObject({ ok: false, error: { cleanupIncomplete: true } }); - expect(fixture.launches).toHaveLength(1); + expect(fixture.launches).toHaveLength(2); + const nextWorkspace = fixture.launches[1]?.cwd; + expect(nextWorkspace).toBeDefined(); + expect(nextWorkspace).not.toBe(workspace); + await access(workspace); + if (nextWorkspace !== undefined) await access(nextWorkspace); }); it("accepts another build on the verified release line and reports it", async () => { diff --git a/tests/composition/android/application.test.ts b/tests/composition/android/application.test.ts index 3529dc4e85..67e2e1c6fd 100644 --- a/tests/composition/android/application.test.ts +++ b/tests/composition/android/application.test.ts @@ -37,12 +37,14 @@ describe("Android application projection", () => { Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]), ), ); - await writer.add( - "lib/arm64-v8a/libreactnativejni.so", + const elf = () => new Uint8ArrayReader( Uint8Array.from([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1, 0]), - ), - ); + ); + await writer.add("lib/arm64-v8a/libreactnativejni.so", elf()); + await writer.add("lib/arm64-v8a/libflutter.so", elf()); + await writer.add("lib/arm64-v8a/libunity.so", elf()); + await writer.add("lib/arm64-v8a/libcommunity.so", elf()); await writer.add("assets/index.js", new TextReader("bridge();")); await writer.add("META-INF/FIXTURE.RSA", new TextReader("opaque signing")); await writeFile(path, await writer.close()); @@ -72,20 +74,34 @@ describe("Android application projection", () => { }); expect(left.components.manifests).toHaveLength(1); expect(left.components.dex).toHaveLength(1); - expect(left.components.native_libraries).toHaveLength(1); + expect(left.components.native_libraries).toHaveLength(4); expect(left.components.javascript).toHaveLength(1); expect(left.components.signing).toHaveLength(1); expect(left.runtime_families).toEqual( expect.arrayContaining([ "dalvik-art", + "flutter", "javascript", "native", "react-native", + "unity", ]), ); - expect(left.bridge_candidates).toEqual([ - expect.objectContaining({ basis: "react-native-convention" }), - ]); + expect(left.runtime_families).not.toContain("community"); + expect(left.bridge_candidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ basis: "react-native-convention" }), + expect.objectContaining({ basis: "flutter-convention" }), + expect.objectContaining({ basis: "unity-convention" }), + ]), + ); + const community = left.bridge_candidates.filter(({ native_path }) => + native_path.endsWith("libcommunity.so"), + ); + expect(community.length).toBeGreaterThan(0); + expect( + community.every(({ basis }) => basis === "jni-library-convention"), + ).toBe(true); expect(left.limitations).toEqual( expect.arrayContaining([ "A bridge basis is inferred from the native path and is repeated for every managed component.",