fix(artifacts): preserve source names and describe materialized reader output

This commit is contained in:
morluto committed 2026-10-24 02:40:21 +08:00
1 parent 5f2a06f1f8
commit bbc75545ec
11 files changed
+92 -5

No files matched your search

+6
View File
@@ -1030,6 +1030,12 @@ bytes and returns the `integrity_contradictions` records for the extracted
files. An active ASAR unpacked entry whose companion file is absent cannot be
materialized; extraction fails as `unavailable` with that logical path.
Occurrences use normalized logical paths for identity and lookup. Optional
`source_path` retains the original path within the owning reader when Unicode
NFC or reader admission changes its spelling; it is source evidence, not an
output destination. POSIX letter-colon names such as `a:b` remain valid logical
names; Windows extraction refuses names its filesystem cannot materialize.
Extraction materializes regular files exposed by the active archive reader.
Its temporary parent follows the filesystem semantics of `TMPDIR`, including
POSIX symlinks followed by `..`. A destination inside a directory source is
@@ -73,3 +73,29 @@ it("refuses directory extraction output inside the source before copying and kee
);
});
});
it.skipIf(process.platform === "win32")(
"inventories and extracts literal letter-colon POSIX names",
async () => {
const root = await createTestTempDirectory("rea-extraction-colon-");
const source = join(root, "source");
await mkdir(source);
await writeFile(join(source, "a:b"), "literal colon bytes");
await withArtifactResourceScope(async (resourceScope) => {
const result = await extractArtifact({
inputPath: source,
inputFormat: "mach-o",
outputRoot: join(root, "output"),
environment: {},
integrityPolicy: "fail",
resourceScope,
});
expect(
result.artifacts.map(({ relative_path }) => relative_path),
).toEqual(["a:b"]);
expect(await readFile(join(result.output_root, "a:b"), "utf8")).toBe(
"literal colon bytes",
);
});
},
);
+8 -1
View File
@@ -18,7 +18,7 @@ it.each(["drive", "escaped", "normalization", "prefix", "collision"])(
const name = "x".repeat(1024 * 1024);
const registry = new ArtifactPathRegistry();
const fail = () => {
if (kind === "drive") normalizeArtifactPath("C:" + name);
if (kind === "drive") normalizeArtifactPath("C:/" + name);
else if (kind === "escaped")
normalizeArtifactPath("\0".repeat(name.length));
else if (kind === "normalization")
@@ -41,6 +41,13 @@ it.each(["drive", "escaped", "normalization", "prefix", "collision"])(
},
);
it.each(["a:b", "C:cache/file.txt", "a:"])(
"preserves a literal POSIX colon name %s",
(path) => {
expect(normalizeArtifactPath(path)).toBe(path);
},
);
const annotate = (paths: readonly string[]) => {
const occurrences = paths.map((logical_path) => ({
entry_kind: "file" as const,
+6 -2
View File
@@ -4,7 +4,7 @@ import { logicalPathNfc } from "../domain/artifactIdentity.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
const DRIVE_PREFIX = /^[A-Za-z]:/u;
const ABSOLUTE_DRIVE_PATH = /^[A-Za-z]:[\\/]/u;
// Bound diagnostic JSON expansion to 768 characters before formatting. This is
// a presentation budget, never a limit on accepted paths or retained facts.
@@ -49,7 +49,11 @@ export class DestinationCaseCollisionFailure extends ArtifactReaderFailure {
/** Normalize slash-separated logical names; backslashes are literal components. */
export const normalizeArtifactPath = (input: string): string => {
if (input.includes("\0") || input.startsWith("/") || DRIVE_PREFIX.test(input))
if (
input.includes("\0") ||
input.startsWith("/") ||
ABSOLUTE_DRIVE_PATH.test(input)
)
throw new ArtifactReaderFailure(
"path",
`Artifact path is absolute or unsafe: ${pathDiagnostic(input)}`,
+2
View File
@@ -36,6 +36,8 @@ export interface ArtifactReaderFailureOptions extends ErrorOptions {
/** Archive-neutral entry metadata. Reader adapters never choose output paths. */
export interface ArtifactEntry {
readonly path: string;
/** Exact reader-source spelling when its admitted path omits source syntax. */
readonly sourcePath?: string;
readonly kind: "file" | "directory" | "symlink" | "slice" | "metadata";
readonly declaredSize: number | null;
readonly compressedSize: number | null;
+1
View File
@@ -297,6 +297,7 @@ const projectZipEntry = (
if (kind === "directory" && (path === "" || path === ".")) return undefined;
return {
path,
...(path === sourcePath ? {} : { sourcePath }),
kind,
declaredSize: kind === "directory" ? null : entry.uncompressedSize,
compressedSize: kind === "directory" ? null : entry.compressedSize,
@@ -461,8 +461,8 @@ const createExtractionResult = (
),
),
traversal.status === "complete"
? "All regular files in the active artifact were materialized; nested archive contents remain represented by their containing file."
: "All inventoried regular files exposed by the active reader were materialized; source traversal coverage is unknown, so the output is not established as a complete source copy. Nested archive contents remain represented by their containing file.",
? "All regular files exposed by the active artifact reader were materialized."
: "All inventoried regular files exposed by the active reader were materialized; source traversal coverage is unknown, so the output is not established as a complete source copy.",
"Publication verifies the selected inventoried regular files; it does not establish complete content decoding.",
...(integrityContradictions.length === 0
? []
@@ -13,6 +13,7 @@ import {
artifactEdgeId,
artifactIdForContent,
occurrenceIdForLocation,
logicalPathNfc,
} from "../../domain/artifactIdentity.js";
import type { ArtifactEntry } from "../ArtifactReader.js";
import type {
@@ -49,6 +50,7 @@ export interface MutableOccurrence {
encrypted: boolean;
hash_status: ArtifactOccurrence["hash_status"];
source_location: ArtifactOccurrence["source_location"];
source_path?: string;
declared_integrity?: NonNullable<ArtifactOccurrence["declared_integrity"]>;
format_metadata?: NonNullable<ArtifactOccurrence["format_metadata"]>;
limitations: string[];
@@ -103,6 +105,10 @@ export const createOccurrence = (
artifact_id: null,
parent_occurrence_id: parent,
logical_path: path,
...(entry.sourcePath !== undefined ||
entry.path !== logicalPathNfc(entry.path)
? { source_path: entry.sourcePath ?? entry.path }
: {}),
entry_kind: entry.kind,
artifact_kind: role.kind,
artifact_format: role.format,
+2
View File
@@ -148,6 +148,8 @@ export const artifactOccurrenceSchema = z.object({
encrypted: z.boolean(),
hash_status: z.enum(["verified", "mismatched", "not-hashed", "unavailable"]),
source_location: artifactByteRangeSchema.nullable(),
/** Original path within this occurrence's reader when normalization changed its spelling. */
source_path: z.string().min(1).exactOptional(),
declared_integrity: artifactIntegrityClaimSchema.exactOptional(),
format_metadata: artifactFormatMetadataSchema.exactOptional(),
limitations: z.array(z.string()),
@@ -72,6 +72,11 @@ cliTest.skipIf(process.platform !== "linux").for([false, true])(
{ evidence: cliEvidence, result: cliExtraction },
{ evidence: mcpEvidence, result: mcpExtraction },
]) {
expect(
result.limitations.some((limitation) =>
limitation.includes("nested archive contents remain"),
),
).toBe(false);
expect(
result.artifacts.map(({ relative_path }) => relative_path),
).toEqual([...files.keys()]);
@@ -157,6 +162,11 @@ cliTest.for([false, true])(
{ evidence: cliEvidence, result: cliExtraction },
{ evidence: mcpEvidence, result: mcpExtraction },
]) {
expect(
result.limitations.some((limitation) =>
limitation.includes("nested archive contents remain"),
),
).toBe(false);
expect(
result.artifacts.map(({ relative_path }) => relative_path),
).toEqual(files.map(([path]) => path));
@@ -185,6 +185,29 @@ it
},
);
it("retains original ZIP names when logical paths use NFC or omit current-directory prefixes", async () => {
const root = await createTestTempDirectory("rea-zip-source-spelling-");
const path = join(root, "spelling.zip");
const decomposed = "cafe\u0301.txt";
await writeOrderedZip(path, [decomposed, "./prefixed.txt", "unchanged.txt"]);
const inventory = await inventoryArtifact(path);
const byPath = new Map(
inventory.occurrences.map((occurrence) => [
occurrence.logical_path,
occurrence,
]),
);
expect(byPath.get("café.txt")).toMatchObject({
source_path: decomposed,
hash_status: "verified",
});
expect(byPath.get("prefixed.txt")).toMatchObject({
source_path: "./prefixed.txt",
hash_status: "verified",
});
expect(byPath.get("unchanged.txt")).not.toHaveProperty("source_path");
});
it("preserves valid Info-ZIP Unicode Path overrides", async () => {
const root = await createTestTempDirectory("rea-zip-unicode-path-");
const path = join(root, "unicode-path.zip");