mirror of
https://github.com/morluto/rea
synced 2026-10-28 06:46:47 -04:00
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
|
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
|
|
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
|
|
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
|
import {
|
|
ArtifactEmbeddedFormatProbes,
|
|
type ArtifactEmbeddedFormatProbeFacts,
|
|
} from "../ArtifactEmbeddedFormatProbes.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 { decodeNodeSeaBlob } from "../nodeSea/NodeSeaBlob.js";
|
|
import { 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 { HashResult } from "../ArtifactHash.js";
|
|
import {
|
|
hashStableRootArtifactHandle,
|
|
openRootArtifact,
|
|
releaseRootArtifact,
|
|
} from "./hashStableRootArtifact.js";
|
|
import type { ArtifactInventoryOptions } from "./types.js";
|
|
import { readMachoHeaderPrefixFacts } from "../../domain/apple/machoContainer.js";
|
|
import { hasDiskImageSignature } from "../../domain/apple/diskImageFormat.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;
|
|
readonly readerAdmission:
|
|
| StableRegularFileDescriptor
|
|
| NodeSeaAdmission
|
|
| 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 source and format facts for child inventory. */
|
|
export const classifyAndHashRootForInventory = async (
|
|
path: string,
|
|
directory: boolean,
|
|
expectedMetadata: Stats,
|
|
options: Pick<
|
|
ArtifactInventoryOptions,
|
|
| "resourceScope"
|
|
| "signal"
|
|
| "asarHeaderAdmission"
|
|
| "selectedPath"
|
|
| "expectedRootSha256"
|
|
>,
|
|
probeFacts?: ArtifactEmbeddedFormatProbeFacts,
|
|
): Promise<RootInventoryClassification> => {
|
|
if (directory && probeFacts !== undefined) {
|
|
abortIfNeeded(options.signal);
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Root artifact changed before inventory: ${path}`,
|
|
);
|
|
}
|
|
if (directory && options.expectedRootSha256 !== undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Artifact root changed after target admission: expected an admitted file at ${path}, observed a directory`,
|
|
{
|
|
userMessage:
|
|
"The opened file was replaced by a directory. Select the intended artifact and reopen it before retrying.",
|
|
},
|
|
);
|
|
if (directory)
|
|
return {
|
|
format: "directory",
|
|
digest: null,
|
|
rootSource: undefined,
|
|
readerAdmission: undefined,
|
|
};
|
|
if (options.expectedRootSha256 !== undefined)
|
|
await assertArtifactSelectedSource(path, options.selectedPath);
|
|
const source = await openRootArtifact(
|
|
path,
|
|
options.resourceScope,
|
|
options.signal,
|
|
);
|
|
const { handle, initial } = source;
|
|
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}`,
|
|
);
|
|
// Prior probe state can prove replacement without interpreting any bytes.
|
|
const probes = new ArtifactEmbeddedFormatProbes(
|
|
path,
|
|
source,
|
|
options.signal,
|
|
probeFacts,
|
|
);
|
|
const admittedDigest =
|
|
options.expectedRootSha256 === undefined
|
|
? undefined
|
|
: await hashStableRootArtifactHandle(
|
|
path,
|
|
handle,
|
|
initial,
|
|
options.signal,
|
|
);
|
|
if (admittedDigest !== undefined)
|
|
assertAdmittedRootDigest(
|
|
path,
|
|
options.expectedRootSha256,
|
|
admittedDigest.sha256,
|
|
);
|
|
const classified = await classifyRootFormat(
|
|
path,
|
|
source,
|
|
probes,
|
|
options.signal,
|
|
options.asarHeaderAdmission,
|
|
options.selectedPath,
|
|
);
|
|
const format =
|
|
typeof classified === "string" ? classified : classified.kind;
|
|
const digest =
|
|
admittedDigest ??
|
|
(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,
|
|
readerAdmission:
|
|
typeof classified !== "string"
|
|
? classified
|
|
: retainSource
|
|
? source
|
|
: undefined,
|
|
},
|
|
};
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
if (outcome.kind === "failed" || outcome.value.rootSource === undefined) {
|
|
await releaseRootArtifact(
|
|
path,
|
|
source.owner,
|
|
options.resourceScope,
|
|
outcome.kind === "completed"
|
|
? { kind: "completed", digest: outcome.value.digest }
|
|
: outcome,
|
|
options.signal,
|
|
);
|
|
}
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
};
|
|
|
|
const classifyRootFormat = async (
|
|
path: string,
|
|
source: RootInventorySource,
|
|
embeddedProbes: ArtifactEmbeddedFormatProbes,
|
|
signal?: AbortSignal,
|
|
asarHeaderAdmission?: AsarHeaderAdmission,
|
|
selectedPath?: string,
|
|
): Promise<ArtifactOccurrence["artifact_format"] | NodeSeaAdmission> => {
|
|
const { handle } = source;
|
|
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 classificationPath = artifactNameForClassification(
|
|
selectedPath ?? path,
|
|
path,
|
|
);
|
|
const lower = classificationPath.toLowerCase();
|
|
if (hasZipSignature(evidence.prefix)) return zipRootPackageFormat(lower);
|
|
if (
|
|
lower.endsWith(".pkg") &&
|
|
evidence.prefix.subarray(0, 4).equals(Buffer.from("xar!"))
|
|
)
|
|
return "pkg";
|
|
// Fill each range: a short result is end of file, not a partial host read.
|
|
const read = async (position: number, length: number): Promise<Buffer> => {
|
|
const bytes = Buffer.alloc(length);
|
|
let filled = 0;
|
|
while (filled < length) {
|
|
abortIfNeeded(signal);
|
|
const observed = await handle.read(
|
|
bytes,
|
|
filled,
|
|
length - filled,
|
|
position + filled,
|
|
);
|
|
abortIfNeeded(signal);
|
|
if (observed.bytesRead === 0) break;
|
|
filled += observed.bytesRead;
|
|
}
|
|
return bytes.subarray(0, filled);
|
|
};
|
|
if (
|
|
lower.endsWith(".dmg") &&
|
|
(await hasDiskImageSignature(archiveBytes, read))
|
|
)
|
|
return "dmg";
|
|
if (await embeddedProbes.godotPck(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(
|
|
classificationPath,
|
|
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, read)) !== undefined
|
|
)
|
|
return zipRootPackageFormat(lower);
|
|
if (["elf", "pe", "mach-o", "mach-o-universal"].includes(format)) {
|
|
const sea = await embeddedProbes.nodeSea();
|
|
abortIfNeeded(signal);
|
|
if (sea !== undefined) {
|
|
const decoded = await decodeNodeSeaBlob(
|
|
await readNodeSeaBlob(handle, sea, archiveBytes, signal),
|
|
signal,
|
|
);
|
|
return { kind: "node-sea", source, location: sea, decoded };
|
|
}
|
|
const packaged = await embeddedProbes.pyInstaller();
|
|
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) };
|
|
};
|
|
|
|
/** Compare physically completed bytes before interpreting an admitted file root. */
|
|
export const assertAdmittedRootDigest = (
|
|
path: string,
|
|
expected: string | undefined,
|
|
observed: string,
|
|
): void => {
|
|
if (expected !== undefined && observed !== expected)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Artifact root changed after target admission at ${path}: expected ${expected}, observed ${observed}`,
|
|
{
|
|
userMessage:
|
|
"The artifact changed after it was opened. Reopen the selected artifact, then retry.",
|
|
},
|
|
);
|
|
};
|