Files

115 lines
2.7 KiB
JavaScript

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'];
}