Repository navigation
fix(nextjs): preserve absolute Windows standalone links safely #272
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
23155e7
45ab20b
e506683
b90d538
c48ba83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -31,6 +31,7 @@ import { fileURLToPath } from 'node:url'; | |||||||
| import { | ||||||||
| assertBundleSymlinksStayInside, | ||||||||
| copyTreeVerbatim, | ||||||||
| createBundleLink, | ||||||||
| isWithin, | ||||||||
| repairWindowsDirectorySymlinks, | ||||||||
| } from '@internal/bundle-paths'; | ||||||||
|
|
@@ -139,6 +140,117 @@ async function collectSymlinks(root: string): Promise<string[]> { | |||||||
| return links; | ||||||||
| } | ||||||||
|
|
||||||||
| async function createAbsoluteLinkStagingRoot(bundleDir: string): Promise<string> { | ||||||||
| const base = path.join(bundleDir, '.prisma-composer-absolute-links'); | ||||||||
| for (let suffix = 0; ; suffix += 1) { | ||||||||
| const candidate = suffix === 0 ? base : `${base}-${suffix}`; | ||||||||
| try { | ||||||||
| await fs.promises.mkdir(candidate); | ||||||||
| return candidate; | ||||||||
| } catch (error) { | ||||||||
| if (error instanceof Error && Reflect.get(error, 'code') === 'EEXIST') continue; | ||||||||
| throw error; | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Windows standalone output can contain absolute package links. An absolute | ||||||||
| * build-machine path cannot ship, even when its target belongs to Next's | ||||||||
| * declared trace root. Stage that exact target under a fresh, collision-free | ||||||||
| * bundle directory, then preserve the link as a relative in-bundle link. | ||||||||
| * | ||||||||
| * The link is never dereferenced: its target is copied separately and the | ||||||||
| * topology remains a link. Targets outside the declared trace root are left | ||||||||
| * untouched for the bundle validator to reject. | ||||||||
| */ | ||||||||
| async function stageAbsoluteStandaloneLinkTargets( | ||||||||
| bundleDir: string, | ||||||||
| manifest: ServerFilesManifest, | ||||||||
| ): Promise<string[]> { | ||||||||
| const tracingRoot = manifest.tracingRoot; | ||||||||
| if (tracingRoot === undefined || (await lstatIfPresent(tracingRoot)) === undefined) return []; | ||||||||
|
|
||||||||
| const tracedRootReal = await fs.promises.realpath(tracingRoot); | ||||||||
| const stagedSources = new Set<string>(); | ||||||||
| const stagedTargets = new Map<string, string>(); | ||||||||
| let stagingRoot: string | undefined; | ||||||||
| let nextStagedTarget = 0; | ||||||||
|
|
||||||||
| /** Copies one trusted target and repairs links whose meaning relocation would change. */ | ||||||||
| async function stageSource(sourceReal: string): Promise<string> { | ||||||||
| const existing = stagedTargets.get(sourceReal); | ||||||||
| if (existing !== undefined) return existing; | ||||||||
|
|
||||||||
| stagingRoot ??= await createAbsoluteLinkStagingRoot(bundleDir); | ||||||||
| const target = path.join(stagingRoot, String(nextStagedTarget)); | ||||||||
| nextStagedTarget += 1; | ||||||||
| stagedTargets.set(sourceReal, target); | ||||||||
|
|
||||||||
| const sourceStat = await fs.promises.stat(sourceReal); | ||||||||
| await copyTreeVerbatim(sourceReal, target); | ||||||||
| stagedSources.add(sourceReal); | ||||||||
|
|
||||||||
| if (!sourceStat.isDirectory()) return target; | ||||||||
| for (const stagedLink of await collectSymlinks(target)) { | ||||||||
| const sourceLink = path.join(sourceReal, path.relative(target, stagedLink)); | ||||||||
| const rawTarget = await fs.promises.readlink(sourceLink); | ||||||||
| const sourceTarget = path.isAbsolute(rawTarget) | ||||||||
| ? rawTarget | ||||||||
| : path.resolve(path.dirname(sourceLink), rawTarget); | ||||||||
| if (isWithin(sourceReal, sourceTarget)) continue; | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor · correctness — Nested absolute symlink inside a staged target is left pointing at the build machine on POSIX, failing assembly On POSIX Recommended fix Skip the rewrite only when the link inside the staged copy already resolves within that copy, instead of when the original's raw target is lexically within
Suggested change
This keeps the Windows re-anchored-junction skip (the staged junction resolves inside the staged copy) while rewriting POSIX verbatim absolute links through the existing realpath → boundary check → stage path. |
||||||||
|
|
||||||||
| let nestedSourceReal: string; | ||||||||
| try { | ||||||||
| nestedSourceReal = await fs.promises.realpath(sourceLink); | ||||||||
| } catch { | ||||||||
| throw new Error( | ||||||||
| `cannot stage ${sourceReal}: it contains a dangling symlink (${sourceLink} -> ${rawTarget})`, | ||||||||
| ); | ||||||||
| } | ||||||||
| if (!isWithin(tracedRootReal, nestedSourceReal)) { | ||||||||
| throw new Error( | ||||||||
| `cannot stage ${sourceReal}: it contains a symlink outside the declared tracing root (${sourceLink} -> ${rawTarget})`, | ||||||||
| ); | ||||||||
| } | ||||||||
|
|
||||||||
| const nestedTarget = await stageSource(nestedSourceReal); | ||||||||
| const nestedStat = await fs.promises.stat(nestedSourceReal); | ||||||||
| await fs.promises.unlink(stagedLink); | ||||||||
| await createBundleLink( | ||||||||
| path.relative(path.dirname(stagedLink), nestedTarget), | ||||||||
| stagedLink, | ||||||||
| nestedStat.isDirectory() ? 'dir' : 'file', | ||||||||
| ); | ||||||||
| } | ||||||||
| return target; | ||||||||
| } | ||||||||
|
|
||||||||
| for (const linkPath of await collectSymlinks(bundleDir)) { | ||||||||
| const rawTarget = await fs.promises.readlink(linkPath); | ||||||||
| if (!path.isAbsolute(rawTarget)) continue; | ||||||||
|
|
||||||||
| let sourceReal: string; | ||||||||
| try { | ||||||||
| sourceReal = await fs.promises.realpath(linkPath); | ||||||||
| } catch { | ||||||||
| continue; | ||||||||
| } | ||||||||
| if (isWithin(bundleDir, sourceReal)) continue; | ||||||||
| if (!isWithin(tracedRootReal, sourceReal)) continue; | ||||||||
|
|
||||||||
| const target = await stageSource(sourceReal); | ||||||||
| const sourceStat = await fs.promises.stat(sourceReal); | ||||||||
| await fs.promises.unlink(linkPath); | ||||||||
| await createBundleLink( | ||||||||
| path.relative(path.dirname(linkPath), target), | ||||||||
| linkPath, | ||||||||
| sourceStat.isDirectory() ? 'dir' : 'file', | ||||||||
| ); | ||||||||
| } | ||||||||
| return [...stagedSources]; | ||||||||
| } | ||||||||
|
|
||||||||
| /** In-bundle link targets that the standalone tree does not contain — the | ||||||||
| * repairs staging has to make. */ | ||||||||
| async function missingLinkTargets(bundleDir: string): Promise<string[]> { | ||||||||
|
|
@@ -236,7 +348,8 @@ export async function assemble(input: AssembleInput): Promise<Bundle> { | |||||||
| // links stay links; the packager validates that every target remains inside | ||||||||
| // the assembled bundle before emitting it into the archive. | ||||||||
| await copyTreeVerbatim(standaloneRoot, bundleDir); | ||||||||
| const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); | ||||||||
| const stagedAbsoluteLinkTargets = await stageAbsoluteStandaloneLinkTargets(bundleDir, manifest); | ||||||||
| const stagedMissingLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest); | ||||||||
| // Staging can make previously dangling directory links repairable. | ||||||||
| await repairWindowsDirectorySymlinks(bundleDir); | ||||||||
|
|
||||||||
|
|
@@ -277,7 +390,7 @@ export async function assemble(input: AssembleInput): Promise<Bundle> { | |||||||
| return { | ||||||||
| dir: workDir, | ||||||||
| entry: path.posix.join('bundle', appRel.split(path.sep).join('/'), 'server.js'), | ||||||||
| watch: [standaloneRoot, ...stagedLinkTargets], | ||||||||
| watch: [standaloneRoot, ...stagedAbsoluteLinkTargets, ...stagedMissingLinkTargets], | ||||||||
| }; | ||||||||
| } | ||||||||
|
|
||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Minor · tests — No test for a dangling absolute link being left for the validator to reject
The PR description promises "Leave dangling and out-of-root links as errors" for absolute links, and the code implements both paths (
realpathfailure →continueat build.ts:235-238, then the dangling-symlink rejection fromassertBundleSymlinksStayInside). Only the out-of-root case is tested (this test). A dangling absolute link exercises a distinct skip-and-fail path with a different error message and is easy to regress (e.g., a future change that treatsrealpathfailure as staging failure or silently drops the link).Recommended fix
Add a sibling test that creates the absolute link with a nonexistent target (e.g.
fs.symlinkSync(path.join(root, 'node_modules', 'gone'), linkPath, 'dir')) and assertsassemblerejects with /dangling symlink/.