Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/guides/building-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
162 changes: 158 additions & 4 deletions packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<unknown[]> {
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', () => {
Expand All @@ -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 });
Expand All @@ -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);
},
);
});
159 changes: 152 additions & 7 deletions packages/0-framework/2-authoring/bundle-paths/src/bundle-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,161 @@
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<void> {
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<BundleLinkKind> {
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<void> {
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 },
);
Comment thread
AmanVarshney01 marked this conversation as resolved.
}
throw error;
}
}

async function lstatIfPresent(candidate: string): Promise<fs.Stats | undefined> {
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<void> {
const copyLink = async (
from: string,
to: string,
target: string,
written: string,
): Promise<void> => {
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);
Comment thread
AmanVarshney01 marked this conversation as resolved.
} 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<void> => {
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<void> {
if (!directoryLinksAreJunctions(platform)) return;

const visit = async (directory: string): Promise<void> => {
for (const entry of await fs.promises.readdir(directory, { withFileTypes: true })) {
const full = path.join(directory, entry.name);
if (entry.isSymbolicLink()) {
const target = await fs.promises.readlink(full);
const resolvedTarget = path.resolve(path.dirname(full), target);
let kind: BundleLinkKind;
try {
if (!(await fs.promises.stat(resolvedTarget)).isDirectory()) continue;
kind = (await fs.promises.stat(resolvedTarget)).isDirectory() ? 'dir' : 'file';
} catch {
continue;
}
await fs.promises.unlink(full);
await fs.promises.symlink(target, full, 'dir');
await createBundleLink(target, full, kind, platform);
} else if (entry.isDirectory()) {
await visit(full);
}
Expand All @@ -34,9 +172,16 @@ export async function repairWindowsDirectorySymlinks(root: string): Promise<void
await visit(root);
}

export async function copyTreeVerbatim(source: string, destination: string): Promise<void> {
export async function copyTreeVerbatim(
source: string,
destination: string,
platform: NodeJS.Platform = process.platform,
): Promise<void> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading