From e93054182382fd48c027aefa3f2b1038e78fe245 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:07:25 +0530 Subject: [PATCH 1/6] fix(bundle-paths): link bundle directories with junctions on Windows Windows only lets a user holding SeCreateSymbolicLinkPrivilege (Developer Mode, or an elevated shell) create a symbolic link; everyone else gets EPERM. Assembly created 'dir' and 'file' symlinks directly, and copied trees with fs.cp, which re-creates every link with an untyped fs.symlink that Node resolves to the same privileged call. GitHub's Windows runners are elevated, so CI never saw it. The platform decision now lives in one helper. planBundleLink / createBundleLink write a directory link as a junction on Windows, for every user, so privileged and unprivileged machines assemble the same bundle; the junction's absolute target is resolved here rather than left to the runtime. A file link stays a file symlink: a junction cannot point at a file, and a copy or hard link would archive as a regular file, which is the dereferencing ADR-0047 forbids. When Windows refuses one, the error names the privilege instead of surfacing a bare EPERM; nothing is retried. copyTreeVerbatim keeps fs.cp on POSIX and uses its own walk on Windows, with links routed through the helper. readlink reports a junction's target as an absolute path, so an absolute target inside the copied tree is re-anchored to the same place in the destination instead of pointing back into the source. A link whose target is absent cannot be typed and is written as a junction; repairWindowsDirectorySymlinks re-types it once staging has supplied the target. The Windows paths run on every platform through a platform test seam, with an absolute symlink standing in for a junction; the junction assertions themselves run on the windows-latest job. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Aman Varshney --- .../bundle-paths/src/bundle-paths.test.ts | 361 +++++++++++++++++- .../bundle-paths/src/bundle-paths.ts | 212 +++++++++- .../bundle-paths/src/exports/index.ts | 4 + 3 files changed, 566 insertions(+), 11 deletions(-) diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts index 9ebb97aab..250f9ceed 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts @@ -1,8 +1,51 @@ -import { describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { assertBundleSymlinksStayInside, isWithin } from './bundle-paths.ts'; +import { + assertBundleSymlinksStayInside, + bundleLinkKind, + copyTreeVerbatim, + createBundleLink, + isWithin, + planBundleLink, + repairWindowsDirectorySymlinks, +} from './bundle-paths.ts'; + +const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-paths-')); +const onWindows = process.platform === 'win32'; + +/** Where a link points, relative to the root it lives under, POSIX-separated. + * Platform-neutral: a Windows junction reads back as an absolute path. */ +function linkTargetWithin(root: string, linkPath: string): string { + const resolved = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath)); + return path.relative(root, resolved).split(path.sep).join('/'); +} + +/** The `type` argument of every `fs.promises.symlink` call made while `run` executes. */ +async function symlinkTypesDuring(run: () => Promise): Promise { + const spy = spyOn(fs.promises, 'symlink'); + try { + await run(); + return spy.mock.calls.map((call) => call[2]); + } finally { + spy.mockRestore(); + } +} + +/** A tree holding each link shape a build output can contain. */ +function writeLinkedTree(root: string): void { + fs.mkdirSync(path.join(root, 'store', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }); + fs.writeFileSync(path.join(root, 'store', 'pkg', 'index.js'), '// pkg'); + fs.writeFileSync(path.join(root, 'store', 'data.json'), '{}'); + fs.symlinkSync(path.join('..', 'store', 'pkg'), path.join(root, 'node_modules', 'pkg'), 'dir'); + fs.symlinkSync( + path.join('..', 'store', 'data.json'), + path.join(root, 'node_modules', 'data.json'), + 'file', + ); +} describe('isWithin', () => { test('the root itself and descendants are within; siblings and parents are not', () => { @@ -16,8 +59,6 @@ describe('isWithin', () => { }); describe('assertBundleSymlinksStayInside', () => { - const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-paths-')); - test('accepts a bundle whose links resolve inside it', async () => { const bundle = path.join(scratch(), 'bundle'); fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); @@ -44,3 +85,315 @@ describe('assertBundleSymlinksStayInside', () => { await expect(assertBundleSymlinksStayInside(bundle)).rejects.toThrow('escapes the bundle'); }); }); + +describe('planBundleLink', () => { + const linkPath = path.join(path.resolve('bundle'), 'node_modules', 'pkg'); + const target = path.join('..', 'store', 'pkg'); + + test('POSIX writes the link exactly as given', () => { + for (const platform of ['linux', 'darwin'] as const) { + expect(planBundleLink(target, linkPath, 'dir', platform)).toEqual({ target, type: 'dir' }); + expect(planBundleLink(target, linkPath, 'file', platform)).toEqual({ target, type: 'file' }); + } + }); + + test('a Windows directory link is a junction with an absolute target', () => { + expect(planBundleLink(target, linkPath, 'dir', 'win32')).toEqual({ + target: path.join(path.resolve('bundle'), 'store', 'pkg'), + type: 'junction', + }); + }); + + test('a Windows file link stays a file symlink with the target as given', () => { + expect(planBundleLink(target, linkPath, 'file', 'win32')).toEqual({ target, type: 'file' }); + }); +}); + +describe('bundleLinkKind', () => { + test('types a link by what its target is, and an absent target as a directory', async () => { + const root = scratch(); + fs.mkdirSync(path.join(root, 'dir')); + fs.writeFileSync(path.join(root, 'file.txt'), 'x'); + + expect(await bundleLinkKind(path.join(root, 'dir'))).toBe('dir'); + expect(await bundleLinkKind(path.join(root, 'file.txt'))).toBe('file'); + expect(await bundleLinkKind(path.join(root, 'absent'))).toBe('dir'); + }); +}); + +describe('createBundleLink', () => { + test('creates directory and file links that resolve inside the bundle', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(path.join(bundle, 'store', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(bundle, 'node_modules')); + fs.writeFileSync(path.join(bundle, 'store', 'pkg', 'index.js'), '// pkg'); + const dirLink = path.join(bundle, 'node_modules', 'pkg'); + const fileLink = path.join(bundle, 'node_modules', 'index.js'); + + await createBundleLink(path.join('..', 'store', 'pkg'), dirLink, 'dir'); + await createBundleLink(path.join('..', 'store', 'pkg', 'index.js'), fileLink, 'file'); + + expect(fs.lstatSync(dirLink).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(fileLink).isSymbolicLink()).toBe(true); + expect(linkTargetWithin(bundle, dirLink)).toBe('store/pkg'); + expect(linkTargetWithin(bundle, fileLink)).toBe('store/pkg/index.js'); + expect(fs.readFileSync(path.join(dirLink, 'index.js'), 'utf8')).toBe('// pkg'); + expect(fs.readFileSync(fileLink, 'utf8')).toBe('// pkg'); + await assertBundleSymlinksStayInside(bundle); + }); + + test('never asks Windows for a directory symlink', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); + + const types = await symlinkTypesDuring(() => + createBundleLink('real', path.join(bundle, 'link'), 'dir', 'win32'), + ); + + expect(types).toEqual(['junction']); + }); + + test('names the missing privilege when Windows refuses a file symlink, and nothing else', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(bundle, { recursive: true }); + const denied = Object.assign(new Error('EPERM: operation not permitted, symlink'), { + code: 'EPERM', + }); + const spy = spyOn(fs.promises, 'symlink').mockRejectedValue(denied); + try { + const fileLink = createBundleLink('file.txt', path.join(bundle, 'link'), 'file', 'win32'); + await expect(fileLink).rejects.toThrow(/symbolic-link privilege.*Developer Mode/s); + await expect(fileLink).rejects.toHaveProperty('cause', denied); + // Not a fallback: no second attempt is made, and any other refusal is + // reported as it came. + expect(spy.mock.calls.length).toBe(1); + await expect( + createBundleLink('real', path.join(bundle, 'junction'), 'dir', 'win32'), + ).rejects.toBe(denied); + await expect( + createBundleLink('file.txt', path.join(bundle, 'posix'), 'file', 'linux'), + ).rejects.toBe(denied); + } finally { + spy.mockRestore(); + } + }); + + test.skipIf(!onWindows)('a directory link on Windows is a junction', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); + fs.writeFileSync(path.join(bundle, 'real', 'index.js'), '// real'); + const link = path.join(bundle, 'link'); + + const types = await symlinkTypesDuring(() => createBundleLink('real', link, 'dir')); + + expect(types).toEqual(['junction']); + // What the rest of the pipeline relies on: a junction is reported as a + // symlink, and reads back as a plain absolute path inside the bundle. + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + const recorded = fs.readlinkSync(link); + expect(path.isAbsolute(recorded)).toBe(true); + expect(path.relative(bundle, recorded)).toBe('real'); + expect(fs.readFileSync(path.join(link, 'index.js'), 'utf8')).toBe('// real'); + expect( + fs + .readdirSync(bundle, { withFileTypes: true }) + .find((entry) => entry.name === 'link') + ?.isSymbolicLink(), + ).toBe(true); + await assertBundleSymlinksStayInside(bundle); + }); + + test.skipIf(!onWindows)('a junction whose target escapes the bundle is rejected', async () => { + const parent = scratch(); + const bundle = path.join(parent, 'bundle'); + fs.mkdirSync(path.join(parent, 'outside'), { recursive: true }); + fs.mkdirSync(bundle, { recursive: true }); + await createBundleLink(path.join('..', 'outside'), path.join(bundle, 'link'), 'dir'); + + await expect(assertBundleSymlinksStayInside(bundle)).rejects.toThrow('escapes the bundle'); + }); +}); + +describe('copyTreeVerbatim', () => { + const copies: string[] = []; + afterEach(() => { + for (const dir of copies.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + test('keeps directory and file links as links that resolve inside the copy', async () => { + const parent = scratch(); + copies.push(parent); + const source = path.join(parent, 'source'); + const destination = path.join(parent, 'nested', 'destination'); + writeLinkedTree(source); + + await copyTreeVerbatim(source, destination); + + const dirLink = path.join(destination, 'node_modules', 'pkg'); + const fileLink = path.join(destination, 'node_modules', 'data.json'); + expect(fs.lstatSync(dirLink).isSymbolicLink()).toBe(true); + expect(fs.lstatSync(fileLink).isSymbolicLink()).toBe(true); + expect(linkTargetWithin(destination, dirLink)).toBe('store/pkg'); + expect(linkTargetWithin(destination, fileLink)).toBe('store/data.json'); + expect(fs.readFileSync(path.join(dirLink, 'index.js'), 'utf8')).toBe('// pkg'); + await assertBundleSymlinksStayInside(destination); + }); + + // The Windows copy, run on every platform: an absolute symlink stands in for + // a junction, which is how `readlink` reports one. + test('the Windows copy creates junctions and file links only, re-anchoring in-tree absolute targets', async () => { + const parent = scratch(); + copies.push(parent); + const source = path.join(parent, 'source'); + const destination = path.join(parent, 'destination'); + writeLinkedTree(source); + fs.mkdirSync(path.join(parent, 'outside')); + fs.symlinkSync( + path.join(source, 'store', 'pkg'), + path.join(source, 'node_modules', 'junction'), + 'dir', + ); + fs.symlinkSync( + path.join(parent, 'outside'), + path.join(source, 'node_modules', 'escaping'), + 'dir', + ); + fs.symlinkSync( + path.join('..', 'store', 'absent'), + path.join(source, 'node_modules', 'dangling'), + 'dir', + ); + + const types = await symlinkTypesDuring(() => copyTreeVerbatim(source, destination, 'win32')); + + expect([...types].sort()).toEqual(['file', 'junction', 'junction', 'junction', 'junction']); + const copied = (name: string) => path.join(destination, 'node_modules', name); + expect(linkTargetWithin(destination, copied('pkg'))).toBe('store/pkg'); + expect(linkTargetWithin(destination, copied('data.json'))).toBe('store/data.json'); + expect(linkTargetWithin(destination, copied('junction'))).toBe('store/pkg'); + expect(linkTargetWithin(destination, copied('dangling'))).toBe('store/absent'); + expect(linkTargetWithin(parent, copied('escaping'))).toBe('outside'); + expect(fs.readFileSync(path.join(copied('junction'), 'index.js'), 'utf8')).toBe('// pkg'); + expect(fs.readFileSync(path.join(destination, 'store', 'data.json'), 'utf8')).toBe('{}'); + }); + + test('the Windows copy recognises an in-tree target spelled through the real path of the tree', async () => { + const parent = scratch(); + copies.push(parent); + // `source` reaches the tree through an aliased parent, as a short path or a + // mapped drive would; the junction records the tree's real spelling. + fs.mkdirSync(path.join(parent, 'real')); + fs.symlinkSync(path.join(parent, 'real'), path.join(parent, 'alias'), 'dir'); + const source = path.join(parent, 'alias', 'source'); + const destination = path.join(parent, 'destination'); + writeLinkedTree(source); + fs.symlinkSync( + path.join(await fs.promises.realpath(source), 'store', 'pkg'), + path.join(source, 'node_modules', 'junction'), + 'dir', + ); + + await copyTreeVerbatim(source, destination, 'win32'); + + expect(linkTargetWithin(destination, path.join(destination, 'node_modules', 'junction'))).toBe( + 'store/pkg', + ); + }); + + test('the Windows copy replaces a link already at the destination and copies a single file or link', async () => { + const parent = scratch(); + copies.push(parent); + const source = path.join(parent, 'source'); + const destination = path.join(parent, 'destination'); + writeLinkedTree(source); + fs.mkdirSync(path.join(destination, 'node_modules'), { recursive: true }); + fs.symlinkSync('stale', path.join(destination, 'node_modules', 'pkg'), 'dir'); + + await copyTreeVerbatim(source, destination, 'win32'); + await copyTreeVerbatim( + path.join(source, 'store', 'data.json'), + path.join(parent, 'single', 'data.json'), + 'win32', + ); + await copyTreeVerbatim( + path.join(source, 'node_modules', 'data.json'), + path.join(destination, 'node_modules', 'again.json'), + 'win32', + ); + + expect(linkTargetWithin(destination, path.join(destination, 'node_modules', 'pkg'))).toBe( + 'store/pkg', + ); + expect(fs.readFileSync(path.join(parent, 'single', 'data.json'), 'utf8')).toBe('{}'); + expect( + linkTargetWithin(destination, path.join(destination, 'node_modules', 'again.json')), + ).toBe('store/data.json'); + }); + + test.skipIf(!onWindows)( + 'copies a tree of real junctions without creating a directory symlink', + async () => { + const parent = scratch(); + copies.push(parent); + const source = path.join(parent, 'source'); + const destination = path.join(parent, 'destination'); + fs.mkdirSync(path.join(source, 'store', 'pkg'), { recursive: true }); + fs.mkdirSync(path.join(source, 'node_modules')); + fs.writeFileSync(path.join(source, 'store', 'pkg', 'index.js'), '// pkg'); + fs.symlinkSync( + path.join(source, 'store', 'pkg'), + path.join(source, 'node_modules', 'pkg'), + 'junction', + ); + + const types = await symlinkTypesDuring(() => copyTreeVerbatim(source, destination)); + + expect(types).toEqual(['junction']); + const copied = path.join(destination, 'node_modules', 'pkg'); + expect(linkTargetWithin(destination, copied)).toBe('store/pkg'); + expect(fs.readFileSync(path.join(copied, 'index.js'), 'utf8')).toBe('// pkg'); + await assertBundleSymlinksStayInside(destination); + }, + ); +}); + +describe('repairWindowsDirectorySymlinks', () => { + test('re-types a link whose target arrived after it was created', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(bundle, { recursive: true }); + const lateFile = path.join(bundle, 'late-file'); + const lateDir = path.join(bundle, 'late-dir'); + const dangling = path.join(bundle, 'dangling'); + for (const [link, target] of [ + [lateFile, 'file.txt'], + [lateDir, 'dir'], + [dangling, 'absent'], + ] as const) { + await createBundleLink( + target, + link, + await bundleLinkKind(path.join(bundle, target)), + 'win32', + ); + } + fs.writeFileSync(path.join(bundle, 'file.txt'), 'staged'); + fs.mkdirSync(path.join(bundle, 'dir')); + + const types = await symlinkTypesDuring(() => repairWindowsDirectorySymlinks(bundle, 'win32')); + + expect([...types].sort()).toEqual(['file', 'junction']); + expect(fs.readFileSync(lateFile, 'utf8')).toBe('staged'); + expect(linkTargetWithin(bundle, lateDir)).toBe('dir'); + expect(linkTargetWithin(bundle, dangling)).toBe('absent'); + }); + + test('leaves POSIX links alone', async () => { + const bundle = path.join(scratch(), 'bundle'); + fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); + fs.symlinkSync('real', path.join(bundle, 'link'), 'dir'); + + const types = await symlinkTypesDuring(() => repairWindowsDirectorySymlinks(bundle, 'linux')); + + expect(types).toEqual([]); + }); +}); diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index 6d026482d..76f63d6a0 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -4,13 +4,200 @@ * writer, the local extractor). This predicate is the enforcement point of * ADR-0047's boundary — a symlink may be preserved only while its target * stays inside the assembled bundle — so it exists exactly once. + * + * It also owns how a bundle link is written. What a link is on disk differs by + * platform (`planBundleLink`); what it means — and what the packager archives — + * does not. Every seam that creates or copies a link goes through here, so no + * caller branches on the platform. */ import fs from 'node:fs'; import path from 'node:path'; -/** Restores directory-link metadata lost by `fs.cp` on Windows. */ -export async function repairWindowsDirectorySymlinks(root: string): Promise { - if (process.platform !== 'win32') return; +/** What a bundle link points at. Windows types its links; POSIX ignores this. */ +export type BundleLinkKind = 'dir' | 'file'; + +/** The exact `fs.symlink(target, linkPath, type)` arguments for one bundle link. */ +export interface BundleLinkPlan { + readonly target: string; + readonly type: 'junction' | 'dir' | 'file'; +} + +/** + * The one platform fact every link decision below derives from. Windows only + * lets a process holding SeCreateSymbolicLinkPrivilege (Developer Mode, or an + * elevated shell) create a symbolic link; a default user gets EPERM. A + * junction needs no privilege, so on Windows a directory link is always a + * junction — for every user, privileged or not, so the same inputs assemble the + * same bundle on every Windows machine. + */ +function directoryLinksAreJunctions(platform: NodeJS.Platform): boolean { + return platform === 'win32'; +} + +/** + * Decides how a bundle link is created — the only place that decision is made. + * + * - POSIX: the link is written as given (links are untyped there). + * - Windows, directory: a junction. A junction stores an absolute target, so a + * relative `target` is resolved against the link's own directory here rather + * than left to the runtime. Absoluteness is only the on-disk representation: + * the packager archives an in-bundle absolute target as the same relative + * link entry POSIX produces. + * - Windows, file: a file symbolic link. A junction cannot point at a file, and + * a copy or a hard link would archive as a regular file — a dereferenced link, + * which ADR-0047 forbids — so a file link stays a real link and needs the + * privilege. + * + * `platform` is a test seam; production callers leave it at the default. + */ +export function planBundleLink( + target: string, + linkPath: string, + kind: BundleLinkKind, + platform: NodeJS.Platform = process.platform, +): BundleLinkPlan { + if (directoryLinksAreJunctions(platform) && kind === 'dir') { + return { target: path.resolve(path.dirname(linkPath), target), type: 'junction' }; + } + return { target, type: kind }; +} + +/** The kind of link `resolvedTarget` needs. A target that is not there cannot + * be typed; it is treated as a directory because that link can be created + * without privilege on every platform. A link left dangling is rejected by + * `assertBundleSymlinksStayInside`, and one whose target is staged afterwards is + * re-typed by `repairWindowsDirectorySymlinks`. */ +export async function bundleLinkKind(resolvedTarget: string): Promise { + try { + return (await fs.promises.stat(resolvedTarget)).isDirectory() ? 'dir' : 'file'; + } catch { + return 'dir'; + } +} + +/** Creates the link at `linkPath` pointing at `target` (relative to the link's + * directory, or absolute). Every link an assembly seam writes goes through + * here; see `planBundleLink` for what is written on each platform. */ +export async function createBundleLink( + target: string, + linkPath: string, + kind: BundleLinkKind, + platform: NodeJS.Platform = process.platform, +): Promise { + const plan = planBundleLink(target, linkPath, kind, platform); + try { + await fs.promises.symlink(plan.target, linkPath, plan.type); + } catch (error) { + const denied = error instanceof Error && Reflect.get(error, 'code') === 'EPERM'; + if (denied && directoryLinksAreJunctions(platform) && plan.type === 'file') { + throw new Error( + `cannot create the file symlink ${linkPath} -> ${target}: Windows only lets a user with the ` + + 'symbolic-link privilege create one. Enable Developer Mode (or deploy from an elevated shell), ' + + 'or build so the output links directories rather than files — directory links need no privilege.', + { cause: error }, + ); + } + throw error; + } +} + +async function lstatIfPresent(candidate: string): Promise { + try { + return await fs.promises.lstat(candidate); + } catch (error) { + if (error instanceof Error && Reflect.get(error, 'code') === 'ENOENT') return undefined; + throw error; + } +} + +/** + * The Windows tree copy. `fs.cp` cannot be used there: it re-creates every link + * with an untyped `fs.symlink`, which Node resolves to a 'dir' or 'file' + * symbolic link — EPERM without the privilege — and mistypes a link whose + * target has not been copied yet. This copy is otherwise the same verbatim + * copy, with links routed through `createBundleLink`. + * + * One thing cannot be verbatim: `readlink` reports a junction's target as an + * absolute path, so copying that string would leave the copy pointing back into + * the source tree. An absolute target inside the copied tree is therefore + * re-anchored to the same location in the destination. Absolute targets outside + * the tree are kept as they are, for the bundle validator to judge. + */ +async function copyTreeThroughBundleLinks( + source: string, + destination: string, + platform: NodeJS.Platform, +): Promise { + /** `target` is what `from` records; `written` is what the copy at `to` records. */ + const copyLink = async ( + from: string, + to: string, + target: string, + written: string, + ): Promise => { + const kind = await bundleLinkKind(path.resolve(path.dirname(from), target)); + const existing = await lstatIfPresent(to); + if (existing !== undefined && !existing.isDirectory()) await fs.promises.unlink(to); + await createBundleLink(written, to, kind, platform); + }; + + const sourceStat = await fs.promises.lstat(source); + if (!sourceStat.isDirectory()) { + await fs.promises.mkdir(path.dirname(destination), { recursive: true }); + if (sourceStat.isSymbolicLink()) { + const target = await fs.promises.readlink(source); + await copyLink(source, destination, target, target); + } else if (sourceStat.isFile()) { + await fs.promises.copyFile(source, destination); + } else { + throw new Error(`cannot copy an unsupported filesystem entry: ${source}`); + } + return; + } + + // A junction records whichever spelling of the path its creator used, so an + // in-tree target is recognised under the tree's given and real paths alike. + const sourceRoots = [...new Set([path.resolve(source), await fs.promises.realpath(source)])]; + const reanchored = (target: string, to: string): string => { + if (!path.isAbsolute(target)) return target; + const root = sourceRoots.find((candidate) => isWithin(candidate, target)); + if (root === undefined) return target; + return path.relative(path.dirname(to), path.join(destination, path.relative(root, target))); + }; + + const copyDirectory = async (from: string, to: string): Promise => { + await fs.promises.mkdir(to, { recursive: true }); + for (const entry of await fs.promises.readdir(from, { withFileTypes: true })) { + const entryFrom = path.join(from, entry.name); + const entryTo = path.join(to, entry.name); + if (entry.isSymbolicLink()) { + const target = await fs.promises.readlink(entryFrom); + await copyLink(entryFrom, entryTo, target, reanchored(target, entryTo)); + } else if (entry.isDirectory()) { + await copyDirectory(entryFrom, entryTo); + } else if (entry.isFile()) { + await fs.promises.copyFile(entryFrom, entryTo); + } else { + throw new Error(`cannot copy an unsupported filesystem entry: ${entryFrom}`); + } + } + }; + await copyDirectory(source, destination); +} + +/** + * Re-creates the links under `root` whose kind could not be known when they + * were written: a link created while its target was missing is a junction, and + * staging the target afterwards may reveal a file. Every link whose target now + * exists is re-created through `createBundleLink` with that target's kind; one + * that still dangles is left for `assertBundleSymlinksStayInside` to reject. + * POSIX links are untyped, so there is nothing to repair there. + */ +export async function repairWindowsDirectorySymlinks( + root: string, + platform: NodeJS.Platform = process.platform, +): Promise { + if (!directoryLinksAreJunctions(platform)) return; const visit = async (directory: string): Promise => { for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) { @@ -18,13 +205,14 @@ export async function repairWindowsDirectorySymlinks(root: string): Promise { +/** Copies `source` to `destination` with every link kept a link — never + * followed. POSIX copies link targets verbatim; see + * `copyTreeThroughBundleLinks` for what Windows needs instead. */ +export async function copyTreeVerbatim( + source: string, + destination: string, + platform: NodeJS.Platform = process.platform, +): Promise { + if (directoryLinksAreJunctions(platform)) { + await copyTreeThroughBundleLinks(source, destination, platform); + return; + } await fs.promises.cp(source, destination, { recursive: true, verbatimSymlinks: true }); - await repairWindowsDirectorySymlinks(destination); } /** Lexical containment: `candidate` is `root` itself or below it. Both paths diff --git a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts index 7ca205710..e1ea2a8c9 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/exports/index.ts @@ -1,7 +1,11 @@ /** Public surface. Implementation lives in `../bundle-paths.ts`. */ +export type { BundleLinkKind, BundleLinkPlan } from '../bundle-paths.ts'; export { assertBundleSymlinksStayInside, + bundleLinkKind, copyTreeVerbatim, + createBundleLink, isWithin, + planBundleLink, repairWindowsDirectorySymlinks, } from '../bundle-paths.ts'; From 861c1e24534917c00200d6a7887a4d7277999171 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:07:45 +0530 Subject: [PATCH 2/6] fix(node,nextjs): stage bundle links through the shared link helper The node adapter linked traced dependencies with fs.symlink(..., 'dir' | 'file'), the call a default Windows user is refused. It now asks createBundleLink, so a pnpm-shaped node_modules is staged with junctions on Windows and exactly as before on POSIX, with no platform branch at the call site. The nextjs adapter already copied through copyTreeVerbatim. Its repair of link targets Next omitted skipped every absolute target, which on Windows is now every directory link: a junction can only record an absolute path. The repair considers any target that resolves inside the bundle, however the link spells it; targets outside the bundle are still left alone. Link assertions that compared the raw readlink string now compare where the link points relative to its directory, which holds for a relative symlink and a junction alike. Windows-only tests assemble fixtures linked with junctions, as pnpm writes them, and assert that no directory symlink is ever requested. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Aman Varshney --- .../nextjs/src/__tests__/assemble.test.ts | 74 +++++++++++++--- .../2-authoring/nextjs/src/control/build.ts | 4 +- .../node/src/__tests__/assemble.test.ts | 88 +++++++++++++++++-- .../2-authoring/node/src/control/build.ts | 11 ++- 4 files changed, 156 insertions(+), 21 deletions(-) diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index c7348893c..55f162f9f 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -20,6 +20,17 @@ function moduleUrl(root: string): string { return pathToFileURL(path.join(root, 'src', 'service.ts')).href; } +/** A link's target relative to its own directory, POSIX-separated. Windows + * keeps a directory link as a junction, which reads back as an absolute path, + * so the raw `readlink` string is not comparable across platforms. */ +function linkTarget(linkPath: string): string { + const linkDir = path.dirname(linkPath); + return path + .relative(linkDir, path.resolve(linkDir, fs.readlinkSync(linkPath))) + .split(path.sep) + .join('/'); +} + /** * Writes a `next build` standalone tree with the app nested at `apps/web` (the * monorepo shape — `outputFileTracingRoot` above the app), plus the client assets @@ -113,9 +124,7 @@ describe('assemble()', () => { expect(fs.existsSync(path.join(workDir, 'bundle', 'node_modules', 'next', 'marker.txt'))).toBe( true, ); - expect(fs.readlinkSync(path.join(workDir, 'bundle', 'node_modules', 'next-linked'))).toBe( - 'next', - ); + expect(linkTarget(path.join(workDir, 'bundle', 'node_modules', 'next-linked'))).toBe('next'); // The documented copy: static + public placed beside the app's server.js. expect(fs.existsSync(path.join(bundleApp, '.next', 'static', 'chunk.js'))).toBe(true); expect(fs.existsSync(path.join(bundleApp, 'public', 'favicon.ico'))).toBe(true); @@ -167,12 +176,9 @@ describe('assemble()', () => { 'node_modules', '.pnpm', ); - expect( - fs - .readlinkSync(path.join(bundleStore, 'node_modules', 'semver')) - .split(path.sep) - .join('/'), - ).toBe('../semver@6.3.1/node_modules/semver'); + expect(linkTarget(path.join(bundleStore, 'node_modules', 'semver'))).toBe( + '../semver@6.3.1/node_modules/semver', + ); expect( fs.readFileSync( path.join(bundleStore, 'semver@6.3.1', 'node_modules', 'semver', 'index.js'), @@ -269,4 +275,52 @@ describe('assemble()', () => { expect(fs.existsSync(path.join(result.dir, result.entry))).toBe(true); }, 20_000); + + test.skipIf(process.platform !== 'win32')( + 'links the bundle with junctions on Windows, never a directory symlink', + async () => { + // A default Windows user cannot create a symbolic link (EPERM without + // SeCreateSymbolicLinkPrivilege); a junction needs no privilege. The + // omitted-target link is a junction too, so it records an absolute path + // into the standalone tree, as pnpm's links do on Windows. + const root = makeAppRoot(); + writeNextBuild(root); + const standalone = path.join(root, '.next', 'standalone'); + const storeRelative = path.join('node_modules', '.pnpm', 'semver@6.3.1', 'node_modules'); + const source = path.join(root, storeRelative, 'semver'); + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "6.3.1";\n'); + const linkDir = path.join(standalone, 'node_modules', '.pnpm', 'node_modules'); + fs.mkdirSync(linkDir, { recursive: true }); + fs.symlinkSync( + path.join(standalone, storeRelative, 'semver'), + path.join(linkDir, 'semver'), + 'junction', + ); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); + tmpDirs.push(cwd); + const symlink = spyOn(fs.promises, 'symlink'); + let types: unknown[]; + try { + await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }); + types = symlink.mock.calls.map((call) => call[2]); + } finally { + symlink.mockRestore(); + } + + expect(types.length).toBeGreaterThan(0); + expect(types.filter((type) => type !== 'junction')).toEqual([]); + const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle'); + const staged = path.join(bundle, 'node_modules', '.pnpm', 'node_modules', 'semver'); + expect(linkTarget(path.join(bundle, 'node_modules', 'next-linked'))).toBe('next'); + expect(linkTarget(staged)).toBe('../semver@6.3.1/node_modules/semver'); + expect(fs.readFileSync(path.join(staged, 'index.js'), 'utf8')).toContain('6.3.1'); + }, + 20_000, + ); }); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index 83e093d2f..aa63b46f0 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -144,8 +144,10 @@ async function collectSymlinks(root: string): Promise { async function missingLinkTargets(bundleDir: string): Promise { const missing: string[] = []; for (const linkPath of await collectSymlinks(bundleDir)) { + // A relative target, or an absolute one: Windows keeps a directory link as + // a junction, which always records an absolute path. Either way only a + // target inside the bundle is a repair this staging may make. const rawTarget = await fs.promises.readlink(linkPath); - if (path.isAbsolute(rawTarget)) continue; const target = path.resolve(path.dirname(linkPath), rawTarget); if (!isWithin(bundleDir, target) || (await lstatIfPresent(target)) !== undefined) continue; if (await hasSymlinkAncestor(bundleDir, target)) continue; diff --git a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts index 9fe8ae8b8..303932347 100644 --- a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -57,6 +57,17 @@ function treeContents(dir: string): string[] { .sort(); } +/** A link's target relative to its own directory, POSIX-separated. Windows + * keeps a directory link as a junction, which reads back as an absolute path, + * so the raw `readlink` string is not comparable across platforms. */ +function linkTarget(linkPath: string): string { + const linkDir = path.dirname(linkPath); + return path + .relative(linkDir, path.resolve(linkDir, fs.readlinkSync(linkPath))) + .split(path.sep) + .join('/'); +} + /** * Installs a real, resolvable package into the service dir's own node_modules, * so a bare specifier for it resolves the way it would in a real service @@ -579,7 +590,7 @@ describe('assemble() — the directory form', () => { const copied = path.join(result.dir, 'bundle', 'node_modules', 'linked'); expect(fs.lstatSync(copied).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(copied)).toBe('real'); + expect(linkTarget(copied)).toBe('real'); }); test('rejects a dir that is itself a symlink to a directory — hard-errors instead of dereferencing it and copying the target', async () => { @@ -817,9 +828,7 @@ describe('assemble() — the directory form', () => { // own resolution finds the dependency the same way it did before assembly. const linked = path.join(first.dir, 'bundle', 'node_modules', 'dep'); expect(fs.lstatSync(linked).isSymbolicLink()).toBe(true); - expect(fs.readlinkSync(linked).split(path.sep).join('/')).toBe( - '.pnpm/dep@1.0.0/node_modules/dep', - ); + expect(linkTarget(linked)).toBe('.pnpm/dep@1.0.0/node_modules/dep'); const loaded = await import(pathToFileURL(path.join(first.dir, first.entry)).href); expect(loaded.default).toBe(marker); @@ -827,11 +836,76 @@ describe('assemble() — the directory form', () => { expect(treeContents(path.join(second.dir, 'bundle'))).toEqual( treeContents(path.join(first.dir, 'bundle')), ); - expect(fs.readlinkSync(path.join(second.dir, 'bundle', 'node_modules', 'dep'))).toBe( - fs.readlinkSync(linked), + expect(linkTarget(path.join(second.dir, 'bundle', 'node_modules', 'dep'))).toBe( + linkTarget(linked), ); }, 30_000); + test.skipIf(process.platform !== 'win32')( + 'links the bundle with junctions on Windows, never a directory symlink', + async () => { + // A default Windows user cannot create a symbolic link (EPERM without + // SeCreateSymbolicLinkPrivilege); a junction needs no privilege. The + // fixture uses junctions too — they are what pnpm writes on Windows. + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-junction-')); + tmpDirs.push(workspaceRoot); + const serviceDir = path.join(workspaceRoot, 'apps', 'web'); + fs.mkdirSync(path.join(serviceDir, 'src'), { recursive: true }); + writeTree(path.join(serviceDir, 'dist'), { + 'server/entry.mjs': 'import { marker } from "dep"; export default marker;\n', + 'vendor/real/index.js': 'export const value = 1;\n', + }); + fs.symlinkSync( + path.join(serviceDir, 'dist', 'vendor', 'real'), + path.join(serviceDir, 'dist', 'vendor', 'linked'), + 'junction', + ); + const marker = 'JUNCTION_FIXTURE'; + const storePackage = path.join( + workspaceRoot, + 'node_modules', + '.pnpm', + 'dep@1.0.0', + 'node_modules', + 'dep', + ); + writeTree(storePackage, { + 'package.json': JSON.stringify({ + name: 'dep', + version: '1.0.0', + type: 'module', + main: 'index.js', + }), + 'index.js': `export const marker = ${JSON.stringify(marker)};\n`, + }); + fs.mkdirSync(path.join(serviceDir, 'node_modules'), { recursive: true }); + fs.symlinkSync(storePackage, path.join(serviceDir, 'node_modules', 'dep'), 'junction'); + writeServiceModule(serviceDir); + + const symlink = spyOn(fs.promises, 'symlink'); + let result: Awaited>; + try { + result = await assemble({ + build: node({ module: moduleUrl(serviceDir), dir: '../dist', entry: 'server/entry.mjs' }), + address: 'astro', + cwd: makeCwd(), + }); + expect(symlink.mock.calls.map((call) => call[2])).toEqual(['junction', 'junction']); + } finally { + symlink.mockRestore(); + } + + const bundle = path.join(result.dir, 'bundle'); + expect(linkTarget(path.join(bundle, 'vendor', 'linked'))).toBe('real'); + expect(linkTarget(path.join(bundle, 'node_modules', 'dep'))).toBe( + '.pnpm/dep@1.0.0/node_modules/dep', + ); + const loaded = await import(pathToFileURL(path.join(result.dir, result.entry)).href); + expect(loaded.default).toBe(marker); + }, + 30_000, + ); + test('rejects two traced packages of the same name that would collapse onto one bundle path', async () => { // Staging keeps each file's path from its FIRST node_modules segment, so a // hoisted /node_modules/dup and a nested diff --git a/packages/0-framework/2-authoring/node/src/control/build.ts b/packages/0-framework/2-authoring/node/src/control/build.ts index f30bc3c3d..f4ca7c5a2 100644 --- a/packages/0-framework/2-authoring/node/src/control/build.ts +++ b/packages/0-framework/2-authoring/node/src/control/build.ts @@ -30,7 +30,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { assertBundleSymlinksStayInside, copyTreeVerbatim, isWithin } from '@internal/bundle-paths'; +import { + assertBundleSymlinksStayInside, + bundleLinkKind, + copyTreeVerbatim, + createBundleLink, + isWithin, +} from '@internal/bundle-paths'; import type { BuildAdapter } from '@internal/core'; import type { ExtensionDescriptor } from '@internal/core/config'; import type { AssembleInput, Bundle } from '@internal/core/deploy'; @@ -213,8 +219,7 @@ async function copyTracedEntry( ? path.join(bundleDir, path.relative(dirPath, realTarget)) : stagedRuntimePath(realTarget, stagingRoot, bundleDir); const linkTarget = path.relative(path.dirname(destination), stagedTarget); - const linkType = (await fs.promises.stat(realTarget)).isDirectory() ? 'dir' : 'file'; - await fs.promises.symlink(linkTarget, destination, linkType); + await createBundleLink(linkTarget, destination, await bundleLinkKind(realTarget)); return; } if (stat.isDirectory()) { From 731e457e52e4449ce8320cd414fd5be47690b129 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:07:48 +0530 Subject: [PATCH 3/6] fix(lowering): archive an in-bundle absolute link as the link it stands for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packager already rewrote an absolute in-bundle target to a relative one, but derived it from the fully resolved target. For a link chain (alias -> pkg -> store) that archives alias -> store, where a POSIX bundle's relative link archives alias -> pkg: the same app produced a different artifact hash depending on how its links were spelled. That spelling is now platform-determined — a Windows directory link is a junction, which always reads back absolute — so an absolute target that names a path under the bundle as given is relativised literally, keeping the chain. Any other spelling (an alias outside the bundle) is still re-derived from the resolved target, and both remain behind the realpath containment check and the lexical re-check. Tests hold a bundle linked absolutely, and one linked by createBundleLink, to the same link entries and the same sha256 as the relative bundle; on windows-latest the latter compares junctions against symlinks. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Aman Varshney --- .../lowering/src/__tests__/artifact.test.ts | 77 +++++++++++++++++++ .../lowering/src/compute/artifact.ts | 19 +++-- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts index 0a66bd75c..8b3f92eda 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; +import { createBundleLink } from '@internal/bundle-paths'; import { packageComputeArtifact } from '../compute/artifact.ts'; function makeBundle(files: Record): string { @@ -366,6 +367,82 @@ describe('packageComputeArtifact', () => { expect(archive.link('node_modules/link')).not.toContain(bundleDir); }); + // The files of a pnpm-shaped bundle: an app-level link into a store, a link + // chain (alias -> pkg -> store), and a file link. + const linkedBundleFiles = { + 'main.js': 'export default {};', + 'node_modules/.store/pkg/index.js': '// pkg', + 'node_modules/.store/pkg/data.json': '{}', + }; + const expectedLinks = { + 'node_modules/pkg': '.store/pkg', + 'node_modules/alias': 'pkg', + 'node_modules/data.json': '.store/pkg/data.json', + }; + const packageLinked = (bundleDir: string) => + packageComputeArtifact({ id: 'auth', bundleDir, appEntry: 'server.js', address: 'auth' }); + + /** The bundle as POSIX assembly writes it: every link relative. */ + function makeRelativeLinkedBundle(): string { + const bundleDir = makeBundle(linkedBundleFiles); + for (const [rel, target] of Object.entries(expectedLinks)) { + fs.symlinkSync( + target.split('/').join(path.sep), + path.join(bundleDir, ...rel.split('/')), + rel.endsWith('.json') ? 'file' : 'dir', + ); + } + return bundleDir; + } + + test('archives a directory link written absolutely as the relative link it stands for', () => { + // A Windows directory link is a junction, which can only record an absolute + // path. An absolute symlink reads back the same way on every platform. + const bundleDir = makeBundle(linkedBundleFiles); + for (const [rel, target] of Object.entries(expectedLinks)) { + const linkPath = path.join(bundleDir, ...rel.split('/')); + const isFileLink = rel.endsWith('.json'); + fs.symlinkSync( + isFileLink + ? target.split('/').join(path.sep) + : path.resolve(path.dirname(linkPath), ...target.split('/')), + linkPath, + isFileLink ? 'file' : 'dir', + ); + } + + const absolute = packageLinked(bundleDir); + const archive = readTar(fs.readFileSync(absolute.path)); + + for (const [rel, target] of Object.entries(expectedLinks)) { + expect(archive.link(rel)).toBe(target); + } + // Same entries, same bytes: the artifact does not record which platform, + // or which directory, assembled it. + expect(absolute.sha256).toBe(packageLinked(makeRelativeLinkedBundle()).sha256); + }); + + test('archives links written by createBundleLink identically on every platform', async () => { + const bundleDir = makeBundle(linkedBundleFiles); + for (const [rel, target] of Object.entries(expectedLinks)) { + await createBundleLink( + target.split('/').join(path.sep), + path.join(bundleDir, ...rel.split('/')), + rel.endsWith('.json') ? 'file' : 'dir', + ); + } + + const artifact = packageLinked(bundleDir); + const archive = readTar(fs.readFileSync(artifact.path)); + + for (const [rel, target] of Object.entries(expectedLinks)) { + expect(archive.link(rel)).toBe(target); + } + // On Windows this compares junctions against relative symlinks; the literal + // targets above hold both platforms to the same link entries. + expect(artifact.sha256).toBe(packageLinked(makeRelativeLinkedBundle()).sha256); + }); + test('preserves executable mode for staged runtime files', () => { const bundleDir = makeBundle({ 'main.js': 'export default {};', diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index 65c82fff5..0068be93c 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -87,13 +87,18 @@ function walkEntries(dir: string): BundleEntry[] { `bundle symlink at ${rel} escapes the bundle root: ${target} — deploy artifacts may only preserve links whose targets are inside the assembled bundle.`, ); } - const linkname = ( - path.isAbsolute(target) - ? path.relative(fs.realpathSync(path.dirname(symlinkPath)), realTarget) - : target - ) - .split(path.sep) - .join('/'); + // An absolute target never ships. One that names a path under the + // bundle as given is the same link written absolutely — every Windows + // directory link, since a junction can only record an absolute path — + // so it archives as the relative target POSIX would have recorded, + // link chains included. Any other spelling is re-derived from the + // resolved target. + const relativeTarget = !path.isAbsolute(target) + ? target + : isWithin(dir, target) + ? path.relative(path.dirname(symlinkPath), target) + : path.relative(fs.realpathSync(path.dirname(symlinkPath)), realTarget); + const linkname = (relativeTarget === '' ? '.' : relativeTarget).split(path.sep).join('/'); // The realpath check above proves where the link points on THIS machine; // the archived link is the literal string, which every extractor // re-checks lexically against the unpack root. A target that leaves the From 3c393e887ac436f05929e1392e6f3c4de8c62247 Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:07:50 +0530 Subject: [PATCH 4/6] docs: say what Windows needs to deploy a linked build output A Windows user no longer needs Developer Mode or an elevated shell for a build output whose links are directory links, and gets the same artifact a POSIX deploy produces. A symlink to a file is the remaining case that needs the privilege; the guide and the packaged skill name it so the assembly error is not misread as a broken deploy. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Aman Varshney --- docs/guides/building-an-app.md | 7 ++++++- skills/prisma-composer-core-concepts/SKILL.md | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 4e5fef30f..4c29fdf25 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -572,7 +572,12 @@ ships. Three things to know: resolves inside the built output ships as-is. A link that points outside it, or at something that isn't there, fails the deploy with an error naming the link, rather than shipping a broken artifact or packaging files from your - machine. + machine. On Windows, deploy recreates directory links as junctions, which + need no special privilege, so a pnpm-style `node_modules` assembles without + Developer Mode or an elevated shell, and the artifact records the same + relative links a POSIX deploy produces. A symlink to a *file* is the + exception: Windows only lets a privileged user create one, so assembly + fails, naming the link, until Developer Mode is on. - The entry's runtime imports ship too. Deploy traces the file you named and stages the packages it imports beside `dir`, so framework output that keeps bare imports (Astro's Node adapter, for example) boots without you copying diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index b0afd4ba9..3c640c9d5 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -215,6 +215,11 @@ deploy. Rules that bite: the runtime environment doesn't exist at build time and Next ignores runtime env for prerendered routes. 4. **Always build before `deploy` or `dev`.** Neither builds for you. +5. **On Windows, assembly links directories with junctions**, so a + pnpm-style `node_modules` deploys without Developer Mode or an elevated + shell, and the artifact records the same relative links a POSIX deploy + produces. A symlink to a *file* still needs Windows' symbolic-link + privilege: assembly fails, naming the link, until Developer Mode is on. Deploy configuration lives in `prisma-composer.config.ts` (or `.mts`, `.mjs`, `.js`; nearest ancestor of the entry wins, `.ts` first within a directory). From 9d2a865e11b890139c93eb41d74b1d543870de6f Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Mon, 21 Sep 2026 16:32:52 +0530 Subject: [PATCH 5/6] refactor(bundle-paths): trim the Windows link change to its essentials Review found the comments, docs and tests around the junction fix heavier than the change warrants. Doc blocks now state only the non-obvious why; the guide and skill additions are reverted, since no check requires them and the behaviour needs no action from a user; and the tests keep one case per code path: the planBundleLink contract, the privilege error, the Windows copy through the platform seam (with re-anchoring, link replacement and re-typing folded in), the two real-junction Windows tests, one Windows assemble test per adapter, and one archive-equivalence test. No production behaviour changes. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Aman Varshney --- docs/guides/building-an-app.md | 7 +- .../bundle-paths/src/bundle-paths.test.ts | 337 ++++-------------- .../bundle-paths/src/bundle-paths.ts | 78 +--- .../nextjs/src/__tests__/assemble.test.ts | 24 +- .../2-authoring/nextjs/src/control/build.ts | 4 +- .../node/src/__tests__/assemble.test.ts | 65 ++-- .../lowering/src/__tests__/artifact.test.ts | 99 ++--- .../lowering/src/compute/artifact.ts | 8 +- skills/prisma-composer-core-concepts/SKILL.md | 5 - 9 files changed, 147 insertions(+), 480 deletions(-) diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 4c29fdf25..4e5fef30f 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -572,12 +572,7 @@ ships. Three things to know: resolves inside the built output ships as-is. A link that points outside it, or at something that isn't there, fails the deploy with an error naming the link, rather than shipping a broken artifact or packaging files from your - machine. On Windows, deploy recreates directory links as junctions, which - need no special privilege, so a pnpm-style `node_modules` assembles without - Developer Mode or an elevated shell, and the artifact records the same - relative links a POSIX deploy produces. A symlink to a *file* is the - exception: Windows only lets a privileged user create one, so assembly - fails, naming the link, until Developer Mode is on. + machine. - The entry's runtime imports ship too. Deploy traces the file you named and stages the packages it imports beside `dir`, so framework output that keeps bare imports (Astro's Node adapter, for example) boots without you copying diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts index 250f9ceed..7c89a00fa 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts @@ -1,10 +1,9 @@ -import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { describe, expect, spyOn, test } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { assertBundleSymlinksStayInside, - bundleLinkKind, copyTreeVerbatim, createBundleLink, isWithin, @@ -13,16 +12,13 @@ import { } from './bundle-paths.ts'; const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-paths-')); -const onWindows = process.platform === 'win32'; -/** Where a link points, relative to the root it lives under, POSIX-separated. - * Platform-neutral: a Windows junction reads back as an absolute path. */ +/** Where a link points relative to `root`, POSIX-separated (a junction reads back absolute). */ function linkTargetWithin(root: string, linkPath: string): string { const resolved = path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath)); return path.relative(root, resolved).split(path.sep).join('/'); } -/** The `type` argument of every `fs.promises.symlink` call made while `run` executes. */ async function symlinkTypesDuring(run: () => Promise): Promise { const spy = spyOn(fs.promises, 'symlink'); try { @@ -33,18 +29,10 @@ async function symlinkTypesDuring(run: () => Promise): Promise } } -/** A tree holding each link shape a build output can contain. */ -function writeLinkedTree(root: string): void { +function writeStore(root: string): void { fs.mkdirSync(path.join(root, 'store', 'pkg'), { recursive: true }); fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }); fs.writeFileSync(path.join(root, 'store', 'pkg', 'index.js'), '// pkg'); - fs.writeFileSync(path.join(root, 'store', 'data.json'), '{}'); - fs.symlinkSync(path.join('..', 'store', 'pkg'), path.join(root, 'node_modules', 'pkg'), 'dir'); - fs.symlinkSync( - path.join('..', 'store', 'data.json'), - path.join(root, 'node_modules', 'data.json'), - 'file', - ); } describe('isWithin', () => { @@ -90,13 +78,6 @@ describe('planBundleLink', () => { const linkPath = path.join(path.resolve('bundle'), 'node_modules', 'pkg'); const target = path.join('..', 'store', 'pkg'); - test('POSIX writes the link exactly as given', () => { - for (const platform of ['linux', 'darwin'] as const) { - expect(planBundleLink(target, linkPath, 'dir', platform)).toEqual({ target, type: 'dir' }); - expect(planBundleLink(target, linkPath, 'file', platform)).toEqual({ target, type: 'file' }); - } - }); - test('a Windows directory link is a junction with an absolute target', () => { expect(planBundleLink(target, linkPath, 'dir', 'win32')).toEqual({ target: path.join(path.resolve('bundle'), 'store', 'pkg'), @@ -104,242 +85,103 @@ describe('planBundleLink', () => { }); }); - test('a Windows file link stays a file symlink with the target as given', () => { + test('POSIX links and Windows file links are written as given', () => { + expect(planBundleLink(target, linkPath, 'dir', 'linux')).toEqual({ target, type: 'dir' }); + expect(planBundleLink(target, linkPath, 'file', 'linux')).toEqual({ target, type: 'file' }); expect(planBundleLink(target, linkPath, 'file', 'win32')).toEqual({ target, type: 'file' }); }); }); -describe('bundleLinkKind', () => { - test('types a link by what its target is, and an absent target as a directory', async () => { - const root = scratch(); - fs.mkdirSync(path.join(root, 'dir')); - fs.writeFileSync(path.join(root, 'file.txt'), 'x'); - - expect(await bundleLinkKind(path.join(root, 'dir'))).toBe('dir'); - expect(await bundleLinkKind(path.join(root, 'file.txt'))).toBe('file'); - expect(await bundleLinkKind(path.join(root, 'absent'))).toBe('dir'); - }); -}); - describe('createBundleLink', () => { - test('creates directory and file links that resolve inside the bundle', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(path.join(bundle, 'store', 'pkg'), { recursive: true }); - fs.mkdirSync(path.join(bundle, 'node_modules')); - fs.writeFileSync(path.join(bundle, 'store', 'pkg', 'index.js'), '// pkg'); - const dirLink = path.join(bundle, 'node_modules', 'pkg'); - const fileLink = path.join(bundle, 'node_modules', 'index.js'); - - await createBundleLink(path.join('..', 'store', 'pkg'), dirLink, 'dir'); - await createBundleLink(path.join('..', 'store', 'pkg', 'index.js'), fileLink, 'file'); - - expect(fs.lstatSync(dirLink).isSymbolicLink()).toBe(true); - expect(fs.lstatSync(fileLink).isSymbolicLink()).toBe(true); - expect(linkTargetWithin(bundle, dirLink)).toBe('store/pkg'); - expect(linkTargetWithin(bundle, fileLink)).toBe('store/pkg/index.js'); - expect(fs.readFileSync(path.join(dirLink, 'index.js'), 'utf8')).toBe('// pkg'); - expect(fs.readFileSync(fileLink, 'utf8')).toBe('// pkg'); - await assertBundleSymlinksStayInside(bundle); - }); - - test('never asks Windows for a directory symlink', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); - - const types = await symlinkTypesDuring(() => - createBundleLink('real', path.join(bundle, 'link'), 'dir', 'win32'), - ); - - expect(types).toEqual(['junction']); - }); - - test('names the missing privilege when Windows refuses a file symlink, and nothing else', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(bundle, { recursive: true }); - const denied = Object.assign(new Error('EPERM: operation not permitted, symlink'), { - code: 'EPERM', - }); + test('names the missing privilege when Windows refuses a file symlink', async () => { + const link = path.join(scratch(), 'link'); + const denied = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' }); const spy = spyOn(fs.promises, 'symlink').mockRejectedValue(denied); try { - const fileLink = createBundleLink('file.txt', path.join(bundle, 'link'), 'file', 'win32'); - await expect(fileLink).rejects.toThrow(/symbolic-link privilege.*Developer Mode/s); - await expect(fileLink).rejects.toHaveProperty('cause', denied); - // Not a fallback: no second attempt is made, and any other refusal is - // reported as it came. - expect(spy.mock.calls.length).toBe(1); - await expect( - createBundleLink('real', path.join(bundle, 'junction'), 'dir', 'win32'), - ).rejects.toBe(denied); - await expect( - createBundleLink('file.txt', path.join(bundle, 'posix'), 'file', 'linux'), - ).rejects.toBe(denied); + const refused = createBundleLink('file.txt', link, 'file', 'win32'); + await expect(refused).rejects.toThrow(/symbolic-link privilege.*Developer Mode/s); + await expect(refused).rejects.toHaveProperty('cause', denied); + await expect(createBundleLink('real', link, 'dir', 'win32')).rejects.toBe(denied); + expect(spy.mock.calls.length).toBe(2); } finally { spy.mockRestore(); } }); - test.skipIf(!onWindows)('a directory link on Windows is a junction', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); - fs.writeFileSync(path.join(bundle, 'real', 'index.js'), '// real'); - const link = path.join(bundle, 'link'); - - const types = await symlinkTypesDuring(() => createBundleLink('real', link, 'dir')); - - expect(types).toEqual(['junction']); - // What the rest of the pipeline relies on: a junction is reported as a - // symlink, and reads back as a plain absolute path inside the bundle. - expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); - const recorded = fs.readlinkSync(link); - expect(path.isAbsolute(recorded)).toBe(true); - expect(path.relative(bundle, recorded)).toBe('real'); - expect(fs.readFileSync(path.join(link, 'index.js'), 'utf8')).toBe('// real'); - expect( - fs - .readdirSync(bundle, { withFileTypes: true }) - .find((entry) => entry.name === 'link') - ?.isSymbolicLink(), - ).toBe(true); - await assertBundleSymlinksStayInside(bundle); - }); + test.skipIf(process.platform !== 'win32')( + 'a directory link on Windows is a junction', + async () => { + const bundle = path.join(scratch(), 'bundle'); + writeStore(bundle); + const link = path.join(bundle, 'node_modules', 'pkg'); - test.skipIf(!onWindows)('a junction whose target escapes the bundle is rejected', async () => { - const parent = scratch(); - const bundle = path.join(parent, 'bundle'); - fs.mkdirSync(path.join(parent, 'outside'), { recursive: true }); - fs.mkdirSync(bundle, { recursive: true }); - await createBundleLink(path.join('..', 'outside'), path.join(bundle, 'link'), 'dir'); + const types = await symlinkTypesDuring(() => + createBundleLink(path.join('..', 'store', 'pkg'), link, 'dir'), + ); - await expect(assertBundleSymlinksStayInside(bundle)).rejects.toThrow('escapes the bundle'); - }); + expect(types).toEqual(['junction']); + expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); + expect(path.isAbsolute(fs.readlinkSync(link))).toBe(true); + expect(linkTargetWithin(bundle, link)).toBe('store/pkg'); + expect(fs.readFileSync(path.join(link, 'index.js'), 'utf8')).toBe('// pkg'); + await assertBundleSymlinksStayInside(bundle); + }, + ); }); describe('copyTreeVerbatim', () => { - const copies: string[] = []; - afterEach(() => { - for (const dir of copies.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); - }); - - test('keeps directory and file links as links that resolve inside the copy', async () => { - const parent = scratch(); - copies.push(parent); - const source = path.join(parent, 'source'); - const destination = path.join(parent, 'nested', 'destination'); - writeLinkedTree(source); - - await copyTreeVerbatim(source, destination); - - const dirLink = path.join(destination, 'node_modules', 'pkg'); - const fileLink = path.join(destination, 'node_modules', 'data.json'); - expect(fs.lstatSync(dirLink).isSymbolicLink()).toBe(true); - expect(fs.lstatSync(fileLink).isSymbolicLink()).toBe(true); - expect(linkTargetWithin(destination, dirLink)).toBe('store/pkg'); - expect(linkTargetWithin(destination, fileLink)).toBe('store/data.json'); - expect(fs.readFileSync(path.join(dirLink, 'index.js'), 'utf8')).toBe('// pkg'); - await assertBundleSymlinksStayInside(destination); - }); - - // The Windows copy, run on every platform: an absolute symlink stands in for - // a junction, which is how `readlink` reports one. - test('the Windows copy creates junctions and file links only, re-anchoring in-tree absolute targets', async () => { - const parent = scratch(); - copies.push(parent); - const source = path.join(parent, 'source'); - const destination = path.join(parent, 'destination'); - writeLinkedTree(source); - fs.mkdirSync(path.join(parent, 'outside')); - fs.symlinkSync( - path.join(source, 'store', 'pkg'), - path.join(source, 'node_modules', 'junction'), - 'dir', - ); - fs.symlinkSync( - path.join(parent, 'outside'), - path.join(source, 'node_modules', 'escaping'), - 'dir', - ); - fs.symlinkSync( - path.join('..', 'store', 'absent'), - path.join(source, 'node_modules', 'dangling'), - 'dir', - ); - - const types = await symlinkTypesDuring(() => copyTreeVerbatim(source, destination, 'win32')); - - expect([...types].sort()).toEqual(['file', 'junction', 'junction', 'junction', 'junction']); - const copied = (name: string) => path.join(destination, 'node_modules', name); - expect(linkTargetWithin(destination, copied('pkg'))).toBe('store/pkg'); - expect(linkTargetWithin(destination, copied('data.json'))).toBe('store/data.json'); - expect(linkTargetWithin(destination, copied('junction'))).toBe('store/pkg'); - expect(linkTargetWithin(destination, copied('dangling'))).toBe('store/absent'); - expect(linkTargetWithin(parent, copied('escaping'))).toBe('outside'); - expect(fs.readFileSync(path.join(copied('junction'), 'index.js'), 'utf8')).toBe('// pkg'); - expect(fs.readFileSync(path.join(destination, 'store', 'data.json'), 'utf8')).toBe('{}'); - }); - - test('the Windows copy recognises an in-tree target spelled through the real path of the tree', async () => { + test('the Windows copy requests only junctions and file links, re-anchoring in-tree absolute targets', async () => { const parent = scratch(); - copies.push(parent); - // `source` reaches the tree through an aliased parent, as a short path or a - // mapped drive would; the junction records the tree's real spelling. fs.mkdirSync(path.join(parent, 'real')); fs.symlinkSync(path.join(parent, 'real'), path.join(parent, 'alias'), 'dir'); const source = path.join(parent, 'alias', 'source'); const destination = path.join(parent, 'destination'); - writeLinkedTree(source); - fs.symlinkSync( - path.join(await fs.promises.realpath(source), 'store', 'pkg'), - path.join(source, 'node_modules', 'junction'), - 'dir', - ); - - await copyTreeVerbatim(source, destination, 'win32'); - - expect(linkTargetWithin(destination, path.join(destination, 'node_modules', 'junction'))).toBe( - 'store/pkg', - ); - }); - - test('the Windows copy replaces a link already at the destination and copies a single file or link', async () => { - const parent = scratch(); - copies.push(parent); - const source = path.join(parent, 'source'); - const destination = path.join(parent, 'destination'); - writeLinkedTree(source); - fs.mkdirSync(path.join(destination, 'node_modules'), { recursive: true }); - fs.symlinkSync('stale', path.join(destination, 'node_modules', 'pkg'), 'dir'); + writeStore(source); + const link = (name: string, target: string, type: 'dir' | 'file' = 'dir') => + fs.symlinkSync(target, path.join(source, 'node_modules', name), type); + link('relative', path.join('..', 'store', 'pkg')); + link('given', path.join(source, 'store', 'pkg')); + link('real', path.join(await fs.promises.realpath(source), 'store', 'pkg')); + link('file.js', path.join('..', 'store', 'pkg', 'index.js'), 'file'); + link('late', path.join('..', 'store', 'late.txt')); + const copied = (name: string) => path.join(destination, 'node_modules', name); + fs.mkdirSync(path.dirname(copied('relative')), { recursive: true }); + fs.symlinkSync('stale', copied('relative'), 'dir'); + + const types = await symlinkTypesDuring(async () => { + await copyTreeVerbatim(source, destination, 'win32'); + await copyTreeVerbatim( + path.join(source, 'node_modules', 'file.js'), + copied('again.js'), + 'win32', + ); + }); - await copyTreeVerbatim(source, destination, 'win32'); - await copyTreeVerbatim( - path.join(source, 'store', 'data.json'), - path.join(parent, 'single', 'data.json'), - 'win32', - ); - await copyTreeVerbatim( - path.join(source, 'node_modules', 'data.json'), - path.join(destination, 'node_modules', 'again.json'), - 'win32', - ); + expect([...types].sort()).toEqual([ + 'file', + 'file', + 'junction', + 'junction', + 'junction', + 'junction', + ]); + for (const name of ['relative', 'given', 'real']) { + expect(linkTargetWithin(destination, copied(name))).toBe('store/pkg'); + } + expect(fs.readFileSync(copied('again.js'), 'utf8')).toBe('// pkg'); - expect(linkTargetWithin(destination, path.join(destination, 'node_modules', 'pkg'))).toBe( - 'store/pkg', - ); - expect(fs.readFileSync(path.join(parent, 'single', 'data.json'), 'utf8')).toBe('{}'); - expect( - linkTargetWithin(destination, path.join(destination, 'node_modules', 'again.json')), - ).toBe('store/data.json'); + fs.writeFileSync(path.join(destination, 'store', 'late.txt'), 'staged'); + await repairWindowsDirectorySymlinks(destination, 'win32'); + expect(fs.readFileSync(copied('late'), 'utf8')).toBe('staged'); }); - test.skipIf(!onWindows)( + test.skipIf(process.platform !== 'win32')( 'copies a tree of real junctions without creating a directory symlink', async () => { const parent = scratch(); - copies.push(parent); const source = path.join(parent, 'source'); const destination = path.join(parent, 'destination'); - fs.mkdirSync(path.join(source, 'store', 'pkg'), { recursive: true }); - fs.mkdirSync(path.join(source, 'node_modules')); - fs.writeFileSync(path.join(source, 'store', 'pkg', 'index.js'), '// pkg'); + writeStore(source); fs.symlinkSync( path.join(source, 'store', 'pkg'), path.join(source, 'node_modules', 'pkg'), @@ -356,44 +198,3 @@ describe('copyTreeVerbatim', () => { }, ); }); - -describe('repairWindowsDirectorySymlinks', () => { - test('re-types a link whose target arrived after it was created', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(bundle, { recursive: true }); - const lateFile = path.join(bundle, 'late-file'); - const lateDir = path.join(bundle, 'late-dir'); - const dangling = path.join(bundle, 'dangling'); - for (const [link, target] of [ - [lateFile, 'file.txt'], - [lateDir, 'dir'], - [dangling, 'absent'], - ] as const) { - await createBundleLink( - target, - link, - await bundleLinkKind(path.join(bundle, target)), - 'win32', - ); - } - fs.writeFileSync(path.join(bundle, 'file.txt'), 'staged'); - fs.mkdirSync(path.join(bundle, 'dir')); - - const types = await symlinkTypesDuring(() => repairWindowsDirectorySymlinks(bundle, 'win32')); - - expect([...types].sort()).toEqual(['file', 'junction']); - expect(fs.readFileSync(lateFile, 'utf8')).toBe('staged'); - expect(linkTargetWithin(bundle, lateDir)).toBe('dir'); - expect(linkTargetWithin(bundle, dangling)).toBe('absent'); - }); - - test('leaves POSIX links alone', async () => { - const bundle = path.join(scratch(), 'bundle'); - fs.mkdirSync(path.join(bundle, 'real'), { recursive: true }); - fs.symlinkSync('real', path.join(bundle, 'link'), 'dir'); - - const types = await symlinkTypesDuring(() => repairWindowsDirectorySymlinks(bundle, 'linux')); - - expect(types).toEqual([]); - }); -}); diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index 76f63d6a0..bcbd46e42 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -4,52 +4,26 @@ * writer, the local extractor). This predicate is the enforcement point of * ADR-0047's boundary — a symlink may be preserved only while its target * stays inside the assembled bundle — so it exists exactly once. - * - * It also owns how a bundle link is written. What a link is on disk differs by - * platform (`planBundleLink`); what it means — and what the packager archives — - * does not. Every seam that creates or copies a link goes through here, so no - * caller branches on the platform. */ import fs from 'node:fs'; import path from 'node:path'; -/** What a bundle link points at. Windows types its links; POSIX ignores this. */ export type BundleLinkKind = 'dir' | 'file'; -/** The exact `fs.symlink(target, linkPath, type)` arguments for one bundle link. */ export interface BundleLinkPlan { readonly target: string; readonly type: 'junction' | 'dir' | 'file'; } -/** - * The one platform fact every link decision below derives from. Windows only - * lets a process holding SeCreateSymbolicLinkPrivilege (Developer Mode, or an - * elevated shell) create a symbolic link; a default user gets EPERM. A - * junction needs no privilege, so on Windows a directory link is always a - * junction — for every user, privileged or not, so the same inputs assemble the - * same bundle on every Windows machine. - */ +/** Windows refuses symlinks (EPERM) without SeCreateSymbolicLinkPrivilege; a + * junction needs none, so every Windows user gets one. */ function directoryLinksAreJunctions(platform: NodeJS.Platform): boolean { return platform === 'win32'; } -/** - * Decides how a bundle link is created — the only place that decision is made. - * - * - POSIX: the link is written as given (links are untyped there). - * - Windows, directory: a junction. A junction stores an absolute target, so a - * relative `target` is resolved against the link's own directory here rather - * than left to the runtime. Absoluteness is only the on-disk representation: - * the packager archives an in-bundle absolute target as the same relative - * link entry POSIX produces. - * - Windows, file: a file symbolic link. A junction cannot point at a file, and - * a copy or a hard link would archive as a regular file — a dereferenced link, - * which ADR-0047 forbids — so a file link stays a real link and needs the - * privilege. - * - * `platform` is a test seam; production callers leave it at the default. - */ +/** Windows: directory links are junctions (no privilege needed, absolute + * target); file links stay symlinks because a copy would archive as a regular + * file (ADR-0047). `platform` is a test seam. */ export function planBundleLink( target: string, linkPath: string, @@ -62,11 +36,8 @@ export function planBundleLink( return { target, type: kind }; } -/** The kind of link `resolvedTarget` needs. A target that is not there cannot - * be typed; it is treated as a directory because that link can be created - * without privilege on every platform. A link left dangling is rejected by - * `assertBundleSymlinksStayInside`, and one whose target is staged afterwards is - * re-typed by `repairWindowsDirectorySymlinks`. */ +/** An absent target cannot be typed; 'dir' is the kind every user can create, + * and `repairWindowsDirectorySymlinks` re-types it once the target is staged. */ export async function bundleLinkKind(resolvedTarget: string): Promise { try { return (await fs.promises.stat(resolvedTarget)).isDirectory() ? 'dir' : 'file'; @@ -75,9 +46,6 @@ export async function bundleLinkKind(resolvedTarget: string): Promise } } -/** - * The Windows tree copy. `fs.cp` cannot be used there: it re-creates every link - * with an untyped `fs.symlink`, which Node resolves to a 'dir' or 'file' - * symbolic link — EPERM without the privilege — and mistypes a link whose - * target has not been copied yet. This copy is otherwise the same verbatim - * copy, with links routed through `createBundleLink`. - * - * One thing cannot be verbatim: `readlink` reports a junction's target as an - * absolute path, so copying that string would leave the copy pointing back into - * the source tree. An absolute target inside the copied tree is therefore - * re-anchored to the same location in the destination. Absolute targets outside - * the tree are kept as they are, for the bundle validator to judge. - */ +/** `fs.cp` recreates links as privileged symlinks, so Windows copies through + * `createBundleLink`. Junction targets read back absolute, so in-tree targets + * are re-anchored into the destination. */ async function copyTreeThroughBundleLinks( source: string, destination: string, platform: NodeJS.Platform, ): Promise { - /** `target` is what `from` records; `written` is what the copy at `to` records. */ const copyLink = async ( from: string, to: string, @@ -155,8 +112,7 @@ async function copyTreeThroughBundleLinks( return; } - // A junction records whichever spelling of the path its creator used, so an - // in-tree target is recognised under the tree's given and real paths alike. + // A junction may record the tree's real path rather than the one given. const sourceRoots = [...new Set([path.resolve(source), await fs.promises.realpath(source)])]; const reanchored = (target: string, to: string): string => { if (!path.isAbsolute(target)) return target; @@ -185,14 +141,7 @@ async function copyTreeThroughBundleLinks( await copyDirectory(source, destination); } -/** - * Re-creates the links under `root` whose kind could not be known when they - * were written: a link created while its target was missing is a junction, and - * staging the target afterwards may reveal a file. Every link whose target now - * exists is re-created through `createBundleLink` with that target's kind; one - * that still dangles is left for `assertBundleSymlinksStayInside` to reject. - * POSIX links are untyped, so there is nothing to repair there. - */ +/** Re-types links created while their target was absent (see `bundleLinkKind`). */ export async function repairWindowsDirectorySymlinks( root: string, platform: NodeJS.Platform = process.platform, @@ -222,9 +171,6 @@ export async function repairWindowsDirectorySymlinks( await visit(root); } -/** Copies `source` to `destination` with every link kept a link — never - * followed. POSIX copies link targets verbatim; see - * `copyTreeThroughBundleLinks` for what Windows needs instead. */ export async function copyTreeVerbatim( source: string, destination: string, diff --git a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts index 55f162f9f..8c2951385 100644 --- a/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts @@ -20,9 +20,7 @@ function moduleUrl(root: string): string { return pathToFileURL(path.join(root, 'src', 'service.ts')).href; } -/** A link's target relative to its own directory, POSIX-separated. Windows - * keeps a directory link as a junction, which reads back as an absolute path, - * so the raw `readlink` string is not comparable across platforms. */ +/** A link's target relative to its directory (a Windows junction reads back absolute). */ function linkTarget(linkPath: string): string { const linkDir = path.dirname(linkPath); return path @@ -279,10 +277,6 @@ describe('assemble()', () => { test.skipIf(process.platform !== 'win32')( 'links the bundle with junctions on Windows, never a directory symlink', async () => { - // A default Windows user cannot create a symbolic link (EPERM without - // SeCreateSymbolicLinkPrivilege); a junction needs no privilege. The - // omitted-target link is a junction too, so it records an absolute path - // into the standalone tree, as pnpm's links do on Windows. const root = makeAppRoot(); writeNextBuild(root); const standalone = path.join(root, '.next', 'standalone'); @@ -301,17 +295,15 @@ describe('assemble()', () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-')); tmpDirs.push(cwd); const symlink = spyOn(fs.promises, 'symlink'); - let types: unknown[]; - try { - await assemble({ - address: 'storefront.web', - cwd, - build: nextjs({ module: moduleUrl(root), appDir: '..' }), - }); + let types: unknown[] = []; + await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }).finally(() => { types = symlink.mock.calls.map((call) => call[2]); - } finally { symlink.mockRestore(); - } + }); expect(types.length).toBeGreaterThan(0); expect(types.filter((type) => type !== 'junction')).toEqual([]); diff --git a/packages/0-framework/2-authoring/nextjs/src/control/build.ts b/packages/0-framework/2-authoring/nextjs/src/control/build.ts index aa63b46f0..2646717a0 100644 --- a/packages/0-framework/2-authoring/nextjs/src/control/build.ts +++ b/packages/0-framework/2-authoring/nextjs/src/control/build.ts @@ -144,9 +144,7 @@ async function collectSymlinks(root: string): Promise { async function missingLinkTargets(bundleDir: string): Promise { const missing: string[] = []; for (const linkPath of await collectSymlinks(bundleDir)) { - // A relative target, or an absolute one: Windows keeps a directory link as - // a junction, which always records an absolute path. Either way only a - // target inside the bundle is a repair this staging may make. + // Windows junctions read back absolute, so absolute targets are not skipped. const rawTarget = await fs.promises.readlink(linkPath); const target = path.resolve(path.dirname(linkPath), rawTarget); if (!isWithin(bundleDir, target) || (await lstatIfPresent(target)) !== undefined) continue; diff --git a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts index 303932347..a28ee7ba7 100644 --- a/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts +++ b/packages/0-framework/2-authoring/node/src/__tests__/assemble.test.ts @@ -57,9 +57,7 @@ function treeContents(dir: string): string[] { .sort(); } -/** A link's target relative to its own directory, POSIX-separated. Windows - * keeps a directory link as a junction, which reads back as an absolute path, - * so the raw `readlink` string is not comparable across platforms. */ +/** A link's target relative to its directory (a Windows junction reads back absolute). */ function linkTarget(linkPath: string): string { const linkDir = path.dirname(linkPath); return path @@ -844,64 +842,55 @@ describe('assemble() — the directory form', () => { test.skipIf(process.platform !== 'win32')( 'links the bundle with junctions on Windows, never a directory symlink', async () => { - // A default Windows user cannot create a symbolic link (EPERM without - // SeCreateSymbolicLinkPrivilege); a junction needs no privilege. The - // fixture uses junctions too — they are what pnpm writes on Windows. const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-junction-')); tmpDirs.push(workspaceRoot); const serviceDir = path.join(workspaceRoot, 'apps', 'web'); - fs.mkdirSync(path.join(serviceDir, 'src'), { recursive: true }); - writeTree(path.join(serviceDir, 'dist'), { - 'server/entry.mjs': 'import { marker } from "dep"; export default marker;\n', - 'vendor/real/index.js': 'export const value = 1;\n', - }); - fs.symlinkSync( - path.join(serviceDir, 'dist', 'vendor', 'real'), - path.join(serviceDir, 'dist', 'vendor', 'linked'), - 'junction', - ); - const marker = 'JUNCTION_FIXTURE'; + const dist = path.join(serviceDir, 'dist'); const storePackage = path.join( workspaceRoot, 'node_modules', '.pnpm', - 'dep@1.0.0', + 'dep', 'node_modules', 'dep', ); + fs.mkdirSync(path.join(serviceDir, 'src'), { recursive: true }); + fs.mkdirSync(path.join(serviceDir, 'node_modules')); + writeTree(dist, { + 'server/entry.mjs': 'import { marker } from "dep"; export default marker;\n', + 'vendor/real/index.js': 'export const value = 1;\n', + }); writeTree(storePackage, { - 'package.json': JSON.stringify({ - name: 'dep', - version: '1.0.0', - type: 'module', - main: 'index.js', - }), - 'index.js': `export const marker = ${JSON.stringify(marker)};\n`, + 'package.json': JSON.stringify({ name: 'dep', type: 'module', main: 'index.js' }), + 'index.js': 'export const marker = "JUNCTION_FIXTURE";\n', }); - fs.mkdirSync(path.join(serviceDir, 'node_modules'), { recursive: true }); + fs.symlinkSync( + path.join(dist, 'vendor', 'real'), + path.join(dist, 'vendor', 'linked'), + 'junction', + ); fs.symlinkSync(storePackage, path.join(serviceDir, 'node_modules', 'dep'), 'junction'); writeServiceModule(serviceDir); const symlink = spyOn(fs.promises, 'symlink'); - let result: Awaited>; - try { - result = await assemble({ - build: node({ module: moduleUrl(serviceDir), dir: '../dist', entry: 'server/entry.mjs' }), - address: 'astro', - cwd: makeCwd(), - }); - expect(symlink.mock.calls.map((call) => call[2])).toEqual(['junction', 'junction']); - } finally { + let types: unknown[] = []; + const result = await assemble({ + build: node({ module: moduleUrl(serviceDir), dir: '../dist', entry: 'server/entry.mjs' }), + address: 'astro', + cwd: makeCwd(), + }).finally(() => { + types = symlink.mock.calls.map((call) => call[2]); symlink.mockRestore(); - } + }); + expect(types).toEqual(['junction', 'junction']); const bundle = path.join(result.dir, 'bundle'); expect(linkTarget(path.join(bundle, 'vendor', 'linked'))).toBe('real'); expect(linkTarget(path.join(bundle, 'node_modules', 'dep'))).toBe( - '.pnpm/dep@1.0.0/node_modules/dep', + '.pnpm/dep/node_modules/dep', ); const loaded = await import(pathToFileURL(path.join(result.dir, result.entry)).href); - expect(loaded.default).toBe(marker); + expect(loaded.default).toBe('JUNCTION_FIXTURE'); }, 30_000, ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts index 8b3f92eda..d02d6dedb 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/artifact.test.ts @@ -3,7 +3,6 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; -import { createBundleLink } from '@internal/bundle-paths'; import { packageComputeArtifact } from '../compute/artifact.ts'; function makeBundle(files: Record): string { @@ -367,80 +366,36 @@ describe('packageComputeArtifact', () => { expect(archive.link('node_modules/link')).not.toContain(bundleDir); }); - // The files of a pnpm-shaped bundle: an app-level link into a store, a link - // chain (alias -> pkg -> store), and a file link. - const linkedBundleFiles = { - 'main.js': 'export default {};', - 'node_modules/.store/pkg/index.js': '// pkg', - 'node_modules/.store/pkg/data.json': '{}', - }; - const expectedLinks = { - 'node_modules/pkg': '.store/pkg', - 'node_modules/alias': 'pkg', - 'node_modules/data.json': '.store/pkg/data.json', - }; - const packageLinked = (bundleDir: string) => - packageComputeArtifact({ id: 'auth', bundleDir, appEntry: 'server.js', address: 'auth' }); - - /** The bundle as POSIX assembly writes it: every link relative. */ - function makeRelativeLinkedBundle(): string { - const bundleDir = makeBundle(linkedBundleFiles); - for (const [rel, target] of Object.entries(expectedLinks)) { - fs.symlinkSync( - target.split('/').join(path.sep), - path.join(bundleDir, ...rel.split('/')), - rel.endsWith('.json') ? 'file' : 'dir', - ); - } - return bundleDir; - } - - test('archives a directory link written absolutely as the relative link it stands for', () => { - // A Windows directory link is a junction, which can only record an absolute - // path. An absolute symlink reads back the same way on every platform. - const bundleDir = makeBundle(linkedBundleFiles); - for (const [rel, target] of Object.entries(expectedLinks)) { - const linkPath = path.join(bundleDir, ...rel.split('/')); - const isFileLink = rel.endsWith('.json'); - fs.symlinkSync( - isFileLink - ? target.split('/').join(path.sep) - : path.resolve(path.dirname(linkPath), ...target.split('/')), - linkPath, - isFileLink ? 'file' : 'dir', - ); - } + test('archives absolute in-bundle links, chains included, exactly as the relative links they stand for', () => { + const links = { + 'node_modules/pkg': '.store/pkg', + 'node_modules/alias': 'pkg', + 'node_modules/data.json': '.store/pkg/data.json', + }; + const packageLinked = (absolute: boolean) => { + const bundleDir = makeBundle({ + 'main.js': 'export default {};', + 'node_modules/.store/pkg/data.json': '{}', + }); + for (const [rel, target] of Object.entries(links)) { + const linkPath = path.join(bundleDir, ...rel.split('/')); + const isFile = rel.endsWith('.json'); + fs.symlinkSync( + absolute && !isFile + ? path.resolve(path.dirname(linkPath), ...target.split('/')) + : target.split('/').join(path.sep), + linkPath, + isFile ? 'file' : 'dir', + ); + } + return packageComputeArtifact({ id: 'auth', bundleDir, appEntry: 's.js', address: 'auth' }); + }; - const absolute = packageLinked(bundleDir); + const absolute = packageLinked(true); const archive = readTar(fs.readFileSync(absolute.path)); - for (const [rel, target] of Object.entries(expectedLinks)) { - expect(archive.link(rel)).toBe(target); - } - // Same entries, same bytes: the artifact does not record which platform, - // or which directory, assembled it. - expect(absolute.sha256).toBe(packageLinked(makeRelativeLinkedBundle()).sha256); - }); - - test('archives links written by createBundleLink identically on every platform', async () => { - const bundleDir = makeBundle(linkedBundleFiles); - for (const [rel, target] of Object.entries(expectedLinks)) { - await createBundleLink( - target.split('/').join(path.sep), - path.join(bundleDir, ...rel.split('/')), - rel.endsWith('.json') ? 'file' : 'dir', - ); - } - - const artifact = packageLinked(bundleDir); - const archive = readTar(fs.readFileSync(artifact.path)); - - for (const [rel, target] of Object.entries(expectedLinks)) { - expect(archive.link(rel)).toBe(target); - } - // On Windows this compares junctions against relative symlinks; the literal - // targets above hold both platforms to the same link entries. - expect(artifact.sha256).toBe(packageLinked(makeRelativeLinkedBundle()).sha256); + for (const [rel, target] of Object.entries(links)) expect(archive.link(rel)).toBe(target); + expect(absolute.sha256).toBe(packageLinked(false).sha256); }); test('preserves executable mode for staged runtime files', () => { diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts index 0068be93c..671132c39 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/compute/artifact.ts @@ -87,12 +87,8 @@ function walkEntries(dir: string): BundleEntry[] { `bundle symlink at ${rel} escapes the bundle root: ${target} — deploy artifacts may only preserve links whose targets are inside the assembled bundle.`, ); } - // An absolute target never ships. One that names a path under the - // bundle as given is the same link written absolutely — every Windows - // directory link, since a junction can only record an absolute path — - // so it archives as the relative target POSIX would have recorded, - // link chains included. Any other spelling is re-derived from the - // resolved target. + // A target under the bundle as given (every Windows junction) keeps its + // literal relative form, so link chains archive as they do on POSIX. const relativeTarget = !path.isAbsolute(target) ? target : isWithin(dir, target) diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 3c640c9d5..b0afd4ba9 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -215,11 +215,6 @@ deploy. Rules that bite: the runtime environment doesn't exist at build time and Next ignores runtime env for prerendered routes. 4. **Always build before `deploy` or `dev`.** Neither builds for you. -5. **On Windows, assembly links directories with junctions**, so a - pnpm-style `node_modules` deploys without Developer Mode or an elevated - shell, and the artifact records the same relative links a POSIX deploy - produces. A symlink to a *file* still needs Windows' symbolic-link - privilege: assembly fails, naming the link, until Developer Mode is on. Deploy configuration lives in `prisma-composer.config.ts` (or `.mts`, `.mjs`, `.js`; nearest ancestor of the entry wins, `.ts` first within a directory). From 01facf3cd2eac9679aec2762d0acb7e314fa734e Mon Sep 17 00:00:00 2001 From: Aman Varshney Date: Wed, 23 Sep 2026 12:55:21 +0530 Subject: [PATCH 6/6] docs: clarify Windows bundle link behavior Signed-off-by: Aman Varshney --- docs/guides/building-an-app.md | 4 +++- .../2-authoring/bundle-paths/src/bundle-paths.ts | 1 + skills/prisma-composer-core-concepts/SKILL.md | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index 4e5fef30f..e1c14e52c 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -572,7 +572,9 @@ ships. Three things to know: resolves inside the built output ships as-is. A link that points outside it, or at something that isn't there, fails the deploy with an error naming the link, rather than shipping a broken artifact or packaging files from your - machine. + machine. On Windows, directory links use junctions and need no symlink + privilege. File links still need that privilege (Developer Mode or an + elevated shell); Composer reports the missing privilege if one fails. - The entry's runtime imports ship too. Deploy traces the file you named and stages the packages it imports beside `dir`, so framework output that keeps bare imports (Astro's Node adapter, for example) boots without you copying diff --git a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts index bcbd46e42..d5e6ff8f7 100644 --- a/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts +++ b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts @@ -103,6 +103,7 @@ async function copyTreeThroughBundleLinks( await fs.promises.mkdir(path.dirname(destination), { recursive: true }); if (sourceStat.isSymbolicLink()) { const target = await fs.promises.readlink(source); + // A top-level link has no copied tree root to re-anchor against. await copyLink(source, destination, target, target); } else if (sourceStat.isFile()) { await fs.promises.copyFile(source, destination); diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index b0afd4ba9..634a111cc 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -207,8 +207,10 @@ deploy. Rules that bite: 2. **A directory build uses `dir` + `entry`** (`dir` relative to the service module, `entry` a file inside `dir`; `../` is an error). The tree is copied verbatim, so the server must resolve siblings against - `import.meta.url`, not the working directory. The tree must contain no - symlinks: the packager rejects them, names the link, and assembly fails. + `import.meta.url`, not the working directory. Links remain links and must + resolve inside the assembled bundle; external or dangling links fail. + On Windows, directory links use junctions without special privilege, but + file links need the symlink privilege (Developer Mode or an elevated shell). 3. **Next.js**: `next build` with `output: 'standalone'` is the whole build; `nextjs({ module, appDir })` names the app root. Any page or action that calls `load()` needs `export const dynamic = 'force-dynamic'`, because