mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
331 lines
11 KiB
TypeScript
331 lines
11 KiB
TypeScript
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
|
import { locateGodotPck } from "../GodotPckLocation.js";
|
|
import { sameRegularFileState } from "../../filesystem/RegularFile.js";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import type { Stats } from "node:fs";
|
|
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
|
import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js";
|
|
|
|
import { containsPyInstallerArchive } from "../pyinstaller/CArchiveLayout.js";
|
|
import { decodeNodeSeaBlob } from "../nodeSea/NodeSeaBlob.js";
|
|
import { locateNodeSea, readNodeSeaBlob } from "../nodeSea/NodeSeaLocation.js";
|
|
import { classifyArtifactContent } from "./ArtifactGraphConstruction.js";
|
|
import { abortIfNeeded } from "../ArtifactHash.js";
|
|
import { claimsExecutableHeader } from "../../domain/binaryTarget.js";
|
|
import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js";
|
|
import type { ArtifactOccurrence } from "../../domain/artifactGraph.js";
|
|
import {
|
|
hasZipSignature,
|
|
locateTrailingZipArchive,
|
|
isZipPackageFormat,
|
|
zipRootPackageFormat,
|
|
} from "../../domain/zipPackageFormat.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import type { ArtifactResourceOwner } from "../ArtifactResourceScope.js";
|
|
import type { HashResult } from "../ArtifactHash.js";
|
|
import {
|
|
hashStableRootArtifactHandle,
|
|
openRootArtifact,
|
|
} from "./hashStableRootArtifact.js";
|
|
import type { ArtifactInventoryOptions } from "./types.js";
|
|
import { readMachoHeaderPrefixFacts } from "../../domain/apple/machoContainer.js";
|
|
import {
|
|
observeMzWindowsHeader,
|
|
type MzWindowsHeaderSignatureEvidence,
|
|
} from "../../domain/dosMz.js";
|
|
import type { ArtifactClassificationEvidence } from "../ArtifactClassificationProbe.js";
|
|
|
|
interface RootClassification {
|
|
readonly format: ArtifactOccurrence["artifact_format"];
|
|
readonly digest: HashResult | null;
|
|
}
|
|
|
|
type RootInventoryClassification = RootClassification & {
|
|
readonly rootSource: RootInventorySource | undefined;
|
|
};
|
|
|
|
/** The admitted descriptor and the single owner that can confirm its close. */
|
|
export interface RootInventorySource extends StableRegularFileDescriptor {
|
|
readonly owner: OwnedFileHandle;
|
|
}
|
|
|
|
/** Classify a root and retain its admitted ZIP descriptor for child inventory. */
|
|
export const classifyAndHashRootForInventory = async (
|
|
path: string,
|
|
directory: boolean,
|
|
expectedMetadata: Stats,
|
|
options: Pick<
|
|
ArtifactInventoryOptions,
|
|
"resourceScope" | "signal" | "asarHeaderAdmission"
|
|
>,
|
|
): Promise<RootInventoryClassification> => {
|
|
if (directory)
|
|
return { format: "directory", digest: null, rootSource: undefined };
|
|
const source = await openRootArtifact(
|
|
path,
|
|
options.resourceScope,
|
|
options.signal,
|
|
);
|
|
const { handle, initial } = source;
|
|
const owner: ArtifactResourceOwner = {
|
|
kind: "file-handle",
|
|
handle: source.owner,
|
|
resource: `root artifact descriptor for ${path}`,
|
|
};
|
|
let outcome:
|
|
| {
|
|
readonly kind: "completed";
|
|
readonly value: RootInventoryClassification;
|
|
}
|
|
| { readonly kind: "failed"; readonly cause: unknown };
|
|
try {
|
|
if (!sameRegularFileState(expectedMetadata, initial))
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Root artifact changed before inventory: ${path}`,
|
|
);
|
|
const format = await classifyRootFormat(
|
|
path,
|
|
handle,
|
|
options.signal,
|
|
options.asarHeaderAdmission,
|
|
);
|
|
const digest = await hashStableRootArtifactHandle(
|
|
path,
|
|
handle,
|
|
initial,
|
|
options.signal,
|
|
);
|
|
const retainSource =
|
|
isZipPackageFormat(format) ||
|
|
format === "mach-o-universal" ||
|
|
format === "godot-pck" ||
|
|
format === "pyinstaller" ||
|
|
format === "node-sea";
|
|
outcome = {
|
|
kind: "completed",
|
|
value: {
|
|
format,
|
|
digest,
|
|
rootSource: retainSource ? source : undefined,
|
|
},
|
|
};
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
if (outcome.kind === "failed" || outcome.value.rootSource === undefined) {
|
|
const cleanupAttempt = await options.resourceScope.release(owner);
|
|
if (cleanupAttempt.kind === "failed")
|
|
throw options.resourceScope.cleanupFailure(outcome, [cleanupAttempt]);
|
|
}
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
};
|
|
|
|
const classifyRootFormat = async (
|
|
path: string,
|
|
handle: FileHandle,
|
|
signal?: AbortSignal,
|
|
asarHeaderAdmission?: AsarHeaderAdmission,
|
|
): Promise<ArtifactOccurrence["artifact_format"]> => {
|
|
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
|
|
const chunk = Buffer.alloc(64 * 1024);
|
|
while (probe.length < probe.limit) {
|
|
abortIfNeeded(signal);
|
|
const read = await handle.read(
|
|
chunk,
|
|
0,
|
|
Math.min(chunk.length, probe.limit - probe.length),
|
|
probe.length,
|
|
);
|
|
abortIfNeeded(signal);
|
|
if (read.bytesRead === 0) break;
|
|
probe.write(chunk.subarray(0, read.bytesRead));
|
|
}
|
|
const archiveBytes = (await handle.stat()).size;
|
|
const evidence = probe.finish(archiveBytes);
|
|
abortIfNeeded(signal);
|
|
const lower = path.toLowerCase();
|
|
if (hasZipSignature(evidence.prefix)) return zipRootPackageFormat(lower);
|
|
if (
|
|
lower.endsWith(".pkg") &&
|
|
evidence.prefix.subarray(0, 4).equals(Buffer.from("xar!"))
|
|
)
|
|
return "pkg";
|
|
if (
|
|
lower.endsWith(".dmg") &&
|
|
(await hasKolyTrailer(handle, archiveBytes, signal))
|
|
)
|
|
return "dmg";
|
|
if (await locateGodotPck(handle, archiveBytes, signal, evidence.prefix))
|
|
return "godot-pck";
|
|
const fatEvidence = await readRootFatSliceHeaders(handle, evidence, signal);
|
|
const mzWindowsHeaderSignature = await readRootMzWindowsHeaderSignature(
|
|
handle,
|
|
fatEvidence,
|
|
archiveBytes,
|
|
signal,
|
|
);
|
|
const classificationEvidence =
|
|
mzWindowsHeaderSignature === undefined
|
|
? fatEvidence
|
|
: { ...fatEvidence, mzWindowsHeaderSignature };
|
|
const format = classifyArtifactContent(
|
|
path,
|
|
classificationEvidence,
|
|
archiveBytes,
|
|
).format;
|
|
// Only bytes no other format claims may lead a located ZIP, so an
|
|
// executable with an appended archive keeps its executable classification.
|
|
if (
|
|
(format === "file" || format === "unknown") &&
|
|
!claimsExecutableHeader(evidence.prefix, archiveBytes) &&
|
|
(await locateTrailingZipArchive(archiveBytes, async (position, length) => {
|
|
abortIfNeeded(signal);
|
|
const bytes = Buffer.alloc(length);
|
|
const read = await handle.read(bytes, 0, length, position);
|
|
abortIfNeeded(signal);
|
|
return bytes.subarray(0, read.bytesRead);
|
|
})) !== undefined
|
|
)
|
|
return zipRootPackageFormat(lower);
|
|
if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) {
|
|
const sea = await locateNodeSea(handle, archiveBytes, signal);
|
|
abortIfNeeded(signal);
|
|
if (sea !== undefined) {
|
|
await decodeNodeSeaBlob(
|
|
await readNodeSeaBlob(handle, sea, archiveBytes, signal),
|
|
signal,
|
|
);
|
|
return "node-sea";
|
|
}
|
|
const packaged = await containsPyInstallerArchive(
|
|
handle,
|
|
archiveBytes,
|
|
signal,
|
|
);
|
|
abortIfNeeded(signal);
|
|
if (packaged) return "pyinstaller";
|
|
}
|
|
return format;
|
|
};
|
|
|
|
/** Read exact FAT header windows missed by the bounded prefix probe. */
|
|
const readRootFatSliceHeaders = async (
|
|
handle: FileHandle,
|
|
evidence: ArtifactClassificationEvidence,
|
|
signal?: AbortSignal,
|
|
): Promise<ArtifactClassificationEvidence> => {
|
|
const existing = evidence.fatSliceHeaders;
|
|
if (existing === undefined) return evidence;
|
|
const { header } = existing;
|
|
const sliceHeadersByOffset = new Map(existing.sliceHeadersByOffset);
|
|
for (let index = 0; index < header.slices.length; index += 1) {
|
|
const slice = header.slices[index];
|
|
const observed =
|
|
slice === undefined ? undefined : sliceHeadersByOffset.get(slice.offset);
|
|
if (
|
|
slice === undefined ||
|
|
header.rangeIssues[index] !== null ||
|
|
slice.size < 4 ||
|
|
(observed !== undefined && observed.magic !== null)
|
|
)
|
|
continue;
|
|
const headerLength = Math.min(12, slice.size);
|
|
const thinHeader = Buffer.alloc(headerLength);
|
|
let bytesRead = 0;
|
|
while (bytesRead < thinHeader.length) {
|
|
abortIfNeeded(signal);
|
|
const read = await handle.read(
|
|
thinHeader,
|
|
bytesRead,
|
|
thinHeader.length - bytesRead,
|
|
slice.offset + bytesRead,
|
|
);
|
|
abortIfNeeded(signal);
|
|
if (read.bytesRead === 0) break;
|
|
bytesRead += read.bytesRead;
|
|
}
|
|
sliceHeadersByOffset.set(
|
|
slice.offset,
|
|
readMachoHeaderPrefixFacts(thinHeader.subarray(0, bytesRead)),
|
|
);
|
|
}
|
|
return { ...evidence, fatSliceHeaders: { header, sliceHeadersByOffset } };
|
|
};
|
|
|
|
/** Read only the missing signature bytes requested by the bounded MZ parser. */
|
|
const readRootMzWindowsHeaderSignature = async (
|
|
handle: FileHandle,
|
|
evidence: ArtifactClassificationEvidence,
|
|
fileSize: number,
|
|
signal?: AbortSignal,
|
|
): Promise<MzWindowsHeaderSignatureEvidence | undefined> => {
|
|
const observation = observeMzWindowsHeader(evidence.prefix, fileSize);
|
|
if (observation.state !== "signature-needed")
|
|
return evidence.mzWindowsHeaderSignature;
|
|
const { offset } = observation;
|
|
if (!Number.isSafeInteger(offset) || offset < 0 || offset >= fileSize)
|
|
return evidence.mzWindowsHeaderSignature;
|
|
|
|
const window = Buffer.alloc(4);
|
|
const existing =
|
|
evidence.mzWindowsHeaderSignature?.offset === offset
|
|
? evidence.mzWindowsHeaderSignature.bytes
|
|
: undefined;
|
|
const inline =
|
|
offset < evidence.prefix.length
|
|
? evidence.prefix.subarray(
|
|
offset,
|
|
Math.min(offset + 4, evidence.prefix.length),
|
|
)
|
|
: undefined;
|
|
const known =
|
|
existing !== undefined &&
|
|
(inline === undefined || existing.length > inline.length)
|
|
? existing
|
|
: inline;
|
|
known?.copy(window);
|
|
let available = known?.length ?? 0;
|
|
const requiredLength = (): number =>
|
|
available >= 2 && window[0] === 0x50 && window[1] === 0x45 ? 4 : 2;
|
|
while (available < requiredLength() && offset + available < fileSize) {
|
|
abortIfNeeded(signal);
|
|
const length = Math.min(
|
|
requiredLength() - available,
|
|
fileSize - offset - available,
|
|
);
|
|
const chunk = Buffer.alloc(length);
|
|
const read = await handle.read(chunk, 0, length, offset + available);
|
|
abortIfNeeded(signal);
|
|
if (read.bytesRead === 0) break;
|
|
chunk.copy(window, available, 0, read.bytesRead);
|
|
available += read.bytesRead;
|
|
}
|
|
if (available === 0) return undefined;
|
|
return { offset, bytes: window.subarray(0, available) };
|
|
};
|
|
|
|
const hasKolyTrailer = async (
|
|
handle: FileHandle,
|
|
size: number,
|
|
signal?: AbortSignal,
|
|
): Promise<boolean> => {
|
|
if (size < 512) return false;
|
|
const trailer = Buffer.alloc(4);
|
|
let bytes = 0;
|
|
while (bytes < trailer.length) {
|
|
abortIfNeeded(signal);
|
|
const read = await handle.read(
|
|
trailer,
|
|
bytes,
|
|
trailer.length - bytes,
|
|
size - 512 + bytes,
|
|
);
|
|
abortIfNeeded(signal);
|
|
if (read.bytesRead === 0) return false;
|
|
bytes += read.bytesRead;
|
|
}
|
|
return trailer.equals(Buffer.from("koly"));
|
|
};
|