178 lines
8.3 KiB
JavaScript
Executable File
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);
|
|
}
|