diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index ac7af3c347..ad855a4f0b 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -297,8 +297,7 @@ export class DirectoryArtifactReader implements ArtifactReader { await this.#finishDirectory(handle, directory, traversal); } if (traversal.kind === "failed") throw traversal.cause; - childDirectories.reverse(); - pending.push(...childDirectories); + for (const child of childDirectories.reverse()) pending.push(child); } async #finishDirectory( diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index 4a158fb41d..6548fd3ae5 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -83,7 +83,8 @@ export const semanticRuntimeExportValueSources = ( const values: t.Node[] = []; if (t.isVariableDeclaration(node.declaration)) for (const declaration of node.declaration.declarations) - values.push(...semanticWriteTargets(declaration.id)); + for (const target of semanticWriteTargets(declaration.id)) + values.push(target); else if ( (t.isFunctionDeclaration(node.declaration) || t.isClassDeclaration(node.declaration)) && diff --git a/src/domain/managed/managedNativeVerificationMatch.ts b/src/domain/managed/managedNativeVerificationMatch.ts index 1e5dd83724..063c5a067e 100644 --- a/src/domain/managed/managedNativeVerificationMatch.ts +++ b/src/domain/managed/managedNativeVerificationMatch.ts @@ -45,7 +45,7 @@ export const collectNativeSymbols = ( unsupported += 1; } else { accepted += 1; - symbols.push(...observation.symbols); + for (const symbol of observation.symbols) symbols.push(symbol); if (observation.exportCoverage !== undefined) { if (!observation.exportCoverage.exhaustive) partialExports = true; for (const limitation of observation.exportCoverage.limitations) diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 39dc66fa76..adac16d650 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -252,8 +252,7 @@ const scheduleObservedDirectories = ( state: TraversalState, directories: PendingDirectory[], ): void => { - directories.reverse(); - state.pending.push(...directories); + for (const directory of directories.reverse()) state.pending.push(directory); }; const directoryReadFailure = (cause: unknown): FilesystemFailureDetail => { diff --git a/tests/boundary/cli/managedNativeVerificationLargeInventory.test.ts b/tests/boundary/cli/managedNativeVerificationLargeInventory.test.ts new file mode 100644 index 0000000000..f32ad35136 --- /dev/null +++ b/tests/boundary/cli/managedNativeVerificationLargeInventory.test.ts @@ -0,0 +1,111 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { MANAGED_NATIVE_VERIFICATION_EXAMPLE } from "../../../src/contracts/managed/managedWorkflowExamples.js"; +import { + createEvidence, + parseEvidence, + type Evidence, +} from "../../../src/domain/evidence.js"; +import { + managedNativeVerificationResultSchema, + type ManagedNativeVerificationResult, +} from "../../../src/domain/managed/managedNativeVerificationSchemas.js"; +import { inspectMachoSchema } from "../../../src/domain/native/nativeInspection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +/** More exports than one call can receive as arguments on V8's default stack. */ +const EXPORTS = 130_000; + +/** Recreate the example's Mach-O Evidence with `count` exports, keeping its matching first row. */ +const machoExports = (count: number): Evidence => { + const [source] = MANAGED_NATIVE_VERIFICATION_EXAMPLE.native_observations; + if (source?.subject == null) throw new Error("Missing native example"); + const result = inspectMachoSchema.parse(source.normalized_result); + const [match] = result.exports.items; + if (match === undefined) throw new Error("Missing matching export"); + const items = [match]; + for (let index = 1; index < count; index += 1) + items.push({ + name: `_audit_${String(index)}`, + runtime_name: `audit_${String(index)}`, + address: `0x${(0x1000 + index).toString(16)}`, + weak: false, + reexport: false, + source: "nm", + }); + return createEvidence( + { + path: source.subject.local_path, + sha256: source.subject.digest.sha256, + format: source.subject.format, + }, + source.provider, + { + operation: source.operation, + parameters: source.parameters, + result: { + ...result, + exports: { ...result.exports, items, total: count }, + }, + rawResult: null, + limitations: source.limitations, + }, + ); +}; + +/** P/Invoke outcomes, apart from the native record identity they cite. */ +const outcomes = ( + result: ManagedNativeVerificationResult, + native: Evidence, +): unknown => + JSON.parse( + JSON.stringify(result.pinvoke_imports, (key, value: unknown) => + key === "item_id" ? undefined : value, + ).replaceAll(native.evidence_id, ""), + ); + +cliTest( + "verifies P/Invoke boundaries against a native inventory wider than call arguments allow", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-managed-wide-"); + const verify = async (count: number) => { + const native = machoExports(count); + const path = join(directory, `verify-${String(count)}.json`); + await writeFile( + path, + JSON.stringify({ + managed_boundaries: + MANAGED_NATIVE_VERIFICATION_EXAMPLE.managed_boundaries, + native_observations: [native], + }), + ); + const command = await cli.run({ + arguments: ["verify-managed-native-boundaries", path, "--json"], + }); + expect(command.exitCode, command.stdout + command.stderr).toBe(0); + const result = managedNativeVerificationResultSchema.parse( + parseEvidence(command.json).normalized_result, + ); + return { result, native }; + }; + + const control = await verify(3); + const wide = await verify(EXPORTS); + expect(wide.result.summary).toEqual(control.result.summary); + expect(wide.result.summary).toMatchObject({ verified: 1 }); + expect(outcomes(wide.result, wide.native)).toEqual( + outcomes(control.result, control.native), + ); + expect(wide.result.pinvoke_imports[0]).toMatchObject({ + status: "verified", + matched_native: { + evidence_id: wide.native.evidence_id, + name: "open_native", + }, + }); + }, + 120_000, +); diff --git a/tests/boundary/filesystem/wideDirectoryTraversal.test.ts b/tests/boundary/filesystem/wideDirectoryTraversal.test.ts new file mode 100644 index 0000000000..5e2c18a39f --- /dev/null +++ b/tests/boundary/filesystem/wideDirectoryTraversal.test.ts @@ -0,0 +1,105 @@ +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; +import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js"; +import { readReferenceSource } from "../../support/referenceSourceResourceScope.js"; +import { + createTestWorkspace, + removeTestWorkspace, + type TestWorkspace, +} from "../../support/workspace/workspaceFixture.js"; + +/** More children than one call can receive as arguments on V8's default stack. */ +const CHILD_DIRECTORIES = 130_000; +const childNames = (): string[] => + Array.from({ length: CHILD_DIRECTORIES }, (_, index) => `d${index}`); + +const missingChildren = (paths: Iterable): string[] => { + const listed = new Set(paths); + return childNames().filter((name) => !listed.has(name)); +}; + +const traverse = async ( + root: string, + maximumEntries: number, + stopAfter?: { readonly entries: number; readonly reason: Error }, +): Promise<{ readonly paths: string[]; readonly failure: unknown }> => { + const reader = new DirectoryArtifactReader(root); + const controller = new AbortController(); + const paths: string[] = []; + try { + for await (const entry of reader.entries(controller.signal, { + maximumEntries, + })) { + paths.push(entry.path); + if (paths.length === stopAfter?.entries) + controller.abort(stopAfter.reason); + } + return { paths, failure: undefined }; + } catch (failure: unknown) { + return { paths, failure }; + } finally { + await reader.close(); + } +}; + +// One shallow tree serves both independent traversal owners; every child is +// empty, so only the parent's child list is wide. +describe("directory traversal of a parent wider than call arguments allow", () => { + let workspace: TestWorkspace | undefined; + let root = ""; + beforeAll(async () => { + workspace = await createTestWorkspace("rea-wide-directory-"); + root = await workspace.mkdir("tree"); + for (const name of childNames()) mkdirSync(join(root, name)); + }, 60_000); + afterAll(async () => { + if (workspace !== undefined) await removeTestWorkspace(workspace.root); + }, 60_000); + + it("schedules the artifact reader's children within the selected entry budget", async () => { + // Cancel once the parent listing is complete. The next cancellation + // checkpoint follows scheduling its children for descent. + const reason = new Error("Stop after the wide parent listing"); + const listed = await traverse(root, CHILD_DIRECTORIES, { + entries: CHILD_DIRECTORIES, + reason, + }); + expect(listed.failure).toBeInstanceOf(ArtifactReaderFailure); + expect(listed.failure).toMatchObject({ + reason: "cancelled", + cause: reason, + }); + expect(listed.paths).toHaveLength(CHILD_DIRECTORIES); + expect(missingChildren(listed.paths)).toEqual([]); + + // A smaller selected allowance stays a typed refusal at its actual bound. + const refused = await traverse(root, 2_000); + expect(refused.paths).toHaveLength(2_000); + expect(refused.failure).toBeInstanceOf(ArtifactReaderFailure); + expect(refused.failure).toMatchObject({ + reason: "limit", + examinedEntries: 2_000, + maximumEntries: 2_000, + }); + }, 60_000); + + it.skipIf(process.platform === "win32")( + "reads every child directory into the reference-source inventory", + async () => { + const result = await readReferenceSource(root); + if (!result.ok) throw result.error; + const { entries } = result.value; + expect(entries).toHaveLength(CHILD_DIRECTORIES); + expect( + entries.filter( + (entry) => entry.status !== "read" || entry.kind !== "directory", + ), + ).toEqual([]); + expect(missingChildren(entries.map(({ path }) => path))).toEqual([]); + }, + 120_000, + ); +}); diff --git a/tests/boundary/javascript/wideExportBindings.test.ts b/tests/boundary/javascript/wideExportBindings.test.ts new file mode 100644 index 0000000000..c54cf35718 --- /dev/null +++ b/tests/boundary/javascript/wideExportBindings.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; + +import { analyzeCommonJsStaticFixture } from "../../fixtures/javascriptCommonJsAnalysis.js"; + +// The source analyzer runs on a child process's main-thread stack. This forked +// project shares that stack size; worker threads have a larger default stack. +it("selects a published binding list wider than call arguments allow", () => { + const publish = (count: number) => { + const bindings = Array.from({ length: count }, (_, i) => `v${i}`); + const source = `const{app}=require('electron');export const[${bindings.join(",")}]=[0,app.setAppPath];`; + const analysis = analyzeCommonJsStaticFixture(source); + expect(analysis.parse_error_count).toBe(0); + // The possible change is the exported declaration, which ends the source. + return (analysis.electron.app_path_changes ?? []).map( + ({ location, ...change }) => { + expect(location.end).toEqual({ line: 1, column: source.length }); + return { ...change, start: location.start }; + }, + ); + }; + const control = publish(3); + expect(control).toHaveLength(1); + // More bindings than one call can receive as arguments on V8's default stack. + expect(publish(130_000)).toEqual(control); +});