← Files ModRetro Chromatic PluginARCHIVED FILE

dist/native-import-files.js

21.6 KB · Oct 3, 2026 · 06:38 UTC

↓ Download file

import { createHash, randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { link, lstat, mkdir, mkdtemp, open, opendir, rename, rmdir, unlink } from "node:fs/promises";
import path from "node:path";
import { GameStudioProjectError } from "./project.js";
import { resolveProjectPath } from "./project-files.js";
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
const missing = (error) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
const fail = (code, message) => { throw new GameStudioProjectError(code, message); };
/** Strict relative path and actual sibling alias checks; never follows project symlinks. */
export async function nativeImportPath(root, relative, mustExist = false, allowHardLinks = false) {
    if (!relative || relative.length > 4096 || relative.split("/").length > 64 || relative.includes("\\") || /[\x00-\x1f]/.test(relative)
        || path.posix.isAbsolute(relative) || path.posix.normalize(relative) !== relative || relative === "." || relative === ".." || relative.startsWith("../")) {
        fail("INVALID_RESOURCE_PATH", "Native import paths must be normalized project-relative paths.");
    }
    let parent = root;
    const parts = relative.split("/");
    for (let i = 0; i < parts.length; i++) {
        const component = parts[i], key = component.normalize("NFC").toLowerCase();
        let directory;
        try {
            directory = await opendir(parent);
        }
        catch (error) {
            if (missing(error) && !mustExist)
                break;
            throw error;
        }
        let siblings = 0;
        for await (const entry of directory) {
            if (++siblings > 16384)
                fail("NATIVE_IMPORT_BOUNDS", "Native import path validation supports at most 16384 entries per directory.");
            if (entry.name !== component && entry.name.normalize("NFC").toLowerCase() === key)
                fail("RESOURCE_PATH_ALIAS", `Native import path has a case or Unicode alias: ${relative}.`);
        }
        parent = path.join(parent, component);
        let info;
        try {
            info = await lstat(parent);
        }
        catch (error) {
            if (missing(error) && !mustExist)
                break;
            throw error;
        }
        if (info.isSymbolicLink())
            fail("UNSAFE_SYMLINK", `Native import path contains a symbolic link: ${relative}.`);
        if (i < parts.length - 1 ? !info.isDirectory() : !info.isFile())
            fail("INVALID_RESOURCE_PATH", `Native import path has an invalid file type: ${relative}.`);
        if (i === parts.length - 1 && !allowHardLinks && info.nlink !== 1)
            fail("RESOURCE_PATH_ALIAS", `Native import source has hard-link aliases: ${relative}.`);
    }
    const absolute = path.join(root, relative);
    if (await resolveProjectPath(root, absolute, { mustExist, allowRoot: false }) !== absolute)
        fail("RESOURCE_PATH_ALIAS", "Native import path resolves through an alias.");
    return absolute;
}
export async function readNativeImportSnapshot(root, relative, maximum) {
    const absolute = await nativeImportPath(root, relative, true);
    const handle = await open(absolute, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
    try {
        const before = await handle.stat({ bigint: true });
        if (!before.isFile() || before.nlink !== 1n || before.size < 1n || before.size > BigInt(maximum))
            fail("NATIVE_IMPORT_BOUNDS", `Native import source exceeds its ${maximum}-byte limit or is not a single regular file: ${relative}.`);
        const bytes = Buffer.alloc(Number(before.size) + 1);
        let count = 0;
        while (count < bytes.length) {
            const read = await handle.read(bytes, count, bytes.length - count, count);
            if (!read.bytesRead)
                break;
            count += read.bytesRead;
        }
        const after = await handle.stat({ bigint: true });
        await nativeImportPath(root, relative, true);
        const named = await lstat(absolute, { bigint: true });
        if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
            || after.mode !== before.mode || named.mode !== before.mode || named.dev !== before.dev || named.ino !== before.ino || named.nlink !== 1n || named.size !== before.size || named.mtimeNs !== before.mtimeNs || named.ctimeNs !== before.ctimeNs || BigInt(count) !== before.size) {
            fail("STALE_PROJECT_REVISION", `Native import source changed while reading: ${relative}.`);
        }
        const content = bytes.subarray(0, count);
        return { bytes: content, identity: { dev: String(before.dev), ino: String(before.ino), size: count, sha256: hash(content), mode: Number(before.mode & 4095n) } };
    }
    finally {
        await handle.close();
    }
}
export class NativeImportFileError extends Error {
    originalCause;
    cleanupFailures;
    retainedPaths;
    constructor(originalCause, cleanupFailures, retainedPaths) {
        super(`${originalCause instanceof Error ? originalCause.message : String(originalCause)}${cleanupFailures.length ? `; cleanup: ${cleanupFailures.join("; ")}` : ""}`);
        this.originalCause = originalCause;
        this.cleanupFailures = cleanupFailures;
        this.retainedPaths = retainedPaths;
    }
}
const message = (error) => error instanceof Error ? error.message : String(error);
const relativePath = (root, file) => path.relative(root, file).split(path.sep).join("/");
/** Atomic no-replace publication; notify the transaction before any post-publication await. */
export async function publishNativeImportFile(root, relative, bytes, published) {
    const absolute = await nativeImportPath(root, relative);
    await mkdir(path.dirname(absolute), { recursive: true });
    await nativeImportPath(root, relative);
    const directory = await mkdtemp(path.join(path.dirname(absolute), ".native-import-"));
    const temporary = path.join(directory, "payload");
    let handle;
    let receipt;
    let failure;
    const cleanupFailures = [];
    try {
        handle = await open(temporary, "wx", 0o600);
        await handle.writeFile(bytes);
        await handle.sync();
        const info = await handle.stat({ bigint: true });
        receipt = { dev: String(info.dev), ino: String(info.ino), size: bytes.length, sha256: hash(bytes), temporaryDirectory: directory };
        await nativeImportPath(root, relative);
        await link(temporary, absolute);
        published(receipt);
    }
    catch (error) {
        failure = error && typeof error === "object" && "code" in error && error.code === "EEXIST"
            ? new GameStudioProjectError("STALE_PROJECT_REVISION", `A destination appeared before native import publication: ${relative}.`) : error;
    }
    try {
        await handle?.close();
    }
    catch (error) {
        cleanupFailures.push(`close temporary: ${message(error)}`);
    }
    try {
        await unlink(temporary);
    }
    catch (error) {
        if (!missing(error))
            cleanupFailures.push(`remove temporary: ${message(error)}`);
    }
    let retained = true;
    try {
        await rmdir(directory);
        retained = false;
    }
    catch (error) {
        if (missing(error))
            retained = false;
        else
            cleanupFailures.push(`remove temporary directory: ${message(error)}`);
    }
    if (receipt && !retained)
        delete receipt.temporaryDirectory;
    if (failure || cleanupFailures.length)
        throw new NativeImportFileError(failure ?? new Error("Native import temporary cleanup failed after publication"), cleanupFailures, retained ? [relativePath(root, directory)] : []);
}
export class NativeRecoveryConflict extends Error {
    retainedPaths;
    constructor(message, retainedPaths = []) {
        super(message);
        this.retainedPaths = retainedPaths;
    }
}
/** Capture the pathname atomically, then delete only verified owned bytes. Never overwrite a replacement. */
export async function rollbackNativeImportFile(root, relative, expected) {
    // Validate ancestors while permitting a replaced leaf so it can be preserved in quarantine.
    const parent = path.posix.dirname(relative);
    const absolute = path.join(root, relative);
    await nativeImportPath(root, `${parent}/.rollback-check-${randomUUID()}`);
    const directory = await mkdtemp(path.join(path.dirname(absolute), ".native-recovery-"));
    const captured = path.join(directory, "payload");
    let moved = false;
    let failure;
    try {
        await rename(absolute, captured);
        moved = true;
        const named = await lstat(captured, { bigint: true });
        let owned = named.isFile() && !named.isSymbolicLink() && String(named.dev) === expected.dev && String(named.ino) === expected.ino && named.size === BigInt(expected.size)
            && (expected.mode === undefined || Number(named.mode & 4095n) === expected.mode);
        if (owned) {
            const handle = await open(captured, constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
            try {
                const info = await handle.stat({ bigint: true });
                const bytes = Buffer.alloc(expected.size + 1);
                let count = 0;
                while (count < bytes.length) {
                    const read = await handle.read(bytes, count, bytes.length - count, count);
                    if (!read.bytesRead)
                        break;
                    count += read.bytesRead;
                }
                const after = await handle.stat({ bigint: true });
                const current = await lstat(captured, { bigint: true });
                owned = String(info.dev) === expected.dev && String(info.ino) === expected.ino && info.size === BigInt(expected.size)
                    && after.size === info.size && after.mtimeNs === info.mtimeNs && after.ctimeNs === info.ctimeNs && current.dev === info.dev && current.ino === info.ino
                    && current.size === info.size && current.mtimeNs === info.mtimeNs && current.ctimeNs === info.ctimeNs
                    && (expected.mode === undefined || (Number(info.mode & 4095n) === expected.mode && info.mode === after.mode && info.mode === current.mode))
                    && count === expected.size && hash(bytes.subarray(0, count)) === expected.sha256;
            }
            finally {
                await handle.close();
            }
        }
        if (!owned)
            throw new Error("The current file is not the object published by this import.");
        await unlink(captured);
        moved = false;
    }
    catch (error) {
        const retained = [];
        if (moved) {
            try {
                await nativeImportPath(root, `${parent}/.rollback-check-${randomUUID()}`);
                await link(captured, absolute);
                await unlink(captured);
                moved = false;
            }
            catch {
                retained.push(path.relative(root, captured).split(path.sep).join("/"));
            }
        }
        failure = new NativeRecoveryConflict(`${relative}: ${message(error)}`, retained);
    }
    if (!moved) {
        try {
            await rmdir(directory);
        }
        catch (error) {
            if (!missing(error))
                failure = new NativeRecoveryConflict(`${failure?.message ?? relative}; recovery directory cleanup: ${message(error)}`, [...(failure?.retainedPaths ?? []), relativePath(root, directory)]);
        }
    }
    if (failure)
        throw failure;
}
function sameIdentity(actual, expected) {
    return actual.dev === expected.dev && actual.ino === expected.ino && actual.size === expected.size
        && actual.sha256 === expected.sha256 && actual.mode === expected.mode;
}
/**
 * Capture the old pathname first, verify the captured inode, then publish with
 * link's no-replace semantics. Conflicting pathnames are retained for recovery.
 * The captured original remains available until finalization or rollback.
 */
export async function replaceNativeImportFile(root, relative, before, original, bytes, published) {
    const absolute = await nativeImportPath(root, relative, true);
    const directory = await mkdtemp(path.join(path.dirname(absolute), ".native-replacement-"));
    const payload = path.join(directory, "payload"), backup = path.join(directory, "original");
    const backupPath = relativePath(root, backup), directoryPath = relativePath(root, directory);
    let captured = false, installed = false, handle;
    let payloadIdentity;
    try {
        const directoryInfo = await lstat(directory);
        if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() || (directoryInfo.mode & 0o777) !== 0o700
            || await resolveProjectPath(root, directory, { mustExist: true }) !== directory) {
            throw new Error("The native replacement staging directory is not private and canonical.");
        }
        handle = await open(payload, "wx", 0o600);
        const created = await handle.stat({ bigint: true });
        payloadIdentity = { dev: String(created.dev), ino: String(created.ino), size: 0, sha256: hash(Buffer.alloc(0)), mode: 0o600 };
        await handle.writeFile(bytes);
        await handle.chmod(original.mode);
        await handle.sync();
        const info = await handle.stat({ bigint: true });
        payloadIdentity = { dev: String(info.dev), ino: String(info.ino), size: bytes.length, sha256: hash(bytes), mode: original.mode };
        await handle.close();
        handle = undefined;
        await nativeImportPath(root, relative, true);
        await rename(absolute, backup);
        captured = true;
        const capturedFile = await readNativeImportSnapshot(root, backupPath, before.length);
        if (!sameIdentity(capturedFile.identity, original) || !capturedFile.bytes.equals(before)) {
            fail("STALE_PROJECT_REVISION", `Native update target changed before publication: ${relative}.`);
        }
        await link(payload, absolute);
        installed = true;
        published({ published: payloadIdentity, original: { ...original }, originalBytes: Buffer.from(before), backupPath, directory, backupRemoved: false });
        await unlink(payload);
    }
    catch (error) {
        const cleanupFailures = [], retained = [];
        if (handle)
            try {
                await handle.close();
            }
            catch (closeError) {
                cleanupFailures.push(`close payload: ${message(closeError)}`);
            }
        if (!installed && captured) {
            try {
                await link(backup, absolute);
                await unlink(backup);
                captured = false;
            }
            catch (restoreError) {
                cleanupFailures.push(`restore captured original without overwrite: ${message(restoreError)}`);
                retained.push(backupPath);
            }
        }
        if (payloadIdentity) {
            try {
                const current = await lstat(payload, { bigint: true });
                if (String(current.dev) !== payloadIdentity.dev || String(current.ino) !== payloadIdentity.ino)
                    throw new Error("staging identity changed");
                await unlink(payload);
            }
            catch (cleanupError) {
                if (!missing(cleanupError)) {
                    cleanupFailures.push(`remove payload: ${message(cleanupError)}`);
                    retained.push(relativePath(root, payload));
                }
            }
        }
        else if (handle) {
            cleanupFailures.push("payload identity unavailable; staging was retained");
            retained.push(relativePath(root, payload));
        }
        if (!installed && !captured && retained.length === 0) {
            try {
                await rmdir(directory);
            }
            catch (cleanupError) {
                if (!missing(cleanupError)) {
                    cleanupFailures.push(`remove staging directory: ${message(cleanupError)}`);
                    retained.push(directoryPath);
                }
            }
        }
        if (!installed && captured) {
            throw new NativeRecoveryConflict(`The captured original could not be restored without overwriting a concurrent change: ${message(error)}; ${cleanupFailures.join("; ")}`, retained);
        }
        if (cleanupFailures.length)
            throw new NativeImportFileError(error, cleanupFailures, retained);
        throw error;
    }
}
async function verifiedReplacementBackup(root, receipt) {
    try {
        const backup = await readNativeImportSnapshot(root, receipt.backupPath, receipt.originalBytes.length);
        if (!sameIdentity(backup.identity, receipt.original) || !backup.bytes.equals(receipt.originalBytes)) {
            throw new Error("The captured original changed.");
        }
    }
    catch (error) {
        throw new NativeRecoveryConflict(`The captured original could not be verified: ${message(error)}`, [receipt.backupPath]);
    }
}
/** Remove only the exact original captured by a successful replacement. */
export async function finalizeNativeReplacement(root, receipt) {
    await verifiedReplacementBackup(root, receipt);
    await unlink(path.join(root, receipt.backupPath));
    receipt.backupRemoved = true;
    try {
        await rmdir(receipt.directory);
    }
    catch (error) {
        throw new NativeImportFileError(new Error("Native update committed but staging directory cleanup failed."), [message(error)], [relativePath(root, receipt.directory)]);
    }
}
/** Restore the captured original only after removing the exact published inode. */
export async function rollbackNativeReplacement(root, relative, receipt) {
    if (receipt.backupRemoved)
        throw new NativeRecoveryConflict("The captured original is no longer available for rollback.");
    try {
        await rollbackNativeImportFile(root, relative, receipt.published);
    }
    catch (error) {
        throw new NativeRecoveryConflict(`The published native update could not be safely removed: ${message(error)}`, [receipt.backupPath, ...(error instanceof NativeRecoveryConflict ? error.retainedPaths : [])]);
    }
    try {
        await verifiedReplacementBackup(root, receipt);
        await link(path.join(root, receipt.backupPath), path.join(root, relative));
        await unlink(path.join(root, receipt.backupPath));
        receipt.backupRemoved = true;
        await rmdir(receipt.directory);
    }
    catch (error) {
        throw new NativeRecoveryConflict(`The captured original could not be safely restored: ${message(error)}`, receipt.backupRemoved ? [relativePath(root, receipt.directory)] : [receipt.backupPath]);
    }
}
/** Include every authored sidecar, even when its physical asset is temporarily absent. */
export async function readNativeProjectMetadata(root) {
    const values = [], pending = ["project", "assets"];
    let entries = 0, bytes = 0;
    while (pending.length) {
        const relative = pending.pop();
        if (relative.split("/").length > 64)
            fail("NATIVE_IMPORT_BOUNDS", "Native project metadata exceeds the directory depth limit.");
        const absolute = path.join(root, relative);
        let info;
        try {
            info = await lstat(absolute);
        }
        catch (error) {
            // Only an absent initial subtree is optional; a discovered directory that
            // disappears during the census must not silently hide reserved identities.
            if (missing(error) && (relative === "project" || relative === "assets"))
                continue;
            throw error;
        }
        if (info.isSymbolicLink() || await resolveProjectPath(root, absolute, { mustExist: true }) !== absolute) {
            fail("UNSAFE_SYMLINK", `Native project metadata contains a symbolic link: ${relative}.`);
        }
        if (!info.isDirectory())
            fail("INVALID_RESOURCE", `Native project metadata subtree is not a directory: ${relative}.`);
        const directory = await opendir(absolute);
        for await (const entry of directory) {
            if (++entries > 16384)
                fail("NATIVE_IMPORT_BOUNDS", "Native project identity scan exceeds 16384 entries.");
            const child = `${relative}/${entry.name}`;
            if (entry.isSymbolicLink())
                fail("UNSAFE_SYMLINK", `Native project metadata contains a symbolic link: ${child}.`);
            if (entry.name.toLowerCase().endsWith(".gbsres")) {
                let snapshot;
                try {
                    snapshot = await readNativeImportSnapshot(root, child, 2 * 1024 * 1024);
                }
                catch (error) {
                    if (error instanceof GameStudioProjectError)
                        throw error;
                    throw new GameStudioProjectError("INVALID_RESOURCE", `Cannot read native project metadata: ${child}: ${message(error)}`);
                }
                bytes += snapshot.bytes.length;
                if (bytes > 32 * 1024 * 1024)
                    fail("NATIVE_IMPORT_BOUNDS", "Native project identity scan exceeds 32 MiB of metadata.");
                let value;
                try {
                    value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(snapshot.bytes));
                }
                catch {
                    fail("INVALID_RESOURCE", `Invalid native project metadata: ${child}.`);
                }
                if (!value || typeof value !== "object" || Array.isArray(value))
                    fail("INVALID_RESOURCE", `Native project metadata must be a JSON object: ${child}.`);
                values.push(value);
            }
            else if (entry.isDirectory())
                pending.push(child);
        }
    }
    return values;
}
//# sourceMappingURL=native-import-files.js.map

SHA-256: 5345d41efd95a69481e31ca756a3ccf85bc0a8a63d12d32055bffe86231a3712