Skip to content
Open
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
143 changes: 143 additions & 0 deletions packages/0-framework/2-authoring/nextjs/src/__tests__/assemble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,149 @@ describe('assemble()', () => {
expect(result.watch).toContain(source);
}, 20_000);

test('rewrites an absolute package link to its staged in-bundle target', async () => {
const root = makeAppRoot();
const { appRel } = writeNextBuild(root);
const standalone = path.join(root, '.next', 'standalone');
const source = path.join(root, 'node_modules', 'pg');
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "pg";\n');
const linkDir = path.join(standalone, appRel, '.next', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync(source, path.join(linkDir, 'pg-traced'), 'dir');

const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-'));
tmpDirs.push(cwd);
const result = await assemble({
address: 'storefront.web',
cwd,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});

const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle');
const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced');
const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink));
expect(fs.lstatSync(bundledLink).isSymbolicLink()).toBe(true);
expect(bundledTarget.startsWith(`${bundle}${path.sep}`)).toBe(true);
expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('pg');
expect(result.watch).toContain(fs.realpathSync(source));
}, 20_000);

test('stages a traced sibling referenced by a relative link inside an absolute target', async () => {
const root = makeAppRoot();
const { appRel } = writeNextBuild(root);
const standalone = path.join(root, '.next', 'standalone');
const store = path.join(root, 'node_modules', '.pnpm', 'pkg@1.0.0', 'node_modules');
const source = path.join(store, 'pkg');
const sibling = path.join(store, 'helper');
fs.mkdirSync(source, { recursive: true });
fs.mkdirSync(sibling, { recursive: true });
fs.writeFileSync(path.join(sibling, 'marker.txt'), 'traced sibling\n');
fs.symlinkSync('../helper', path.join(source, 'helper'), 'dir');
const linkDir = path.join(standalone, appRel, '.next', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync(source, path.join(linkDir, 'pkg-traced'), 'dir');

const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-'));
tmpDirs.push(cwd);
const result = await assemble({
address: 'storefront.web',
cwd,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});

const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle');
const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pkg-traced');
const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink));
const nestedLink = path.join(bundledTarget, 'helper');
const nestedTarget = path.resolve(path.dirname(nestedLink), fs.readlinkSync(nestedLink));
expect(fs.lstatSync(nestedLink).isSymbolicLink()).toBe(true);
expect(nestedTarget.startsWith(`${bundle}${path.sep}`)).toBe(true);
expect(fs.readFileSync(path.join(nestedTarget, 'marker.txt'), 'utf8')).toContain(
'traced sibling',
);
expect(result.watch).toContain(fs.realpathSync(source));
expect(result.watch).toContain(fs.realpathSync(sibling));
}, 20_000);

test('rejects an external nested link even when relocation would make it hit bundle content', async () => {
const root = makeAppRoot();
const { appRel } = writeNextBuild(root);
const standalone = path.join(root, '.next', 'standalone');
const source = path.join(root, 'pkg');
fs.mkdirSync(source);
const outsideName = `${path.basename(root)}-outside`;
const outside = path.join(path.dirname(root), outsideName);
tmpDirs.push(outside);
fs.mkdirSync(outside);
fs.writeFileSync(path.join(outside, 'marker.txt'), 'outside trace\n');
fs.symlinkSync(path.relative(source, outside), path.join(source, 'escaped'), 'dir');
const collision = path.join(standalone, outsideName);
fs.mkdirSync(collision);
fs.writeFileSync(path.join(collision, 'marker.txt'), 'bundle collision\n');
const linkDir = path.join(standalone, appRel, '.next', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync(source, path.join(linkDir, 'pkg-traced'), 'dir');

await expect(
assemble({
address: 'storefront.web',
cwd: root,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
}),
).rejects.toThrow(/symlink outside the declared tracing root/);
}, 20_000);

test('does not let an occupied bundle path shadow an absolute-link target', async () => {
const root = makeAppRoot();
const { appRel } = writeNextBuild(root);
const standalone = path.join(root, '.next', 'standalone');
const source = path.join(root, 'node_modules', 'pg');
fs.mkdirSync(source, { recursive: true });
fs.writeFileSync(path.join(source, 'index.js'), 'module.exports = "original";\n');
const occupied = path.join(standalone, 'node_modules', 'pg');
fs.mkdirSync(occupied, { recursive: true });
fs.writeFileSync(path.join(occupied, 'index.js'), 'module.exports = "shadow";\n');
const linkDir = path.join(standalone, appRel, '.next', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync(source, path.join(linkDir, 'pg-traced'), 'dir');

const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-cwd-'));
tmpDirs.push(cwd);
await assemble({
address: 'storefront.web',
cwd,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
});

const bundle = path.join(cwd, '.prisma-composer', 'artifacts', 'storefront.web', 'bundle');
const bundledLink = path.join(bundle, appRel, '.next', 'node_modules', 'pg-traced');
const bundledTarget = path.resolve(path.dirname(bundledLink), fs.readlinkSync(bundledLink));
expect(fs.readFileSync(path.join(bundledTarget, 'index.js'), 'utf8')).toContain('original');
expect(fs.readFileSync(path.join(bundle, 'node_modules', 'pg', 'index.js'), 'utf8')).toContain(
'shadow',
);
}, 20_000);

test('rejects an absolute package link outside the declared tracing root', async () => {
const root = makeAppRoot();
const { appRel } = writeNextBuild(root);
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'prisma-composer-nextjs-outside-'));
tmpDirs.push(outside);
fs.writeFileSync(path.join(outside, 'secret.txt'), 'must not ship');
const linkDir = path.join(root, '.next', 'standalone', appRel, '.next', 'node_modules');
fs.mkdirSync(linkDir, { recursive: true });
fs.symlinkSync(outside, path.join(linkDir, 'escaped'), 'dir');

await expect(
assemble({
address: 'storefront.web',
cwd: root,
build: nextjs({ module: moduleUrl(root), appDir: '..' }),
}),
).rejects.toThrow(/assembled bundle contains a symlink whose target escapes the bundle/);
}, 20_000);

test('refuses a manifest whose app location escapes its tracing root', async () => {
const root = makeAppRoot();
writeNextBuild(root);
Expand Down
116 changes: 114 additions & 2 deletions packages/0-framework/2-authoring/nextjs/src/control/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
* Paths are file-relative (ADR-0004): `appDir` resolves against
* `dirname(build.module)`.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -134,6 +135,116 @@ async function collectSymlinks(root: string): Promise<string[]> {
return links;
}

async function createAbsoluteLinkStagingRoot(bundleDir: string): Promise<string> {
const base = path.join(bundleDir, '.prisma-composer-absolute-links');
for (let suffix = 0; ; suffix += 1) {
const candidate = suffix === 0 ? base : `${base}-${suffix}`;
try {
await fs.promises.mkdir(candidate);
return candidate;
} catch (error) {
if (error instanceof Error && Reflect.get(error, 'code') === 'EEXIST') continue;
throw error;
}
}
}

/**
* Windows standalone output can contain absolute package links. An absolute
* build-machine path cannot ship, even when its target belongs to Next's
* declared trace root. Stage that exact target under a fresh, collision-free
* bundle directory, then preserve the link as a relative in-bundle link.
*
* The link is never dereferenced: its target is copied separately and the
* topology remains a link. Targets outside the declared trace root are left
* untouched for the bundle validator to reject.
*/
async function stageAbsoluteStandaloneLinkTargets(
bundleDir: string,
manifest: ServerFilesManifest,
): Promise<string[]> {
const tracingRoot = manifest.tracingRoot;
if (tracingRoot === undefined || (await lstatIfPresent(tracingRoot)) === undefined) return [];

const tracedRootReal = await fs.promises.realpath(tracingRoot);
const stagedSources = new Set<string>();
const stagedTargets = new Map<string, string>();
let stagingRoot: string | undefined;
let nextStagedTarget = 0;

/** Copies one trusted target and repairs links whose meaning relocation would change. */
async function stageSource(sourceReal: string): Promise<string> {
const existing = stagedTargets.get(sourceReal);
if (existing !== undefined) return existing;

stagingRoot ??= await createAbsoluteLinkStagingRoot(bundleDir);
const target = path.join(stagingRoot, String(nextStagedTarget));
nextStagedTarget += 1;
stagedTargets.set(sourceReal, target);

const sourceStat = await fs.promises.stat(sourceReal);
await fs.promises.cp(sourceReal, target, { recursive: true, verbatimSymlinks: true });
stagedSources.add(sourceReal);

if (!sourceStat.isDirectory()) return target;
for (const stagedLink of await collectSymlinks(target)) {
const sourceLink = path.join(sourceReal, path.relative(target, stagedLink));
const rawTarget = await fs.promises.readlink(sourceLink);
const sourceTarget = path.isAbsolute(rawTarget)
? rawTarget
: path.resolve(path.dirname(sourceLink), rawTarget);
if (!path.isAbsolute(rawTarget) && isWithin(sourceReal, sourceTarget)) continue;

let nestedSourceReal: string;
try {
nestedSourceReal = await fs.promises.realpath(sourceLink);
} catch {
throw new Error(
`cannot stage ${sourceReal}: it contains a dangling symlink (${sourceLink} -> ${rawTarget})`,
);
}
if (!isWithin(tracedRootReal, nestedSourceReal)) {
throw new Error(
`cannot stage ${sourceReal}: it contains a symlink outside the declared tracing root (${sourceLink} -> ${rawTarget})`,
);
}

const nestedTarget = await stageSource(nestedSourceReal);
const nestedStat = await fs.promises.stat(nestedSourceReal);
await fs.promises.rm(stagedLink, { recursive: true, force: true });
await fs.promises.symlink(
path.relative(path.dirname(stagedLink), nestedTarget),
stagedLink,
nestedStat.isDirectory() ? 'dir' : 'file',
);
}
return target;
}

for (const linkPath of await collectSymlinks(bundleDir)) {
const rawTarget = await fs.promises.readlink(linkPath);
if (!path.isAbsolute(rawTarget)) continue;

let sourceReal: string;
try {
sourceReal = await fs.promises.realpath(linkPath);
} catch {
continue;
}
if (!isWithin(tracedRootReal, sourceReal)) continue;

const target = await stageSource(sourceReal);
const sourceStat = await fs.promises.stat(sourceReal);
await fs.promises.rm(linkPath, { recursive: true, force: true });
await fs.promises.symlink(
path.relative(path.dirname(linkPath), target),
linkPath,
sourceStat.isDirectory() ? 'dir' : 'file',
);
}
return [...stagedSources];
}

/** In-bundle link targets that the standalone tree does not contain — the
* repairs staging has to make. */
async function missingLinkTargets(bundleDir: string): Promise<string[]> {
Expand Down Expand Up @@ -234,7 +345,8 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
recursive: true,
verbatimSymlinks: true,
});
const stagedLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest);
const stagedAbsoluteLinkTargets = await stageAbsoluteStandaloneLinkTargets(bundleDir, manifest);
const stagedMissingLinkTargets = await stageMissingStandaloneLinkTargets(bundleDir, manifest);

// The documented copy: Next omits the client assets from standalone; place
// them beside the app's server.js so it serves them (docs: `cp -r public
Expand Down Expand Up @@ -279,7 +391,7 @@ export async function assemble(input: AssembleInput): Promise<Bundle> {
return {
dir: workDir,
entry: path.posix.join('bundle', appRel.split(path.sep).join('/'), 'server.js'),
watch: [standaloneRoot, ...stagedLinkTargets],
watch: [standaloneRoot, ...stagedAbsoluteLinkTargets, ...stagedMissingLinkTargets],
};
}

Expand Down
Loading