fix(artifacts): preserve raw directory name identity

Fixes #6665
This commit is contained in:
morluto
2026-10-23 21:06:27 +08:00
parent bdf13a63c4
commit 47900f99c5
5 changed files with 95 additions and 15 deletions
@@ -513,6 +513,11 @@ inventory digest before parsing, and malformed ASAR operations return typed
format diagnostics that retain the local container path. Native add-ons are
represented by metadata only.
POSIX directory names are read as raw bytes and admitted as logical paths only
when they round-trip through UTF-8. Invalid names are left untouched, retained
siblings remain inventoried, and traversal coverage is marked unknown with the
first rejected basename bytes identified in hexadecimal.
## Bounds and coverage
The reconstruction keeps internal resource safeguards for artifact entries,
+33 -11
View File
@@ -32,6 +32,7 @@ import {
ArtifactFileCleanupFailure,
} from "./ArtifactFileOperation.js";
import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js";
import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js";
export type DirectoryArtifactEntry = ArtifactEntry & {
readonly kind: "file" | "directory" | "symlink";
@@ -103,6 +104,8 @@ export class DirectoryArtifactReader implements ArtifactReader {
examined += 1;
};
const pending = [root];
let unrepresentableNameCount = 0;
let firstUnrepresentableNameHex: string | undefined;
while (pending.length > 0) {
abortIfNeeded(signal);
const directory = pending.pop();
@@ -116,12 +119,23 @@ export class DirectoryArtifactReader implements ArtifactReader {
signal,
options,
examine,
(rawHex) => {
unrepresentableNameCount += 1;
firstUnrepresentableNameHex ??= rawHex;
},
))
yield entry;
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory, admittedRoot.identity),
);
}
if (unrepresentableNameCount > 0)
options?.onCoverage?.({
status: "unknown",
limitations: [
`Directory contains ${String(unrepresentableNameCount)} entry name(s) that are not valid UTF-8; no filesystem operations were performed for them. First raw basename bytes: ${firstUnrepresentableNameHex ?? "unknown"}.`,
],
});
}
/** Admission facts, shared with traversal; the selected root may be a symlink. */
@@ -164,20 +178,23 @@ export class DirectoryArtifactReader implements ArtifactReader {
signal: AbortSignal | undefined,
options: DirectoryTraversalOptions | undefined,
examine: () => void,
noteUnrepresentableName: (rawHex: string) => void,
): AsyncIterable<DirectoryArtifactEntry> {
const admittedRoot = await this.#resolveRoot();
const root = admittedRoot.canonicalPath;
const handle = new OwnedDirectoryHandle(
await opendir(directory).catch((cause: unknown) => {
throw directoryIoFailure(
"open directory",
directory,
cause,
directory === root
? DirectoryArtifactRootFailure
: ArtifactReaderFailure,
);
}),
await opendir(directory, { encoding: "buffer" as BufferEncoding }).catch(
(cause: unknown) => {
throw directoryIoFailure(
"open directory",
directory,
cause,
directory === root
? DirectoryArtifactRootFailure
: ArtifactReaderFailure,
);
},
),
);
this.#directories.add(handle);
const childDirectories: string[] = [];
@@ -195,7 +212,12 @@ export class DirectoryArtifactReader implements ArtifactReader {
if (child === null) break;
examine();
abortIfNeeded(signal);
const absolute = join(directory, child.name);
const decoded = decodeDirectoryEntryName(child.name);
if (decoded.kind === "unrepresentable") {
noteUnrepresentableName(decoded.rawHex);
continue;
}
const absolute = join(directory, decoded.name);
const metadata = await lstat(absolute, { bigint: true }).catch(
(cause: unknown) => {
throw directoryIoFailure("inspect entry", absolute, cause);
@@ -0,0 +1,15 @@
/** A filesystem basename is usable as a logical path only after UTF-8 validity is established. */
export type DirectoryEntryName =
| { readonly kind: "decoded"; readonly name: string }
| { readonly kind: "unrepresentable"; readonly rawHex: string };
/** Decode POSIX directory-entry bytes without allowing replacement-character aliases. */
export const decodeDirectoryEntryName = (
raw: string | Buffer,
): DirectoryEntryName => {
if (typeof raw === "string") return { kind: "decoded", name: raw };
const name = raw.toString("utf8");
return Buffer.from(name, "utf8").equals(raw)
? { kind: "decoded", name }
: { kind: "unrepresentable", rawHex: raw.toString("hex") };
};
@@ -16,6 +16,7 @@ import {
import { isPathWithinRoot } from "../domain/localPath.js";
import { pathFromRoot } from "./ReferenceSourceReaderPaths.js";
import { readStableFile } from "./ReferenceSourceReaderFile.js";
import { decodeDirectoryEntryName } from "../filesystem/decodeDirectoryEntryName.js";
import {
isAborted,
sameFile,
@@ -97,13 +98,13 @@ export const traverseDirectory = async (
readFailure = "Directory entry name was not returned as raw bytes";
break;
}
const name = child.name.toString("utf8");
if (!Buffer.from(name, "utf8").equals(child.name)) {
const decoded = decodeDirectoryEntryName(child.name);
if (decoded.kind === "unrepresentable") {
unrepresentableNameCount += 1;
firstUnrepresentableNameHex ??= child.name.toString("hex");
firstUnrepresentableNameHex ??= decoded.rawHex;
continue;
}
result = await processEntry(state, current, name, directories);
result = await processEntry(state, current, decoded.name, directories);
if (!result.ok) break;
}
} finally {
@@ -0,0 +1,37 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform !== "linux")(
"keeps invalid UTF-8 filenames distinct from a valid U+FFFD sibling",
async () => {
const root = await createTestTempDirectory("rea-artifact-filename-bytes-");
const rawName = Buffer.from([
0x62, 0x61, 0x64, 0xff, 0x2e, 0x74, 0x78, 0x74,
]);
const rawPath = Buffer.concat([Buffer.from(`${root}/`), rawName]);
const replacementPath = join(root, "bad\ufffd.txt");
await Promise.all([
writeFile(rawPath, "RAW_FILE"),
writeFile(replacementPath, "UNICODE_FILE"),
writeFile(join(root, "good.txt"), "GOOD_FILE"),
]);
const inventory = await scanArtifactInventory(root);
const replacementOccurrences = inventory.occurrences.filter(
({ logical_path }) => logical_path === "bad\ufffd.txt",
);
expect(replacementOccurrences).toHaveLength(1);
expect(
inventory.occurrences.map(({ logical_path }) => logical_path),
).toContain("good.txt");
expect(inventory.coverage.status).toBe("unknown");
expect(inventory.limitations.join(" ")).toContain(
`First raw basename bytes: ${rawName.toString("hex")}`,
);
},
);