From 96b2414bf6a600f326e748ff267a9ae5a0ff70ad Mon Sep 17 00:00:00 2001 From: Sujal Shah <56556259+SujalXplores@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:34:05 +0530 Subject: [PATCH] fix: handle ids with multiple null bytes wrapId, unwrapId, and the Lightning CSS filename encoder used String.prototype.replace with a string pattern, which only replaces the first match. Ids that contain more than one null byte (for example a CommonJS proxy wrapping a virtual module, \0commonjs-proxy:\0virtual) were left with raw null bytes after the first one. That breaks the documented contract that a wrapped id is a valid import URL with every \0 encoded as __x00__, and Lightning CSS treats a raw null byte as a string terminator. Use replaceAll so every null byte is encoded and decoded symmetrically. --- packages/vite/src/node/plugins/css.ts | 2 +- .../vite/src/shared/__tests__/utils.spec.ts | 53 +++++++++++++++++++ packages/vite/src/shared/utils.ts | 4 +- 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 packages/vite/src/shared/__tests__/utils.spec.ts diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 3669462c422d53..a7847dca9c2035 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -3214,7 +3214,7 @@ async function compileLightningCSS( const { config } = environment // replace null byte as lightningcss treats that as a string terminator // https://github.com/parcel-bundler/lightningcss/issues/874 - const filename = removeDirectQuery(id).replace('\0', NULL_BYTE_PLACEHOLDER) + const filename = removeDirectQuery(id).replaceAll('\0', NULL_BYTE_PLACEHOLDER) let res: LightningCssTransformAttributeResult | LightningCssTransformResult try { diff --git a/packages/vite/src/shared/__tests__/utils.spec.ts b/packages/vite/src/shared/__tests__/utils.spec.ts new file mode 100644 index 00000000000000..eb6d34e061d593 --- /dev/null +++ b/packages/vite/src/shared/__tests__/utils.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest' +import { NULL_BYTE_PLACEHOLDER, VALID_ID_PREFIX } from '../constants' +import { unwrapId, wrapId } from '../utils' + +describe('wrapId', () => { + test('wraps an unwrapped id', () => { + expect(wrapId('/foo')).toBe(`${VALID_ID_PREFIX}/foo`) + }) + + test('is a noop for an already wrapped id', () => { + const wrapped = `${VALID_ID_PREFIX}/foo` + expect(wrapId(wrapped)).toBe(wrapped) + }) + + test('encodes a leading null byte', () => { + expect(wrapId('\0virtual')).toBe( + `${VALID_ID_PREFIX}${NULL_BYTE_PLACEHOLDER}virtual`, + ) + }) + + test('encodes every null byte, not just the first', () => { + // e.g. a commonjs proxy wrapping a virtual module has multiple null bytes, + // and the wrapped id must stay free of raw null bytes to be a valid URL + const id = '\0commonjs-proxy:\0virtual' + const wrapped = wrapId(id) + expect(wrapped).not.toContain('\0') + expect(wrapped).toBe( + `${VALID_ID_PREFIX}${NULL_BYTE_PLACEHOLDER}commonjs-proxy:${NULL_BYTE_PLACEHOLDER}virtual`, + ) + }) +}) + +describe('unwrapId', () => { + test('unwraps a wrapped id', () => { + expect(unwrapId(`${VALID_ID_PREFIX}/foo`)).toBe('/foo') + }) + + test('is a noop for an already unwrapped id', () => { + expect(unwrapId('/foo')).toBe('/foo') + }) + + test('decodes every null byte placeholder, not just the first', () => { + const wrapped = `${VALID_ID_PREFIX}${NULL_BYTE_PLACEHOLDER}commonjs-proxy:${NULL_BYTE_PLACEHOLDER}virtual` + expect(unwrapId(wrapped)).toBe('\0commonjs-proxy:\0virtual') + }) +}) + +describe('wrapId/unwrapId', () => { + test('round-trips an id with multiple null bytes', () => { + const id = '\0commonjs-proxy:\0virtual' + expect(unwrapId(wrapId(id))).toBe(id) + }) +}) diff --git a/packages/vite/src/shared/utils.ts b/packages/vite/src/shared/utils.ts index 856aa86f02fcbc..208be845426478 100644 --- a/packages/vite/src/shared/utils.ts +++ b/packages/vite/src/shared/utils.ts @@ -11,7 +11,7 @@ export const isWindows: boolean = export function wrapId(id: string): string { return id.startsWith(VALID_ID_PREFIX) ? id - : VALID_ID_PREFIX + id.replace('\0', NULL_BYTE_PLACEHOLDER) + : VALID_ID_PREFIX + id.replaceAll('\0', NULL_BYTE_PLACEHOLDER) } /** @@ -19,7 +19,7 @@ export function wrapId(id: string): string { */ export function unwrapId(id: string): string { return id.startsWith(VALID_ID_PREFIX) - ? id.slice(VALID_ID_PREFIX.length).replace(NULL_BYTE_PLACEHOLDER, '\0') + ? id.slice(VALID_ID_PREFIX.length).replaceAll(NULL_BYTE_PLACEHOLDER, '\0') : id }