862 lines
24 KiB
TypeScript
862 lines
24 KiB
TypeScript
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { isNotFoundPathError, isPathInside } from "./path-guards.js";
|
|
|
|
export type BoundaryPathIntent = "read" | "write" | "create" | "delete" | "stat";
|
|
|
|
export type BoundaryPathAliasPolicy = {
|
|
allowFinalSymlinkForUnlink?: boolean;
|
|
allowFinalHardlinkForUnlink?: boolean;
|
|
};
|
|
|
|
export const BOUNDARY_PATH_ALIAS_POLICIES = {
|
|
strict: Object.freeze({
|
|
allowFinalSymlinkForUnlink: false,
|
|
allowFinalHardlinkForUnlink: false,
|
|
}),
|
|
unlinkTarget: Object.freeze({
|
|
allowFinalSymlinkForUnlink: true,
|
|
allowFinalHardlinkForUnlink: true,
|
|
}),
|
|
} as const;
|
|
|
|
export type ResolveBoundaryPathParams = {
|
|
absolutePath: string;
|
|
rootPath: string;
|
|
boundaryLabel: string;
|
|
intent?: BoundaryPathIntent;
|
|
policy?: BoundaryPathAliasPolicy;
|
|
skipLexicalRootCheck?: boolean;
|
|
rootCanonicalPath?: string;
|
|
};
|
|
|
|
export type ResolvedBoundaryPathKind = "missing" | "file" | "directory" | "symlink" | "other";
|
|
|
|
export type ResolvedBoundaryPath = {
|
|
absolutePath: string;
|
|
canonicalPath: string;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
relativePath: string;
|
|
exists: boolean;
|
|
kind: ResolvedBoundaryPathKind;
|
|
};
|
|
|
|
export async function resolveBoundaryPath(
|
|
params: ResolveBoundaryPathParams,
|
|
): Promise<ResolvedBoundaryPath> {
|
|
const rootPath = path.resolve(params.rootPath);
|
|
const absolutePath = path.resolve(params.absolutePath);
|
|
const rootCanonicalPath = params.rootCanonicalPath
|
|
? path.resolve(params.rootCanonicalPath)
|
|
: await resolvePathViaExistingAncestor(rootPath);
|
|
const context = createBoundaryResolutionContext({
|
|
resolveParams: params,
|
|
rootPath,
|
|
absolutePath,
|
|
rootCanonicalPath,
|
|
outsideLexicalCanonicalPath: await resolveOutsideLexicalCanonicalPathAsync({
|
|
rootPath,
|
|
absolutePath,
|
|
}),
|
|
});
|
|
|
|
const outsideResult = await resolveOutsideBoundaryPathAsync({
|
|
boundaryLabel: params.boundaryLabel,
|
|
context,
|
|
});
|
|
if (outsideResult) {
|
|
return outsideResult;
|
|
}
|
|
|
|
return resolveBoundaryPathLexicalAsync({
|
|
params,
|
|
absolutePath: context.absolutePath,
|
|
rootPath: context.rootPath,
|
|
rootCanonicalPath: context.rootCanonicalPath,
|
|
});
|
|
}
|
|
|
|
export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): ResolvedBoundaryPath {
|
|
const rootPath = path.resolve(params.rootPath);
|
|
const absolutePath = path.resolve(params.absolutePath);
|
|
const rootCanonicalPath = params.rootCanonicalPath
|
|
? path.resolve(params.rootCanonicalPath)
|
|
: resolvePathViaExistingAncestorSync(rootPath);
|
|
const context = createBoundaryResolutionContext({
|
|
resolveParams: params,
|
|
rootPath,
|
|
absolutePath,
|
|
rootCanonicalPath,
|
|
outsideLexicalCanonicalPath: resolveOutsideLexicalCanonicalPathSync({
|
|
rootPath,
|
|
absolutePath,
|
|
}),
|
|
});
|
|
|
|
const outsideResult = resolveOutsideBoundaryPathSync({
|
|
boundaryLabel: params.boundaryLabel,
|
|
context,
|
|
});
|
|
if (outsideResult) {
|
|
return outsideResult;
|
|
}
|
|
|
|
return resolveBoundaryPathLexicalSync({
|
|
params,
|
|
absolutePath: context.absolutePath,
|
|
rootPath: context.rootPath,
|
|
rootCanonicalPath: context.rootCanonicalPath,
|
|
});
|
|
}
|
|
|
|
type LexicalTraversalState = {
|
|
segments: string[];
|
|
allowFinalSymlink: boolean;
|
|
canonicalCursor: string;
|
|
lexicalCursor: string;
|
|
preserveFinalSymlink: boolean;
|
|
};
|
|
|
|
type BoundaryResolutionContext = {
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
rootCanonicalPath: string;
|
|
lexicalInside: boolean;
|
|
canonicalOutsideLexicalPath: string;
|
|
};
|
|
|
|
function isPromiseLike<T>(value: unknown): value is PromiseLike<T> {
|
|
return Boolean(
|
|
value &&
|
|
(typeof value === "object" || typeof value === "function") &&
|
|
"then" in value &&
|
|
typeof (value as { then?: unknown }).then === "function",
|
|
);
|
|
}
|
|
|
|
function createLexicalTraversalState(params: {
|
|
params: ResolveBoundaryPathParams;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
absolutePath: string;
|
|
}): LexicalTraversalState {
|
|
const relative = path.relative(params.rootPath, params.absolutePath);
|
|
return {
|
|
segments: relative.split(path.sep).filter(Boolean),
|
|
allowFinalSymlink: params.params.policy?.allowFinalSymlinkForUnlink === true,
|
|
canonicalCursor: params.rootCanonicalPath,
|
|
lexicalCursor: params.rootPath,
|
|
preserveFinalSymlink: false,
|
|
};
|
|
}
|
|
|
|
function assertLexicalCursorInsideBoundary(params: {
|
|
params: ResolveBoundaryPathParams;
|
|
rootCanonicalPath: string;
|
|
absolutePath: string;
|
|
candidatePath: string;
|
|
}): void {
|
|
assertInsideBoundary({
|
|
boundaryLabel: params.params.boundaryLabel,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
candidatePath: params.candidatePath,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
}
|
|
|
|
function applyMissingSuffixToCanonicalCursor(params: {
|
|
state: LexicalTraversalState;
|
|
missingFromIndex: number;
|
|
rootCanonicalPath: string;
|
|
params: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
}): void {
|
|
const missingSuffix = params.state.segments.slice(params.missingFromIndex);
|
|
params.state.canonicalCursor = path.resolve(params.state.canonicalCursor, ...missingSuffix);
|
|
assertLexicalCursorInsideBoundary({
|
|
params: params.params,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
candidatePath: params.state.canonicalCursor,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
}
|
|
|
|
function advanceCanonicalCursorForSegment(params: {
|
|
state: LexicalTraversalState;
|
|
segment: string;
|
|
rootCanonicalPath: string;
|
|
params: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
}): void {
|
|
params.state.canonicalCursor = path.resolve(params.state.canonicalCursor, params.segment);
|
|
assertLexicalCursorInsideBoundary({
|
|
params: params.params,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
candidatePath: params.state.canonicalCursor,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
}
|
|
|
|
function finalizeLexicalResolution(params: {
|
|
params: ResolveBoundaryPathParams;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
absolutePath: string;
|
|
state: LexicalTraversalState;
|
|
kind: { exists: boolean; kind: ResolvedBoundaryPathKind };
|
|
}): ResolvedBoundaryPath {
|
|
assertLexicalCursorInsideBoundary({
|
|
params: params.params,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
candidatePath: params.state.canonicalCursor,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return buildResolvedBoundaryPath({
|
|
absolutePath: params.absolutePath,
|
|
canonicalPath: params.state.canonicalCursor,
|
|
rootPath: params.rootPath,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
kind: params.kind,
|
|
});
|
|
}
|
|
|
|
function handleLexicalLstatFailure(params: {
|
|
error: unknown;
|
|
state: LexicalTraversalState;
|
|
missingFromIndex: number;
|
|
rootCanonicalPath: string;
|
|
resolveParams: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
}): boolean {
|
|
if (!isNotFoundPathError(params.error)) {
|
|
return false;
|
|
}
|
|
applyMissingSuffixToCanonicalCursor({
|
|
state: params.state,
|
|
missingFromIndex: params.missingFromIndex,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
params: params.resolveParams,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function handleLexicalStatReadFailure(params: {
|
|
error: unknown;
|
|
state: LexicalTraversalState;
|
|
missingFromIndex: number;
|
|
rootCanonicalPath: string;
|
|
resolveParams: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
}): null {
|
|
if (
|
|
handleLexicalLstatFailure({
|
|
error: params.error,
|
|
state: params.state,
|
|
missingFromIndex: params.missingFromIndex,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
resolveParams: params.resolveParams,
|
|
absolutePath: params.absolutePath,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
throw params.error;
|
|
}
|
|
|
|
function handleLexicalStatDisposition(params: {
|
|
state: LexicalTraversalState;
|
|
isSymbolicLink: boolean;
|
|
segment: string;
|
|
isLast: boolean;
|
|
rootCanonicalPath: string;
|
|
resolveParams: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
}): "continue" | "break" | "resolve-link" {
|
|
if (!params.isSymbolicLink) {
|
|
advanceCanonicalCursorForSegment({
|
|
state: params.state,
|
|
segment: params.segment,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
params: params.resolveParams,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return "continue";
|
|
}
|
|
|
|
if (params.state.allowFinalSymlink && params.isLast) {
|
|
params.state.preserveFinalSymlink = true;
|
|
advanceCanonicalCursorForSegment({
|
|
state: params.state,
|
|
segment: params.segment,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
params: params.resolveParams,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return "break";
|
|
}
|
|
|
|
return "resolve-link";
|
|
}
|
|
|
|
function applyResolvedSymlinkHop(params: {
|
|
state: LexicalTraversalState;
|
|
linkCanonical: string;
|
|
rootCanonicalPath: string;
|
|
boundaryLabel: string;
|
|
}): void {
|
|
if (!isPathInside(params.rootCanonicalPath, params.linkCanonical)) {
|
|
throw symlinkEscapeError({
|
|
boundaryLabel: params.boundaryLabel,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
symlinkPath: params.state.lexicalCursor,
|
|
});
|
|
}
|
|
params.state.canonicalCursor = params.linkCanonical;
|
|
params.state.lexicalCursor = params.linkCanonical;
|
|
}
|
|
|
|
function readLexicalStat(params: {
|
|
state: LexicalTraversalState;
|
|
missingFromIndex: number;
|
|
rootCanonicalPath: string;
|
|
resolveParams: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
read: (cursor: string) => fs.Stats | Promise<fs.Stats>;
|
|
}): fs.Stats | null | Promise<fs.Stats | null> {
|
|
try {
|
|
const stat = params.read(params.state.lexicalCursor);
|
|
if (isPromiseLike<fs.Stats>(stat)) {
|
|
return Promise.resolve(stat).catch((error) =>
|
|
handleLexicalStatReadFailure({ ...params, error }),
|
|
);
|
|
}
|
|
return stat;
|
|
} catch (error) {
|
|
return handleLexicalStatReadFailure({ ...params, error });
|
|
}
|
|
}
|
|
|
|
function resolveAndApplySymlinkHop(params: {
|
|
state: LexicalTraversalState;
|
|
rootCanonicalPath: string;
|
|
boundaryLabel: string;
|
|
resolveLinkCanonical: (cursor: string) => string | Promise<string>;
|
|
}): void | Promise<void> {
|
|
const linkCanonical = params.resolveLinkCanonical(params.state.lexicalCursor);
|
|
if (isPromiseLike<string>(linkCanonical)) {
|
|
return Promise.resolve(linkCanonical).then((value) =>
|
|
applyResolvedSymlinkHop({
|
|
state: params.state,
|
|
linkCanonical: value,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
boundaryLabel: params.boundaryLabel,
|
|
}),
|
|
);
|
|
}
|
|
applyResolvedSymlinkHop({
|
|
state: params.state,
|
|
linkCanonical,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
boundaryLabel: params.boundaryLabel,
|
|
});
|
|
}
|
|
|
|
type LexicalTraversalStep = {
|
|
idx: number;
|
|
segment: string;
|
|
isLast: boolean;
|
|
};
|
|
|
|
function* iterateLexicalTraversal(state: LexicalTraversalState): Iterable<LexicalTraversalStep> {
|
|
for (let idx = 0; idx < state.segments.length; idx += 1) {
|
|
const segment = state.segments[idx] ?? "";
|
|
const isLast = idx === state.segments.length - 1;
|
|
state.lexicalCursor = path.join(state.lexicalCursor, segment);
|
|
yield { idx, segment, isLast };
|
|
}
|
|
}
|
|
|
|
async function resolveBoundaryPathLexicalAsync(params: {
|
|
params: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
}): Promise<ResolvedBoundaryPath> {
|
|
const state = createLexicalTraversalState(params);
|
|
const sharedStepParams = {
|
|
state,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
resolveParams: params.params,
|
|
absolutePath: params.absolutePath,
|
|
};
|
|
|
|
for (const { idx, segment, isLast } of iterateLexicalTraversal(state)) {
|
|
const stat = await readLexicalStat({
|
|
...sharedStepParams,
|
|
missingFromIndex: idx,
|
|
read: (cursor) => fsp.lstat(cursor),
|
|
});
|
|
if (!stat) {
|
|
break;
|
|
}
|
|
|
|
const disposition = handleLexicalStatDisposition({
|
|
...sharedStepParams,
|
|
isSymbolicLink: stat.isSymbolicLink(),
|
|
segment,
|
|
isLast,
|
|
});
|
|
if (disposition === "continue") {
|
|
continue;
|
|
}
|
|
if (disposition === "break") {
|
|
break;
|
|
}
|
|
|
|
await resolveAndApplySymlinkHop({
|
|
state,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
boundaryLabel: params.params.boundaryLabel,
|
|
resolveLinkCanonical: (cursor) => resolveSymlinkHopPath(cursor),
|
|
});
|
|
}
|
|
|
|
const kind = await getPathKind(params.absolutePath, state.preserveFinalSymlink);
|
|
return finalizeLexicalResolution({
|
|
...params,
|
|
state,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
function resolveBoundaryPathLexicalSync(params: {
|
|
params: ResolveBoundaryPathParams;
|
|
absolutePath: string;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
}): ResolvedBoundaryPath {
|
|
const state = createLexicalTraversalState(params);
|
|
for (let idx = 0; idx < state.segments.length; idx += 1) {
|
|
const segment = state.segments[idx] ?? "";
|
|
const isLast = idx === state.segments.length - 1;
|
|
state.lexicalCursor = path.join(state.lexicalCursor, segment);
|
|
const maybeStat = readLexicalStat({
|
|
state,
|
|
missingFromIndex: idx,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
resolveParams: params.params,
|
|
absolutePath: params.absolutePath,
|
|
read: (cursor) => fs.lstatSync(cursor),
|
|
});
|
|
if (isPromiseLike<fs.Stats | null>(maybeStat)) {
|
|
throw new Error("Unexpected async lexical stat");
|
|
}
|
|
const stat = maybeStat;
|
|
if (!stat) {
|
|
break;
|
|
}
|
|
|
|
const disposition = handleLexicalStatDisposition({
|
|
state,
|
|
isSymbolicLink: stat.isSymbolicLink(),
|
|
segment,
|
|
isLast,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
resolveParams: params.params,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
if (disposition === "continue") {
|
|
continue;
|
|
}
|
|
if (disposition === "break") {
|
|
break;
|
|
}
|
|
|
|
const maybeApplied = resolveAndApplySymlinkHop({
|
|
state,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
boundaryLabel: params.params.boundaryLabel,
|
|
resolveLinkCanonical: (cursor) => resolveSymlinkHopPathSync(cursor),
|
|
});
|
|
if (isPromiseLike<void>(maybeApplied)) {
|
|
throw new Error("Unexpected async symlink resolution");
|
|
}
|
|
}
|
|
|
|
const kind = getPathKindSync(params.absolutePath, state.preserveFinalSymlink);
|
|
return finalizeLexicalResolution({
|
|
...params,
|
|
state,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
function resolveCanonicalOutsideLexicalPath(params: {
|
|
absolutePath: string;
|
|
outsideLexicalCanonicalPath?: string;
|
|
}): string {
|
|
return params.outsideLexicalCanonicalPath ?? params.absolutePath;
|
|
}
|
|
|
|
function createBoundaryResolutionContext(params: {
|
|
resolveParams: ResolveBoundaryPathParams;
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
rootCanonicalPath: string;
|
|
outsideLexicalCanonicalPath?: string;
|
|
}): BoundaryResolutionContext {
|
|
const lexicalInside = isPathInside(params.rootPath, params.absolutePath);
|
|
const canonicalOutsideLexicalPath = resolveCanonicalOutsideLexicalPath({
|
|
absolutePath: params.absolutePath,
|
|
outsideLexicalCanonicalPath: params.outsideLexicalCanonicalPath,
|
|
});
|
|
assertLexicalBoundaryOrCanonicalAlias({
|
|
skipLexicalRootCheck: params.resolveParams.skipLexicalRootCheck,
|
|
lexicalInside,
|
|
canonicalOutsideLexicalPath,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
boundaryLabel: params.resolveParams.boundaryLabel,
|
|
rootPath: params.rootPath,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return {
|
|
rootPath: params.rootPath,
|
|
absolutePath: params.absolutePath,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
lexicalInside,
|
|
canonicalOutsideLexicalPath,
|
|
};
|
|
}
|
|
|
|
async function resolveOutsideBoundaryPathAsync(params: {
|
|
boundaryLabel: string;
|
|
context: BoundaryResolutionContext;
|
|
}): Promise<ResolvedBoundaryPath | null> {
|
|
if (params.context.lexicalInside) {
|
|
return null;
|
|
}
|
|
const kind = await getPathKind(params.context.absolutePath, false);
|
|
return buildOutsideBoundaryPathFromContext({
|
|
boundaryLabel: params.boundaryLabel,
|
|
context: params.context,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
function resolveOutsideBoundaryPathSync(params: {
|
|
boundaryLabel: string;
|
|
context: BoundaryResolutionContext;
|
|
}): ResolvedBoundaryPath | null {
|
|
if (params.context.lexicalInside) {
|
|
return null;
|
|
}
|
|
const kind = getPathKindSync(params.context.absolutePath, false);
|
|
return buildOutsideBoundaryPathFromContext({
|
|
boundaryLabel: params.boundaryLabel,
|
|
context: params.context,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
function buildOutsideBoundaryPathFromContext(params: {
|
|
boundaryLabel: string;
|
|
context: BoundaryResolutionContext;
|
|
kind: { exists: boolean; kind: ResolvedBoundaryPathKind };
|
|
}): ResolvedBoundaryPath {
|
|
return buildOutsideLexicalBoundaryPath({
|
|
boundaryLabel: params.boundaryLabel,
|
|
rootCanonicalPath: params.context.rootCanonicalPath,
|
|
absolutePath: params.context.absolutePath,
|
|
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
|
|
rootPath: params.context.rootPath,
|
|
kind: params.kind,
|
|
});
|
|
}
|
|
|
|
async function resolveOutsideLexicalCanonicalPathAsync(params: {
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
}): Promise<string | undefined> {
|
|
if (isPathInside(params.rootPath, params.absolutePath)) {
|
|
return undefined;
|
|
}
|
|
return await resolvePathViaExistingAncestor(params.absolutePath);
|
|
}
|
|
|
|
function resolveOutsideLexicalCanonicalPathSync(params: {
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
}): string | undefined {
|
|
if (isPathInside(params.rootPath, params.absolutePath)) {
|
|
return undefined;
|
|
}
|
|
return resolvePathViaExistingAncestorSync(params.absolutePath);
|
|
}
|
|
|
|
function buildOutsideLexicalBoundaryPath(params: {
|
|
boundaryLabel: string;
|
|
rootCanonicalPath: string;
|
|
absolutePath: string;
|
|
canonicalOutsideLexicalPath: string;
|
|
rootPath: string;
|
|
kind: { exists: boolean; kind: ResolvedBoundaryPathKind };
|
|
}): ResolvedBoundaryPath {
|
|
assertInsideBoundary({
|
|
boundaryLabel: params.boundaryLabel,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
candidatePath: params.canonicalOutsideLexicalPath,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
return buildResolvedBoundaryPath({
|
|
absolutePath: params.absolutePath,
|
|
canonicalPath: params.canonicalOutsideLexicalPath,
|
|
rootPath: params.rootPath,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
kind: params.kind,
|
|
});
|
|
}
|
|
|
|
function assertLexicalBoundaryOrCanonicalAlias(params: {
|
|
skipLexicalRootCheck?: boolean;
|
|
lexicalInside: boolean;
|
|
canonicalOutsideLexicalPath: string;
|
|
rootCanonicalPath: string;
|
|
boundaryLabel: string;
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
}): void {
|
|
if (params.skipLexicalRootCheck || params.lexicalInside) {
|
|
return;
|
|
}
|
|
if (isPathInside(params.rootCanonicalPath, params.canonicalOutsideLexicalPath)) {
|
|
return;
|
|
}
|
|
throw pathEscapeError({
|
|
boundaryLabel: params.boundaryLabel,
|
|
rootPath: params.rootPath,
|
|
absolutePath: params.absolutePath,
|
|
});
|
|
}
|
|
|
|
function buildResolvedBoundaryPath(params: {
|
|
absolutePath: string;
|
|
canonicalPath: string;
|
|
rootPath: string;
|
|
rootCanonicalPath: string;
|
|
kind: { exists: boolean; kind: ResolvedBoundaryPathKind };
|
|
}): ResolvedBoundaryPath {
|
|
return {
|
|
absolutePath: params.absolutePath,
|
|
canonicalPath: params.canonicalPath,
|
|
rootPath: params.rootPath,
|
|
rootCanonicalPath: params.rootCanonicalPath,
|
|
relativePath: relativeInsideRoot(params.rootCanonicalPath, params.canonicalPath),
|
|
exists: params.kind.exists,
|
|
kind: params.kind.kind,
|
|
};
|
|
}
|
|
|
|
export async function resolvePathViaExistingAncestor(targetPath: string): Promise<string> {
|
|
const normalized = path.resolve(targetPath);
|
|
let cursor = normalized;
|
|
const missingSuffix: string[] = [];
|
|
|
|
while (!isFilesystemRoot(cursor) && !(await pathExists(cursor))) {
|
|
missingSuffix.unshift(path.basename(cursor));
|
|
const parent = path.dirname(cursor);
|
|
if (parent === cursor) {
|
|
break;
|
|
}
|
|
cursor = parent;
|
|
}
|
|
|
|
if (!(await pathExists(cursor))) {
|
|
return normalized;
|
|
}
|
|
|
|
try {
|
|
const resolvedAncestor = path.resolve(await fsp.realpath(cursor));
|
|
if (missingSuffix.length === 0) {
|
|
return resolvedAncestor;
|
|
}
|
|
return path.resolve(resolvedAncestor, ...missingSuffix);
|
|
} catch {
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
export function resolvePathViaExistingAncestorSync(targetPath: string): string {
|
|
const normalized = path.resolve(targetPath);
|
|
let cursor = normalized;
|
|
const missingSuffix: string[] = [];
|
|
|
|
while (!isFilesystemRoot(cursor) && !fs.existsSync(cursor)) {
|
|
missingSuffix.unshift(path.basename(cursor));
|
|
const parent = path.dirname(cursor);
|
|
if (parent === cursor) {
|
|
break;
|
|
}
|
|
cursor = parent;
|
|
}
|
|
|
|
if (!fs.existsSync(cursor)) {
|
|
return normalized;
|
|
}
|
|
|
|
try {
|
|
// Keep sync behavior aligned with async (`fsp.realpath`) to avoid
|
|
// platform-specific canonical alias drift (notably on Windows).
|
|
const resolvedAncestor = path.resolve(fs.realpathSync(cursor));
|
|
if (missingSuffix.length === 0) {
|
|
return resolvedAncestor;
|
|
}
|
|
return path.resolve(resolvedAncestor, ...missingSuffix);
|
|
} catch {
|
|
return normalized;
|
|
}
|
|
}
|
|
|
|
async function getPathKind(
|
|
absolutePath: string,
|
|
preserveFinalSymlink: boolean,
|
|
): Promise<{ exists: boolean; kind: ResolvedBoundaryPathKind }> {
|
|
try {
|
|
const stat = preserveFinalSymlink
|
|
? await fsp.lstat(absolutePath)
|
|
: await fsp.stat(absolutePath);
|
|
return { exists: true, kind: toResolvedKind(stat) };
|
|
} catch (error) {
|
|
if (isNotFoundPathError(error)) {
|
|
return { exists: false, kind: "missing" };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getPathKindSync(
|
|
absolutePath: string,
|
|
preserveFinalSymlink: boolean,
|
|
): { exists: boolean; kind: ResolvedBoundaryPathKind } {
|
|
try {
|
|
const stat = preserveFinalSymlink ? fs.lstatSync(absolutePath) : fs.statSync(absolutePath);
|
|
return { exists: true, kind: toResolvedKind(stat) };
|
|
} catch (error) {
|
|
if (isNotFoundPathError(error)) {
|
|
return { exists: false, kind: "missing" };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function toResolvedKind(stat: fs.Stats): ResolvedBoundaryPathKind {
|
|
if (stat.isFile()) {
|
|
return "file";
|
|
}
|
|
if (stat.isDirectory()) {
|
|
return "directory";
|
|
}
|
|
if (stat.isSymbolicLink()) {
|
|
return "symlink";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function relativeInsideRoot(rootPath: string, targetPath: string): string {
|
|
const relative = path.relative(path.resolve(rootPath), path.resolve(targetPath));
|
|
if (!relative || relative === ".") {
|
|
return "";
|
|
}
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
return "";
|
|
}
|
|
return relative;
|
|
}
|
|
|
|
function assertInsideBoundary(params: {
|
|
boundaryLabel: string;
|
|
rootCanonicalPath: string;
|
|
candidatePath: string;
|
|
absolutePath: string;
|
|
}): void {
|
|
if (isPathInside(params.rootCanonicalPath, params.candidatePath)) {
|
|
return;
|
|
}
|
|
throw new Error(
|
|
`Path resolves outside ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.absolutePath)}`,
|
|
);
|
|
}
|
|
|
|
function pathEscapeError(params: {
|
|
boundaryLabel: string;
|
|
rootPath: string;
|
|
absolutePath: string;
|
|
}): Error {
|
|
return new Error(
|
|
`Path escapes ${params.boundaryLabel} (${shortPath(params.rootPath)}): ${shortPath(params.absolutePath)}`,
|
|
);
|
|
}
|
|
|
|
function symlinkEscapeError(params: {
|
|
boundaryLabel: string;
|
|
rootCanonicalPath: string;
|
|
symlinkPath: string;
|
|
}): Error {
|
|
return new Error(
|
|
`Symlink escapes ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.symlinkPath)}`,
|
|
);
|
|
}
|
|
|
|
function shortPath(value: string): string {
|
|
const home = os.homedir();
|
|
if (value.startsWith(home)) {
|
|
return `~${value.slice(home.length)}`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isFilesystemRoot(candidate: string): boolean {
|
|
return path.parse(candidate).root === candidate;
|
|
}
|
|
|
|
async function pathExists(targetPath: string): Promise<boolean> {
|
|
try {
|
|
await fsp.lstat(targetPath);
|
|
return true;
|
|
} catch (error) {
|
|
if (isNotFoundPathError(error)) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function resolveSymlinkHopPath(symlinkPath: string): Promise<string> {
|
|
try {
|
|
return path.resolve(await fsp.realpath(symlinkPath));
|
|
} catch (error) {
|
|
if (!isNotFoundPathError(error)) {
|
|
throw error;
|
|
}
|
|
const linkTarget = await fsp.readlink(symlinkPath);
|
|
const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget);
|
|
return resolvePathViaExistingAncestor(linkAbsolute);
|
|
}
|
|
}
|
|
|
|
function resolveSymlinkHopPathSync(symlinkPath: string): string {
|
|
try {
|
|
return path.resolve(fs.realpathSync(symlinkPath));
|
|
} catch (error) {
|
|
if (!isNotFoundPathError(error)) {
|
|
throw error;
|
|
}
|
|
const linkTarget = fs.readlinkSync(symlinkPath);
|
|
const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget);
|
|
return resolvePathViaExistingAncestorSync(linkAbsolute);
|
|
}
|
|
}
|