diff --git a/src/artifacts/MachOSliceArtifactReader.ts b/src/artifacts/MachOSliceArtifactReader.ts index f812fdc1a..f306025c8 100644 --- a/src/artifacts/MachOSliceArtifactReader.ts +++ b/src/artifacts/MachOSliceArtifactReader.ts @@ -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 | undefined; + readonly #ownsSource: boolean; + #closed = false; + #closePromise: Promise | 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 { + 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 { - return Promise.resolve(); + async close(): Promise { + this.#closed = true; + this.#closePromise ??= this.#closeSource().catch((cause: unknown) => { + this.#closePromise = undefined; + throw cause; + }); + return this.#closePromise; + } + + async #ensureSource(): Promise { + 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 { + 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 { + 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 { + 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>> => { - 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 = ( diff --git a/src/artifacts/inventory/classify.ts b/src/artifacts/inventory/classify.ts index 6e69c64c8..fbb8215a1 100644 --- a/src/artifacts/inventory/classify.ts +++ b/src/artifacts/inventory/classify.ts @@ -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 => { 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(); diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 21ad5adea..33e5a195a 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -13,7 +13,7 @@ export const createReader = async ( format: ArtifactOccurrence["artifact_format"], environment: Readonly, signal?: AbortSignal, - zipSource?: StableRegularFileDescriptor, + rootSource?: StableRegularFileDescriptor, ): Promise => { 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; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 9ae0db083..bec77ca1f 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -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; @@ -173,7 +173,7 @@ interface SnapshotBuildInput { const buildInventorySnapshot = async ( input: SnapshotBuildInput, ): Promise => { - 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 => { 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 (