src / paths.ts
import { promises as fs } from "fs";
import * as path from "path";
export const OUT_OF_SCOPE = "Error: path is outside the working directory";
export type Resolved = { ok: true; abs: string } | { ok: false; error: string };
/**
* realpath that tolerates a not-yet-existing tail: resolve the nearest existing
* ancestor and re-append the remaining segments. Returns null for a dangling
* symlink (exists as a link but its target does not), which is never safe.
*/
async function realpathLoose(abs: string): Promise<string | null> {
const rest: string[] = [];
let cur = abs;
for (;;) {
try {
const real = await fs.realpath(cur);
return path.join(real, ...rest.reverse());
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTDIR") throw e;
if (await fs.lstat(cur).catch(() => null)) return null;
const parent = path.dirname(cur);
if (parent === cur) throw e;
rest.push(path.basename(cur));
cur = parent;
}
}
}
export async function resolveInScope(root: string, userPath: string): Promise<Resolved> {
if (typeof userPath !== "string" || userPath.trim() === "") {
return { ok: false, error: "Error: no path provided" };
}
let realRoot: string;
try {
realRoot = await fs.realpath(root);
} catch {
return { ok: false, error: "Error: working directory is not accessible" };
}
let real: string | null;
try {
real = await realpathLoose(path.resolve(realRoot, userPath));
} catch {
return { ok: false, error: OUT_OF_SCOPE };
}
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
if (real === null || (real !== realRoot && !real.startsWith(prefix))) {
return { ok: false, error: OUT_OF_SCOPE };
}
return { ok: true, abs: real };
}
src / paths.ts
import { promises as fs } from "fs";
import * as path from "path";
export const OUT_OF_SCOPE = "Error: path is outside the working directory";
export type Resolved = { ok: true; abs: string } | { ok: false; error: string };
/**
* realpath that tolerates a not-yet-existing tail: resolve the nearest existing
* ancestor and re-append the remaining segments. Returns null for a dangling
* symlink (exists as a link but its target does not), which is never safe.
*/
async function realpathLoose(abs: string): Promise<string | null> {
const rest: string[] = [];
let cur = abs;
for (;;) {
try {
const real = await fs.realpath(cur);
return path.join(real, ...rest.reverse());
} catch (e) {
const code = (e as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "ENOTDIR") throw e;
if (await fs.lstat(cur).catch(() => null)) return null;
const parent = path.dirname(cur);
if (parent === cur) throw e;
rest.push(path.basename(cur));
cur = parent;
}
}
}
export async function resolveInScope(root: string, userPath: string): Promise<Resolved> {
if (typeof userPath !== "string" || userPath.trim() === "") {
return { ok: false, error: "Error: no path provided" };
}
let realRoot: string;
try {
realRoot = await fs.realpath(root);
} catch {
return { ok: false, error: "Error: working directory is not accessible" };
}
let real: string | null;
try {
real = await realpathLoose(path.resolve(realRoot, userPath));
} catch {
return { ok: false, error: OUT_OF_SCOPE };
}
const prefix = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
if (real === null || (real !== realRoot && !real.startsWith(prefix))) {
return { ok: false, error: OUT_OF_SCOPE };
}
return { ok: true, abs: real };
}