import fs from 'node:fs/promises'; import path from 'node:path'; export class Sandbox { constructor(roots, options = {}) { if (!roots.length) { throw new Error('At least one allowed root is required'); } this.readOnly = Boolean(options.readOnly); this.roots = roots.map((root, index) => ({ id: `root${index + 1}`, label: root, path: path.resolve(root) })); } async init() { const usable = []; for (const root of this.roots) { const real = await fs.realpath(root.path); const stat = await fs.stat(real); if (!stat.isDirectory()) { throw new Error(`Allowed root is not a directory: ${root.path}`); } usable.push({ ...root, real }); } this.roots = usable; return this; } publicConfig() { return { readOnly: this.readOnly, roots: this.roots.map((root) => ({ id: root.id, label: root.label, path: root.real })) }; } assertWritable() { if (this.readOnly) { const error = new Error('Read-only mode is enabled'); error.status = 403; throw error; } } rootFor(inputPath = '/') { const normalized = String(inputPath || '/'); if (normalized === '/' || normalized === '') { return this.roots[0]; } for (const root of this.roots) { if (normalized === root.real || normalized.startsWith(`${root.real}${path.sep}`)) { return root; } } const error = new Error('Path is outside allowed roots'); error.status = 403; throw error; } rootForReal(realPath) { for (const root of this.roots) { if (this.isInside(realPath, root.real)) { return root; } } const error = new Error('Path is outside allowed roots'); error.status = 403; throw error; } async resolveExisting(inputPath = '/') { const candidate = path.resolve(String(inputPath || this.roots[0].real)); const real = await fs.realpath(candidate); const root = this.rootForReal(real); return { root, path: candidate, real }; } async resolveDestination(inputPath) { const candidate = path.resolve(String(inputPath || '')); const parent = path.dirname(candidate); const parentReal = await fs.realpath(parent); const root = this.rootForReal(parentReal); return { root, path: candidate, parentReal }; } isInside(candidate, root) { const relative = path.relative(root, candidate); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } } export function rootsFromEnv(env = process.env) { if (env.U_NAV_ROOTS) { return env.U_NAV_ROOTS.split(',').map((item) => item.trim()).filter(Boolean); } if (env.U_NAV_ROOT) { return [env.U_NAV_ROOT]; } return ['/mnt/user']; }