test(artifacts): verify extraction races with real filesystem changes

Co-authored-by: dakjdakd <164857476+dakjdakd@users.noreply.github.com>
This commit is contained in:
Lucien 2026-10-10 02:38:33 +08:00 committed by GitHub
parent f6cfbfcc55
commit 1ac8389c5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 58 additions and 24 deletions

View File

@ -415,6 +415,7 @@ jobs:
tests/boundary/filesystem/binaryTarget.test.ts
tests/boundary/filesystem/safeOutputTree.test.ts
tests/boundary/providers/native/artifactProvider.archiveSafety.test.ts
tests/boundary/providers/native/artifactProvider.extractionCompleteness.test.ts
tests/boundary/filesystem/setupUnavailableProvider.test.ts
tests/boundary/filesystem/windowsNpxRegistration.test.ts
tests/boundary/filesystem/webScriptManifestPaths.test.ts

View File

@ -1,11 +1,12 @@
import { createHash } from "node:crypto";
import { watch } from "node:fs";
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { basename, dirname, join } from "node:path";
import { createPackage, createPackageWithOptions } from "@electron/asar";
import { expect, it, onTestFinished, vi } from "vitest";
import { expect, it, onTestFinished } from "vitest";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
import { SafeOutputTree } from "../../../../src/artifacts/SafeOutputTree.js";
import { artifactExtractionResultSchema } from "../../../../src/domain/artifactGraph.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@ -17,10 +18,11 @@ it.each(["file", "directory"] as const)(
const source = join(root, "source");
await mkdir(join(source, "z"), { recursive: true });
// Equal bytes share an artifact node, but both occurrences must be copied.
await writeFile(join(source, "a.txt"), "same bytes\n");
await writeFile(join(source, "z", "missing.txt"), "same bytes\n");
const bytes = Buffer.alloc(8 * 1024 * 1024, "same bytes\n");
await writeFile(join(source, "a.txt"), bytes);
await writeFile(join(source, "z", "missing.txt"), bytes);
const output = join(root, "output");
afterInventory(async () => {
const changed = afterInventory(output, async () => {
await rm(
removedKind === "file"
? join(source, "z", "missing.txt")
@ -30,18 +32,36 @@ it.each(["file", "directory"] as const)(
});
const result = await extract(source, output);
await changed();
if (result.ok) throw new Error("Incomplete extraction must fail");
expect(projectAnalysisError(result.error)).toMatchObject({
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "artifact_operation_failed",
details: {
operation: "extract_artifact",
},
});
// A directory already returned by readdir can disappear before its stat.
// That earlier reader failure retains ENOENT instead of reaching the final
// completeness check. Both paths must reject publication and roll back.
if (removedKind === "directory" && projected.details?.reason === "io") {
expect(projected.details).toMatchObject({
reason: "io",
detail: `Could not inspect entry at ${join(source, "z")} (ENOENT)`,
});
} else {
expect(projected.details).toMatchObject({
reason: "integrity",
detail:
"Inventoried regular artifact entries were not materialized: z/missing.txt",
},
});
});
}
await expect(access(output)).rejects.toThrow();
expect(await readFile(join(source, "a.txt"), "utf8")).toBe("same bytes\n");
expect(
createHash("sha256")
.update(await readFile(join(source, "a.txt")))
.digest("hex"),
).toBe(createHash("sha256").update(bytes).digest("hex"));
},
);
@ -49,12 +69,16 @@ 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");
await mkdir(source);
await writeFile(join(source, "a.txt"), "original\n");
await writeFile(
join(source, "a.txt"),
Buffer.alloc(8 * 1024 * 1024, "original\n"),
);
const output = join(root, "output");
afterInventory(async () => {
const changed = afterInventory(output, async () => {
await writeFile(join(source, "z.txt"), "new\n");
});
const result = await extract(source, output);
await changed();
if (result.ok) throw new Error("Changed inventory must fail");
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
@ -136,18 +160,27 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
await expect(access(activeOutput)).rejects.toThrow();
});
// Synchronize a real filesystem change after the actual inventory scan. The
// production reader, writes, failure translation and rollback all remain real.
const afterInventory = (change: () => Promise<void>): void => {
const create = SafeOutputTree.create;
const hook = vi
.spyOn(SafeOutputTree, "create")
.mockImplementationOnce(async (...args) => {
const output = await create(...args);
await change();
return output;
});
onTestFinished(() => hook.mockRestore());
// The real destination is created only after inventory. A streamed first file
// leaves time to mutate a later entry without replacing any production method.
const afterInventory = (output: string, change: () => Promise<void>) => {
let mutation:
| Promise<{ ok: true } | { ok: false; cause: unknown }>
| undefined;
const watcher = watch(dirname(output), (_event, filename) => {
if (filename !== basename(output) || mutation !== undefined) return;
watcher.close();
mutation = change().then(
() => ({ ok: true as const }),
(cause: unknown) => ({ ok: false as const, cause }),
);
});
onTestFinished(() => watcher.close());
return async () => {
if (mutation === undefined)
throw new Error("Extraction destination was not observed");
const result = await mutation;
if (!result.ok) throw result.cause;
};
};
const extract = (source: string, output: string) =>