fix(artifacts): retain Mach-O source identity through inventory
This commit is contained in:
parent
a9fd1aecae
commit
5daff4d67e
|
|
@ -1,5 +1,5 @@
|
|||
import { createReadStream } from "node:fs";
|
||||
import { open as openFile, stat } from "node:fs/promises";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import type { ArtifactCommand } from "../domain/artifactGraph.js";
|
||||
|
|
@ -19,11 +19,21 @@ import {
|
|||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
import { streamChunkToBuffer } from "./StreamBytes.js";
|
||||
import {
|
||||
openRegularFile,
|
||||
sameRegularFileState,
|
||||
type StableRegularFileDescriptor,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
|
||||
/** Read-only universal Mach-O slice reader backed by native lipo metadata. */
|
||||
export class MachOSliceArtifactReader implements ArtifactReader {
|
||||
readonly format = "file" as const;
|
||||
#command: ArtifactCommand | undefined;
|
||||
#source: StableRegularFileDescriptor | undefined;
|
||||
#pendingSource: Promise<StableRegularFileDescriptor> | undefined;
|
||||
readonly #ownsSource: boolean;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly path: string,
|
||||
|
|
@ -31,9 +41,15 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
private readonly runner: NativeCommandRunner = new XcrunCommandRunner(
|
||||
environment,
|
||||
),
|
||||
) {}
|
||||
source?: StableRegularFileDescriptor,
|
||||
) {
|
||||
this.#source = source;
|
||||
this.#ownsSource = source === undefined;
|
||||
}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
const source = await this.#ensureSource();
|
||||
await this.#verifySource(source);
|
||||
const captured = await this.runner.run(
|
||||
"lipo",
|
||||
["-detailed_info", this.path],
|
||||
|
|
@ -53,9 +69,11 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
exit_code: captured.value.exitCode,
|
||||
effects: ["read"],
|
||||
};
|
||||
const fileSize = (await stat(this.path)).size;
|
||||
await this.#verifySource(source);
|
||||
const fileSize = source.initial.size;
|
||||
const architectures = parseLipoArchitectures(captured.value.stdout);
|
||||
const structural = await readStructuralSlices(this.path, fileSize);
|
||||
const structural = await readStructuralSlices(source.handle, fileSize);
|
||||
await this.#verifySource(source);
|
||||
if (structural.status === "malformed")
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
|
|
@ -168,22 +186,28 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
"integrity",
|
||||
"Invalid Mach-O slice byte range",
|
||||
);
|
||||
const fileSize = (await stat(this.path)).size;
|
||||
if (!Number.isSafeInteger(offset + size) || offset + size > fileSize)
|
||||
const source = await this.#ensureSource();
|
||||
await this.#verifySource(source);
|
||||
if (
|
||||
!Number.isSafeInteger(offset + size) ||
|
||||
offset + size > source.initial.size
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Mach-O slice range is outside the artifact: ${entry.path}`,
|
||||
);
|
||||
const source = createReadStream(this.path, {
|
||||
const sourceStream = source.handle.createReadStream({
|
||||
start: offset,
|
||||
end: offset + size - 1,
|
||||
autoClose: false,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
const verifySource = () => this.#verifySource(source);
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
let observedBytes = 0;
|
||||
try {
|
||||
for await (const raw of source) {
|
||||
for await (const raw of sourceStream) {
|
||||
const chunk = streamChunkToBuffer(raw);
|
||||
observedBytes += chunk.byteLength;
|
||||
if (observedBytes > size)
|
||||
|
|
@ -212,6 +236,7 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
"integrity",
|
||||
`Mach-O slice size disagrees with lipo metadata: ${entry.path}`,
|
||||
);
|
||||
await verifySource();
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
|
@ -220,28 +245,80 @@ export class MachOSliceArtifactReader implements ArtifactReader {
|
|||
return this.#command === undefined ? [] : [structuredClone(this.#command)];
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
this.#closePromise ??= this.#closeSource().catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #ensureSource(): Promise<StableRegularFileDescriptor> {
|
||||
if (this.#closed)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Universal Mach-O reader is closed",
|
||||
);
|
||||
if (this.#source !== undefined) return this.#source;
|
||||
this.#pendingSource ??= this.#acquireSource();
|
||||
const source = await this.#pendingSource;
|
||||
if (this.#closed)
|
||||
throw new ArtifactReaderFailure(
|
||||
"unavailable",
|
||||
"Universal Mach-O reader closed during source acquisition",
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
async #acquireSource(): Promise<StableRegularFileDescriptor> {
|
||||
const handle = await openRegularFile(this.path, { symlinks: "reject" });
|
||||
try {
|
||||
const initial = await handle.stat();
|
||||
const source = { handle, initial };
|
||||
this.#source = source;
|
||||
return source;
|
||||
} catch (cause: unknown) {
|
||||
await handle.close().catch(() => undefined);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async #closeSource(): Promise<void> {
|
||||
await this.#pendingSource?.catch(() => undefined);
|
||||
if (!this.#ownsSource || this.#source === undefined) return;
|
||||
const source = this.#source;
|
||||
await source.handle.close();
|
||||
this.#source = undefined;
|
||||
}
|
||||
|
||||
async #verifySource(source: StableRegularFileDescriptor): Promise<void> {
|
||||
const [opened, currentPath] = await Promise.all([
|
||||
source.handle.stat(),
|
||||
lstat(this.path),
|
||||
]);
|
||||
if (
|
||||
!sameRegularFileState(source.initial, opened) ||
|
||||
!sameRegularFileState(source.initial, currentPath)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"integrity",
|
||||
`Universal Mach-O source changed during inventory: ${this.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Compare lipo's independent ranges with structurally parsed slice identity. */
|
||||
const readStructuralSlices = async (
|
||||
path: string,
|
||||
file: FileHandle,
|
||||
size: number,
|
||||
): Promise<Awaited<ReturnType<typeof readMachoImage>>> => {
|
||||
const file = await openFile(path, "r");
|
||||
try {
|
||||
const readAt: ReadAt = async (offset, length) => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
const { bytesRead } = await file.read(bytes, 0, length, offset);
|
||||
return bytes.subarray(0, bytesRead);
|
||||
};
|
||||
const facts = await readMachoImage(readAt, size);
|
||||
return facts;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
const readAt: ReadAt = async (offset, length) => {
|
||||
const bytes = Buffer.alloc(length);
|
||||
const { bytesRead } = await file.read(bytes, 0, length, offset);
|
||||
return bytes.subarray(0, bytesRead);
|
||||
};
|
||||
return readMachoImage(readAt, size);
|
||||
};
|
||||
|
||||
const lipoMatchesSlice = (
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface RootClassification {
|
|||
}
|
||||
|
||||
type RootInventoryClassification = RootClassification & {
|
||||
readonly zipSource: StableRegularFileDescriptor | undefined;
|
||||
readonly rootSource: StableRegularFileDescriptor | undefined;
|
||||
};
|
||||
|
||||
/** Classify and hash one file root through the same stable open descriptor. */
|
||||
|
|
@ -43,7 +43,7 @@ export const classifyAndHashRoot = async (
|
|||
try {
|
||||
return { format: result.format, digest: result.digest };
|
||||
} finally {
|
||||
await result.zipSource?.handle.close();
|
||||
await result.rootSource?.handle.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ export const classifyAndHashRootForInventory = async (
|
|||
signal?: AbortSignal,
|
||||
): Promise<RootInventoryClassification> => {
|
||||
if (directory)
|
||||
return { format: "directory", digest: null, zipSource: undefined };
|
||||
return { format: "directory", digest: null, rootSource: undefined };
|
||||
const handle = await openRootFile(path, signal);
|
||||
let transferHandle = false;
|
||||
try {
|
||||
|
|
@ -72,12 +72,12 @@ export const classifyAndHashRootForInventory = async (
|
|||
initial,
|
||||
signal,
|
||||
);
|
||||
const zipSource = isZipFormat(format);
|
||||
if (zipSource) transferHandle = true;
|
||||
const retainSource = isZipFormat(format) || format === "mach-o-universal";
|
||||
if (retainSource) transferHandle = true;
|
||||
return {
|
||||
format,
|
||||
digest,
|
||||
zipSource: zipSource ? { handle, initial } : undefined,
|
||||
rootSource: retainSource ? { handle, initial } : undefined,
|
||||
};
|
||||
} finally {
|
||||
if (!transferHandle) await handle.close();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const createReader = async (
|
|||
format: ArtifactOccurrence["artifact_format"],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
signal?: AbortSignal,
|
||||
zipSource?: StableRegularFileDescriptor,
|
||||
rootSource?: StableRegularFileDescriptor,
|
||||
): Promise<ArtifactReader | undefined> => {
|
||||
switch (format) {
|
||||
case "directory":
|
||||
|
|
@ -23,12 +23,12 @@ export const createReader = async (
|
|||
case "apk":
|
||||
case "msix":
|
||||
case "appx":
|
||||
return new ZipArtifactReader(path, format, undefined, zipSource);
|
||||
return new ZipArtifactReader(path, format, undefined, rootSource);
|
||||
case "asar":
|
||||
return new AsarArtifactReader(path);
|
||||
case "mach-o-universal":
|
||||
return process.platform === "darwin"
|
||||
? new MachOSliceArtifactReader(path, environment)
|
||||
? new MachOSliceArtifactReader(path, environment, undefined, rootSource)
|
||||
: undefined;
|
||||
case "dmg":
|
||||
if (process.platform !== "darwin") return undefined;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||
const {
|
||||
format: rootFormat,
|
||||
digest: rootDigest,
|
||||
zipSource,
|
||||
rootSource,
|
||||
} = await classifyAndHashRootForInventory(
|
||||
path,
|
||||
metadata.isDirectory(),
|
||||
|
|
@ -73,7 +73,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||
rootFormat,
|
||||
options.environment ?? {},
|
||||
options.signal,
|
||||
zipSource,
|
||||
rootSource,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
readerCreationFailure = { cause };
|
||||
|
|
@ -102,7 +102,7 @@ export const scanCanonicalArtifactInventory = async (
|
|||
metadata,
|
||||
rootFormat,
|
||||
rootDigest,
|
||||
zipSource,
|
||||
rootSource,
|
||||
reader,
|
||||
signal: options.signal,
|
||||
nodes,
|
||||
|
|
@ -133,13 +133,13 @@ export const scanCanonicalArtifactInventory = async (
|
|||
);
|
||||
}
|
||||
}
|
||||
if (zipSource !== undefined) {
|
||||
if (rootSource !== undefined) {
|
||||
try {
|
||||
await zipSource.handle.close();
|
||||
await rootSource.handle.close();
|
||||
} catch (cleanupCause: unknown) {
|
||||
const cleanup = ArtifactReaderFailure.cleanupObservation(
|
||||
cleanupCause,
|
||||
`root ZIP descriptor for ${path}`,
|
||||
`root artifact descriptor for ${path}`,
|
||||
);
|
||||
cleanupFailure = ArtifactReaderFailure.withCleanup(
|
||||
cleanupFailure ??
|
||||
|
|
@ -161,7 +161,7 @@ interface SnapshotBuildInput {
|
|||
readonly metadata: Stats;
|
||||
readonly rootFormat: ArtifactOccurrence["artifact_format"];
|
||||
readonly rootDigest: HashResult | null;
|
||||
readonly zipSource: StableRegularFileDescriptor | undefined;
|
||||
readonly rootSource: StableRegularFileDescriptor | undefined;
|
||||
readonly reader: ArtifactReader | undefined;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly nodes: Map<string, ArtifactNode>;
|
||||
|
|
@ -173,7 +173,7 @@ interface SnapshotBuildInput {
|
|||
const buildInventorySnapshot = async (
|
||||
input: SnapshotBuildInput,
|
||||
): Promise<ArtifactInventorySnapshot> => {
|
||||
const { path, metadata, rootFormat, rootDigest, zipSource, reader, signal } =
|
||||
const { path, metadata, rootFormat, rootDigest, rootSource, reader, signal } =
|
||||
input;
|
||||
materializeDirectoryNodes(input.occurrences, input.nodes);
|
||||
const rootNode = createRootNode({
|
||||
|
|
@ -218,7 +218,7 @@ const buildInventorySnapshot = async (
|
|||
ordinal,
|
||||
}));
|
||||
|
||||
await verifyRootDigest(path, rootDigest, zipSource, signal);
|
||||
await verifyRootDigest(path, rootDigest, rootSource, signal);
|
||||
|
||||
const graphSha256 = artifactGraphDigest({
|
||||
nodes: orderedNodes,
|
||||
|
|
@ -293,17 +293,17 @@ const buildIntegrityContradictions = (
|
|||
const verifyRootDigest = async (
|
||||
path: string,
|
||||
rootDigest: HashResult | null,
|
||||
zipSource: StableRegularFileDescriptor | undefined,
|
||||
rootSource: StableRegularFileDescriptor | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<void> => {
|
||||
if (rootDigest === null) return;
|
||||
const verified =
|
||||
zipSource === undefined
|
||||
rootSource === undefined
|
||||
? await hashStableRootArtifact(path, signal)
|
||||
: await hashStableRootArtifactHandle(
|
||||
path,
|
||||
zipSource.handle,
|
||||
zipSource.initial,
|
||||
rootSource.handle,
|
||||
rootSource.initial,
|
||||
signal,
|
||||
);
|
||||
if (
|
||||
|
|
|
|||
Loading…
Reference in New Issue