From ef5e7f63d4d501e3e10d388e6631db2096f07c88 Mon Sep 17 00:00:00 2001 From: Josh White Date: Mon, 11 May 2026 16:58:15 -0400 Subject: [PATCH 1/2] Reject empty or whitespace-only configKey values in include_assets builds --- .../copy-config-key-entry.test.ts | 40 +++++++++++++++++++ .../include-assets/copy-config-key-entry.ts | 7 ++++ 2 files changed, 47 insertions(+) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts index 66134f8837e..eaaaf9d5146 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts @@ -354,4 +354,44 @@ describe('copyConfigKeyEntry', () => { await expect(fileExists(joinPath(outDir, 'tools.json'))).resolves.toBe(true) }) }) + + describe('value guard', () => { + test('throws when value is an empty string', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outDir = joinPath(tmpDir, 'out') + await mkdir(outDir) + const context = makeContext({assets: ''}) + const promise = copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context}) + await expect(promise).rejects.toThrow(AbortError) + await expect(promise).rejects.toThrow(`'assets' can't be empty.`) + }) + }) + + test('throws when value is whitespace-only', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outDir = joinPath(tmpDir, 'out') + await mkdir(outDir) + const context = makeContext({assets: ' '}) + const promise = copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context}) + await expect(promise).rejects.toThrow(AbortError) + await expect(promise).rejects.toThrow(`'assets' can't be empty.`) + }) + }) + + test('throws with the field name only, not the full configKey, when the key is nested', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const outDir = joinPath(tmpDir, 'out') + await mkdir(outDir) + const context = makeContext({extension_points: [{assets: ''}]}) + const promise = copyConfigKeyEntry({ + key: 'extension_points[].assets', + baseDir: tmpDir, + outputDir: outDir, + context, + }) + await expect(promise).rejects.toThrow(AbortError) + await expect(promise).rejects.toThrow(`'assets' can't be empty.`) + }) + }) + }) }) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts index c46e20a8e2c..8c81d10a212 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts @@ -62,6 +62,13 @@ export async function copyConfigKeyEntry(config: { // should only be copied once; the pathMap entry is reused for all references. const uniquePaths = [...new Set(paths)] + const fieldName = key.split('.').pop()?.replace(/\[\]$/, '') ?? key + for (const sourcePath of uniquePaths) { + if (sourcePath.trim() === '') { + throw new AbortError(`'${fieldName}' can't be empty.`) + } + } + // Process sequentially to avoid filesystem race conditions on shared output paths. const pathMap = new Map() let filesCopied = 0 From 3880b9a0f49f20ff049f366c053d1b7bae45e65f Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Tue, 12 May 2026 19:14:59 -0400 Subject: [PATCH 2/2] Optimize empty path checking --- .../copy-config-key-entry.test.ts | 21 +++++++++++++++---- .../include-assets/copy-config-key-entry.ts | 13 ++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts index eaaaf9d5146..9e6cddf87b9 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts @@ -361,7 +361,13 @@ describe('copyConfigKeyEntry', () => { const outDir = joinPath(tmpDir, 'out') await mkdir(outDir) const context = makeContext({assets: ''}) - const promise = copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context}) + const promise = copyConfigKeyEntry({ + key: 'assets', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) await expect(promise).rejects.toThrow(AbortError) await expect(promise).rejects.toThrow(`'assets' can't be empty.`) }) @@ -372,13 +378,19 @@ describe('copyConfigKeyEntry', () => { const outDir = joinPath(tmpDir, 'out') await mkdir(outDir) const context = makeContext({assets: ' '}) - const promise = copyConfigKeyEntry({key: 'assets', baseDir: tmpDir, outputDir: outDir, context}) + const promise = copyConfigKeyEntry({ + key: 'assets', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) await expect(promise).rejects.toThrow(AbortError) await expect(promise).rejects.toThrow(`'assets' can't be empty.`) }) }) - test('throws with the field name only, not the full configKey, when the key is nested', async () => { + test('throws with the full configKey when the key is nested', async () => { await inTemporaryDirectory(async (tmpDir) => { const outDir = joinPath(tmpDir, 'out') await mkdir(outDir) @@ -388,9 +400,10 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, }) await expect(promise).rejects.toThrow(AbortError) - await expect(promise).rejects.toThrow(`'assets' can't be empty.`) + await expect(promise).rejects.toThrow(`'extension_points[].assets' can't be empty.`) }) }) }) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts index 8c81d10a212..c952cd8abf1 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts @@ -51,6 +51,12 @@ export async function copyConfigKeyEntry(config: { paths = [] } + for (const sourcePath of paths) { + if (sourcePath.trim() === '') { + throw new AbortError(`'${key}' can't be empty.`) + } + } + if (paths.length === 0) { outputDebug(`No value for configKey '${key}', skipping\n`, stdout) return {filesCopied: 0, pathMap: new Map()} @@ -62,13 +68,6 @@ export async function copyConfigKeyEntry(config: { // should only be copied once; the pathMap entry is reused for all references. const uniquePaths = [...new Set(paths)] - const fieldName = key.split('.').pop()?.replace(/\[\]$/, '') ?? key - for (const sourcePath of uniquePaths) { - if (sourcePath.trim() === '') { - throw new AbortError(`'${fieldName}' can't be empty.`) - } - } - // Process sequentially to avoid filesystem race conditions on shared output paths. const pathMap = new Map() let filesCopied = 0