mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): recover only from owned destination case collisions
This commit is contained in:
1 parent
a4a548718d
commit
932fbb7d84
6 files changed
+217
-35
No files matched your search
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user