94 lines
2.5 KiB
JavaScript
94 lines
2.5 KiB
JavaScript
import { lstat, realpath, stat } from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
function isMissing(error) {
|
|
return error && typeof error === 'object' && error.code === 'ENOENT';
|
|
}
|
|
|
|
export async function canonicalExistingPath(candidate, label = 'Path') {
|
|
const absolute = path.resolve(candidate);
|
|
try {
|
|
return await realpath(absolute);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`${label} does not resolve to an existing path: ${absolute}`,
|
|
{
|
|
cause: error,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function canonicalProspectivePath(candidate) {
|
|
const absolute = path.resolve(candidate);
|
|
const suffix = [];
|
|
let current = absolute;
|
|
|
|
while (true) {
|
|
let details;
|
|
try {
|
|
details = await lstat(current);
|
|
} catch (error) {
|
|
if (!isMissing(error)) throw error;
|
|
const parent = path.dirname(current);
|
|
if (parent === current) throw error;
|
|
suffix.unshift(path.basename(current));
|
|
current = parent;
|
|
continue;
|
|
}
|
|
|
|
let canonical;
|
|
try {
|
|
canonical = await realpath(current);
|
|
} catch (error) {
|
|
if (details.isSymbolicLink() && isMissing(error)) {
|
|
throw new Error(`Path contains a dangling symbolic link: ${current}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (suffix.length > 0) {
|
|
const canonicalDetails = await stat(canonical);
|
|
if (!canonicalDetails.isDirectory()) {
|
|
throw new Error(
|
|
`Path has a non-directory existing ancestor: ${current}`
|
|
);
|
|
}
|
|
}
|
|
return path.join(canonical, ...suffix);
|
|
}
|
|
}
|
|
|
|
export function canonicalPathsOverlap(left, right) {
|
|
const resolvedLeft = path.resolve(left);
|
|
const resolvedRight = path.resolve(right);
|
|
return (
|
|
resolvedLeft === resolvedRight ||
|
|
resolvedLeft.startsWith(`${resolvedRight}${path.sep}`) ||
|
|
resolvedRight.startsWith(`${resolvedLeft}${path.sep}`)
|
|
);
|
|
}
|
|
|
|
export async function assertSafeRemovalTarget(target) {
|
|
const canonical = await canonicalProspectivePath(target);
|
|
const root = path.parse(canonical).root;
|
|
const protectedPaths = await Promise.all([
|
|
canonicalProspectivePath(process.cwd()),
|
|
canonicalProspectivePath(os.homedir()),
|
|
]);
|
|
if (
|
|
canonical === root ||
|
|
protectedPaths.some(
|
|
(protectedPath) =>
|
|
protectedPath === canonical ||
|
|
protectedPath.startsWith(`${canonical}${path.sep}`)
|
|
)
|
|
) {
|
|
throw new Error(`Refusing unsafe output target: ${canonical}`);
|
|
}
|
|
return canonical;
|
|
}
|