fix(artifacts): bind member reads to emitted metadata

Reject Mach-O and ZIP entries whose metadata no longer matches the reader inventory, preventing a sibling adapter key from selecting bytes under another entry. Reuse the existing ASAR metadata snapshot and equality rules and read Mach-O ranges from owned records.

Move the real system-lipo test to the provider boundary suite, release its reader, and derive slice expectations from the host FAT table.

Validation: 68 focused artifact, ASAR and MCP tests plus ZIP cancellation passed. Real macOS lipo was exercised against /bin/ls; Ghidra, IDA and other deep providers were not run. Static, docs, architecture and test discovery checks passed.
This commit is contained in:
morluto 2026-10-11 03:53:26 +08:00
parent 3967cfa43e
commit 39da0b150b
7 changed files with 164 additions and 70 deletions

View File

@ -31,6 +31,37 @@ export interface ArtifactEntry {
};
}
/** Snapshot mutable entry metadata before the producer later validates open(). */
export const copyArtifactEntry = (entry: ArtifactEntry): ArtifactEntry => ({
...entry,
limitations: [...entry.limitations],
...(entry.sourceIdentity === undefined
? {}
: { sourceIdentity: { ...entry.sourceIdentity } }),
});
/** Compare every portable and adapter-owned fact supplied with an entry. */
export const sameArtifactEntry = (
entry: ArtifactEntry,
produced: ArtifactEntry,
): boolean =>
entry.path === produced.path &&
entry.kind === produced.kind &&
entry.declaredSize === produced.declaredSize &&
entry.compressedSize === produced.compressedSize &&
entry.executable === produced.executable &&
entry.encrypted === produced.encrypted &&
entry.byteOffset === produced.byteOffset &&
entry.declaredSha256 === produced.declaredSha256 &&
entry.unpacked === produced.unpacked &&
entry.adapterKey === produced.adapterKey &&
entry.limitations.length === produced.limitations.length &&
entry.limitations.every(
(limitation, index) => limitation === produced.limitations[index],
) &&
entry.sourceIdentity?.device === produced.sourceIdentity?.device &&
entry.sourceIdentity?.inode === produced.sourceIdentity?.inode;
/** Read-only adapter over one directory, archive, or virtual container. */
export interface ArtifactReader {
readonly format: "directory" | ZipPackageFormat | "asar" | "file";

View File

@ -23,6 +23,8 @@ import { listPackage, statFile, uncache } from "@electron/asar";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
@ -490,35 +492,6 @@ const asarUnpackedEntryFailure = (
return asarFailure(archivePath, `read ${entry.path}`, cause);
};
const copyArtifactEntry = (entry: ArtifactEntry): ArtifactEntry => ({
...entry,
limitations: [...entry.limitations],
...(entry.sourceIdentity === undefined
? {}
: { sourceIdentity: { ...entry.sourceIdentity } }),
});
const sameArtifactEntry = (
entry: ArtifactEntry,
produced: ArtifactEntry,
): boolean =>
entry.path === produced.path &&
entry.kind === produced.kind &&
entry.declaredSize === produced.declaredSize &&
entry.compressedSize === produced.compressedSize &&
entry.executable === produced.executable &&
entry.encrypted === produced.encrypted &&
entry.byteOffset === produced.byteOffset &&
entry.declaredSha256 === produced.declaredSha256 &&
entry.unpacked === produced.unpacked &&
entry.adapterKey === produced.adapterKey &&
entry.limitations.length === produced.limitations.length &&
entry.limitations.every(
(limitation, index) => limitation === produced.limitations[index],
) &&
entry.sourceIdentity?.device === produced.sourceIdentity?.device &&
entry.sourceIdentity?.inode === produced.sourceIdentity?.inode;
const isAsarFileMetadata = (
metadata: ReturnType<typeof statFile>,
): metadata is AsarFileMetadata =>

View File

@ -15,6 +15,8 @@ import type { MachoSlice } from "../domain/apple/dylibResolution.js";
import { readMachoImage, type ReadAt } from "./apple/MachoLoadCommandReader.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
@ -31,6 +33,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
#command: ArtifactCommand | undefined;
#source: StableRegularFileDescriptor | undefined;
#pendingSource: Promise<StableRegularFileDescriptor> | undefined;
readonly #entries = new Map<string, ArtifactEntry>();
readonly #ownsSource: boolean;
#closed = false;
#closePromise: Promise<void> | undefined;
@ -48,6 +51,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
}
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
this.#entries.clear();
const source = await this.#ensureSource();
await this.#verifySource(source);
const captured = await this.runner.run(
@ -127,7 +131,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
"integrity",
`lipo reported an out-of-bounds Mach-O slice: ${architecture.name}`,
);
yield {
const entry: ArtifactEntry = {
path: `slices/${architecture.name}`,
kind: "slice",
declaredSize: sliceSize,
@ -163,6 +167,8 @@ export class MachOSliceArtifactReader implements ArtifactReader {
],
adapterKey: `${String(offset)}:${String(sliceSize)}`,
};
this.#entries.set(entry.adapterKey, copyArtifactEntry(entry));
yield entry;
}
}
@ -171,14 +177,19 @@ export class MachOSliceArtifactReader implements ArtifactReader {
return Promise.reject(
new ArtifactReaderFailure("cancelled", "Mach-O slice read cancelled"),
);
const [offsetText, sizeText] = entry.adapterKey.split(":");
const offset = parseSliceKeyInteger(offsetText);
const size = parseSliceKeyInteger(sizeText);
const produced = this.#entries.get(entry.adapterKey);
if (produced === undefined || !sameArtifactEntry(entry, produced))
throw new ArtifactReaderFailure(
"integrity",
"Mach-O slice entry metadata does not match this reader's inventory",
);
const offset = produced.byteOffset;
const size = produced.declaredSize;
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset === null ||
size === null ||
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size <= 0
)
@ -357,9 +368,3 @@ const lipoMatchesSlice = (
const lipoNameMatches = (reported: string, observed: string): boolean =>
reported === observed ||
(observed === "arm64e" && /^arm64e\.[A-Za-z0-9_]+$/u.test(reported));
const parseSliceKeyInteger = (value: string | undefined): number | null => {
if (value === undefined || !/^\d+$/u.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
};

View File

@ -6,6 +6,8 @@ import { Reader, ZipReader, type Entry, type FileEntry } from "@zip.js/zip.js";
import {
ArtifactReaderFailure,
copyArtifactEntry,
sameArtifactEntry,
type ArtifactEntry,
type ArtifactReader,
} from "./ArtifactReader.js";
@ -97,7 +99,10 @@ export class ZipArtifactReader implements ArtifactReader {
readonly format: ZipPackageFormat;
readonly #source: NodeFileReader;
readonly #reader: ZipReader<string>;
readonly #entries = new Map<string, Entry>();
readonly #entries = new Map<
string,
{ readonly source: Entry; readonly metadata: ArtifactEntry }
>();
/** Optionally bound each metadata read before allocating its backing buffer. */
constructor(
@ -120,39 +125,38 @@ export class ZipArtifactReader implements ArtifactReader {
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
for await (const entry of this.#reader.getEntriesGenerator()) {
abortIfNeeded(signal);
this.#entries.set(entry.filename, entry);
const symlink = isSymlink(entry);
yield {
path: entry.filename,
kind: entry.directory ? "directory" : symlink ? "symlink" : "file",
declaredSize: entry.directory ? null : entry.uncompressedSize,
compressedSize: entry.directory ? null : entry.compressedSize,
executable: entry.executable,
encrypted: entry.encrypted,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations: symlink ? ["Archive symlink target was not read."] : [],
adapterKey: entry.filename,
};
const metadata = projectZipEntry(entry);
this.#entries.set(entry.filename, {
source: entry,
metadata: copyArtifactEntry(metadata),
});
yield metadata;
}
}
open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
abortIfNeeded(signal);
const stored = this.#entries.get(entry.adapterKey);
if (stored === undefined || stored.directory || isSymlink(stored))
if (stored === undefined || !sameArtifactEntry(entry, stored.metadata))
return Promise.reject(
new ArtifactReaderFailure(
"integrity",
"ZIP entry metadata does not match this reader's inventory",
),
);
const source = stored.source;
if (source.directory || isSymlink(source))
return Promise.reject(
new ArtifactReaderFailure("format", "ZIP entry is not a regular file"),
);
if (stored.encrypted)
if (source.encrypted)
return Promise.reject(
new ArtifactReaderFailure(
"unavailable",
"Encrypted ZIP entry is unsupported",
),
);
return Promise.resolve(extractStream(stored, signal));
return Promise.resolve(extractStream(source, signal));
}
async close(): Promise<void> {
@ -166,6 +170,23 @@ export class ZipArtifactReader implements ArtifactReader {
}
}
const projectZipEntry = (entry: Entry): ArtifactEntry => {
const symlink = isSymlink(entry);
return {
path: entry.filename,
kind: entry.directory ? "directory" : symlink ? "symlink" : "file",
declaredSize: entry.directory ? null : entry.uncompressedSize,
compressedSize: entry.directory ? null : entry.compressedSize,
executable: entry.executable,
encrypted: entry.encrypted,
byteOffset: null,
declaredSha256: null,
unpacked: false,
limitations: symlink ? ["Archive symlink target was not read."] : [],
adapterKey: entry.filename,
};
};
const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => {
const output = new PassThrough();
const controller = new AbortController();

View File

@ -35,6 +35,48 @@ it("bounds a requested metadata read without rejecting the underlying valid ZIP"
}
});
it("binds ZIP opens to the emitted entry metadata", async () => {
const root = await createTestTempDirectory("rea-zip-entry-identity-");
const path = join(root, "entries.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("first.txt", new TextReader("first bytes"));
await writer.add("second.txt", new TextReader("second bytes"));
await writeFile(path, await writer.close());
const reader = new ZipArtifactReader(path, "zip");
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
const first = entries[0];
const second = entries[1];
if (first === undefined || second === undefined)
throw new Error("Expected both ZIP entries");
await expect(
reader.open({ ...second, adapterKey: first.adapterKey }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...second, path: first.path }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...second, declaredSize: first.declaredSize }),
).rejects.toMatchObject({ reason: "integrity" });
const originalKey = second.adapterKey;
Reflect.set(second, "adapterKey", first.adapterKey);
await expect(reader.open(second)).rejects.toMatchObject({
reason: "integrity",
});
Reflect.set(second, "adapterKey", originalKey);
const stream = await reader.open({ ...second });
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString()).toBe("second bytes");
} finally {
await reader.close();
}
});
it("rejects overlapping ZIP member data ranges through artifact inventory", async () => {
const root = await createTestTempDirectory("rea-zip-overlap-");
const path = join(root, "overlap.zip");

View File

@ -83,9 +83,10 @@ describe("artifact Mach-O slices", () => {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries).toHaveLength(2);
const firstEntry = entries[0];
const secondEntry = entries[1];
expect(secondEntry).toBeDefined();
if (secondEntry === undefined) return;
if (firstEntry === undefined || secondEntry === undefined)
throw new Error("Expected both universal Mach-O slice entries");
expect(secondEntry).toMatchObject({
path: "slices/arm64",
byteOffset: 8192,
@ -95,8 +96,26 @@ describe("artifact Mach-O slices", () => {
await expect(
reader.open({ ...secondEntry, adapterKey }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...secondEntry, adapterKey: firstEntry.adapterKey }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...secondEntry, path: firstEntry.path }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...secondEntry, byteOffset: firstEntry.byteOffset }),
).rejects.toMatchObject({ reason: "integrity" });
await expect(
reader.open({ ...secondEntry, declaredSize: arm.length + 1 }),
).rejects.toMatchObject({ reason: "integrity" });
const originalKey = secondEntry.adapterKey;
Reflect.set(secondEntry, "adapterKey", firstEntry.adapterKey);
await expect(reader.open(secondEntry)).rejects.toMatchObject({
reason: "integrity",
});
Reflect.set(secondEntry, "adapterKey", originalKey);
const chunks: Buffer[] = [];
const stream = await reader.open(secondEntry);
const stream = await reader.open({ ...secondEntry });
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks)).toEqual(Buffer.from(arm));
expect(reader.provenance()).toEqual([

View File

@ -1,8 +1,8 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished } from "vitest";
import { MachOSliceArtifactReader } from "./MachOSliceArtifactReader.js";
import { MachOSliceArtifactReader } from "../../../../src/artifacts/MachOSliceArtifactReader.js";
/**
* Read a 32-bit FAT table directly. The host's /bin/ls changes between macOS
@ -21,15 +21,18 @@ const fatSlices = async (
describe("Mach-O slice reader with system lipo", () => {
it.skipIf(process.platform !== "darwin")(
"checks real /bin/ls lipo CPU symbols and ptrauth capabilities against the FAT table",
"checks real /bin/ls slice ranges against its FAT table",
async () => {
const reader = new MachOSliceArtifactReader("/bin/ls", {});
onTestFinished(() => reader.close());
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries.map(({ path }) => path)).toEqual([
"slices/x86_64",
"slices/arm64e",
]);
expect(new Set(entries.map(({ path }) => path)).size).toBe(
entries.length,
);
expect(entries.every(({ path }) => path.startsWith("slices/"))).toBe(
true,
);
expect(
entries.map(({ byteOffset, declaredSize }) => [
byteOffset,