Skip to content

Commit 7ad4e6b

Browse files
committed
fix(module): refuse to add a config key a spread or computed key could shadow
1 parent ff8529a commit 7ad4e6b

3 files changed

Lines changed: 129 additions & 3 deletions

File tree

‎packages/nuxt-cli/src/utils/config-parse.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ function locateWithParser(parseSync: ParseSync, source: string, filename: string
151151
thrownewUnknownAstError('the config object does not hold ESTree properties')
152152
}
153153

154+
constdynamic=object.properties.some((property: any)=>property.type==='SpreadElement'||property.computed===true)
155+
154156
constpropertyStarts=object.properties.map((property: any)=>property.start)
155157
constkeys={}asRecord<ConfigKey,ArrayLocation>
156158

@@ -165,6 +167,7 @@ function locateWithParser(parseSync: ParseSync, source: string, filename: string
165167
objectStart: object.start,
166168
propertyStarts,
167169
keys,
170+
dynamic,
168171
quote: detectQuote(source,allElements(keys)),
169172
}
170173
}
@@ -287,7 +290,7 @@ function locateWithScan(source: string): ConfigLocation {
287290
thrownewConfigShapeError('Default export is missing in the config file!')
288291
}
289292

290-
constproperties=scanProperties(source,objectStart)
293+
const{properties, complete }=scanProperties(source,objectStart)
291294
constkeys={}asRecord<ConfigKey,ArrayLocation>
292295

293296
for(constkeyofCONFIG_KEYS){
@@ -299,6 +302,7 @@ function locateWithScan(source: string): ConfigLocation {
299302
objectStart,
300303
propertyStarts: properties.map(property=>property.start),
301304
keys,
305+
dynamic: !complete,
302306
quote: detectQuote(source,allElements(keys)),
303307
}
304308
}
@@ -357,30 +361,34 @@ interface ScannedProperty {
357361
valueStart: number
358362
}
359363

360-
functionscanProperties(source: string,objectStart: number): ScannedProperty[]{
364+
functionscanProperties(source: string,objectStart: number): {properties: ScannedProperty[],complete: boolean}{
361365
constobjectEnd=matchBracket(source,objectStart)
362366
if(objectEnd===undefined){
363367
thrownewConfigShapeError('The config object is not terminated.')
364368
}
365369

366370
constproperties: ScannedProperty[]=[]
371+
letcomplete=true
367372
letat=skipTrivia(source,objectStart+1)
368373

369374
while(at<objectEnd){
370375
conststart=at
371376
constkey=readKey(source,at)
372377
if(!key){
378+
complete=false
373379
break
374380
}
375381
at=skipTrivia(source,key.end)
376382
if(source[at]!==':'){
383+
complete=false
377384
break
378385
}
379386
constvalueStart=at+1
380387
properties.push({key: key.value, start, valueStart })
381388

382389
constvalueEnd=skipValue(source,valueStart,objectEnd)
383390
if(valueEnd===undefined){
391+
complete=false
384392
break
385393
}
386394
at=skipTrivia(source,valueEnd)
@@ -389,7 +397,7 @@ function scanProperties(source: string, objectStart: number): ScannedProperty[]
389397
}
390398
}
391399

392-
returnproperties
400+
return{properties, complete }
393401
}
394402

395403
functionreadKey(source: string,at: number): {value: string,end: number}|undefined{
@@ -566,6 +574,12 @@ export interface ConfigLocation {
566574
/** Offsets of each property key in the exported object, used to match indentation. */
567575
propertyStarts: number[]
568576
keys: Record<ConfigKey,ArrayLocation>
577+
/**
578+
* Whether the object holds something that could define a key we cannot see: a
579+
* spread, a computed key, or (for the scanner) a property it could not read.
580+
* Adding a key such a config may already set would silently shadow it.
581+
*/
582+
dynamic: boolean
569583
/** Quote character to use for new entries. */
570584
quote: string
571585
}

‎packages/nuxt-cli/src/utils/config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export async function addNuxtConfigEntries(config: NuxtConfigFile, entries: Conf
108108
edits.push(buildInsert(source,location,array,names))
109109
}
110110
else{
111+
if(location.dynamic){
112+
thrownewActionableError(`Could not add \`${key}\` to ${config.file}: the config spreads or computes keys, so a new \`${key}\` could be silently overridden. Add ${names.map(name=>`\`${name}\``).join(', ')} to \`${key}\` by hand.`)
113+
}
111114
created.push(buildProperty(source,location,key,names))
112115
}
113116
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import{mkdir,mkdtemp,readFile,realpath,symlink,writeFile}from'node:fs/promises'
2+
import{tmpdir}from'node:os'
3+
import{fileURLToPath}from'node:url'
4+
5+
import{dirname,join}from'pathe'
6+
import{afterAll,afterEach,describe,expect,it,vi}from'vitest'
7+
8+
import{addNuxtConfigEntries,readNuxtConfig}from'../../../src/utils/config'
9+
10+
constrolldownPath=dirname(fileURLToPath(import.meta.resolve('rolldown/package.json')))
11+
12+
constdirectories: string[]=[]
13+
14+
afterEach(()=>{
15+
vi.unstubAllEnvs()
16+
})
17+
18+
afterAll(async()=>{
19+
const{ rm }=awaitimport('node:fs/promises')
20+
awaitPromise.all(directories.splice(0).map(directory=>rm(directory,{recursive: true,force: true})))
21+
})
22+
23+
asyncfunctioncreateProject(source: string,{ parser }: {parser: boolean}): Promise<string>{
24+
constcwd=awaitrealpath(awaitmkdtemp(join(tmpdir(),'nuxi-config-dynamic-')))
25+
directories.push(cwd)
26+
awaitwriteFile(join(cwd,'nuxt.config.ts'),source,'utf8')
27+
if(parser){
28+
awaitmkdir(join(cwd,'node_modules'),{recursive: true})
29+
awaitsymlink(rolldownPath,join(cwd,'node_modules/rolldown'),'dir')
30+
}
31+
else{
32+
vi.stubEnv('NUXT_CLI_PARSER','scanner')
33+
}
34+
returncwd
35+
}
36+
37+
describe.each([
38+
['scanner',{parser: false}],
39+
['parser',{parser: true}],
40+
])('%s',(name,options)=>{
41+
constrefusal=name==='parser' ? 'Could not find a config object' : 'Default export is missing'
42+
asyncfunctionadd(source: string): Promise<{error?: string,after: string}>{
43+
constcwd=awaitcreateProject(source,options)
44+
constconfig=awaitreadNuxtConfig(cwd)
45+
leterror: string|undefined
46+
try{
47+
awaitaddNuxtConfigEntries(config!,{modules: ['@nuxt/image']})
48+
}
49+
catch(err){
50+
error=(errasError).message
51+
}
52+
return{ error,after: awaitreadFile(join(cwd,'nuxt.config.ts'),'utf8')}
53+
}
54+
55+
it('should refuse to add a key to a config that spreads another object',async()=>{
56+
constsource='export default defineNuxtConfig({ ...base })\n'
57+
58+
const{ error, after }=awaitadd(source)
59+
60+
expect(error).toContain('silently overridden')
61+
expect(after).toBe(source)
62+
})
63+
64+
it('should never end up with two `modules` keys next to a spread',async()=>{
65+
const{ error, after }=awaitadd('export default defineNuxtConfig({ ...base, modules: [\'a\'] })\n')
66+
67+
expect(after.match(/modules:/g)).toHaveLength(1)
68+
if(error){
69+
expect(error).toContain('silently overridden')
70+
}
71+
else{
72+
expect(after).toContain('modules: [\'a\', \'@nuxt/image\']')
73+
}
74+
})
75+
76+
it('should refuse to add a key next to a computed one',async()=>{
77+
constsource='const key = \'modules\'\nexport default defineNuxtConfig({ [key]: [\'a\'] })\n'
78+
79+
const{ error, after }=awaitadd(source)
80+
81+
expect(error).toContain('silently overridden')
82+
expect(after).toBe(source)
83+
})
84+
85+
it('should refuse a config whose default export is not an object',async()=>{
86+
constcwd=awaitcreateProject('export default defineNuxtConfig(base)\n',options)
87+
88+
awaitexpect(readNuxtConfig(cwd)).rejects.toThrow(refusal)
89+
})
90+
91+
it('should refuse a config chosen by a conditional',async()=>{
92+
constcwd=awaitcreateProject('export default defineNuxtConfig(x ? { modules: [] } : { modules: [\'a\'] })\n',options)
93+
94+
awaitexpect(readNuxtConfig(cwd)).rejects.toThrow(refusal)
95+
})
96+
97+
it('should refuse a config returned from a function',async()=>{
98+
constcwd=awaitcreateProject('export default defineNuxtConfig(() => ({ modules: [] }))\n',options)
99+
100+
awaitexpect(readNuxtConfig(cwd)).rejects.toThrow(refusal)
101+
})
102+
103+
it('should add a key to an ordinary config',async()=>{
104+
const{ error, after }=awaitadd('export default defineNuxtConfig({ ssr: false })\n')
105+
106+
expect(error).toBeUndefined()
107+
expect(after).toContain('@nuxt/image')
108+
})
109+
})

0 commit comments

Comments
 (0)