mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
* feat(artifacts): read Godot PCK packs Add a Godot Engine PCK artifact reader for standalone packs and packs embedded behind an executable trailer (formats 2 and 3). Members are uncompressed byte ranges verified against their declared MD5 on read. Encrypted directories, sparse bundles, patch removal entries, unknown flags and format 1 fail closed as unavailable; encrypted members are reported without being read. inspect-artifact and extract-artifact recognize standalone packs by their GDPC magic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(artifacts): bound Godot PCK metadata and own member streams Cap member paths at 4096 bytes and the retained directory at 64 MiB before allocation. Read members with positional chunk reads through pipeline so cancellation is typed, abandoned consumers release their source, and the shared descriptor stays reader-owned. Retain the root descriptor for godot-pck inventory, as for ZIP and universal Mach-O. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(artifacts): complete Godot PCK facts, integrity and selected views * fix(artifacts): preserve atomic-root inventory Evidence * fix(artifacts): bound aggregate PCK member expansion --------- Co-authored-by: Samucahub <151155051+Samucahub@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
608 lines
19 KiB
TypeScript
608 lines
19 KiB
TypeScript
import { constants, type BigIntStats } from "node:fs";
|
|
import {
|
|
lstat,
|
|
open,
|
|
opendir,
|
|
realpath,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
import { Readable } from "node:stream";
|
|
|
|
import {
|
|
ArtifactReaderFailure,
|
|
sameArtifactSourceIdentity,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
type ArtifactTraversalOmission,
|
|
type ArtifactTraversalOptions,
|
|
} from "./ArtifactReader.js";
|
|
import { hostExecutableMarking } from "./hostExecutableMarking.js";
|
|
import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
|
|
import {
|
|
withOwnedFileHandle,
|
|
RegularFileCleanupFailure,
|
|
RegularFileCancelled,
|
|
} from "../filesystem/OwnedRegularFile.js";
|
|
import { RegularFileOperationCancelled } from "../filesystem/RegularFile.js";
|
|
import {
|
|
ArtifactFileCancelled,
|
|
ArtifactFileCleanupFailure,
|
|
} from "./ArtifactFileOperation.js";
|
|
import { AnalysisCleanupLease } from "./AnalysisCleanupLease.js";
|
|
|
|
export type DirectoryArtifactEntry = ArtifactEntry & {
|
|
readonly kind: "file" | "directory" | "symlink";
|
|
};
|
|
|
|
interface DirectoryArtifactRoot {
|
|
readonly canonicalPath: string;
|
|
readonly identity: { readonly dev: bigint; readonly ino: bigint };
|
|
}
|
|
|
|
interface DirectoryTraversalOptions extends ArtifactTraversalOptions {
|
|
readonly descendInto?: (path: string) => boolean;
|
|
/** Bound examined children, including omitted special entries. */
|
|
readonly maximumEntries?: number;
|
|
}
|
|
|
|
/** Exhausted an examined-entry budget; unrelated I/O and cleanup failures stay distinct. */
|
|
export class DirectoryArtifactTraversalLimit extends ArtifactReaderFailure {
|
|
constructor(
|
|
readonly examinedEntries: number,
|
|
readonly maximumEntries: number,
|
|
) {
|
|
super("limit", "Directory traversal reached its examined-entry budget", {
|
|
boundary: "capacity",
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Non-symlink-following directory reader. */
|
|
export class DirectoryArtifactReader implements ArtifactReader {
|
|
readonly format = "directory" as const;
|
|
#rootPromise: Promise<DirectoryArtifactRoot> | undefined;
|
|
readonly #rootPath: string;
|
|
readonly #directories = new Set<OwnedDirectoryHandle>();
|
|
readonly #files = new Map<OwnedFileHandle, string>();
|
|
readonly #fileCleanup = new WeakMap<OwnedFileHandle, AnalysisCleanupLease>();
|
|
|
|
constructor(root: string) {
|
|
this.#rootPath = root;
|
|
}
|
|
|
|
/** Enumerate entries, optionally restricting descent while retaining directories. */
|
|
async *entries(
|
|
signal?: AbortSignal,
|
|
selection?: DirectoryTraversalOptions | ((path: string) => boolean),
|
|
): AsyncIterable<DirectoryArtifactEntry> {
|
|
const admittedRoot = await this.#resolveRoot();
|
|
const root = admittedRoot.canonicalPath;
|
|
const options =
|
|
typeof selection === "function" ? { descendInto: selection } : selection;
|
|
const maximum = options?.maximumEntries;
|
|
if (
|
|
maximum !== undefined &&
|
|
(!Number.isSafeInteger(maximum) || maximum < 0)
|
|
)
|
|
throw new RangeError(
|
|
"Directory traversal maximumEntries must be a nonnegative safe integer",
|
|
);
|
|
let examined = 0;
|
|
const examine = () => {
|
|
if (maximum !== undefined && examined >= maximum)
|
|
throw new DirectoryArtifactTraversalLimit(examined, maximum);
|
|
examined += 1;
|
|
};
|
|
const pending = [root];
|
|
while (pending.length > 0) {
|
|
abortIfNeeded(signal);
|
|
const directory = pending.pop();
|
|
if (directory === undefined) break;
|
|
await withDirectoryIoContext("inspect directory", directory, () =>
|
|
assertContainedDirectory(root, directory, admittedRoot.identity),
|
|
);
|
|
for await (const entry of this.#readDirectory(
|
|
directory,
|
|
pending,
|
|
signal,
|
|
options,
|
|
examine,
|
|
))
|
|
yield entry;
|
|
await withDirectoryIoContext("inspect directory", directory, () =>
|
|
assertContainedDirectory(root, directory, admittedRoot.identity),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Admission facts, shared with traversal; the selected root may be a symlink. */
|
|
rootObservation(): Promise<DirectoryArtifactRoot> {
|
|
return this.#resolveRoot();
|
|
}
|
|
|
|
async #resolveRoot(): Promise<DirectoryArtifactRoot> {
|
|
return (this.#rootPromise ??= (async () => {
|
|
const root = await realpath(this.#rootPath);
|
|
const metadata = await lstat(root, { bigint: true });
|
|
if (metadata.isSymbolicLink())
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"Artifact root changed during traversal",
|
|
);
|
|
if (!metadata.isDirectory())
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"Artifact root must be a directory",
|
|
);
|
|
return Object.freeze({
|
|
canonicalPath: root,
|
|
identity: Object.freeze({ dev: metadata.dev, ino: metadata.ino }),
|
|
});
|
|
})().catch((cause: unknown) => {
|
|
if (cause instanceof ArtifactReaderFailure) throw cause;
|
|
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
|
|
}));
|
|
}
|
|
|
|
async *#readDirectory(
|
|
directory: string,
|
|
pending: string[],
|
|
signal: AbortSignal | undefined,
|
|
options: DirectoryTraversalOptions | undefined,
|
|
examine: () => void,
|
|
): AsyncIterable<DirectoryArtifactEntry> {
|
|
const admittedRoot = await this.#resolveRoot();
|
|
const root = admittedRoot.canonicalPath;
|
|
const handle = new OwnedDirectoryHandle(
|
|
await opendir(directory).catch((cause: unknown) => {
|
|
throw directoryIoFailure("open directory", directory, cause);
|
|
}),
|
|
);
|
|
this.#directories.add(handle);
|
|
const childDirectories: string[] = [];
|
|
let traversal:
|
|
| { readonly kind: "complete" }
|
|
| { readonly kind: "failed"; readonly cause: unknown } = {
|
|
kind: "complete",
|
|
};
|
|
try {
|
|
for (;;) {
|
|
await withDirectoryIoContext("inspect directory", directory, () =>
|
|
assertContainedDirectory(root, directory, admittedRoot.identity),
|
|
);
|
|
const child = await handle.handle.read();
|
|
if (child === null) break;
|
|
examine();
|
|
abortIfNeeded(signal);
|
|
const absolute = join(directory, child.name);
|
|
const metadata = await lstat(absolute, { bigint: true }).catch(
|
|
(cause: unknown) => {
|
|
throw directoryIoFailure("inspect entry", absolute, cause);
|
|
},
|
|
);
|
|
const path = relative(root, absolute).split(sep).join("/");
|
|
const entry = artifactEntry(path, absolute, metadata);
|
|
if (entry === undefined) {
|
|
options?.onOmission?.({ path, kind: unsupportedEntryKind(metadata) });
|
|
continue;
|
|
}
|
|
yield entry;
|
|
if (entry.kind === "directory") {
|
|
if (options?.descendInto?.(path) ?? true)
|
|
childDirectories.push(absolute);
|
|
else options?.onOmission?.({ path, kind: "directory-descendants" });
|
|
}
|
|
}
|
|
} catch (cause: unknown) {
|
|
traversal = {
|
|
kind: "failed",
|
|
cause:
|
|
cause instanceof ArtifactReaderFailure
|
|
? cause
|
|
: directoryIoFailure("read directory", directory, cause),
|
|
};
|
|
} finally {
|
|
await this.#finishDirectory(handle, directory, traversal);
|
|
}
|
|
if (traversal.kind === "failed") throw traversal.cause;
|
|
childDirectories.reverse();
|
|
pending.push(...childDirectories);
|
|
}
|
|
|
|
async #finishDirectory(
|
|
handle: OwnedDirectoryHandle,
|
|
path: string,
|
|
traversal:
|
|
| { readonly kind: "complete" }
|
|
| { readonly kind: "failed"; readonly cause: unknown },
|
|
): Promise<void> {
|
|
try {
|
|
await handle.close();
|
|
this.#directories.delete(handle);
|
|
} catch (cause: unknown) {
|
|
const closeFailure = directoryIoFailure("close directory", path, cause);
|
|
if (traversal.kind === "failed")
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
traversal.cause,
|
|
ArtifactReaderFailure.cleanupObservation(closeFailure, path),
|
|
);
|
|
throw closeFailure;
|
|
}
|
|
}
|
|
|
|
async #openFile(
|
|
entry: ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<OwnedFileHandle<FileHandle>> {
|
|
abortIfNeeded(signal);
|
|
if (entry.kind !== "file")
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"Only regular files can be opened",
|
|
);
|
|
const fileHandle = await open(
|
|
entry.adapterKey,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
).catch((cause: unknown) => {
|
|
throw directoryIoFailure("open entry", entry.adapterKey, cause);
|
|
});
|
|
const owner = new OwnedFileHandle(fileHandle);
|
|
this.#files.set(owner, entry.adapterKey);
|
|
try {
|
|
const observed = await owner.handle
|
|
.stat({ bigint: true })
|
|
.catch((cause: unknown) => {
|
|
throw directoryIoFailure(
|
|
"inspect opened entry",
|
|
entry.adapterKey,
|
|
cause,
|
|
);
|
|
});
|
|
if (
|
|
!observed.isFile() ||
|
|
!sameArtifactSourceIdentity(entry.sourceIdentity, observed)
|
|
) {
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Directory entry changed before open: ${entry.path}`,
|
|
);
|
|
}
|
|
abortIfNeeded(signal);
|
|
return owner;
|
|
} catch (cause: unknown) {
|
|
try {
|
|
await this.#closeFile(owner);
|
|
} catch (closeCause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
cause,
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
directoryIoFailure(
|
|
"close opened entry",
|
|
entry.adapterKey,
|
|
closeCause,
|
|
),
|
|
entry.adapterKey,
|
|
),
|
|
);
|
|
}
|
|
throw cause;
|
|
}
|
|
}
|
|
|
|
/** Borrow an admitted entry for bounded random reads; retain failed cleanup. */
|
|
async inspectFile<Value>(
|
|
entry: ArtifactEntry,
|
|
inspect: (file: FileHandle) => Promise<Value>,
|
|
signal?: AbortSignal,
|
|
): Promise<Value> {
|
|
const owner = await this.#openFile(entry, signal);
|
|
try {
|
|
const value = await withOwnedFileHandle(
|
|
entry.adapterKey,
|
|
owner,
|
|
signal === undefined ? {} : { signal },
|
|
async (file) => {
|
|
let outcome:
|
|
| { kind: "completed"; value: Value }
|
|
| { kind: "failed"; cause: unknown };
|
|
try {
|
|
outcome = { kind: "completed", value: await inspect(file) };
|
|
} catch (cause: unknown) {
|
|
outcome = { kind: "failed", cause };
|
|
}
|
|
const [opened, named] = await Promise.all([
|
|
file.stat({ bigint: true }),
|
|
lstat(entry.adapterKey, { bigint: true }),
|
|
]);
|
|
abortIfNeeded(signal);
|
|
if (
|
|
!opened.isFile() ||
|
|
!named.isFile() ||
|
|
!sameArtifactSourceIdentity(entry.sourceIdentity, opened) ||
|
|
!sameArtifactSourceIdentity(entry.sourceIdentity, named)
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Directory entry changed during inspection: ${entry.path}`,
|
|
);
|
|
if (outcome.kind === "failed") throw outcome.cause;
|
|
return outcome.value;
|
|
},
|
|
);
|
|
this.#files.delete(owner);
|
|
return value;
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof RegularFileCleanupFailure) {
|
|
const lease = new AnalysisCleanupLease(
|
|
() => this.#closeFile(owner),
|
|
[entry.adapterKey],
|
|
);
|
|
this.#fileCleanup.set(owner, lease);
|
|
throw new ArtifactFileCleanupFailure(cause, lease);
|
|
}
|
|
this.#files.delete(owner);
|
|
if (cause instanceof RegularFileCancelled)
|
|
throw new ArtifactFileCancelled(cause);
|
|
if (cause instanceof RegularFileOperationCancelled)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Directory inspection cancelled",
|
|
{ cause: cause.reason },
|
|
);
|
|
throw directoryIoFailure("inspect entry", entry.adapterKey, cause);
|
|
}
|
|
}
|
|
|
|
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
|
const owner = await this.#openFile(entry, signal);
|
|
const source = Readable.from(
|
|
mapStableFileRead(owner.handle, entry, signal),
|
|
{ objectMode: false },
|
|
);
|
|
source.once("close", () => {
|
|
void this.#closeFile(owner).catch(() => undefined);
|
|
});
|
|
return source;
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
const failures: unknown[] = [];
|
|
for (const [owner, path] of this.#files) {
|
|
try {
|
|
const lease = this.#fileCleanup.get(owner);
|
|
if (lease === undefined) await this.#closeFile(owner);
|
|
else await lease.release();
|
|
} catch (cause: unknown) {
|
|
failures.push(directoryIoFailure("close opened entry", path, cause));
|
|
}
|
|
}
|
|
for (const owner of this.#directories) {
|
|
try {
|
|
await owner.close();
|
|
this.#directories.delete(owner);
|
|
} catch (cause: unknown) {
|
|
failures.push(
|
|
directoryIoFailure("close directory", owner.handle.path, cause),
|
|
);
|
|
}
|
|
}
|
|
if (failures.length > 0) {
|
|
const reason = failures.map(failureMessage).join("; ");
|
|
throw new ArtifactReaderFailure(
|
|
"io",
|
|
`Directory artifact cleanup failed: ${reason}`,
|
|
{
|
|
cause: new AggregateError(failures),
|
|
cleanup: {
|
|
reason,
|
|
resources: [
|
|
...this.#files.values(),
|
|
...[...this.#directories].map((owner) => owner.handle.path),
|
|
],
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
async #closeFile(owner: OwnedFileHandle<FileHandle>): Promise<void> {
|
|
await owner.close();
|
|
this.#files.delete(owner);
|
|
this.#fileCleanup.delete(owner);
|
|
}
|
|
|
|
provenance(): readonly [] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
const unsupportedEntryKind = (
|
|
metadata: BigIntStats,
|
|
): ArtifactTraversalOmission["kind"] =>
|
|
metadata.isFIFO()
|
|
? "fifo"
|
|
: metadata.isSocket()
|
|
? "socket"
|
|
: metadata.isBlockDevice()
|
|
? "block-device"
|
|
: metadata.isCharacterDevice()
|
|
? "character-device"
|
|
: "unsupported";
|
|
|
|
const artifactEntry = (
|
|
path: string,
|
|
absolute: string,
|
|
metadata: BigIntStats,
|
|
): DirectoryArtifactEntry | undefined => {
|
|
const kind = metadata.isSymbolicLink()
|
|
? "symlink"
|
|
: metadata.isDirectory()
|
|
? "directory"
|
|
: metadata.isFile()
|
|
? "file"
|
|
: undefined;
|
|
if (kind === undefined) return undefined;
|
|
return {
|
|
path,
|
|
kind,
|
|
declaredSize: kind === "file" ? safeArtifactSize(metadata.size) : null,
|
|
compressedSize: null,
|
|
executable: hostExecutableMarking(absolute, metadata),
|
|
encrypted: false,
|
|
byteOffset: null,
|
|
declaredIntegrity: null,
|
|
unpacked: false,
|
|
limitations:
|
|
kind === "symlink"
|
|
? ["Symlink target was not followed or disclosed."]
|
|
: [],
|
|
adapterKey: absolute,
|
|
...(kind === "file"
|
|
? {
|
|
sourceIdentity: {
|
|
device: metadata.dev,
|
|
inode: metadata.ino,
|
|
size: metadata.size,
|
|
mode: metadata.mode,
|
|
mtimeNs: metadata.mtimeNs,
|
|
ctimeNs: metadata.ctimeNs,
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
};
|
|
|
|
const safeArtifactSize = (size: bigint): number | null => {
|
|
if (size < 0n || size > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
|
return Number(size);
|
|
};
|
|
|
|
const directoryIoFailure = (
|
|
operation: string,
|
|
path: string,
|
|
cause: unknown,
|
|
): ArtifactReaderFailure => {
|
|
if (cause instanceof ArtifactReaderFailure) return cause;
|
|
const code =
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
typeof cause.code === "string"
|
|
? ` (${cause.code})`
|
|
: "";
|
|
const detail = failureMessage(cause);
|
|
return new ArtifactReaderFailure(
|
|
"io",
|
|
`Could not ${operation} at ${path}${code}: ${detail}`,
|
|
{ cause },
|
|
);
|
|
};
|
|
|
|
const failureMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
const mapStableFileRead = async function* (
|
|
handle: FileHandle,
|
|
entry: ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): AsyncGenerator<Buffer> {
|
|
let bytes = 0n;
|
|
try {
|
|
const expectedBytes = entry.sourceIdentity?.size;
|
|
if (expectedBytes === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Directory entry has no captured file state: ${entry.path}`,
|
|
);
|
|
for await (const chunk of readArtifactFileChunks(handle, {
|
|
// Include one extra byte so a growing file cannot make this read unbounded.
|
|
end: expectedBytes,
|
|
signal,
|
|
})) {
|
|
abortIfNeeded(signal);
|
|
bytes += BigInt(chunk.length);
|
|
yield chunk;
|
|
}
|
|
abortIfNeeded(signal);
|
|
const [opened, current] = await Promise.all([
|
|
handle.stat({ bigint: true }),
|
|
lstat(entry.adapterKey, { bigint: true }),
|
|
]);
|
|
abortIfNeeded(signal);
|
|
if (
|
|
!opened.isFile() ||
|
|
!current.isFile() ||
|
|
!sameArtifactSourceIdentity(entry.sourceIdentity, opened) ||
|
|
!sameArtifactSourceIdentity(entry.sourceIdentity, current) ||
|
|
bytes !== expectedBytes
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Directory entry changed during read: ${entry.path}`,
|
|
);
|
|
} catch (cause: unknown) {
|
|
if (
|
|
cause instanceof Error &&
|
|
"code" in cause &&
|
|
["ENOENT", "ENOTDIR", "ELOOP"].includes(String(cause.code))
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`Directory entry changed during read: ${entry.path}`,
|
|
{ cause },
|
|
);
|
|
if (!(cause instanceof ArtifactReaderFailure))
|
|
throw directoryIoFailure("validate read entry", entry.adapterKey, cause);
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const withDirectoryIoContext = async <T>(
|
|
operation: string,
|
|
path: string,
|
|
action: () => Promise<T>,
|
|
): Promise<T> => {
|
|
try {
|
|
return await action();
|
|
} catch (cause: unknown) {
|
|
throw directoryIoFailure(operation, path, cause);
|
|
}
|
|
};
|
|
|
|
const abortIfNeeded = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted === true)
|
|
throw new ArtifactReaderFailure(
|
|
"cancelled",
|
|
"Artifact traversal cancelled",
|
|
{ cause: signal.reason },
|
|
);
|
|
};
|
|
|
|
const assertContainedDirectory = async (
|
|
root: string,
|
|
directory: string,
|
|
rootIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined,
|
|
): Promise<void> => {
|
|
const canonical = await realpath(directory);
|
|
const fromRoot = relative(root, canonical);
|
|
const metadata = await lstat(directory);
|
|
const currentRoot = await lstat(root, { bigint: true });
|
|
if (
|
|
rootIdentity === undefined ||
|
|
currentRoot.dev !== rootIdentity.dev ||
|
|
currentRoot.ino !== rootIdentity.ino ||
|
|
metadata.isSymbolicLink() ||
|
|
!metadata.isDirectory() ||
|
|
fromRoot === ".." ||
|
|
fromRoot.startsWith(`..${sep}`) ||
|
|
isAbsolute(fromRoot)
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"Directory traversal escaped or changed its artifact root",
|
|
);
|
|
};
|