Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: escape glob path #9842

Merged
merged 9 commits into from Aug 29, 2022
36 changes: 33 additions & 3 deletions packages/vite/src/node/plugins/importMetaGlob.ts
Expand Up @@ -463,6 +463,34 @@ type IdResolver = (
importer?: string
) => Promise<string | undefined> | 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,
Expand All @@ -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 './'`
Expand Down
26 changes: 26 additions & 0 deletions 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,
Expand Down Expand Up @@ -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_<dirname>_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)
})
5 changes: 5 additions & 0 deletions 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 }
1 change: 1 addition & 0 deletions playground/glob-import/escape/(parenthesis)/mod/index.js
@@ -0,0 +1 @@
export const msg = 'foo'
5 changes: 5 additions & 0 deletions 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 }
1 change: 1 addition & 0 deletions playground/glob-import/escape/[brackets]/mod/index.js
@@ -0,0 +1 @@
export const msg = 'foo'
3 changes: 3 additions & 0 deletions 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 }
1 change: 1 addition & 0 deletions playground/glob-import/escape/{curlies}/mod/index.js
@@ -0,0 +1 @@
export const msg = 'foo'
22 changes: 22 additions & 0 deletions playground/glob-import/index.html
Expand Up @@ -17,6 +17,10 @@ <h2>Tree shake Eager CSS</h2>
<p class="tree-shake-eager-css">Should be orange</p>
<p class="no-tree-shake-eager-css">Should be orange</p>
<pre class="no-tree-shake-eager-css-result"></pre>
<h2>Escape relative glob</h2>
<pre class="escape-relative"></pre>
<h2>Escape alias glob</h2>
<pre class="escape-alias"></pre>

<script type="module" src="./dir/index.js"></script>
<script type="module">
Expand Down Expand Up @@ -118,3 +122,21 @@ <h2>Tree shake Eager CSS</h2>
document.querySelector('.no-tree-shake-eager-css-result').textContent =
results['/no-tree-shake.css'].default
</script>

<script type="module">
const globs = import.meta.glob('/escape/**/glob.js', {
eager: true
})
console.log(globs)
globalThis.globs = globs
const relative = Object.entries(globs)
.filter(([_, mod]) => Object.keys(mod?.relative ?? {}).length === 1)
.map(([glob]) => glob)
document.querySelector('.escape-relative').textContent = relative
.sort()
.join('\n')
const alias = Object.entries(globs)
.filter(([_, mod]) => Object.keys(mod?.alias ?? {}).length === 1)
.map(([glob]) => glob)
document.querySelector('.escape-alias').textContent = alias.sort().join('\n')
</script>
14 changes: 14 additions & 0 deletions 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<string, string>, dir) => {
aliases[`@escape_${dir}_mod`] = path.resolve(
__dirname,
`./escape/${dir}/mod`
)
return aliases
}, {})

export default defineConfig({
resolve: {
alias: {
...escapeAliases,
'@dir': path.resolve(__dirname, './dir/')
}
},
Expand Down