返回 slidev
importGuard.ts
根目录 / packages / slidev / node / vite / importGuard.ts
1 import type { Plugin, ResolvedConfig } from 'vite'
2 import { existsSync, realpathSync } from 'node:fs'
3 import { fileURLToPath } from 'node:url'
4 import path from 'pathe'
5 import { parseSync } from 'vite'
6 import { templateLegacyTitles } from '../virtual/deprecated'
7 import { templateTitleRendererMd } from '../virtual/titles'
8 import { regexSlideSourceId } from './common'
9
10 interface ImportSource {
11 value: string
12 start?: number
13 }
14
15 const virtualSlideMarkdownIds = new Set([
16 templateTitleRendererMd.id,
17 templateLegacyTitles.id,
18 ])
19
20 export function createSlideImportGuardPlugin(): Plugin {
21 let config: ResolvedConfig
22 let allowRoots: string[] = []
23
24 return {
25 name: 'slidev:slide-import-guard',
26
27 configResolved(resolved) {
28 config = resolved
29 allowRoots = config.server.fs.allow.map(normalizeFsPath)
30 },
31
32 async transform(code, id) {
33 if (!isSlideMarkdownId(id) || !config?.server.fs.strict)
34 return null
35
36 const importer = filePathFromId(id) ?? id
37 await Promise.all(extractImportSources(code, id).map(async ({ value, start }) => {
38 // Public-directory assets are referenced by root-absolute URL (`/foo.png`)
39 // and served by Vite's public-dir middleware, not module-resolved. Such a
40 // URL is a Vite URL, not a filesystem-absolute path, so exempt it from the
41 // fs.allow check when it maps to an existing file under `config.publicDir`.
42 if (isPublicAsset(value, config))
43 return
44
45 const resolved = await this.resolve(value, importer, { skipSelf: true })
46 if (!resolved || resolved.external)
47 return
48
49 const filePath = filePathFromId(resolved.id)
50 if (!filePath)
51 return
52
53 const normalized = normalizeFsPath(filePath)
54 if (isAllowedFile(normalized, allowRoots))
55 return
56 if (isBareImport(value) && isDependencyFile(normalized))
57 return
58
59 this.error(
60 `[slidev] Import "${value}" from slide Markdown resolves outside of Vite server.fs.allow: ${normalized}`,
61 start,
62 )
63 }))
64
65 return null
66 },
67 }
68 }
69
70 export function isSlideMarkdownId(id: string) {
71 const clean = cleanUrl(id)
72 return regexSlideSourceId.test(clean) || virtualSlideMarkdownIds.has(clean)
73 }
74
75 export function filePathFromId(id: string): string | null {
76 const clean = cleanUrl(id)
77 if (clean.startsWith('file://'))
78 return fileURLToPath(clean)
79 if (clean.startsWith('/@fs/'))
80 return clean.slice('/@fs'.length)
81 if (clean.startsWith('/@'))
82 return null
83 if (path.isAbsolute(clean))
84 return clean
85 return null
86 }
87
88 export function isAllowedFile(filePath: string, allowRoots: string[]) {
89 return allowRoots.some(root => isFileInRoot(root, filePath))
90 }
91
92 /**
93 * Whether a static import `value` is a public-directory asset, i.e. a
94 * root-absolute Vite URL (`/foo.png`) that maps to an existing file under
95 * `config.publicDir`. The Vue SFC compiler turns `<img src="/foo.png">` into a
96 * static `import`, but public assets are served by URL rather than
97 * module-resolved, so `/foo.png` is a Vite URL — not a filesystem-absolute path
98 * — and must be exempted from the `server.fs.allow` check.
99 */
100 export function isPublicAsset(value: string, config: Pick<ResolvedConfig, 'publicDir'>): boolean {
101 // `publicDir` is `''` when disabled (`publicDir: false`). Vite treats `/@fs/`,
102 // `/@id/`, … as internal URLs, never public assets, so leave those alone.
103 if (!config.publicDir || !value.startsWith('/') || value.startsWith('/@'))
104 return false
105 const publicDir = normalizeFsPath(config.publicDir)
106 const publicPath = normalizeFsPath(path.join(publicDir, cleanUrl(value).slice(1)))
107 return isFileInRoot(publicDir, publicPath) && existsSync(publicPath)
108 }
109
110 export function extractImportSources(code: string, id: string): ImportSource[] {
111 const result = parseSync(id, code)
112 const sources: ImportSource[] = []
113
114 for (const item of result.module.staticImports) {
115 sources.push({
116 value: item.moduleRequest.value,
117 start: item.moduleRequest.start,
118 })
119 }
120
121 for (const item of result.module.staticExports) {
122 for (const entry of item.entries) {
123 if (entry.moduleRequest) {
124 sources.push({
125 value: entry.moduleRequest.value,
126 start: entry.moduleRequest.start,
127 })
128 }
129 }
130 }
131
132 for (const item of result.module.dynamicImports) {
133 const source = parseStringLiteral(code.slice(item.moduleRequest.start, item.moduleRequest.end))
134 if (source) {
135 sources.push({
136 value: source,
137 start: item.moduleRequest.start,
138 })
139 }
140 }
141
142 return sources
143 }
144
145 function cleanUrl(id: string) {
146 return id.replace(/[?#].*$/, '')
147 }
148
149 function normalizeFsPath(filePath: string) {
150 const absolute = path.resolve(filePath)
151 if (!existsSync(absolute))
152 return normalizeMissingFsPath(absolute)
153 return realpathSync.native(absolute)
154 }
155
156 function normalizeMissingFsPath(filePath: string): string {
157 const dir = path.dirname(filePath)
158 if (dir === filePath)
159 return filePath
160 if (existsSync(dir))
161 return path.join(realpathSync.native(dir), path.basename(filePath))
162 return path.join(normalizeMissingFsPath(dir), path.basename(filePath))
163 }
164
165 function isFileInRoot(root: string, filePath: string) {
166 const relative = path.relative(root, filePath)
167 return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
168 }
169
170 function isDependencyFile(filePath: string) {
171 return filePath.split(/[\\/]/).includes('node_modules')
172 }
173
174 function isBareImport(source: string) {
175 return /^(?![a-z]:)[\w@](?!.*:\/\/)/i.test(source)
176 }
177
178 function parseStringLiteral(raw: string) {
179 const trimmed = raw.trim()
180 const quote = trimmed[0]
181 if (quote !== '"' && quote !== '\'' && quote !== '`')
182 return null
183 if (trimmed.at(-1) !== quote)
184 return null
185 if (quote === '`' && trimmed.includes('${'))
186 return null
187
188 try {
189 if (quote === '"')
190 return JSON.parse(trimmed) as string
191 return JSON.parse(`"${trimmed.slice(1, -1).replace(/"/g, '\\"')}"`) as string
192 }
193 catch {
194 return trimmed.slice(1, -1)
195 }
196 }
197
197 lines TYPESCRIPT