Files
2026-11-03 12:41:09 -05:00

178 lines
8.3 KiB
JavaScript
Executable File

// Local, trusted parent directories are required. No shell, links or shared-tree deletion.
import { constants, createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import path from 'node:path';
import { setTimeout as retryDelay } from 'node:timers/promises';
export const MANIFEST = 'sha256-manifest.json';
export const json = value => JSON.stringify(value, null, 2) + '\n';
export async function exists(filename) {
try { await fs.lstat(filename); return true; }
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
}
export function absolute(filename) {
if (typeof filename !== 'string' || !path.isAbsolute(filename) || filename.includes('\0') ||
filename.split(/[\\/]/).includes('..')) throw new Error('An absolute path without traversal is required.');
return path.normalize(filename);
}
export function relativeName(name) {
if (typeof name !== 'string' || !name || name.includes('\\') || name.split('/').some(part =>
!part || part === '.' || part === '..' || /[\x00-\x1f<>:"|?*]/.test(part) || /[. ]$/.test(part) ||
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) throw new Error('Unsafe portable relative path.');
return name;
}
export async function safePath(filename) {
filename = absolute(filename);
const root = path.parse(filename).root;
let current = root;
for (const part of filename.slice(root.length).split(path.sep).filter(Boolean)) {
current = path.join(current, part);
try {
if ((await fs.lstat(current)).isSymbolicLink()) throw new Error('Refusing symbolic link or junction.');
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
return filename;
}
export async function ensureDirectory(directory) {
await safePath(directory);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
await safePath(directory);
if (!(await fs.lstat(directory)).isDirectory()) throw new Error('Expected directory.');
}
export function disjoint(a, b) {
const key = value => process.platform === 'win32' ? value.toLowerCase() : value;
a = key(absolute(a)); b = key(absolute(b));
const inside = (parent, child) => child === parent || child.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
if (inside(a, b) || inside(b, a)) throw new Error('Source and destination must not overlap.');
}
export async function inventory(root, omitManifest = false) {
await safePath(root);
if (!(await fs.lstat(root)).isDirectory()) throw new Error('Expected directory.');
const entries = [];
const folded = new Set();
async function visit(directory, prefix = '') {
for (const name of (await fs.readdir(directory)).sort()) {
const relative = relativeName(prefix + name);
if (omitManifest && relative === MANIFEST) continue;
const foldedName = relative.toLowerCase();
if (folded.has(foldedName)) throw new Error('Case-colliding paths are not portable.');
folded.add(foldedName);
const filename = path.join(root, ...relative.split('/'));
const stat = await fs.lstat(filename);
if (stat.isSymbolicLink()) throw new Error('Refusing symbolic link or junction.');
if (stat.isDirectory()) {
entries.push({ path: relative, type: 'directory' });
await visit(filename, relative + '/');
} else if (stat.isFile()) {
const hash = createHash('sha256');
for await (const chunk of createReadStream(filename, { flags: constants.O_RDONLY | (constants.O_NOFOLLOW || 0) })) hash.update(chunk);
entries.push({ path: relative, type: 'file', size: stat.size, sha256: hash.digest('hex') });
} else throw new Error('Only regular files and directories are supported.');
}
}
await visit(root);
return entries.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
}
export async function copyTree(from, to, { intoEmpty = false, exclude = new Set() } = {}) {
const entries = await inventory(from);
await safePath(to);
if (intoEmpty) {
if (!(await fs.lstat(to)).isDirectory() || (await fs.readdir(to)).length) throw new Error('Copy target must be an empty private stage.');
} else await fs.mkdir(to, { mode: 0o700 });
for (const entry of entries) {
if ([...exclude].some(prefix => entry.path === prefix || entry.path.startsWith(prefix + '/'))) continue;
const target = path.join(to, ...entry.path.split('/'));
if (entry.type === 'directory') await fs.mkdir(target, { mode: 0o700 });
else {
await fs.copyFile(path.join(from, ...entry.path.split('/')), target, constants.COPYFILE_EXCL);
await fs.chmod(target, 0o600);
}
}
return entries;
}
export async function writeManifest(root, kind) {
const manifest = { format: 1, algorithm: 'sha256', kind, entries: await inventory(root, true) };
await fs.writeFile(path.join(root, MANIFEST), json(manifest), { flag: 'wx', mode: 0o600 });
return manifest;
}
export async function verifyManifest(root, kind) {
await safePath(path.join(absolute(root), MANIFEST));
const stat = await fs.lstat(path.join(root, MANIFEST));
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error('Invalid manifest file.');
const manifest = JSON.parse(await fs.readFile(path.join(root, MANIFEST), 'utf8'));
if (manifest.format !== 1 || manifest.algorithm !== 'sha256' || manifest.kind !== kind || !Array.isArray(manifest.entries)) {
throw new Error('Unsupported SHA256 manifest.');
}
for (const entry of manifest.entries) relativeName(entry.path);
if (json(manifest.entries) !== json(await inventory(root, true))) throw new Error('SHA256 manifest mismatch: corrupt, missing or extra files/directories.');
return manifest;
}
// Exclusive sibling guard serializes our writers. Parent must not be writable by
// adversaries: Node has no portable rename-no-replace primitive for directories.
export async function atomicDirectory(destination, build) {
destination = await safePath(destination);
if (path.parse(destination).root === destination) throw new Error('Refusing filesystem root destination.');
const parent = path.dirname(destination);
await safePath(parent);
if (!(await fs.lstat(parent)).isDirectory()) throw new Error('Destination parent must exist.');
const guard = destination + '.hearth-write-lock';
const handle = await fs.open(guard, 'wx', 0o600);
let stage;
try {
if (await exists(destination)) throw new Error('Refusing to overwrite an existing or unmanaged destination.');
stage = await fs.mkdtemp(path.join(parent, '.hearth-stage-'));
await fs.chmod(stage, 0o700);
await build(stage);
// Windows virus scanners/indexers can briefly hold newly generated PNGs.
// Retry only transient sharing failures, rechecking nonexistence each time;
// never fall back to copying into or removing an existing destination.
for (let attempt = 0; ; attempt++) {
await safePath(destination);
if (await exists(destination)) throw new Error('Destination appeared during operation; refusing overwrite.');
try { await fs.rename(stage, destination); break; }
catch (error) {
if (process.platform !== 'win32' || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt >= 5) throw error;
await retryDelay(25 * 2 ** attempt);
}
}
stage = undefined;
return destination;
} finally {
if (stage) await fs.rm(stage, { recursive: true, force: true });
await handle.close();
await fs.unlink(guard);
}
}
const leases = new WeakSet();
export async function withLauncherLock(runtime, action) {
await ensureDirectory(runtime);
const filename = path.join(runtime, 'launcher.lock');
let handle;
try { handle = await fs.open(filename, 'wx', 0o600); }
catch (error) {
if (error.code === 'EEXIST') throw new Error('Launcher lock exists. Stop the engine before manually clearing a stale lock.');
throw error;
}
const lease = Object.freeze({ runtime: absolute(runtime), id: randomUUID() });
leases.add(lease);
try { await handle.writeFile(`${process.pid}\n`); return await action(lease); }
finally { leases.delete(lease); await handle.close(); await fs.unlink(filename); }
}
export function validLease(lease, runtime) {
return lease && leases.has(lease) && lease.runtime === absolute(runtime);
}