// 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); }