382 lines
13 KiB
TypeScript
382 lines
13 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { join } from "node:path";
|
|
import { parseBinary } from "plist";
|
|
import { z } from "zod";
|
|
import {
|
|
AnalysisAccessDeniedError,
|
|
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,
|
|
retainBinaryPlistUidIdentity,
|
|
scanBinaryPlistScalars,
|
|
UnsupportedBinaryPlistUidError,
|
|
} from "./PlistScalars.js";
|
|
import {
|
|
omittedPrototypeKeysLimitation,
|
|
parseXmlPropertyList,
|
|
} from "../../domain/propertyListKeys.js";
|
|
import {
|
|
KeyedArchiveKindError,
|
|
keyedArchiveInputSchema,
|
|
keyedArchiveResultSchema,
|
|
projectKeyedArchive,
|
|
} from "../../domain/apple/keyedArchive.js";
|
|
import {
|
|
DirectoryArtifactReader,
|
|
DirectoryArtifactEntryAccessDenied,
|
|
} from "../DirectoryArtifactReader.js";
|
|
import { ArtifactReaderFailure } from "../ArtifactReader.js";
|
|
import type { ArtifactEntry } from "../ArtifactReader.js";
|
|
import { bindArtifactFileCleanup } from "../ArtifactFileOperation.js";
|
|
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
|
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
|
import { translateArtifactFailure } from "../ArtifactOperationFailure.js";
|
|
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.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,
|
|
uidProjection: true,
|
|
})
|
|
: undefined;
|
|
const binaryScalarMetadata = binary
|
|
? scanBinaryPlistScalars(bytes, binaryExpansion)
|
|
: undefined;
|
|
const parsed =
|
|
binaryExpansion !== undefined
|
|
? {
|
|
value: retainBinaryPlistUidIdentity(
|
|
parseBinary(
|
|
decodeBinaryPlistScalars(
|
|
bytes,
|
|
binaryScalarMetadata!,
|
|
binaryExpansion!,
|
|
),
|
|
),
|
|
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.";
|
|
};
|
|
|
|
interface BundleKeyedArchiveInput {
|
|
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;
|
|
resources?: ArtifactResourceScope;
|
|
}
|
|
|
|
/** Keep the selected archive and its reader under the caller's cleanup owner. */
|
|
export const inspectBundleKeyedArchive = (input: BundleKeyedArchiveInput) => {
|
|
const resources = input.resources ?? new ArtifactResourceScope();
|
|
return resources.run(async () => {
|
|
const reader = new DirectoryArtifactReader(input.bundlePath);
|
|
let outcome:
|
|
| {
|
|
kind: "completed";
|
|
value: Awaited<ReturnType<typeof readSelectedKeyedArchive>>;
|
|
}
|
|
| { kind: "failed"; cause: unknown };
|
|
try {
|
|
outcome = {
|
|
kind: "completed",
|
|
value: await readSelectedKeyedArchive(input, reader),
|
|
};
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
const released = await resources.release({
|
|
kind: "reader",
|
|
reader,
|
|
resource: input.bundlePath,
|
|
});
|
|
if (released.kind === "failed") {
|
|
if (outcome.kind === "failed" && outcome.cause instanceof AnalysisError) {
|
|
const cleanup = translateArtifactFailure(
|
|
"inspect_keyed_archive",
|
|
resources.cleanupFailure({ kind: "completed" }, [released]),
|
|
);
|
|
throw analysisErrorWithCleanupFailure(
|
|
outcome.cause,
|
|
cleanup,
|
|
"inspect_keyed_archive",
|
|
).bindCleanupDependencies([outcome.cause, cleanup]);
|
|
}
|
|
throw resources.cleanupFailure(outcome, [released]);
|
|
}
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
});
|
|
};
|
|
|
|
/** Read exactly one regular, contained bundle entry without following symlinks. */
|
|
const readSelectedKeyedArchive = async (
|
|
input: BundleKeyedArchiveInput,
|
|
reader: DirectoryArtifactReader,
|
|
) => {
|
|
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}`,
|
|
);
|
|
// 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)
|
|
.catch((cause: unknown) => {
|
|
throw selectedArchiveReadFailure(input.bundlePath, entry, cause);
|
|
});
|
|
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",
|
|
{ cause: input.signal.reason },
|
|
);
|
|
}
|
|
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;
|
|
if (cause instanceof UnsupportedBinaryPlistUidError)
|
|
throw new AnalysisUnsupportedTargetError(
|
|
"inspect_keyed_archive",
|
|
join(input.bundlePath, entry.path),
|
|
cause.message,
|
|
{ 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 },
|
|
);
|
|
}
|
|
};
|
|
|
|
/** 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 },
|
|
]);
|
|
|
|
/**
|
|
* The caller selected this entry, so a refused open is a selection outcome:
|
|
* preserve the host's read-access denial and its system code instead of
|
|
* reporting a generic artifact execution failure.
|
|
*/
|
|
const selectedArchiveReadFailure = (
|
|
bundlePath: string,
|
|
entry: ArtifactEntry,
|
|
cause: unknown,
|
|
): unknown => {
|
|
if (!(cause instanceof DirectoryArtifactEntryAccessDenied)) return cause;
|
|
const error = new AnalysisAccessDeniedError(
|
|
"inspect_keyed_archive",
|
|
join(bundlePath, entry.path),
|
|
cause.systemCode,
|
|
{
|
|
cause,
|
|
// The cleanup observation, not just the leases, makes the projection
|
|
// report an unresolved resource; binding leases alone would claim the
|
|
// denied open left nothing owned.
|
|
...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }),
|
|
...(cause.partialObservation === undefined
|
|
? {}
|
|
: { partialObservation: cause.partialObservation }),
|
|
},
|
|
);
|
|
bindArtifactFileCleanup(cause, error);
|
|
return error;
|
|
};
|