mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
fix(artifacts): extract readable inventoried entries (#7827)
* fix(artifacts): extract readable inventoried entries * test(artifacts): align directory omission coverage
This commit is contained in:
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");
|
||||
|
||||
Reference in new issue
Block a user