77 lines
2.2 KiB
TypeScript
77 lines
2.2 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
|
|
function normalizeUnicodeSpaces(str: string): string {
|
|
return str.replace(UNICODE_SPACES, " ");
|
|
}
|
|
|
|
function expandPath(filePath: string): string {
|
|
const normalized = normalizeUnicodeSpaces(filePath);
|
|
if (normalized === "~") {
|
|
return os.homedir();
|
|
}
|
|
if (normalized.startsWith("~/")) {
|
|
return os.homedir() + normalized.slice(1);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function resolveToCwd(filePath: string, cwd: string): string {
|
|
const expanded = expandPath(filePath);
|
|
if (path.isAbsolute(expanded)) return expanded;
|
|
return path.resolve(cwd, expanded);
|
|
}
|
|
|
|
export function resolveSandboxPath(params: { filePath: string; cwd: string; root: string }): {
|
|
resolved: string;
|
|
relative: string;
|
|
} {
|
|
const resolved = resolveToCwd(params.filePath, params.cwd);
|
|
const rootResolved = path.resolve(params.root);
|
|
const relative = path.relative(rootResolved, resolved);
|
|
if (!relative || relative === "") {
|
|
return { resolved, relative: "" };
|
|
}
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
throw new Error(`Path escapes sandbox root (${shortPath(rootResolved)}): ${params.filePath}`);
|
|
}
|
|
return { resolved, relative };
|
|
}
|
|
|
|
export async function assertSandboxPath(params: { filePath: string; cwd: string; root: string }) {
|
|
const resolved = resolveSandboxPath(params);
|
|
await assertNoSymlink(resolved.relative, path.resolve(params.root));
|
|
return resolved;
|
|
}
|
|
|
|
async function assertNoSymlink(relative: string, root: string) {
|
|
if (!relative) return;
|
|
const parts = relative.split(path.sep).filter(Boolean);
|
|
let current = root;
|
|
for (const part of parts) {
|
|
current = path.join(current, part);
|
|
try {
|
|
const stat = await fs.lstat(current);
|
|
if (stat.isSymbolicLink()) {
|
|
throw new Error(`Symlink not allowed in sandbox path: ${current}`);
|
|
}
|
|
} catch (err) {
|
|
const anyErr = err as { code?: string };
|
|
if (anyErr.code === "ENOENT") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function shortPath(value: string) {
|
|
if (value.startsWith(os.homedir())) {
|
|
return `~${value.slice(os.homedir().length)}`;
|
|
}
|
|
return value;
|
|
}
|