fix(artifacts): recover only from owned destination case collisions

This commit is contained in:
morluto committed 2026-10-22 18:17:44 +08:00
1 parent a4a548718d
commit 932fbb7d84
6 files changed
+217 -35

No files matched your search

+25
View File
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
ArtifactPathRegistry,
destinationCaseCollision,
DestinationCaseCollisionFailure,
annotateEnglishUnicodeCaseCollisions,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
normalizeArtifactPath,
@@ -155,3 +157,26 @@ const caseVariants = (lowercase: string, count: number): string[] =>
)
.join(""),
);
it("retains both source spellings and the native failure in a destination collision", () => {
const cause = Object.assign(new Error("exclusive create refused"), {
code: "EEXIST",
});
const collision = destinationCaseCollision("res/café.js", ["CAFÉ.js"], {
cause,
});
expect(collision).toBeInstanceOf(DestinationCaseCollisionFailure);
expect(collision).toMatchObject({
reason: "path",
requestedPath: "res/café.js",
existingPath: "res/CAFÉ.js",
});
expect(collision?.cause).toBe(cause);
});
it.each([["app.js"], ["app.js", "App.js"], ["other.js"]])(
"does not invent a destination alias from listing %j",
(...names) => {
expect(destinationCaseCollision("res/app.js", names)).toBeUndefined();
},
);
+23 -6
View File
@@ -31,9 +31,21 @@ const ENGLISH_UNICODE_CASE_NOTE =
/** Inventory limitation when logical names collide under English Unicode case folding. */
export const ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION = `Logical archive names collide under English (en-US) Unicode case folding; a case-insensitive destination cannot store both spellings. ${ENGLISH_UNICODE_CASE_NOTE}`;
/** User-facing prefix for a destination that cannot keep two logical spellings. */
export const DESTINATION_CASE_COLLISION_PREFIX =
"Destination filesystem cannot store both ";
/** A rejected destination create whose directory listing identifies another spelling. */
export class DestinationCaseCollisionFailure extends ArtifactReaderFailure {
constructor(
readonly requestedPath: string,
readonly existingPath: string,
options?: ErrorOptions,
) {
super(
"path",
`Destination filesystem cannot store both ${requestedPath} and ${existingPath}; inventory retains both logical names.`,
options,
);
this.name = "DestinationCaseCollisionFailure";
}
}
/** Normalize slash-separated logical names; backslashes are literal components. */
export const normalizeArtifactPath = (input: string): string => {
@@ -168,10 +180,11 @@ const caseCollisionLimitation = (
* Name the directory-listing spelling that differs from the requested segment
* only by English (en-US) Unicode case. Exact matches are the same logical name.
*/
export const destinationCaseCollisionMessage = (
export const destinationCaseCollision = (
requestedPath: string,
existingNames: readonly string[],
): string | undefined => {
options?: ErrorOptions,
): DestinationCaseCollisionFailure | undefined => {
const slash = requestedPath.lastIndexOf("/");
const segment = slash < 0 ? requestedPath : requestedPath.slice(slash + 1);
if (existingNames.includes(segment)) return undefined;
@@ -181,7 +194,11 @@ export const destinationCaseCollisionMessage = (
.sort(compareUnicodeCodePoints)[0];
if (existing === undefined) return undefined;
const parent = slash < 0 ? "" : requestedPath.slice(0, slash + 1);
return `${DESTINATION_CASE_COLLISION_PREFIX}${requestedPath} and ${parent}${existing}; inventory retains both logical names.`;
return new DestinationCaseCollisionFailure(
requestedPath,
`${parent}${existing}`,
options,
);
};
const englishUnicodeCaseCollisions = (
+20 -14
View File
@@ -15,7 +15,7 @@ import { streamChunkToBuffer } from "./StreamBytes.js";
import {
ArtifactPathRegistry,
destinationCaseCollisionMessage,
destinationCaseCollision,
normalizeArtifactPath,
} from "./ArtifactPaths.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -188,7 +188,8 @@ export class SafeOutputTree {
constants.O_NOFOLLOW,
0o600,
).catch(async (cause: unknown) => {
await throwIfDestinationCaseCollision(parent.path, path, cause);
if (isAlreadyExists(cause))
await throwIfDestinationCaseCollision(parent.path, path, cause);
throw new ArtifactReaderFailure(
"path",
`Could not exclusively create extraction path: ${path}`,
@@ -482,13 +483,19 @@ export class SafeOutputTree {
logicalParent.length === 0 ? part : `${logicalParent}/${part}`;
current = join(current, part);
await assertPathIdentity(parent.path, parent.identity);
let created = true;
await mkdir(current, { mode: 0o700 }).catch((cause: unknown) => {
if (!isAlreadyExists(cause)) throw cause;
created = false;
});
if (!created) {
await throwIfDestinationCaseCollision(parent.path, logicalPath);
const creation = await mkdir(current, { mode: 0o700 }).then(
() => ({ kind: "created" }) as const,
(cause: unknown) => {
if (!isAlreadyExists(cause)) throw cause;
return { kind: "existing", cause } as const;
},
);
if (creation.kind === "existing") {
await throwIfDestinationCaseCollision(
parent.path,
logicalPath,
creation.cause,
);
const identity = this.#nestedDirectories.get(current);
if (identity === undefined) throw replacedDirectory(current);
await assertPathIdentity(current, identity);
@@ -684,11 +691,10 @@ const throwIfDestinationCaseCollision = async (
): Promise<void> => {
const names = await readdir(parentDirectory).catch(() => undefined);
if (names === undefined) return;
const message = destinationCaseCollisionMessage(logicalPath, names);
if (message === undefined) return;
throw new ArtifactReaderFailure(
"path",
message,
const failure = destinationCaseCollision(
logicalPath,
names,
cause === undefined ? undefined : { cause },
);
if (failure !== undefined) throw failure;
};
+6 -11
View File
@@ -4,7 +4,7 @@ import { Readable } from "node:stream";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { DESTINATION_CASE_COLLISION_PREFIX } from "../../artifacts/ArtifactPaths.js";
import { DestinationCaseCollisionFailure } from "../../artifacts/ArtifactPaths.js";
import { SafeOutputTree } from "../../artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../artifacts/SafeOutputTreeCreationFailure.js";
import { PLAYWRIGHT_RESPONSE_BODY_PROVENANCE_LIMITATION } from "../../domain/browserNetworkEvidence.js";
@@ -185,8 +185,10 @@ const publishPlannedScript = async (
await write(record.content.relative_path);
return record;
} catch (cause: unknown) {
const collision = destinationCollisionReason(cause);
if (collision === undefined || record.content.layout !== "url-path")
if (
!(cause instanceof DestinationCaseCollisionFailure) ||
record.content.layout !== "url-path"
)
throw cause;
const relativePath = isolatedScriptPath(script, index);
await write(relativePath);
@@ -196,7 +198,7 @@ const publishPlannedScript = async (
...record.content,
relative_path: relativePath,
layout: "isolated",
layout_reason: collision,
layout_reason: cause.message,
},
};
}
@@ -208,12 +210,5 @@ const isolatedScriptPath = (
): string =>
`isolated/source-${index + 1}-${createHash("sha256").update(JSON.stringify(script.source)).digest("hex")}.js`;
const destinationCollisionReason = (cause: unknown): string | undefined =>
cause instanceof ArtifactReaderFailure &&
cause.reason === "path" &&
cause.message.startsWith(DESTINATION_CASE_COLLISION_PREFIX)
? cause.message
: undefined;
const message = (cause: unknown): string =>
cause instanceof Error ? cause.message : "Unknown publication failure";
@@ -1,4 +1,5 @@
import { execFile } from "node:child_process";
import { constants } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
@@ -28,6 +29,8 @@ import { exportWebScripts } from "../../../src/application/WebScriptExportServic
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { publishWebScripts } from "../../../src/browser/assets/PublishWebScripts.js";
import { DestinationCaseCollisionFailure } from "../../../src/artifacts/ArtifactPaths.js";
import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js";
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutputTreeCreationFailure.js";
import { selectScriptCapture } from "../../../src/browser/assets/ScriptCaptureAdapters.js";
@@ -1097,3 +1100,138 @@ describe("capture handle ownership", () => {
}
});
});
it("does not isolate a permission refusal just because a differently cased peer exists", async () => {
const { input, resources } = await setup(
scriptScenarioFixture([
{
url: "https://fixture.test/App.js",
bytes: Buffer.from("export const upper = 1;"),
},
{
url: "https://fixture.test/app.js",
bytes: Buffer.from("export const lower = 2;"),
},
]),
);
const denied = Object.assign(new Error("controlled selected-path denial"), {
code: "EACCES",
});
let refusals = 0;
openMock.mockImplementation(async (...args) => {
if (
String(args[0]).endsWith("/app.js") &&
typeof args[1] === "number" &&
(args[1] & constants.O_EXCL) !== 0
) {
refusals++;
throw denied;
}
return actualFs.open(...args);
});
const result = await exportWebScripts(input, resources);
expect(refusals).toBe(1);
if (result.ok) throw new Error("Permission failure was hidden by recovery");
expect(result.error.cause).toBeInstanceOf(ArtifactReaderFailure);
if (!(result.error.cause instanceof ArtifactReaderFailure))
throw new Error("Missing file creation failure");
expect(result.error.cause.cause).toBe(denied);
await expect(access(input.output_directory)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("does not choose collision recovery from an untyped diagnostic prefix", async () => {
const { input, resources } = await setup();
const refusal = new ArtifactReaderFailure(
"path",
"Destination filesystem cannot store both diagnostic words and authority.",
);
const write = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockRejectedValueOnce(refusal);
try {
const result = await exportWebScripts(input, resources);
expect(result.ok).toBe(false);
await expect(access(input.output_directory)).rejects.toMatchObject({
code: "ENOENT",
});
} finally {
write.mockRestore();
}
});
it("isolates an observed exclusive-create alias using typed facts when diagnostic wording changes", async () => {
const scripts = [
{
url: "https://fixture.test/App.js",
bytes: Buffer.from("export const upper = 1;"),
},
{
url: "https://fixture.test/app.js",
bytes: Buffer.from("export const lower = 2;"),
},
];
const { input, resources } = await setup(scriptScenarioFixture(scripts));
const native = Object.assign(
new Error("controlled case-insensitive exclusive create"),
{ code: "EEXIST" },
);
openMock.mockImplementation(async (...args) => {
if (
String(args[0]).endsWith("/app.js") &&
typeof args[1] === "number" &&
(args[1] & constants.O_EXCL) !== 0
)
throw native;
return actualFs.open(...args);
});
const collisions: DestinationCaseCollisionFailure[] = [];
const original = SafeOutputTree.prototype.write;
const write = vi
.spyOn(SafeOutputTree.prototype, "write")
.mockImplementation(async function (this: SafeOutputTree, ...args) {
try {
return await original.apply(this, args);
} catch (cause) {
if (cause instanceof DestinationCaseCollisionFailure) {
collisions.push(cause);
cause.message = "Observed destination spelling conflict.";
}
throw cause;
}
});
try {
const exported = await exportWebScripts(input, resources);
if (!exported.ok) throw exported.error;
const result = webScriptExportResultSchema.parse(
exported.value.normalized_result,
);
expect(collisions).toHaveLength(1);
expect(collisions[0]?.requestedPath).toMatch(/\/app\.js$/u);
expect(collisions[0]?.existingPath).toMatch(/\/App\.js$/u);
expect(collisions[0]?.cause).toBe(native);
expect(
result.scripts.map(({ content }) =>
content.state === "exported" ? content.layout : null,
),
).toEqual(["url-path", "isolated"]);
expect(result.scripts[1]?.content).toMatchObject({
layout_reason: "Observed destination spelling conflict.",
});
if (result.analysis_input === null)
throw new Error("Missing exported bytes");
for (const [index, script] of result.scripts.entries()) {
if (script.content.state !== "exported")
throw new Error("Missing script bytes");
expect(script.url).toBe(scripts[index]?.url);
expect(
await readFile(
join(result.analysis_input.input_path, script.content.relative_path),
),
).toEqual(scripts[index]?.bytes);
}
} finally {
write.mockRestore();
}
});
@@ -7,7 +7,7 @@ import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js
import { parseBinaryTarget } from "../../../../src/application/BinaryTargetResolver.js";
import {
destinationCaseCollisionMessage,
destinationCaseCollision,
ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION,
} from "../../../../src/artifacts/ArtifactPaths.js";
import { ArtifactProvider } from "../../../../src/artifacts/ArtifactProvider.js";
@@ -85,9 +85,10 @@ it("reports an unsafe path as a hostile artifact path", async () => {
});
it("returns a destination case collision as the user-facing path message", () => {
const detail = destinationCaseCollisionMessage("res/2f.xml", ["2F.xml"]);
expect(detail).toEqual(expect.any(String));
if (detail === undefined) return;
const collision = destinationCaseCollision("res/2f.xml", ["2F.xml"]);
if (collision === undefined)
throw new Error("Expected destination collision");
const detail = collision.message;
expect(
projectAnalysisError(
new ArtifactOperationError("extract_artifact", "path", undefined, detail),