mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
import type { FileHandle } from "node:fs/promises";
|
|
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
|
import {
|
|
assertArtifactFileRange,
|
|
readArtifactFileBytes,
|
|
} from "./ArtifactFileRange.js";
|
|
import { decodeLz4Block } from "../gpu/Lz4Block.js";
|
|
import {
|
|
BinaryFormatFailure,
|
|
BinaryResourceFailure,
|
|
} from "../native/metadata/BinaryContainer.js";
|
|
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import {
|
|
readUnityFsHeaderFacts,
|
|
UNITYFS_COMPRESSION_LZ4,
|
|
UNITYFS_COMPRESSION_LZ4HC,
|
|
UNITYFS_COMPRESSION_NONE,
|
|
UNITYFS_FLAG_BLOCKS_INFO_AT_END,
|
|
UNITYFS_FLAG_COMPRESSION_MASK,
|
|
UNITYFS_FORMAT_VERSION_MAX,
|
|
UNITYFS_FORMAT_VERSION_MIN,
|
|
UNITYFS_HEADER_STRING_MAX,
|
|
UNITYFS_SIGNATURE,
|
|
unityFsLayoutFlags,
|
|
} from "../domain/unityfsFormat.js";
|
|
|
|
/** Per-decode resource limits, not format limits. Plain blocks are streamed. */
|
|
const MAX_BLOCK_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
const MAX_BLOCK_ENCODED_BYTES =
|
|
MAX_BLOCK_UNCOMPRESSED_BYTES +
|
|
Math.floor(MAX_BLOCK_UNCOMPRESSED_BYTES / 255) +
|
|
16;
|
|
const MAX_TABLE_BYTES = 16 * 1024 * 1024;
|
|
const DIRECTORY_BUDGET_BYTES = 64 * 1024 * 1024;
|
|
|
|
const directoryLimit = (): ArtifactReaderFailure =>
|
|
new ArtifactReaderFailure(
|
|
"limit",
|
|
"UnityFS directory exceeds its 16 MiB buffer or 64 MiB representation budget",
|
|
{ boundary: "memory" },
|
|
);
|
|
|
|
/** Reserve buffers, JS rows and escaped path projections before constructing them. */
|
|
class UnityFsDirectoryBudget {
|
|
#remaining = DIRECTORY_BUDGET_BYTES;
|
|
reserve(bytes: number): void {
|
|
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
|
|
throw directoryLimit();
|
|
this.#remaining -= bytes;
|
|
}
|
|
}
|
|
/** Signature, version, two bounded strings and the fixed tail; reads are bounded. */
|
|
const HEADER_PREFIX_MAX = 8 + 4 + UNITYFS_HEADER_STRING_MAX * 2 + 20;
|
|
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
|
export interface UnityFsHeader {
|
|
readonly formatVersion: number;
|
|
readonly playerVersion: string;
|
|
readonly engineVersion: string;
|
|
readonly declaredSize: number;
|
|
readonly flags: number;
|
|
readonly compressedBlocksInfoSize: number;
|
|
readonly uncompressedBlocksInfoSize: number;
|
|
readonly blocksInfoAtEnd: boolean;
|
|
readonly compression: number;
|
|
readonly layout:
|
|
| {
|
|
readonly status:
|
|
| "encrypted"
|
|
| "unsupported-compression"
|
|
| "unsupported-layout";
|
|
}
|
|
| {
|
|
readonly status: "available";
|
|
readonly blocksInfoOffset: number;
|
|
readonly afterHeader: number;
|
|
readonly dataPadding: boolean;
|
|
};
|
|
}
|
|
|
|
export interface UnityFsBlock {
|
|
readonly uncompressedSize: number;
|
|
readonly compressedSize: number;
|
|
readonly flags: number;
|
|
readonly compression: number;
|
|
/** Prefix count lets range consumers identify unsupported content in O(1). */
|
|
readonly unavailableCount: number;
|
|
/** Cumulative compressed byte offset inside the data area. */
|
|
readonly compressedStart: number;
|
|
/** Cumulative uncompressed byte offset inside the virtual stream. */
|
|
readonly streamStart: number;
|
|
}
|
|
|
|
export interface UnityFsNode {
|
|
readonly offset: number;
|
|
readonly size: number;
|
|
readonly flags: number;
|
|
readonly path: string;
|
|
}
|
|
|
|
export interface UnityFsBlocksInfo {
|
|
readonly dataHash: string;
|
|
readonly blocks: readonly UnityFsBlock[];
|
|
readonly nodes: readonly UnityFsNode[];
|
|
readonly dataStart: number;
|
|
}
|
|
|
|
export const unityFsFormatFailure = (detail: string): ArtifactReaderFailure =>
|
|
new ArtifactReaderFailure("format", `UnityFS is malformed: ${detail}`);
|
|
|
|
const align16 = (value: number): number => (value + 15) & ~15;
|
|
|
|
/** Decode header fields once; unsupported content does not erase observed headers. */
|
|
export const readUnityFsHeader = async (
|
|
handle: FileHandle,
|
|
size: number,
|
|
signal?: AbortSignal,
|
|
): Promise<UnityFsHeader | undefined> => {
|
|
const prefix = await readArtifactFileBytes(
|
|
handle,
|
|
0,
|
|
Math.min(size, HEADER_PREFIX_MAX),
|
|
size,
|
|
signal,
|
|
);
|
|
if (prefix.length < 20 || !prefix.subarray(0, 8).equals(UNITYFS_SIGNATURE))
|
|
return undefined;
|
|
const facts = readUnityFsHeaderFacts(prefix);
|
|
if (facts === undefined)
|
|
throw unityFsFormatFailure("header is truncated or exceeds its bounds");
|
|
if (
|
|
facts.formatVersion < UNITYFS_FORMAT_VERSION_MIN ||
|
|
facts.formatVersion > UNITYFS_FORMAT_VERSION_MAX
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`UnityFS format ${String(facts.formatVersion)} is unsupported; ${String(UNITYFS_FORMAT_VERSION_MIN)}-${String(UNITYFS_FORMAT_VERSION_MAX)} are supported`,
|
|
);
|
|
if (facts.declaredSize > BigInt(size))
|
|
throw unityFsFormatFailure("declared size exceeds the file");
|
|
const blocksInfoAtEnd = (facts.flags & UNITYFS_FLAG_BLOCKS_INFO_AT_END) !== 0;
|
|
if (blocksInfoAtEnd && facts.compressedBlocksInfoSize > size)
|
|
throw unityFsFormatFailure("declared blocks-info exceeds the file");
|
|
const compression = facts.flags & UNITYFS_FLAG_COMPRESSION_MASK;
|
|
const common = {
|
|
formatVersion: facts.formatVersion,
|
|
playerVersion: facts.playerVersion,
|
|
engineVersion: facts.engineVersion,
|
|
declaredSize: Number(facts.declaredSize),
|
|
flags: facts.flags,
|
|
compressedBlocksInfoSize: facts.compressedBlocksInfoSize,
|
|
uncompressedBlocksInfoSize: facts.uncompressedBlocksInfoSize,
|
|
blocksInfoAtEnd,
|
|
compression,
|
|
};
|
|
const layout = unityFsLayoutFlags(
|
|
facts.formatVersion,
|
|
facts.engineVersion,
|
|
facts.flags,
|
|
);
|
|
if (layout.encrypted === true)
|
|
return { ...common, layout: { status: "encrypted" } };
|
|
if (
|
|
layout.encrypted === undefined ||
|
|
layout.headerAligned === undefined ||
|
|
layout.dataPadding === undefined
|
|
)
|
|
return { ...common, layout: { status: "unsupported-layout" } };
|
|
if (
|
|
compression !== UNITYFS_COMPRESSION_NONE &&
|
|
compression !== UNITYFS_COMPRESSION_LZ4 &&
|
|
compression !== UNITYFS_COMPRESSION_LZ4HC
|
|
)
|
|
return { ...common, layout: { status: "unsupported-compression" } };
|
|
const position = layout.headerAligned
|
|
? align16(facts.afterFixed)
|
|
: facts.afterFixed;
|
|
return {
|
|
...common,
|
|
layout: {
|
|
status: "available",
|
|
afterHeader: position,
|
|
blocksInfoOffset: blocksInfoAtEnd
|
|
? size - facts.compressedBlocksInfoSize
|
|
: position,
|
|
dataPadding: layout.dataPadding,
|
|
},
|
|
};
|
|
};
|
|
|
|
const decodeBlocksInfoTable = async (
|
|
header: UnityFsHeader,
|
|
compressed: Buffer,
|
|
signal?: AbortSignal,
|
|
): Promise<Buffer> => {
|
|
const compression = header.compression;
|
|
if (compression === UNITYFS_COMPRESSION_NONE) {
|
|
if (compressed.length !== header.uncompressedBlocksInfoSize)
|
|
throw unityFsFormatFailure(
|
|
"plain directory size disagrees with its declaration",
|
|
);
|
|
return compressed;
|
|
}
|
|
if (
|
|
compression === UNITYFS_COMPRESSION_LZ4 ||
|
|
compression === UNITYFS_COMPRESSION_LZ4HC
|
|
)
|
|
return decodeUnityFsLz4(
|
|
compressed,
|
|
header.uncompressedBlocksInfoSize,
|
|
signal,
|
|
);
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`UnityFS blocks-info compression ${String(compression)} is unsupported`,
|
|
);
|
|
};
|
|
|
|
/** Translate decoder failures once, without erasing cancellation or resource reasons. */
|
|
export const decodeUnityFsLz4 = async (
|
|
encoded: Buffer,
|
|
expected: number,
|
|
signal?: AbortSignal,
|
|
): Promise<Buffer> => {
|
|
try {
|
|
return await decodeLz4Block(encoded, expected, signal);
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof ArtifactReaderFailure) throw cause;
|
|
if (cause instanceof BinaryResourceFailure)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"UnityFS LZ4 exceeds the decoder memory budget",
|
|
{ cause, boundary: "memory" },
|
|
);
|
|
if (cause instanceof BinaryFormatFailure)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"UnityFS is malformed: LZ4 block does not decode to its declared size",
|
|
{ cause },
|
|
);
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const parseBlocks = async (
|
|
table: Buffer,
|
|
budget: UnityFsDirectoryBudget,
|
|
signal?: AbortSignal,
|
|
): Promise<UnityFsBlock[]> => {
|
|
if (table.length < 24)
|
|
throw unityFsFormatFailure("blocks-info table is truncated");
|
|
const blockCount = table.readUInt32BE(16);
|
|
if (20 + blockCount * 10 + 4 > table.length)
|
|
throw unityFsFormatFailure("declared block count exceeds the table");
|
|
// Includes array slots, numeric fields and the retained lookup representation.
|
|
budget.reserve(blockCount * 256);
|
|
const blocks: UnityFsBlock[] = [];
|
|
let cursor = 20;
|
|
let compressedStart = 0;
|
|
let streamStart = 0;
|
|
let unavailableCount = 0;
|
|
for (let index = 0; index < blockCount; index++) {
|
|
if (index % 256 === 0) await setImmediate();
|
|
assertArtifactReadActive(signal);
|
|
const uncompressedSize = table.readUInt32BE(cursor);
|
|
const compressedSize = table.readUInt32BE(cursor + 4);
|
|
const flags = table.readUInt16BE(cursor + 8);
|
|
const compression = flags & UNITYFS_FLAG_COMPRESSION_MASK;
|
|
cursor += 10;
|
|
const lz4 =
|
|
compression === UNITYFS_COMPRESSION_LZ4 ||
|
|
compression === UNITYFS_COMPRESSION_LZ4HC;
|
|
if (
|
|
lz4 &&
|
|
(uncompressedSize > MAX_BLOCK_UNCOMPRESSED_BYTES ||
|
|
compressedSize > MAX_BLOCK_ENCODED_BYTES)
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
"UnityFS LZ4 block exceeds its 64 MiB decoded or bounded encoded buffer budget",
|
|
{ boundary: "memory" },
|
|
);
|
|
if (
|
|
compression === UNITYFS_COMPRESSION_NONE &&
|
|
compressedSize !== uncompressedSize
|
|
)
|
|
throw unityFsFormatFailure(
|
|
"plain block size disagrees with its declaration",
|
|
);
|
|
if (
|
|
uncompressedSize > 0 &&
|
|
compression !== UNITYFS_COMPRESSION_NONE &&
|
|
!lz4
|
|
)
|
|
unavailableCount++;
|
|
if (compression !== UNITYFS_COMPRESSION_NONE && compressedSize === 0)
|
|
throw unityFsFormatFailure("compressed block declares no bytes");
|
|
blocks.push({
|
|
uncompressedSize,
|
|
compressedSize,
|
|
flags,
|
|
compression,
|
|
unavailableCount,
|
|
compressedStart,
|
|
streamStart,
|
|
});
|
|
compressedStart += compressedSize;
|
|
streamStart += uncompressedSize;
|
|
}
|
|
return blocks;
|
|
};
|
|
|
|
const parseNodes = async (
|
|
table: Buffer,
|
|
blocks: readonly UnityFsBlock[],
|
|
budget: UnityFsDirectoryBudget,
|
|
signal?: AbortSignal,
|
|
): Promise<UnityFsNode[]> => {
|
|
let cursor = 20 + blocks.length * 10;
|
|
if (cursor + 4 > table.length)
|
|
throw unityFsFormatFailure("node count is missing from the table");
|
|
const nodeCount = table.readUInt32BE(cursor);
|
|
cursor += 4;
|
|
if (cursor + nodeCount * 21 > table.length)
|
|
throw unityFsFormatFailure("declared node count exceeds the table");
|
|
// Covers parsed rows plus the reader's owned entries/map; paths are charged separately.
|
|
budget.reserve(nodeCount * 512);
|
|
const streamSize =
|
|
blocks.length === 0
|
|
? 0
|
|
: (blocks[blocks.length - 1]?.streamStart ?? 0) +
|
|
(blocks[blocks.length - 1]?.uncompressedSize ?? 0);
|
|
const nodes: UnityFsNode[] = [];
|
|
for (let index = 0; index < nodeCount; index++) {
|
|
if (index % 256 === 0) await setImmediate();
|
|
assertArtifactReadActive(signal);
|
|
if (cursor + 20 > table.length)
|
|
throw unityFsFormatFailure("node fixed fields are truncated");
|
|
const offsetBig = table.readBigUInt64BE(cursor);
|
|
const sizeBig = table.readBigUInt64BE(cursor + 8);
|
|
const flags = table.readUInt32BE(cursor + 16);
|
|
cursor += 20;
|
|
const nameEnd = table.indexOf(0, cursor);
|
|
if (nameEnd === -1) throw unityFsFormatFailure("node path is unterminated");
|
|
// UTF-16 plus repeated JSON-escaped source/canonical path projections.
|
|
budget.reserve((nameEnd - cursor) * 24);
|
|
let path: string;
|
|
try {
|
|
path = UTF8.decode(table.subarray(cursor, nameEnd));
|
|
} catch (cause: unknown) {
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"UnityFS node path is not valid UTF-8",
|
|
{ cause },
|
|
);
|
|
}
|
|
cursor = nameEnd + 1;
|
|
// Preserve the source spelling; ArtifactPaths owns destination validation.
|
|
if (path.length === 0) throw unityFsFormatFailure("empty member path");
|
|
if (
|
|
offsetBig > BigInt(streamSize) ||
|
|
sizeBig > BigInt(streamSize) - offsetBig
|
|
)
|
|
throw unityFsFormatFailure(`member exceeds the stream: ${path}`);
|
|
nodes.push({
|
|
offset: Number(offsetBig),
|
|
size: Number(sizeBig),
|
|
flags,
|
|
path,
|
|
});
|
|
}
|
|
return nodes;
|
|
};
|
|
|
|
const resolveDataStart = (
|
|
header: UnityFsHeader,
|
|
blocks: readonly UnityFsBlock[],
|
|
size: number,
|
|
): number => {
|
|
const layout = header.layout;
|
|
if (layout.status !== "available")
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"UnityFS directory layout is unavailable",
|
|
);
|
|
let dataStart = header.blocksInfoAtEnd
|
|
? layout.afterHeader
|
|
: layout.blocksInfoOffset + header.compressedBlocksInfoSize;
|
|
if (layout.dataPadding) dataStart = align16(dataStart);
|
|
const compressedTotal = blocks.reduce(
|
|
(total, block) => total + block.compressedSize,
|
|
0,
|
|
);
|
|
const dataEnd = header.blocksInfoAtEnd ? layout.blocksInfoOffset : size;
|
|
if (dataStart < 0 || dataStart + compressedTotal > dataEnd)
|
|
throw unityFsFormatFailure("declared data area exceeds the file");
|
|
return dataStart;
|
|
};
|
|
|
|
export const readUnityFsBlocksInfo = async (
|
|
handle: FileHandle,
|
|
header: UnityFsHeader,
|
|
size: number,
|
|
signal?: AbortSignal,
|
|
): Promise<UnityFsBlocksInfo> => {
|
|
const layout = header.layout;
|
|
if (layout.status !== "available")
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"UnityFS directory layout is unavailable",
|
|
);
|
|
assertArtifactFileRange(
|
|
layout.blocksInfoOffset,
|
|
header.compressedBlocksInfoSize,
|
|
size,
|
|
);
|
|
if (
|
|
header.compressedBlocksInfoSize > MAX_TABLE_BYTES ||
|
|
header.uncompressedBlocksInfoSize > MAX_TABLE_BYTES
|
|
)
|
|
throw directoryLimit();
|
|
const budget = new UnityFsDirectoryBudget();
|
|
budget.reserve(header.compressedBlocksInfoSize);
|
|
if (header.compression !== UNITYFS_COMPRESSION_NONE)
|
|
budget.reserve(header.uncompressedBlocksInfoSize);
|
|
const compressed = await readArtifactFileBytes(
|
|
handle,
|
|
layout.blocksInfoOffset,
|
|
header.compressedBlocksInfoSize,
|
|
size,
|
|
signal,
|
|
);
|
|
const table = await decodeBlocksInfoTable(header, compressed, signal);
|
|
if (table.length < 20)
|
|
throw unityFsFormatFailure("blocks-info table is truncated");
|
|
const blocks = await parseBlocks(table, budget, signal);
|
|
const nodes = await parseNodes(table, blocks, budget, signal);
|
|
return {
|
|
dataHash: table.subarray(0, 16).toString("hex"),
|
|
blocks,
|
|
nodes,
|
|
dataStart: resolveDataStart(header, blocks, size),
|
|
};
|
|
};
|