diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 64ce1374f0..47516e8f16 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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, diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 764915923a..f7531244c8 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -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 { 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); diff --git a/src/filesystem/decodeDirectoryEntryName.ts b/src/filesystem/decodeDirectoryEntryName.ts new file mode 100644 index 0000000000..98d3994b5c --- /dev/null +++ b/src/filesystem/decodeDirectoryEntryName.ts @@ -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") }; +}; diff --git a/src/reference/ReferenceSourceReaderEntries.ts b/src/reference/ReferenceSourceReaderEntries.ts index 39d2b520c8..09e369c68d 100644 --- a/src/reference/ReferenceSourceReaderEntries.ts +++ b/src/reference/ReferenceSourceReaderEntries.ts @@ -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 { diff --git a/tests/boundary/filesystem/directoryArtifactFilenameBytes.test.ts b/tests/boundary/filesystem/directoryArtifactFilenameBytes.test.ts new file mode 100644 index 0000000000..5ad5925122 --- /dev/null +++ b/tests/boundary/filesystem/directoryArtifactFilenameBytes.test.ts @@ -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")}`, + ); + }, +);