mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(artifacts): preserve source names and describe materialized reader output
This commit is contained in:
1 parent
5f2a06f1f8
commit
bbc75545ec
11 files changed
+92
-5
No files matched your search
@@ -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",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user