import { cp, readdir, readlink, realpath, rm, stat } from "node:fs/promises"; import path from "node:path"; /** * Materialize symlinks inside a build artifact that point outside the artifact * directory but still live under the original app tree (typical for Next.js * standalone output referencing monorepo node_modules). */ export async function normalizeArtifactSymlinks( artifactDir: string, appPath: string, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const normalizedArtifactDir = await realpath(path.resolve(artifactDir)); const normalizedAppPath = await realpath(path.resolve(appPath)); await walkDirectory(path.resolve(artifactDir)); async function walkDirectory(directory: string): Promise { signal?.throwIfAborted(); const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { await walkDirectory(fullPath); continue; } if (!entry.isSymbolicLink()) { continue; } const target = await readlink(fullPath); const resolvedTarget = path.resolve(path.dirname(fullPath), target); const realTarget = await realpath(resolvedTarget); if (isPathWithin(normalizedArtifactDir, realTarget)) { continue; } if (!isPathWithin(normalizedAppPath, realTarget)) { throw new Error( `Build artifact symlink escapes the app directory: ${realTarget}`, ); } const targetStat = await stat(realTarget); await rm(fullPath, { force: true, recursive: true }); await cp(realTarget, fullPath, { recursive: targetStat.isDirectory(), // Keep nested symlinks intact so walkDirectory can validate each one. dereference: false, }); if (targetStat.isDirectory()) { await walkDirectory(fullPath); } } } } function isPathWithin(rootPath: string, candidatePath: string): boolean { const relativePath = path.relative(rootPath, candidatePath); return ( relativePath === "" || (!relativePath.startsWith(`..${path.sep}`) && relativePath !== ".." && !path.isAbsolute(relativePath)) ); }