diff --git a/packages/vite/src/node/plugins/importMetaGlob.ts b/packages/vite/src/node/plugins/importMetaGlob.ts index bd88c971fa4343..f0aac1e8dc98b5 100644 --- a/packages/vite/src/node/plugins/importMetaGlob.ts +++ b/packages/vite/src/node/plugins/importMetaGlob.ts @@ -463,6 +463,34 @@ type IdResolver = ( importer?: string ) => Promise | string | undefined +function globSafePath(path: string) { + // slash path to ensure \ is converted to / as \ could lead to a double escape scenario + // see https://github.com/mrmlnc/fast-glob#advanced-syntax + return fg.escapePath(normalizePath(path)) +} + +function lastNthChar(str: string, n: number) { + return str.charAt(str.length - 1 - n) +} + +function globSafeResolvedPath(resolved: string, glob: string) { + // we have to escape special glob characters in the resolved path, but keep the user specified globby suffix + // walk back both strings until a character difference is found + // then slice up the resolved path at that pos and escape the first part + let numEqual = 0 + const maxEqual = Math.min(resolved.length, glob.length) + while ( + numEqual < maxEqual && + lastNthChar(resolved, numEqual) === lastNthChar(glob, numEqual) + ) { + numEqual += 1 + } + const staticPartEnd = resolved.length - numEqual + const staticPart = resolved.slice(0, staticPartEnd) + const dynamicPart = resolved.slice(staticPartEnd) + return globSafePath(staticPart) + dynamicPart +} + export async function toAbsoluteGlob( glob: string, root: string, @@ -474,15 +502,17 @@ export async function toAbsoluteGlob( pre = '!' glob = glob.slice(1) } - - const dir = importer ? dirname(importer) : root + root = globSafePath(root) + const dir = importer ? globSafePath(dirname(importer)) : root if (glob.startsWith('/')) return pre + posix.join(root, glob.slice(1)) if (glob.startsWith('./')) return pre + posix.join(dir, glob.slice(2)) if (glob.startsWith('../')) return pre + posix.join(dir, glob) if (glob.startsWith('**')) return pre + glob const resolved = normalizePath((await resolveId(glob, importer)) || glob) - if (isAbsolute(resolved)) return pre + resolved + if (isAbsolute(resolved)) { + return pre + globSafeResolvedPath(resolved, glob) + } throw new Error( `Invalid glob: "${glob}" (resolved: "${resolved}"). It must start with '/' or './'` diff --git a/playground/glob-import/__tests__/glob-import.spec.ts b/playground/glob-import/__tests__/glob-import.spec.ts index 078ac0cdd0a6e1..a438ce00d2d62b 100644 --- a/playground/glob-import/__tests__/glob-import.spec.ts +++ b/playground/glob-import/__tests__/glob-import.spec.ts @@ -1,3 +1,5 @@ +import path from 'node:path' +import { readdir } from 'node:fs/promises' import { expect, test } from 'vitest' import { addFile, @@ -187,3 +189,27 @@ test('tree-shake eager css', async () => { expect(content).not.toMatch('.tree-shake-eager-css') } }) + +test('escapes special chars in globs without mangling user supplied glob suffix', async () => { + // the escape dir contains subdirectories where each has a name that needs escaping for glob safety + // inside each of them is a glob.js that exports the result of a relative glob `./**/*.js` + // and an alias glob `@escape__mod/**/*.js`. The matching aliases are generated in vite.config.ts + // index.html has a script that loads all these glob.js files and prints the globs that returned the expected result + // this test finally compares the printed output of index.js with the list of directories with special chars, + // expecting that they all work + const files = await readdir(path.join(__dirname, '..', 'escape'), { + withFileTypes: true + }) + const expectedNames = files + .filter((f) => f.isDirectory()) + .map((f) => `/escape/${f.name}/glob.js`) + .sort() + const foundRelativeNames = (await page.textContent('.escape-relative')) + .split('\n') + .sort() + expect(expectedNames).toEqual(foundRelativeNames) + const foundAliasNames = (await page.textContent('.escape-alias')) + .split('\n') + .sort() + expect(expectedNames).toEqual(foundAliasNames) +}) diff --git a/playground/glob-import/escape/(parenthesis)/glob.js b/playground/glob-import/escape/(parenthesis)/glob.js new file mode 100644 index 00000000000000..e97de892c30e2b --- /dev/null +++ b/playground/glob-import/escape/(parenthesis)/glob.js @@ -0,0 +1,5 @@ +const relative = import.meta.glob('./**/*.js', { eager: true }) +const alias = import.meta.glob('@escape_(parenthesis)_mod/**/*.js', { + eager: true +}) +export { relative, alias } diff --git a/playground/glob-import/escape/(parenthesis)/mod/index.js b/playground/glob-import/escape/(parenthesis)/mod/index.js new file mode 100644 index 00000000000000..4eeb2ac0e1dbb4 --- /dev/null +++ b/playground/glob-import/escape/(parenthesis)/mod/index.js @@ -0,0 +1 @@ +export const msg = 'foo' diff --git a/playground/glob-import/escape/[brackets]/glob.js b/playground/glob-import/escape/[brackets]/glob.js new file mode 100644 index 00000000000000..7cc656bfe97464 --- /dev/null +++ b/playground/glob-import/escape/[brackets]/glob.js @@ -0,0 +1,5 @@ +const relative = import.meta.glob('./**/*.js', { eager: true }) +const alias = import.meta.glob('@escape_[brackets]_mod/**/*.js', { + eager: true +}) +export { relative, alias } diff --git a/playground/glob-import/escape/[brackets]/mod/index.js b/playground/glob-import/escape/[brackets]/mod/index.js new file mode 100644 index 00000000000000..4eeb2ac0e1dbb4 --- /dev/null +++ b/playground/glob-import/escape/[brackets]/mod/index.js @@ -0,0 +1 @@ +export const msg = 'foo' diff --git a/playground/glob-import/escape/{curlies}/glob.js b/playground/glob-import/escape/{curlies}/glob.js new file mode 100644 index 00000000000000..a6d286001567e9 --- /dev/null +++ b/playground/glob-import/escape/{curlies}/glob.js @@ -0,0 +1,3 @@ +const relative = import.meta.glob('./**/*.js', { eager: true }) +const alias = import.meta.glob('@escape_{curlies}_mod/**/*.js', { eager: true }) +export { relative, alias } diff --git a/playground/glob-import/escape/{curlies}/mod/index.js b/playground/glob-import/escape/{curlies}/mod/index.js new file mode 100644 index 00000000000000..4eeb2ac0e1dbb4 --- /dev/null +++ b/playground/glob-import/escape/{curlies}/mod/index.js @@ -0,0 +1 @@ +export const msg = 'foo' diff --git a/playground/glob-import/index.html b/playground/glob-import/index.html index 85e9e98d2c5ae7..359b4fb75ef5f8 100644 --- a/playground/glob-import/index.html +++ b/playground/glob-import/index.html @@ -17,6 +17,10 @@

Tree shake Eager CSS

Should be orange

Should be orange


+

Escape relative glob

+

+

Escape alias glob

+

 
 
 
+
+
diff --git a/playground/glob-import/vite.config.ts b/playground/glob-import/vite.config.ts
index a90136bb449662..298a471907cfec 100644
--- a/playground/glob-import/vite.config.ts
+++ b/playground/glob-import/vite.config.ts
@@ -1,9 +1,23 @@
+import fs from 'node:fs'
 import path from 'node:path'
 import { defineConfig } from 'vite'
 
+const escapeAliases = fs
+  .readdirSync(path.join(__dirname, 'escape'), { withFileTypes: true })
+  .filter((f) => f.isDirectory())
+  .map((f) => f.name)
+  .reduce((aliases: Record, dir) => {
+    aliases[`@escape_${dir}_mod`] = path.resolve(
+      __dirname,
+      `./escape/${dir}/mod`
+    )
+    return aliases
+  }, {})
+
 export default defineConfig({
   resolve: {
     alias: {
+      ...escapeAliases,
       '@dir': path.resolve(__dirname, './dir/')
     }
   },