115 lines
2.7 KiB
JavaScript
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'];
|
|
}
|