mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 19:35:56 +00:00
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
import { readGodotPckHeaderFacts } from "../domain/godotPckFormat.js";
|
|
import { artifactExpansionBudgetBytes } from "../domain/artifactExpansionBudget.js";
|
|
import type { FileHandle } from "node:fs/promises";
|
|
import { setImmediate } from "node:timers/promises";
|
|
import { Readable, Transform, pipeline } from "node:stream";
|
|
import {
|
|
ArtifactReaderFailure,
|
|
copyArtifactEntry,
|
|
sameArtifactEntry,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
type ArtifactTraversalOptions,
|
|
} from "./ArtifactReader.js";
|
|
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
|
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
|
import { locateGodotPck, readPckBytes } from "./GodotPckLocation.js";
|
|
import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js";
|
|
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
|
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
|
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
|
|
import { ArtifactFileSource } from "./ArtifactFileSource.js";
|
|
|
|
const PACK_DIR_ENCRYPTED = 1;
|
|
const PACK_REL_FILEBASE = 2;
|
|
const PACK_SPARSE_BUNDLE = 4;
|
|
const PACK_FILE_ENCRYPTED = 1;
|
|
const MINIMUM_ENTRY_BYTES = 40;
|
|
const DIRECTORY_CHUNK_BYTES = 64 * 1024;
|
|
/** Encoded retained entry metadata, including escaped paths and source facts. */
|
|
const MAX_DIRECTORY_METADATA_BYTES = 64 * 1024 * 1024;
|
|
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
|
|
/** One descriptor, one validated directory, and bounded positional member reads. */
|
|
export class GodotPckArtifactReader implements ArtifactReader {
|
|
readonly format = "godot-pck" as const;
|
|
readonly #scope = new ArtifactResourceScope();
|
|
readonly #entries = new Map<string, ArtifactEntry>();
|
|
readonly #streams = new Set<Readable>();
|
|
readonly #source: ArtifactFileSource;
|
|
#prepared: Promise<void> | undefined;
|
|
#header: GodotPckMetadata | undefined;
|
|
#closing = false;
|
|
|
|
constructor(path: string, admitted?: StableRegularFileDescriptor) {
|
|
this.#source = new ArtifactFileSource(path, "Godot PCK", admitted);
|
|
}
|
|
|
|
formatMetadata(): GodotPckMetadata | undefined {
|
|
return this.#header === undefined ? undefined : { ...this.#header };
|
|
}
|
|
|
|
async prepareContainer(
|
|
expectedSha256: string,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
await this.#scope.run(async () => {
|
|
this.#assertOpen(signal);
|
|
await this.#source.bindSha256(expectedSha256, signal);
|
|
});
|
|
}
|
|
|
|
async *entries(
|
|
signal?: AbortSignal,
|
|
options?: ArtifactTraversalOptions,
|
|
): AsyncIterable<ArtifactEntry> {
|
|
await this.#scope.run(async () => {
|
|
this.#assertOpen(signal);
|
|
this.#prepared ??= this.#prepare(signal);
|
|
await this.#prepared;
|
|
await this.#source.read(signal);
|
|
});
|
|
if (this.#header?.directory_status !== "available") {
|
|
options?.onCoverage?.({
|
|
status: "unknown",
|
|
limitations: [
|
|
this.#header?.directory_status === "encrypted"
|
|
? "Godot PCK directory is encrypted; member identities and contents remain unknown."
|
|
: "Godot PCK sparse-bundle member bytes are external and were not traversed.",
|
|
],
|
|
});
|
|
return;
|
|
}
|
|
let index = 0;
|
|
for (const entry of this.#entries.values()) {
|
|
if (index++ % 256 === 0) await setImmediate();
|
|
this.#assertOpen(signal);
|
|
yield copyArtifactEntry(entry);
|
|
}
|
|
}
|
|
|
|
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
|
return this.#scope.run(async () => {
|
|
this.#assertOpen(signal);
|
|
const stored = this.#entries.get(entry.adapterKey);
|
|
if (stored === undefined || !sameArtifactEntry(entry, stored))
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"Godot PCK entry does not match its owning inventory",
|
|
);
|
|
if (stored.encrypted)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`Encrypted Godot PCK member is unsupported: ${stored.path}`,
|
|
);
|
|
const { handle } = await this.#source.read(signal);
|
|
this.#assertOpen(signal);
|
|
if (stored.byteOffset === null || stored.declaredSize === null)
|
|
throw formatFailure("member has no physical byte range");
|
|
const source =
|
|
stored.declaredSize === 0
|
|
? Readable.from([])
|
|
: readFileHandleChunks(handle, {
|
|
start: stored.byteOffset,
|
|
end: stored.byteOffset + stored.declaredSize - 1,
|
|
signal,
|
|
cancellationFailure: (reason) =>
|
|
new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Godot PCK read cancelled",
|
|
{ cause: reason },
|
|
),
|
|
});
|
|
const output = this.#memberStream(stored, signal);
|
|
// The returned stream still reports failures to its consumer. Also handle
|
|
// retirement before a caller has attached its own error listener.
|
|
output.on("error", () => undefined);
|
|
this.#streams.add(output);
|
|
void this.#scope
|
|
.run(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
pipeline(source, output, () => {
|
|
this.#streams.delete(output);
|
|
resolve();
|
|
});
|
|
}),
|
|
)
|
|
.catch((cause: unknown) =>
|
|
output.destroy(
|
|
cause instanceof Error
|
|
? cause
|
|
: new Error("Godot PCK stream ownership failed", { cause }),
|
|
),
|
|
);
|
|
return output;
|
|
});
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.#closing = true;
|
|
for (const stream of this.#streams)
|
|
stream.destroy(
|
|
new ArtifactReaderFailure("unavailable", "Godot PCK reader closed"),
|
|
);
|
|
await this.#scope.close();
|
|
await this.#source.close();
|
|
this.#entries.clear();
|
|
}
|
|
|
|
provenance(): readonly [] {
|
|
return [];
|
|
}
|
|
|
|
#assertOpen(signal?: AbortSignal): void {
|
|
assertArtifactReadActive(signal);
|
|
if (this.#closing)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
"Godot PCK reader is closed",
|
|
);
|
|
}
|
|
|
|
async #prepare(signal?: AbortSignal): Promise<void> {
|
|
const { handle, initial } = await this.#source.read(signal);
|
|
const header = await readGodotPckHeader(handle, initial.size, signal);
|
|
this.#header = header;
|
|
if (header.directory_status !== "available") return;
|
|
const cursor = new PckCursor(
|
|
handle,
|
|
header.directory_start + 4,
|
|
header.pack_end,
|
|
signal,
|
|
);
|
|
let retainedBytes = 0;
|
|
let declaredPlaintextBytes = 0n;
|
|
const expansionBudget = BigInt(
|
|
artifactExpansionBudgetBytes(header.pack_end - header.pack_start),
|
|
);
|
|
for (let index = 0; index < header.declared_file_count; index++) {
|
|
if (index % 256 === 0) await setImmediate();
|
|
assertArtifactReadActive(signal);
|
|
const pathBytes = await cursor.uint32();
|
|
// Check worst-case JSON escaping before decoding or allocating the path.
|
|
// The logical path, raw source path, and metadata source path can each
|
|
// need six JSON bytes per input byte before retained facts are charged.
|
|
if (pathBytes * 18 > MAX_DIRECTORY_METADATA_BYTES - retainedBytes)
|
|
throw metadataLimit();
|
|
const rawPath = await cursor.bytes(pathBytes);
|
|
const offset = await cursor.uint64();
|
|
const size = await cursor.uint64();
|
|
const md5 = (await cursor.bytes(16)).toString("hex");
|
|
const flags = await cursor.uint32();
|
|
if ((flags & ~PACK_FILE_ENCRYPTED) !== 0)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`Godot patch or unknown member flags are unsupported: 0x${flags.toString(16)}`,
|
|
);
|
|
const sourcePath = pckMemberPath(rawPath);
|
|
const start = toSafeOffset(
|
|
offset,
|
|
header.file_base,
|
|
header.pack_end,
|
|
sourcePath,
|
|
);
|
|
if (size > BigInt(header.pack_end - start))
|
|
throw formatFailure(`member exceeds the pack: ${sourcePath}`);
|
|
// Aliases are valid, but each path causes another hash/copy. Admit the
|
|
// complete directory before yielding anything, including all-zero claims.
|
|
if ((flags & PACK_FILE_ENCRYPTED) === 0) {
|
|
declaredPlaintextBytes += size;
|
|
if (declaredPlaintextBytes > expansionBudget)
|
|
throw new ArtifactReaderFailure(
|
|
"limit",
|
|
`Godot PCK readable entries declare ${String(declaredPlaintextBytes)} plaintext bytes, exceeding this pack's ${String(expansionBudget)}-byte expansion budget`,
|
|
);
|
|
}
|
|
const metadata = {
|
|
path: sourcePath.startsWith("res://")
|
|
? sourcePath.slice(6)
|
|
: sourcePath,
|
|
sourcePath,
|
|
kind: "file" as const,
|
|
declaredSize: Number(size),
|
|
compressedSize: null,
|
|
executable: false,
|
|
encrypted: (flags & PACK_FILE_ENCRYPTED) !== 0,
|
|
byteOffset: start,
|
|
declaredIntegrity: /^0{32}$/u.test(md5)
|
|
? null
|
|
: { algorithm: "md5" as const, digest: md5 },
|
|
unpacked: false,
|
|
limitations: /^0{32}$/u.test(md5)
|
|
? ["Godot PCK declares an all-zero MD5; source integrity is unknown."]
|
|
: [],
|
|
adapterKey: `godot-pck-entry:${String(index)}`,
|
|
formatMetadata: {
|
|
kind: "godot-pck-entry" as const,
|
|
index,
|
|
source_path: sourcePath,
|
|
declared_offset: Number(offset),
|
|
flags,
|
|
md5_status: /^0{32}$/u.test(md5)
|
|
? ("all-zero" as const)
|
|
: ("declared" as const),
|
|
},
|
|
} satisfies ArtifactEntry;
|
|
retainedBytes += jsonValueByteLength(
|
|
metadata,
|
|
MAX_DIRECTORY_METADATA_BYTES - retainedBytes,
|
|
);
|
|
if (retainedBytes > MAX_DIRECTORY_METADATA_BYTES) throw metadataLimit();
|
|
this.#entries.set(metadata.adapterKey, metadata);
|
|
}
|
|
await this.#source.read(signal);
|
|
}
|
|
|
|
#memberStream(entry: ArtifactEntry, signal?: AbortSignal): Transform {
|
|
let bytes = 0;
|
|
const check = async (): Promise<void> => {
|
|
if (bytes !== entry.declaredSize)
|
|
throw formatFailure(`member ended early: ${entry.path}`);
|
|
await this.#source.read(signal);
|
|
};
|
|
return new Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
bytes += chunk.length;
|
|
callback(null, chunk);
|
|
},
|
|
flush(callback) {
|
|
void check().then(
|
|
() => callback(),
|
|
(cause: unknown) =>
|
|
callback(
|
|
cause instanceof Error
|
|
? cause
|
|
: new Error("Godot PCK read failed", { cause }),
|
|
),
|
|
);
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Decode header fields once; unsupported content does not erase observed headers. */
|
|
const readGodotPckHeader = async (
|
|
handle: FileHandle,
|
|
size: number,
|
|
signal?: AbortSignal,
|
|
): Promise<GodotPckMetadata> => {
|
|
const location = await locateGodotPck(handle, size, signal);
|
|
if (location === undefined)
|
|
throw formatFailure("no standalone header or executable trailer");
|
|
const { start, end, embedded } = location;
|
|
const fixed = await readPckBytes(handle, start, 40, end, signal);
|
|
const header = readGodotPckHeaderFacts(fixed);
|
|
const version = header.formatVersion;
|
|
if (![2, 3, 4].includes(version))
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`Godot PCK format ${String(version)} is unsupported; plain formats 2, 3 and 4 are supported`,
|
|
);
|
|
const flags = fixed.readUInt32LE(20);
|
|
if (
|
|
(flags & ~(PACK_DIR_ENCRYPTED | PACK_REL_FILEBASE | PACK_SPARSE_BUNDLE)) !==
|
|
0
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`Godot PCK declares unknown pack flags 0x${flags.toString(16)}`,
|
|
);
|
|
const fileBase = toSafeOffset(
|
|
fixed.readBigUInt64LE(24),
|
|
version >= 3 || (flags & PACK_REL_FILEBASE) !== 0 ? start : 0,
|
|
end,
|
|
"file base",
|
|
);
|
|
const directory =
|
|
version >= 3
|
|
? toSafeOffset(fixed.readBigUInt64LE(32), start, end, "directory")
|
|
: start + 96;
|
|
const count = (
|
|
await readPckBytes(handle, directory, 4, end, signal)
|
|
).readUInt32LE(0);
|
|
const status =
|
|
(flags & PACK_DIR_ENCRYPTED) !== 0
|
|
? "encrypted"
|
|
: (flags & PACK_SPARSE_BUNDLE) !== 0
|
|
? "sparse-bundle"
|
|
: "available";
|
|
if (
|
|
status === "available" &&
|
|
count * MINIMUM_ENTRY_BYTES > end - directory - 4
|
|
)
|
|
throw formatFailure("declared member count cannot fit in the directory");
|
|
return {
|
|
kind: "godot-pck",
|
|
format_version: version,
|
|
engine_major: header.engineMajor,
|
|
engine_minor: header.engineMinor,
|
|
engine_patch: header.enginePatch,
|
|
pack_start: start,
|
|
pack_end: end,
|
|
embedded,
|
|
pack_flags: flags,
|
|
file_base: fileBase,
|
|
directory_start: directory,
|
|
declared_file_count: count,
|
|
directory_status: status,
|
|
};
|
|
};
|
|
|
|
const pckMemberPath = (raw: Buffer): string => {
|
|
let end = raw.length;
|
|
while (end > 0 && raw[end - 1] === 0) end--;
|
|
let path: string;
|
|
try {
|
|
path = UTF8.decode(raw.subarray(0, end));
|
|
} catch (cause: unknown) {
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Godot PCK path is not valid UTF-8",
|
|
{ cause },
|
|
);
|
|
}
|
|
if (path.length === 0 || path === "res://" || path.includes("\0"))
|
|
throw formatFailure("empty or NUL-containing member path");
|
|
return path;
|
|
};
|
|
|
|
/** One bounded read window; no whole-pack buffer or per-member source reopening. */
|
|
class PckCursor {
|
|
#buffer: Buffer = Buffer.alloc(0);
|
|
#bufferStart = 0;
|
|
constructor(
|
|
private readonly handle: FileHandle,
|
|
private position: number,
|
|
private readonly end: number,
|
|
private readonly signal?: AbortSignal,
|
|
) {}
|
|
async bytes(length: number): Promise<Buffer> {
|
|
if (length > this.end - this.position)
|
|
throw formatFailure("directory entry exceeds the pack");
|
|
if (length > MAX_DIRECTORY_METADATA_BYTES / 18) throw metadataLimit();
|
|
if (length > DIRECTORY_CHUNK_BYTES) {
|
|
// A large valid virtual path is bounded by retained representation, not
|
|
// the host filesystem's PATH_MAX. Only this path receives a larger read.
|
|
const value = await readPckBytes(
|
|
this.handle,
|
|
this.position,
|
|
length,
|
|
this.end,
|
|
this.signal,
|
|
);
|
|
this.position += length;
|
|
return value;
|
|
}
|
|
const offset = this.position - this.#bufferStart;
|
|
if (offset < 0 || offset + length > this.#buffer.length) {
|
|
this.#buffer = await readPckBytes(
|
|
this.handle,
|
|
this.position,
|
|
Math.min(DIRECTORY_CHUNK_BYTES, this.end - this.position),
|
|
this.end,
|
|
this.signal,
|
|
);
|
|
this.#bufferStart = this.position;
|
|
}
|
|
const start = this.position - this.#bufferStart;
|
|
this.position += length;
|
|
return this.#buffer.subarray(start, start + length);
|
|
}
|
|
async uint32(): Promise<number> {
|
|
return (await this.bytes(4)).readUInt32LE(0);
|
|
}
|
|
async uint64(): Promise<bigint> {
|
|
return (await this.bytes(8)).readBigUInt64LE(0);
|
|
}
|
|
}
|
|
|
|
const toSafeOffset = (
|
|
offset: bigint,
|
|
base: number,
|
|
end: number,
|
|
subject: string,
|
|
): number => {
|
|
if (base > end || offset > BigInt(end - base))
|
|
throw formatFailure(`${subject} offset exceeds the pack`);
|
|
return base + Number(offset);
|
|
};
|
|
const formatFailure = (detail: string): ArtifactReaderFailure =>
|
|
new ArtifactReaderFailure("format", `Godot PCK is malformed: ${detail}`);
|
|
const metadataLimit = (): ArtifactReaderFailure =>
|
|
new ArtifactReaderFailure(
|
|
"limit",
|
|
"Godot PCK directory exceeds the 64 MiB encoded metadata budget",
|
|
{ boundary: "memory" },
|
|
);
|