From bbc75545ec8026a883add4c64491f145822f4971 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:39:16 +0800 Subject: [PATCH] fix(artifacts): preserve source names and describe materialized reader output --- docs/mcp-contracts.md | 6 +++++ .../artifacts/ArtifactCallerPaths.test.ts | 26 +++++++++++++++++++ src/artifacts/ArtifactPaths.test.ts | 9 ++++++- src/artifacts/ArtifactPaths.ts | 8 ++++-- src/artifacts/ArtifactReader.ts | 2 ++ src/artifacts/ZipArtifactReader.ts | 1 + .../extraction/ArtifactExtraction.ts | 4 +-- .../inventory/ArtifactGraphConstruction.ts | 6 +++++ src/domain/artifactGraph.ts | 2 ++ .../artifactExtractionCoverage.test.ts | 10 +++++++ .../filesystem/artifactZipIntegrity.test.ts | 23 ++++++++++++++++ 11 files changed, 92 insertions(+), 5 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index ab0411ea97..c92afd5fe1 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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 diff --git a/src/application/artifacts/ArtifactCallerPaths.test.ts b/src/application/artifacts/ArtifactCallerPaths.test.ts index 553e3ffbed..095ec06593 100644 --- a/src/application/artifacts/ArtifactCallerPaths.test.ts +++ b/src/application/artifacts/ArtifactCallerPaths.test.ts @@ -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", + ); + }); + }, +); diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts index de8cc75c82..8f7a8de17b 100644 --- a/src/artifacts/ArtifactPaths.test.ts +++ b/src/artifacts/ArtifactPaths.test.ts @@ -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, diff --git a/src/artifacts/ArtifactPaths.ts b/src/artifacts/ArtifactPaths.ts index 314f6777c5..2f9d6df74e 100644 --- a/src/artifacts/ArtifactPaths.ts +++ b/src/artifacts/ArtifactPaths.ts @@ -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)}`, diff --git a/src/artifacts/ArtifactReader.ts b/src/artifacts/ArtifactReader.ts index 1c48da5350..e08d4b0b20 100644 --- a/src/artifacts/ArtifactReader.ts +++ b/src/artifacts/ArtifactReader.ts @@ -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; diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index 76cf292deb..39b814189f 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -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, diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index 7b0d95e03b..d8e3ec2e11 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -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 ? [] diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index f369f8a018..354299b502 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -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; format_metadata?: NonNullable; 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, diff --git a/src/domain/artifactGraph.ts b/src/domain/artifactGraph.ts index 70ddbd52a1..3f831c2288 100644 --- a/src/domain/artifactGraph.ts +++ b/src/domain/artifactGraph.ts @@ -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()), diff --git a/tests/acceptance/artifactExtractionCoverage.test.ts b/tests/acceptance/artifactExtractionCoverage.test.ts index f22ba370f4..9a0ff8ce4b 100644 --- a/tests/acceptance/artifactExtractionCoverage.test.ts +++ b/tests/acceptance/artifactExtractionCoverage.test.ts @@ -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)); diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index b771df65f7..8ad141f4d0 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -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");