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.test.ts b/packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts index 9ebb97aab..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,8 +1,39 @@ -import { describe, expect, 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, isWithin } from './bundle-paths.ts'; +import { + assertBundleSymlinksStayInside, + copyTreeVerbatim, + createBundleLink, + isWithin, + planBundleLink, + repairWindowsDirectorySymlinks, +} from './bundle-paths.ts'; + +const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-paths-')); + +/** 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('/'); +} + +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(); + } +} + +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'); +} describe('isWithin', () => { test('the root itself and descendants are within; siblings and parents are not', () => { @@ -16,8 +47,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 +73,128 @@ 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('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('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('createBundleLink', () => { + 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 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(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'); + + const types = await symlinkTypesDuring(() => + createBundleLink(path.join('..', 'store', 'pkg'), link, 'dir'), + ); + + 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', () => { + test('the Windows copy requests only junctions and file links, re-anchoring in-tree absolute targets', async () => { + const parent = scratch(); + 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'); + 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', + ); + }); + + 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'); + + fs.writeFileSync(path.join(destination, 'store', 'late.txt'), 'staged'); + await repairWindowsDirectorySymlinks(destination, 'win32'); + expect(fs.readFileSync(copied('late'), 'utf8')).toBe('staged'); + }); + + test.skipIf(process.platform !== 'win32')( + 'copies a tree of real junctions without creating a directory symlink', + async () => { + const parent = scratch(); + const source = path.join(parent, 'source'); + const destination = path.join(parent, 'destination'); + writeStore(source); + 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); + }, + ); +}); 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..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 @@ -8,9 +8,146 @@ 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; +export type BundleLinkKind = 'dir' | 'file'; + +export interface BundleLinkPlan { + readonly target: string; + readonly type: 'junction' | 'dir' | 'file'; +} + +/** 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'; +} + +/** 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, + 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 }; +} + +/** 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'; + } catch { + return 'dir'; + } +} + +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; + } +} + +/** `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 { + 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); + // 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); + } else { + throw new Error(`cannot copy an unsupported filesystem entry: ${source}`); + } + return; + } + + // 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; + 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-types links created while their target was absent (see `bundleLinkKind`). */ +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 +155,14 @@ export async function repairWindowsDirectorySymlinks(root: string): Promise { +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'; 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..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 @@ -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,15 @@ function moduleUrl(root: string): string { return pathToFileURL(path.join(root, 'src', 'service.ts')).href; } +/** 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 + .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 +122,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 +174,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 +273,46 @@ 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 () => { + 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[] = []; + await assemble({ + address: 'storefront.web', + cwd, + build: nextjs({ module: moduleUrl(root), appDir: '..' }), + }).finally(() => { + types = symlink.mock.calls.map((call) => call[2]); + 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..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,8 +144,8 @@ async function collectSymlinks(root: string): Promise { async function missingLinkTargets(bundleDir: string): Promise { const missing: string[] = []; for (const linkPath of await collectSymlinks(bundleDir)) { + // Windows junctions read back absolute, so absolute targets are not skipped. 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..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 @@ -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,15 @@ function treeContents(dir: string): string[] { .sort(); } +/** 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 + .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 +588,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 +826,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 +834,67 @@ 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 () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-junction-')); + tmpDirs.push(workspaceRoot); + const serviceDir = path.join(workspaceRoot, 'apps', 'web'); + const dist = path.join(serviceDir, 'dist'); + const storePackage = path.join( + workspaceRoot, + 'node_modules', + '.pnpm', + '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', type: 'module', main: 'index.js' }), + 'index.js': 'export const marker = "JUNCTION_FIXTURE";\n', + }); + 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 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/node_modules/dep', + ); + const loaded = await import(pathToFileURL(path.join(result.dir, result.entry)).href); + expect(loaded.default).toBe('JUNCTION_FIXTURE'); + }, + 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()) { 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..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 @@ -366,6 +366,38 @@ describe('packageComputeArtifact', () => { expect(archive.link('node_modules/link')).not.toContain(bundleDir); }); + 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(true); + const archive = readTar(fs.readFileSync(absolute.path)); + + 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', () => { 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..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,13 +87,14 @@ 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('/'); + // 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) + ? 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 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