diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 85a488b1e3..8a8d764219 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -648,7 +648,10 @@ const createReader = ( ? undefined : createArtifactReader(path, containerFormat, { environment: input.environment, - unreadableDirectories: "fail", + // Inventory already records inaccessible descendants as omissions. + // Keep that traversal policy here; the selected-path check below + // still refuses any inventoried file that cannot be materialized. + unreadableDirectories: "omit", selectedPath: absoluteCallerPath( input.selectedPath ?? input.inputPath, ), diff --git a/tests/acceptance/artifactExtractionCoverage.test.ts b/tests/acceptance/artifactExtractionCoverage.test.ts index 9047b52f04..c64420fc69 100644 --- a/tests/acceptance/artifactExtractionCoverage.test.ts +++ b/tests/acceptance/artifactExtractionCoverage.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; @@ -14,6 +14,94 @@ import { PYTHON_39_MAGIC, } from "../../src/artifacts/pyinstaller/CArchive.fixture.js"; +cliTest.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "extracts readable inventoried files when a child directory is inaccessible", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-extraction-denied-"); + const source = join(root, "Fixture.app"); + const privateDir = join(source, "Contents", "private"); + await mkdir(join(source, "Contents", "MacOS"), { recursive: true }); + await mkdir(privateDir, { recursive: true }); + const macho = Buffer.alloc(32); + macho.writeUInt32LE(0xfeedfacf, 0); + macho.writeUInt32LE(0x0100000c, 4); + macho.writeUInt32LE(2, 12); + const files = new Map([ + [ + "Contents/Info.plist", + Buffer.from( + "CFBundlePackageTypeAPPLCFBundleExecutablefixture", + ), + ], + ["Contents/MacOS/fixture", macho], + ["Contents/good.txt", Buffer.from("retained bytes\n")], + ]); + for (const [path, bytes] of files) + await writeFile(join(source, path), bytes); + await writeFile(join(privateDir, "hidden.txt"), "hidden bytes\n"); + await chmod(privateDir, 0); + onTestFinished(() => chmod(privateDir, 0o755)); + + const result = await cli.run({ + arguments: ["extract-artifact", source, "--json"], + }); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + const extraction = artifactExtractionResultSchema.parse( + parseEvidence(result.json).normalized_result, + ); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected a stdio MCP session"); + const opened = await call("open_binary", { path: source }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const reply = await call("extract_artifact", {}); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const mcpExtraction = artifactExtractionResultSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + for (const current of [extraction, mcpExtraction]) { + onTestFinished(() => + rm(current.output_root, { recursive: true, force: true }), + ); + expect(current.coverage.status).toBe("unknown"); + expect(current.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("Contents/private"), + expect.stringContaining("source traversal coverage is unknown"), + ]), + ); + expect( + current.artifacts.map(({ relative_path }) => relative_path), + ).toEqual([...files.keys()]); + for (const [path, bytes] of files) + expect( + Buffer.compare( + await readFile(join(current.output_root, path)), + bytes, + ), + ).toBe(0); + } + + await chmod(privateDir, 0o755); + const control = await cli.run({ + arguments: ["extract-artifact", source, "--json"], + }); + expect(control.exitCode, control.stdout + control.stderr).toBe(0); + const complete = artifactExtractionResultSchema.parse( + parseEvidence(control.json).normalized_result, + ); + onTestFinished(() => + rm(complete.output_root, { recursive: true, force: true }), + ); + expect(complete.coverage.status).toBe("complete"); + expect( + await readFile( + join(complete.output_root, "Contents/private/hidden.txt"), + "utf8", + ), + ).toBe("hidden bytes\n"); + }, +); + // APFS rejects the raw FF basename with EILSEQ; match the filename-byte lane. cliTest.skipIf(process.platform !== "linux").for([false, true])( "preserves extraction traversal uncertainty through CLI and stdio MCP (opaque name: %s)", diff --git a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts index 462a540acb..bcc8dfeb25 100644 --- a/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts +++ b/tests/boundary/providers/native/artifactProvider.directoryErrors.test.ts @@ -1,18 +1,21 @@ -import { access, chmod, mkdir, writeFile } from "node:fs/promises"; +import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; -import { artifactInventoryResultSchema } from "../../../../src/domain/artifactGraph.js"; +import { + artifactExtractionResultSchema, + artifactInventoryResultSchema, +} from "../../../../src/domain/artifactGraph.js"; import { thinMach } from "../../../../src/domain/binaryTarget.fixture.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { withArtifactClient } from "../../../support/artifactClientScope.js"; describe("artifact directory diagnostics", () => { it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( - "omits inaccessible directory descendants during inventory but refuses incomplete extraction", + "retains the omission when extracting readable inventoried entries", async () => { const root = await createTestTempDirectory("rea-directory-policy-"); const source = join(root, "Fixture.app"); @@ -53,14 +56,23 @@ describe("artifact directory diagnostics", () => { const extracted = await client.execute("extract_artifact", { output_root: output, }); - expect(extracted).toMatchObject({ - ok: false, - error: { - _tag: "ArtifactOperationError", - detail: expect.stringContaining("EACCES"), - }, - }); - await expect(access(output)).rejects.toMatchObject({ + if (!extracted.ok) throw extracted.error; + const result = artifactExtractionResultSchema.parse( + extracted.value.result, + ); + expect(result.coverage.status).toBe("unknown"); + expect(result.limitations).toContainEqual( + expect.stringContaining("EACCES"), + ); + expect( + result.artifacts.map(({ relative_path }) => relative_path), + ).toContain("readable.txt"); + expect(await readFile(join(output, "readable.txt"), "utf8")).toBe( + "retained bytes\n", + ); + await expect( + access(join(output, "blocked", "hidden.txt")), + ).rejects.toMatchObject({ code: "ENOENT", }); }); diff --git a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts index 12340e4d86..9b1568e6bf 100644 --- a/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts +++ b/tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts @@ -1,9 +1,16 @@ import { createHash } from "node:crypto"; -import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { + access, + chmod, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { createPackage, createPackageWithOptions } from "@electron/asar"; -import { expect, it, vi } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js"; import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js"; @@ -109,6 +116,31 @@ it.each(["file", "directory"] as const)( }, ); +it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "rolls back when a previously inventoried directory becomes unreadable", + async () => { + const root = await createTestTempDirectory("rea-extract-denied-late-"); + const source = join(root, "source"); + const child = join(source, "child"); + await mkdir(child, { recursive: true }); + await writeFile(join(child, "selected.txt"), "selected bytes\n"); + const output = join(root, "output"); + const materialize = await inventoriedExtraction(source, output); + await chmod(child, 0); + onTestFinished(() => chmod(child, 0o755)); + await expect(materialize()).rejects.toMatchObject({ + reason: "integrity", + message: + "Inventoried regular artifact entries were not materialized: child/selected.txt", + }); + await expect(access(output)).rejects.toThrow(); + await chmod(child, 0o755); + expect(await readFile(join(child, "selected.txt"), "utf8")).toBe( + "selected bytes\n", + ); + }, +); + it("still refuses a regular file added after inventory and rolls back", async () => { const root = await createTestTempDirectory("rea-extract-added-"); const source = join(root, "source");