Project Files
src / pathGuard.ts
import * as path from "node:path";
import * as fs from "node:fs/promises";
import * as os from "node:os";
export class PathError extends Error {
constructor(message: string) {
super(message);
this.name = "PathError";
}
}
export function expandTilde(p: string): string {
if (p === "~") return os.homedir();
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
return p;
}
export async function canonicalizeRoots(rawRoots: string[]): Promise<string[]> {
const out: string[] = [];
for (const raw of rawRoots) {
if (!raw.trim()) continue;
const expanded = path.resolve(expandTilde(raw.trim()));
try {
out.push(await fs.realpath(expanded));
} catch {
out.push(expanded);
}
}
return out;
}
export const BACKUP_DIR_NAME = ".lmstudio-fs-backup";
export function isInsideBackupDir(absPath: string, root: string): boolean {
const backupRoot = path.join(root, BACKUP_DIR_NAME);
return absPath === backupRoot || absPath.startsWith(backupRoot + path.sep);
}
export async function resolveSafe(
rawPath: string,
allowedRoots: string[],
): Promise<{ abs: string; root: string }> {
if (!allowedRoots.length) {
throw new PathError(
"No allowed paths configured. Set 'Allowed root paths' in the plugin settings.",
);
}
if (!rawPath || typeof rawPath !== "string") {
throw new PathError("Path is required.");
}
const expanded = expandTilde(rawPath);
const candidate = path.isAbsolute(expanded)
? expanded
: path.resolve(allowedRoots[0], expanded);
const normalized = path.resolve(candidate);
// Resolve symlinks if the file (or its parent) exists; else use the
// normalized form. Either way we then check against the roots.
let resolved = normalized;
try {
resolved = await fs.realpath(normalized);
} catch {
try {
resolved = path.join(
await fs.realpath(path.dirname(normalized)),
path.basename(normalized),
);
} catch {
resolved = normalized;
}
}
for (const root of allowedRoots) {
if (resolved === root || resolved.startsWith(root + path.sep)) {
return { abs: resolved, root };
}
}
throw new PathError(
`Path "${rawPath}" resolves to "${resolved}", outside allowed roots: ${allowedRoots.join(", ")}.`,
);
}