mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
@@ -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,
|
||||
|
||||
@@ -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")}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user