From 7a0fd2d920b55fbb2bb7e8a338690411b07aa7ef 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 70a12187d2..1d6e9fff20 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 @@ -316,4 +316,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 edb9b9e6b3..800547137f 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 @@ -59,6 +59,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 5f076ef1818739fa0bb27383044da5e52809ebd5 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 --- .../steps/include-assets/copy-config-key-entry.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 800547137f..96e7098efe 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 @@ -48,6 +48,13 @@ export async function copyConfigKeyEntry(config: { paths = [] } + for (const sourcePath of paths) { + if (sourcePath.trim() === '') { + const fieldName = key.split('.').pop()?.replace(/\[\]$/, '') ?? key + throw new AbortError(`'${fieldName}' can't be empty.`) + } + } + if (paths.length === 0) { outputDebug(`No value for configKey '${key}', skipping\n`, stdout) return {filesCopied: 0, pathMap: new Map()} @@ -59,13 +66,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