From 58882d2447a0cf1597002416dc8a06b1dd653311 Mon Sep 17 00:00:00 2001 From: Den Kong Date: Sat, 3 Oct 2026 12:07:41 +0800 Subject: [PATCH 1/2] Fix Windows bundle file keys (#651) --- src/index.ts | 2 +- src/utils.ts | 8 +- test/spec/windows-bundle-paths.js | 129 ++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 test/spec/windows-bundle-paths.js diff --git a/src/index.ts b/src/index.ts index 1eb51f25..048ac89b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,7 +99,7 @@ export const build: BuildV3 = async ({ ]), ], }), - name => path.join('user', name) + name => path.posix.join('user', name.replace(/\\/g, '/')) ); // Show some debug notes during build diff --git a/src/utils.ts b/src/utils.ts index dd1c7bae..972205c2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,7 +10,13 @@ const PHP_LIB_DIR = path.join(PHP_PKG, "native/lib"); const COMPOSER_BIN = path.join(PHP_BIN_DIR, "composer"); export async function getPhpFiles(): Promise { - const files = await libphp.getFiles(); + // Bundle keys are POSIX paths; physical file paths retain host separators. + const files = Object.fromEntries( + Object.entries(await libphp.getFiles()).map(([filename, filepath]) => [ + filename.replace(/\\/g, '/'), + filepath, + ]) + ); // Drop CGI + FPM from libphp, it's not needed for our case delete files['php/php-cgi']; diff --git a/test/spec/windows-bundle-paths.js b/test/spec/windows-bundle-paths.js new file mode 100644 index 00000000..3324e300 --- /dev/null +++ b/test/spec/windows-bundle-paths.js @@ -0,0 +1,129 @@ +const path = require('path'); + +jest.mock('@libphp/almalinux-9-v85', () => { + const actual = jest.requireActual('@libphp/almalinux-9-v85'); + return { ...actual, getFiles: jest.fn(actual.getFiles) }; +}); + +jest.mock('@vercel/build-utils', () => { + const actual = jest.requireActual('@vercel/build-utils'); + return { ...actual, glob: jest.fn(actual.glob) }; +}); + +const libphp = require('@libphp/almalinux-9-v85'); +const { FileFsRef, FileBlob, glob } = require('@vercel/build-utils'); +const { getPhpFiles, modifyPhpIni, readRuntimeFile } = require('../../dist/utils'); +const { build } = require('../../dist/index'); +const actualLibphp = jest.requireActual('@libphp/almalinux-9-v85'); +const excluded = ['php/php-cgi', 'php/php-fpm', 'php/php-fpm.ini']; +const fixturePath = path.resolve(__dirname, '../examples/01-cowsay'); +const userIni = 'memory_limit = 192M\n'; + +function logicalKey(name, separators, index) { + const posixName = name.replace(/\\/g, '/'); + return separators === 'backslash' || (separators === 'mixed' && index % 2 === 0) + ? posixName.replace(/\//g, '\\') + : posixName; +} + +async function verifyRuntimeFiles(inputFiles) { + const runtimeFiles = await getPhpFiles(); + const normalized = Object.fromEntries( + Object.entries(inputFiles).map(([name, fsPath]) => [name.replace(/\\/g, '/'), fsPath]) + ); + + expect(Object.keys(runtimeFiles).sort()).toEqual( + Object.keys(normalized).filter(name => !excluded.includes(name)).sort() + ); + for (const name of excluded) { + expect(runtimeFiles).not.toHaveProperty(name); + } + for (const [name, file] of Object.entries(runtimeFiles)) { + expect(name).not.toContain('\\'); + expect(file).toBeInstanceOf(FileFsRef); + expect(file.fsPath).toBe(normalized[name]); + } + expect(runtimeFiles['php/php'].mode).toBe(33261); + expect(runtimeFiles['php/composer'].mode).toBe(33261); + expect(runtimeFiles['php/php.ini']).toBeInstanceOf(FileFsRef); + + const merged = await modifyPhpIni( + { 'api/php.ini': new FileBlob({ data: userIni }) }, + runtimeFiles + ); + expect(merged).toBeInstanceOf(FileBlob); + expect(merged.data.toString()).toBe( + `${await readRuntimeFile(runtimeFiles['php/php.ini'])}; [User]\n${userIni}` + ); +} + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('collects the installed runtime with native keys and unchanged physical paths', async () => { + await verifyRuntimeFiles(await actualLibphp.getFiles()); +}); + +test.each(['slash', 'backslash', 'mixed'])( + 'normalizes %s runtime keys before exclusions and preserves the source map', + async separators => { + const installedFiles = await actualLibphp.getFiles(); + const inputFiles = Object.fromEntries( + Object.entries(installedFiles).map(([name, fsPath], index) => [ + logicalKey(name, separators, index), fsPath, + ]) + ); + const original = { ...inputFiles }; + libphp.getFiles.mockResolvedValueOnce(inputFiles); + + await verifyRuntimeFiles(inputFiles); + expect(inputFiles).toEqual(original); + } +); + +test('builds a real fixture with POSIX bundle keys', async () => { + const { output } = await build({ + files: {}, + entrypoint: 'index.php', + workPath: fixturePath, + config: {}, + meta: {}, + }); + + expect(output.files['user/index.php']).toBeInstanceOf(FileFsRef); + expect(output.files['php/php.ini']).toBeInstanceOf(FileFsRef); + expect(Object.keys(output.files).every(name => !name.includes('\\'))).toBe(true); + for (const name of excluded) { + expect(output.files).not.toHaveProperty(name); + } +}); + +test.each(['slash', 'backslash', 'mixed'])( + 'prefixes %s harvested keys with user/ without changing file references', + async separators => { + const names = ['api/index.php', 'vendor/package/file.php']; + const refs = names.map(() => new FileFsRef({ + fsPath: path.join(fixturePath, 'index.php'), + })); + glob.mockResolvedValueOnce(Object.fromEntries( + names.map((name, index) => [logicalKey(name, separators, index), refs[index]]) + )); + + const { output } = await build({ + files: {}, + entrypoint: 'api/index.php', + workPath: fixturePath, + config: {}, + meta: {}, + }); + + expect(Object.keys(output.files).filter(name => name.startsWith('user/')).sort()) + .toEqual(names.map(name => `user/${name}`).sort()); + names.forEach((name, index) => { + expect(output.files[`user/${name}`]).toBe(refs[index]); + expect(output.files[`user/${name}`].fsPath).toBe(path.join(fixturePath, 'index.php')); + }); + expect(Object.keys(output.files).every(name => !name.includes('\\'))).toBe(true); + } +); From 1ccf1328d4441811c26a3e1e30ddb3a78bf3031f Mon Sep 17 00:00:00 2001 From: Den Kong Date: Sat, 3 Oct 2026 12:11:46 +0800 Subject: [PATCH 2/2] Make native path test expectations portable --- test/spec/launchers/cgi.js | 22 ++++++++++++++++++---- test/spec/path.js | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/test/spec/launchers/cgi.js b/test/spec/launchers/cgi.js index 2328b6f4..34c1810c 100644 --- a/test/spec/launchers/cgi.js +++ b/test/spec/launchers/cgi.js @@ -1,6 +1,14 @@ +const path = require('path'); const cgi = require('./../../../dist/launchers/cgi'); -test('create CGI request', () => { +afterEach(() => { + jest.restoreAllMocks(); +}); + +test.each([ + ['default root', undefined], + ['configured root', path.resolve('/var/task')], +])('create CGI request with %s', (_name, taskRoot) => { const request = { entrypoint: "index.php", path: "/index.php", @@ -8,11 +16,17 @@ test('create CGI request', () => { method: "GET", headers: {} }; - process.env.CUSTOM_VALUE = "custom-value"; + jest.replaceProperty(process, 'env', { ...process.env, CUSTOM_VALUE: 'custom-value' }); + if (taskRoot === undefined) { + delete process.env.LAMBDA_TASK_ROOT; + } else { + process.env.LAMBDA_TASK_ROOT = taskRoot; + } const { env } = cgi.createCGIReq(request); + const userDir = path.join(taskRoot || '/', 'user'); - expect(env).toHaveProperty("SERVER_ROOT", "/user"); - expect(env).toHaveProperty("DOCUMENT_ROOT", "/user"); + expect(env).toHaveProperty("SERVER_ROOT", userDir); + expect(env).toHaveProperty("DOCUMENT_ROOT", userDir); expect(env).toHaveProperty("SERVER_NAME", request.host); expect(env).toHaveProperty("SERVER_PORT", 443); expect(env).toHaveProperty("HTTPS", 'On'); diff --git a/test/spec/path.js b/test/spec/path.js index ac132e21..11068868 100644 --- a/test/spec/path.js +++ b/test/spec/path.js @@ -5,5 +5,5 @@ test('relative path', () => { const request = '/var/task/user/api/users.php'; const file = path.relative(rootdir, request); - expect(file).toBe('api/users.php'); + expect(file).toBe(path.join('api', 'users.php')); });