mirror of
https://github.com/morluto/rea.git
synced 2026-11-02 21:06:04 +00:00
Interpret complete scalar source at the XML owner instead of accepting numeric prefixes, permissive date strings or nonempty booleans. Retain malformed content as explicit unknown scalars with original literals and reasons, while preserving the surrounding archive and independent numeric facts. Share the representation across plist, keyed archive, Interface Builder and entitlement projection; remove the superseded numeric fallback and helpers. Parse date fields as UTC, retain real entity/CDATA semantics, and distinguish native integer/real whitespace without ambiguous whitespace backtracking. Validation: 276 source tests and 60 compiled/public tests passed, with updated scalar regressions rerun; static, architecture and docs checks passed. Native macOS plutil controls and CLI/MCP source-hash/time-zone controls executed. No Linux/Windows or deep-provider qualification. Tiny completed CLI task costs recorded; no performance improvement claimed. BREAKING CHANGE: Unresolved XML scalar values now use $plist_type unknown wrappers with element, literal and reason. This replaces the former real/null unknown marker and malformed scalar prefix fallback. Supported extended UTC years now preserve their timestamp. Fixes #4852
270 lines
9.5 KiB
TypeScript
270 lines
9.5 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 {
|
|
negativeZeroRealLimitation,
|
|
nonFiniteRealLimitation,
|
|
unknownDateLimitation,
|
|
unknownScalarLimitation,
|
|
} from "../../domain/apple/plistValue.js";
|
|
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
|
|
import {
|
|
decodeBinaryPlistNumbers,
|
|
scanBinaryPlistNumberLiterals,
|
|
} from "./PlistNumberLiterals.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 "./InterfaceBuilderDecodeBudget.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 binaryNumberMetadata = binary
|
|
? scanBinaryPlistNumberLiterals(bytes)
|
|
: undefined;
|
|
const parsed =
|
|
binaryExpansion !== undefined
|
|
? {
|
|
value: parseBinary(
|
|
decodeBinaryPlistNumbers(bytes, binaryNumberMetadata!),
|
|
),
|
|
numberLiterals: binaryNumberMetadata!.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)]),
|
|
...(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;
|
|
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,
|
|
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 },
|
|
]);
|