mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 02:15:44 +00:00
Decode native binary plists from bounded owned source bytes before conversion can erase scalar values. Share scalar preflight and isolate overlapping decoder records while retaining exact integers, date uncertainty, and original bytes. Refuse numeric payloads that read structural table bytes, and avoid retaining projection metadata when callers only need structural validation. Closes #5701 Closes #5703 Closes #5705 Closes #5782 Closes #5783 Closes #5792
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import { parseBinary } from "plist";
|
|
import { z } from "zod";
|
|
import {
|
|
AnalysisInputError,
|
|
AnalysisUnsupportedTargetError,
|
|
} from "../../domain/analysisErrorCore.js";
|
|
import {
|
|
binaryDatePrecisionOverlapLimitation,
|
|
negativeZeroRealLimitation,
|
|
nonFiniteRealLimitation,
|
|
unknownDateLimitation,
|
|
unknownScalarLimitation,
|
|
} from "../../domain/apple/plistValue.js";
|
|
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
|
|
import {
|
|
decodeBinaryPlistScalars,
|
|
scanBinaryPlistScalars,
|
|
} from "./PlistScalars.js";
|
|
import {
|
|
omittedPrototypeKeysLimitation,
|
|
parseXmlPropertyList,
|
|
} from "../../domain/propertyListKeys.js";
|
|
import {
|
|
KeyedArchiveKindError,
|
|
keyedArchiveInputSchema,
|
|
keyedArchiveResultSchema,
|
|
projectKeyedArchive,
|
|
} from "../../domain/apple/keyedArchive.js";
|
|
import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import type { ArtifactEntry } from "../ArtifactReader.js";
|
|
|
|
import { decodeXmlPlistText } from "../../domain/propertyListXmlText.js";
|
|
import {
|
|
hasBinaryPlistSignature,
|
|
hasNibArchiveSignature,
|
|
} from "../../domain/apple/appleArchiveFormat.js";
|
|
import { estimateBinaryPlistExpansion } from "./PropertyListDecodeBudget.js";
|
|
|
|
const MAX_BYTES = 64 * 1024 * 1024;
|
|
/** Decoded representation budget, the same 8:1 ratio Interface Builder uses. */
|
|
const MAX_DECODE_BYTES = 8 * MAX_BYTES;
|
|
|
|
/** Parse inert plist bytes, preserving data and dates with explicit typed values. */
|
|
export const decodeKeyedArchiveBytes = (
|
|
bytes: Buffer,
|
|
selection: { root?: string | undefined; offset: number; limit: number },
|
|
) => {
|
|
if (bytes.length > MAX_BYTES)
|
|
throw new RangeError("Keyed archive exceeds 64 MiB");
|
|
if (hasNibArchiveSignature(bytes))
|
|
throw new KeyedArchiveKindError(
|
|
"Selected file is a compiled NIBArchive, not a Foundation plist archive; decode it with decode_interface_builder",
|
|
"nib-archive",
|
|
);
|
|
const binary = hasBinaryPlistSignature(bytes);
|
|
const xmlText = binary ? undefined : decodeXmlPlistText(bytes);
|
|
// plist.parseBinary recurses on every object reference and copies shared
|
|
// containers, so a few hundred bytes can exhaust memory or the stack.
|
|
// RangeError reports a resource limit; a reference cycle is malformed.
|
|
const binaryExpansion = binary
|
|
? estimateBinaryPlistExpansion(bytes, MAX_DECODE_BYTES, {
|
|
budget: "the keyed archive decode budget",
|
|
fail: (kind, message) =>
|
|
kind === "cycle"
|
|
? new TypeError(
|
|
"binary plist object references form a cycle, which a property list cannot represent",
|
|
)
|
|
: new RangeError(message),
|
|
bounds: "decoder",
|
|
numberProjection: true,
|
|
})
|
|
: undefined;
|
|
const binaryScalarMetadata = binary
|
|
? scanBinaryPlistScalars(bytes, binaryExpansion)
|
|
: undefined;
|
|
const parsed =
|
|
binaryExpansion !== undefined
|
|
? {
|
|
value: parseBinary(
|
|
decodeBinaryPlistScalars(
|
|
bytes,
|
|
binaryScalarMetadata!,
|
|
binaryExpansion!,
|
|
),
|
|
),
|
|
numberLiterals: binaryScalarMetadata!.literals,
|
|
omittedPrototypeKeys: binaryExpansion.binaryPrototypeKeyObserved
|
|
? null
|
|
: 0,
|
|
}
|
|
: parseXmlPropertyList(xmlText ?? "");
|
|
const {
|
|
nonFiniteRealCount,
|
|
negativeZeroRealCount,
|
|
unknownDateCount,
|
|
unknownScalarCount,
|
|
...graph
|
|
} = projectKeyedArchive(parsed.value, selection);
|
|
const numbers = createPlistNumberProjection(parsed.numberLiterals);
|
|
// Classify the serialized graph before projecting values to typed decimals.
|
|
// Numeric IDs, node kinds, references and pagination retain their original meaning.
|
|
const roots = Object.fromEntries(
|
|
Object.entries(graph.roots).map(([name, value]) => [
|
|
name,
|
|
numbers.project(value),
|
|
]),
|
|
);
|
|
const objects = graph.objects.map((object) => ({
|
|
...object,
|
|
value: numbers.project(object.value),
|
|
}));
|
|
return {
|
|
archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const),
|
|
...graph,
|
|
roots,
|
|
objects,
|
|
limitations: [
|
|
...graph.limitations,
|
|
...numbers.limitations(),
|
|
...(parsed.omittedPrototypeKeys === 0
|
|
? []
|
|
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
|
|
...(nonFiniteRealCount === 0
|
|
? []
|
|
: [nonFiniteRealLimitation(nonFiniteRealCount)]),
|
|
...(negativeZeroRealCount === 0
|
|
? []
|
|
: [negativeZeroRealLimitation(negativeZeroRealCount)]),
|
|
...(unknownDateCount === 0
|
|
? []
|
|
: [unknownDateLimitation(unknownDateCount)]),
|
|
...(binaryExpansion === undefined ||
|
|
binaryExpansion.datePrecisionOverlapCount === 0
|
|
? []
|
|
: [
|
|
binaryDatePrecisionOverlapLimitation(
|
|
binaryExpansion.datePrecisionOverlapCount,
|
|
),
|
|
]),
|
|
...(unknownScalarCount === 0
|
|
? []
|
|
: [unknownScalarLimitation(unknownScalarCount)]),
|
|
],
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Point to the workflow that reads the selected kind of file on this host.
|
|
* decode_interface_builder is portable; inspect_plist requires macOS.
|
|
*/
|
|
const keyedArchiveKindRemediation = (
|
|
kind: KeyedArchiveKindError["kind"],
|
|
platform: NodeJS.Platform,
|
|
): string => {
|
|
if (kind === "nib-archive")
|
|
return "Decode compiled NIBArchive files with decode_interface_builder on the app bundle.";
|
|
return platform === "darwin"
|
|
? "Inspect an ordinary property list with inspect_plist; inspect_keyed_archive decodes NSKeyedArchiver archives only."
|
|
: "Select an NSKeyedArchiver archive; inspect_keyed_archive decodes only those. inspect_plist, which reads ordinary property lists, requires a macOS host.";
|
|
};
|
|
|
|
/** Read exactly one regular, contained bundle entry without following symlinks. */
|
|
export const inspectBundleKeyedArchive = async (input: {
|
|
bundlePath: string;
|
|
targetSha256: string | null;
|
|
resourceRoot?: import("../../domain/apple/bundleResourceRoot.js").BundleResourceRoot;
|
|
parameters: unknown;
|
|
signal?: AbortSignal;
|
|
/** Host whose available workflows the remediation names. */
|
|
platform?: NodeJS.Platform;
|
|
}) => {
|
|
const selected = keyedArchiveInputSchema.parse(input.parameters);
|
|
if (
|
|
selected.path.startsWith("/") ||
|
|
selected.path
|
|
.split("/")
|
|
.some((part) => part === ".." || part === "." || part.length === 0)
|
|
)
|
|
throw archivePathError(
|
|
"invalid_format",
|
|
selected.path === "."
|
|
? "Select a keyed archive path relative to the active app bundle."
|
|
: `Archive path must be a canonical relative bundle path without empty, '.', or '..' segments: ${selected.path}`,
|
|
);
|
|
const reader = new DirectoryArtifactReader(input.bundlePath);
|
|
try {
|
|
// Match inventory spelling while retaining the raw path for I/O and evidence.
|
|
const wanted = selected.path.normalize("NFC");
|
|
let entry: ArtifactEntry | undefined;
|
|
for await (const candidate of reader.entries(input.signal, (directory) =>
|
|
wanted.startsWith(`${directory.normalize("NFC")}/`),
|
|
)) {
|
|
if (candidate.path.normalize("NFC") !== wanted) continue;
|
|
if (candidate.kind !== "file")
|
|
throw archivePathError(
|
|
"invalid_value",
|
|
`Archive path selects a ${candidate.kind}, not a regular file: ${selected.path}`,
|
|
);
|
|
if ((candidate.declaredSize ?? 0) > MAX_BYTES)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"Keyed archive exceeds 64 MiB",
|
|
);
|
|
if (entry !== undefined)
|
|
throw archivePathError(
|
|
"invalid_value",
|
|
`Archive path matches multiple Unicode-equivalent bundle entries; selection is ambiguous: ${selected.path}`,
|
|
);
|
|
entry = candidate;
|
|
}
|
|
if (entry === undefined)
|
|
throw archivePathError(
|
|
"invalid_value",
|
|
`No regular file exists at ${selected.path} in the active app bundle.`,
|
|
);
|
|
const stream = await reader.open(entry, input.signal);
|
|
const chunks: Buffer[] = [];
|
|
let size = 0;
|
|
for await (const chunk of stream) {
|
|
if (input.signal?.aborted) {
|
|
stream.destroy();
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Archive inspection cancelled",
|
|
);
|
|
}
|
|
const bytes = z.instanceof(Buffer).parse(chunk);
|
|
size += bytes.length;
|
|
if (size > MAX_BYTES) {
|
|
stream.destroy();
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"Keyed archive exceeds 64 MiB",
|
|
);
|
|
}
|
|
chunks.push(bytes);
|
|
}
|
|
const bytes = Buffer.concat(chunks, size);
|
|
try {
|
|
return keyedArchiveResultSchema.parse({
|
|
target_sha256: input.targetSha256,
|
|
...(input.resourceRoot === undefined
|
|
? {}
|
|
: { resource_root: input.resourceRoot }),
|
|
archive_path: entry.path,
|
|
archive_sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
...decodeKeyedArchiveBytes(bytes, selected),
|
|
});
|
|
} catch (cause) {
|
|
if (cause instanceof AnalysisInputError) throw cause;
|
|
// An intact file of another kind is not a damaged archive.
|
|
if (cause instanceof KeyedArchiveKindError)
|
|
throw new AnalysisUnsupportedTargetError(
|
|
"inspect_keyed_archive",
|
|
join(input.bundlePath, entry.path),
|
|
cause.message,
|
|
{
|
|
cause,
|
|
remediationAction: keyedArchiveKindRemediation(
|
|
cause.kind,
|
|
input.platform ?? process.platform,
|
|
),
|
|
},
|
|
);
|
|
throw new ArtifactReaderFailure(
|
|
cause instanceof RangeError ? "limit" : "format",
|
|
`Cannot decode selected Foundation keyed archive: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
{ cause },
|
|
);
|
|
}
|
|
} finally {
|
|
await reader.close();
|
|
}
|
|
};
|
|
|
|
/** A caller-selected archive path, not the artifact, failed its constraint. */
|
|
const archivePathError = (
|
|
reason: "invalid_format" | "invalid_value",
|
|
message: string,
|
|
) =>
|
|
new AnalysisInputError("inspect_keyed_archive", undefined, [
|
|
{ path: ["path"], reason, message },
|
|
]);
|