fix(artifacts): extract readable inventoried entries (#7827)

* fix(artifacts): extract readable inventoried entries

* test(artifacts): align directory omission coverage
This commit is contained in:
morluto authored and GitHub committed 2026-10-29 04:58:43 +08:00
1 parent 5c60720ad7
commit 2dd9a3092e
4 files changed
+150 -15

No files matched your search

@@ -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,
),
@@ -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(
"<plist><dict><key>CFBundlePackageType</key><string>APPL</string><key>CFBundleExecutable</key><string>fixture</string></dict></plist>",
),
],
["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)",
@@ -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",
});
});
@@ -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");