From 926db8b24d1ce1ff249115fc40300a9ef7c457fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 13 May 2026 15:16:51 +0200 Subject: [PATCH 1/4] Guard CLI bundle upload against oversized and out-of-app paths --- .../guard-bundle-upload-size-and-paths.md | 5 ++ .../build/steps/include-assets-step.test.ts | 2 +- .../build/steps/include-assets-step.ts | 5 ++ .../assert-path-within-app.test.ts | 30 +++++++++++ .../include-assets/assert-path-within-app.ts | 23 +++++++++ .../include-assets/copy-by-pattern.test.ts | 12 +++++ .../steps/include-assets/copy-by-pattern.ts | 6 ++- .../copy-config-key-entry.test.ts | 50 ++++++++++++++++--- .../include-assets/copy-config-key-entry.ts | 4 ++ .../include-assets/copy-source-entry.test.ts | 11 ++-- .../steps/include-assets/copy-source-entry.ts | 5 +- packages/app/src/cli/services/bundle.test.ts | 48 +++++++++++++++++- packages/app/src/cli/services/bundle.ts | 14 +++++- .../dev/extension/server/middlewares.test.ts | 1 + 14 files changed, 201 insertions(+), 15 deletions(-) create mode 100644 .changeset/guard-bundle-upload-size-and-paths.md create mode 100644 packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts create mode 100644 packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts diff --git a/.changeset/guard-bundle-upload-size-and-paths.md b/.changeset/guard-bundle-upload-size-and-paths.md new file mode 100644 index 00000000000..4508c32ff75 --- /dev/null +++ b/.changeset/guard-bundle-upload-size-and-paths.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Guard app bundle uploads against oversized bundles and asset paths resolving outside the app directory diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index e9decd6b02d..765bb34955b 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -25,7 +25,7 @@ describe('executeIncludeAssetsStep', () => { options: { stdout: mockStdout, stderr: mockStderr, - app: {} as any, + app: {directory: '/test'} as any, environment: 'production', }, stepResults: new Map(), diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.ts b/packages/app/src/cli/services/build/steps/include-assets-step.ts index 1aba68d5d5d..dc7bdba614f 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.ts @@ -125,6 +125,7 @@ export async function executeIncludeAssetsStep( const config = IncludeAssetsConfigSchema.parse(step.config) const {extension, options} = context const outputDir = resolveOutputDir(extension.outputPath) + const appDirectory = options.app.directory const aggregatedPathMap = new Map() // Track basenames written across all configKey entries in this build. In @@ -147,6 +148,7 @@ export async function executeIncludeAssetsStep( baseDir: extension.directory, outputDir, context, + appDirectory, destination: sanitizedDest, usedBasenames, preserveFilePaths: entry.preserveFilePaths, @@ -172,6 +174,8 @@ export async function executeIncludeAssetsStep( outputDir: destinationDir, patterns: entry.include, ignore: entry.ignore ?? [], + appDirectory, + sourceDirConfigValue: entry.baseDir ?? '.', }, options, ) @@ -189,6 +193,7 @@ export async function executeIncludeAssetsStep( destination: sanitizedDest, baseDir: extension.directory, outputDir, + appDirectory, }, options, ) diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts new file mode 100644 index 00000000000..3c90b3f9954 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts @@ -0,0 +1,30 @@ +import {assertPathWithinAppDir} from './assert-path-within-app.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test} from 'vitest' + +describe('assertPathWithinAppDir', () => { + test('allows a path inside the app directory', () => { + expect(() => + assertPathWithinAppDir('/app/extensions/ext-a/icon.png', '/app', 'extensions/ext-a/icon.png'), + ).not.toThrow() + }) + + test('allows the app directory itself', () => { + expect(() => assertPathWithinAppDir('/app', '/app', '.')).not.toThrow() + }) + + test('rejects a relative path that escapes the app directory via ..', () => { + expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow(AbortError) + expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow( + /resolves outside the app directory/, + ) + }) + + test('rejects an absolute path that points outside the app directory', () => { + expect(() => assertPathWithinAppDir('/Users/me', '/app', '/Users/me')).toThrow(AbortError) + }) + + test('includes the original config value in the error message for debuggability', () => { + expect(() => assertPathWithinAppDir('/other', '/app', '~/anywhere')).toThrow(/Asset path '~\/anywhere'/) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts new file mode 100644 index 00000000000..732011a309b --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts @@ -0,0 +1,23 @@ +import {relativePath, isAbsolutePath} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' + +/** + * Throws if `resolvedPath` is not inside `appDirectory`. + * + * Guards against accidental misuse where an extension config points an asset + * source outside the app folder (e.g. `source = "../../"` or an absolute home + * directory path). Adversarial bypass via symlinks is out of scope — server-side + * size enforcement is the real boundary; this check is a fast-fail for DX. + * + * `configValue` is the raw value from configuration used in the error message + * so the user can locate the offending entry. + */ +export function assertPathWithinAppDir(resolvedPath: string, appDirectory: string, configValue: string): void { + const relative = relativePath(appDirectory, resolvedPath) + if (relative.startsWith('..') || isAbsolutePath(relative)) { + throw new AbortError( + `Asset path '${configValue}' resolves outside the app directory.`, + `Asset sources must be inside the app folder. Resolved to: ${resolvedPath}`, + ) + } +} diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts index ea4cf855116..9e4a47b1e0e 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts @@ -24,6 +24,8 @@ describe('copyByPattern', () => { outputDir: '/out', patterns: ['**/*.ts', '**/*.tsx'], ignore: [], + appDirectory: '/src', + sourceDirConfigValue: '.', }, {stdout: mockStdout}, ) @@ -47,6 +49,8 @@ describe('copyByPattern', () => { outputDir: '/out', patterns: ['**/*.png'], ignore: [], + appDirectory: '/src', + sourceDirConfigValue: '.', }, {stdout: mockStdout}, ) @@ -72,6 +76,8 @@ describe('copyByPattern', () => { outputDir: '/out/sub', patterns: ['**/*'], ignore: [], + appDirectory: '/out', + sourceDirConfigValue: 'sub', }, {stdout: mockStdout}, ) @@ -96,6 +102,8 @@ describe('copyByPattern', () => { outputDir: '/out', patterns: ['*.png'], ignore: [], + appDirectory: '/out', + sourceDirConfigValue: '.', }, {stdout: mockStdout}, ) @@ -120,6 +128,8 @@ describe('copyByPattern', () => { outputDir: '/out/dist', patterns: ['*.js'], ignore: [], + appDirectory: '/src', + sourceDirConfigValue: '.', }, {stdout: mockStdout}, ) @@ -139,6 +149,8 @@ describe('copyByPattern', () => { outputDir: '/out', patterns: ['**/*'], ignore: ['**/*.test.ts', 'node_modules/**'], + appDirectory: '/src', + sourceDirConfigValue: '.', }, {stdout: mockStdout}, ) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts index c1716d7d2da..7dfa4ea65e4 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts @@ -1,3 +1,4 @@ +import {assertPathWithinAppDir} from './assert-path-within-app.js' import {joinPath, dirname, relativePath} from '@shopify/cli-kit/node/path' import {glob, copyFile, mkdir} from '@shopify/cli-kit/node/fs' @@ -10,10 +11,13 @@ export async function copyByPattern( outputDir: string patterns: string[] ignore: string[] + appDirectory: string + sourceDirConfigValue: string }, options: {stdout: NodeJS.WritableStream}, ): Promise<{filesCopied: number; outputPaths: string[]}> { - const {sourceDir, outputDir, patterns, ignore} = config + const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config + assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue) const files = await glob(patterns, { absolute: true, cwd: sourceDir, 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 70a12187d21..66134f8837e 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 @@ -28,7 +28,13 @@ describe('copyConfigKeyEntry', () => { await mkdir(outDir) const context = makeContext({static_root: 'public'}, mockStdout) - const result = await copyConfigKeyEntry({key: 'static_root', baseDir: tmpDir, outputDir: outDir, context}) + const result = await copyConfigKeyEntry({ + key: 'static_root', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) expect(result.filesCopied).toBe(2) await expect(fileExists(joinPath(outDir, 'index.html'))).resolves.toBe(true) @@ -48,7 +54,13 @@ describe('copyConfigKeyEntry', () => { await mkdir(outDir) const context = makeContext({schema_path: 'src/schema.json'}, mockStdout) - const result = await copyConfigKeyEntry({key: 'schema_path', baseDir: tmpDir, outputDir: outDir, context}) + const result = await copyConfigKeyEntry({ + key: 'schema_path', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) expect(result.filesCopied).toBe(1) await expect(fileExists(joinPath(outDir, 'schema.json'))).resolves.toBe(true) @@ -72,7 +84,13 @@ describe('copyConfigKeyEntry', () => { await mkdir(outDir) const context = makeContext({files: ['a/schema.json', 'b/schema.json']}, mockStdout) - const result = await copyConfigKeyEntry({key: 'files', baseDir: tmpDir, outputDir: outDir, context}) + const result = await copyConfigKeyEntry({ + key: 'files', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) expect(result.filesCopied).toBe(2) // Both have basename schema.json — second one gets renamed @@ -87,7 +105,13 @@ describe('copyConfigKeyEntry', () => { await mkdir(outDir) const context = makeContext({}, mockStdout) - const result = await copyConfigKeyEntry({key: 'static_root', baseDir: tmpDir, outputDir: outDir, context}) + const result = await copyConfigKeyEntry({ + key: 'static_root', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) expect(result.filesCopied).toBe(0) expect(result.pathMap.size).toBe(0) @@ -101,7 +125,7 @@ describe('copyConfigKeyEntry', () => { const context = makeContext({assets_dir: 'nonexistent'}, mockStdout) await expect( - copyConfigKeyEntry({key: 'assets_dir', baseDir: tmpDir, outputDir: outDir, context}), + copyConfigKeyEntry({key: 'assets_dir', baseDir: tmpDir, outputDir: outDir, context, appDirectory: tmpDir}), ).rejects.toThrow(`Couldn't find`) }) }) @@ -121,7 +145,13 @@ describe('copyConfigKeyEntry', () => { await mkdir(outDir) const context = makeContext({roots: ['public', 'assets']}, mockStdout) - const result = await copyConfigKeyEntry({key: 'roots', baseDir: tmpDir, outputDir: outDir, context}) + const result = await copyConfigKeyEntry({ + key: 'roots', + baseDir: tmpDir, + outputDir: outDir, + context, + appDirectory: tmpDir, + }) // Promise.all runs copies sequentially; glob on the shared outDir may see files // from the other copy, so the total count is at least 3 (one per real file). @@ -147,6 +177,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, destination: 'static/icons', }) @@ -174,6 +205,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, }) expect(result.filesCopied).toBe(3) @@ -200,6 +232,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, }) expect(result.filesCopied).toBe(0) @@ -227,6 +260,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context: contextA, + appDirectory: tmpDir, usedBasenames, preserveFilePaths: true, }) @@ -237,6 +271,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context: contextB, + appDirectory: tmpDir, usedBasenames, preserveFilePaths: true, }) @@ -263,6 +298,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, preserveFilePaths: true, }) await expect(promise).rejects.toThrow(AbortError) @@ -285,6 +321,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, preserveFilePaths: true, }) @@ -310,6 +347,7 @@ describe('copyConfigKeyEntry', () => { baseDir: tmpDir, outputDir: outDir, context, + appDirectory: tmpDir, }) expect(result.filesCopied).toBe(1) 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 edb9b9e6b38..49143be9560 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 @@ -1,3 +1,4 @@ +import {assertPathWithinAppDir} from './assert-path-within-app.js' import {joinPath, basename, relativePath, extname} from '@shopify/cli-kit/node/path' import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' @@ -24,6 +25,7 @@ export async function copyConfigKeyEntry(config: { baseDir: string outputDir: string context: BuildContext + appDirectory: string destination?: string usedBasenames?: Set preserveFilePaths?: boolean @@ -33,6 +35,7 @@ export async function copyConfigKeyEntry(config: { baseDir, outputDir, context, + appDirectory, destination, usedBasenames = new Set(), preserveFilePaths = false, @@ -66,6 +69,7 @@ export async function copyConfigKeyEntry(config: { /* eslint-disable no-await-in-loop */ for (const sourcePath of uniquePaths) { const fullPath = joinPath(baseDir, sourcePath) + assertPathWithinAppDir(fullPath, appDirectory, sourcePath) const exists = await fileExists(fullPath) if (!exists) { throw new Error( diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts index 1a98fdb70a8..5c1436fc2d2 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts @@ -23,6 +23,7 @@ describe('copySourceEntry', () => { destination: undefined, baseDir: '/ext', outputDir: '/out', + appDirectory: '/ext', }, {stdout: mockStdout}, ), @@ -43,6 +44,7 @@ describe('copySourceEntry', () => { destination: 'assets/icon.png', baseDir: '/ext', outputDir: '/out', + appDirectory: '/ext', }, {stdout: mockStdout}, ) @@ -63,7 +65,7 @@ describe('copySourceEntry', () => { // When const result = await copySourceEntry( - {source: 'dist', destination: undefined, baseDir: '/ext', outputDir: '/out'}, + {source: 'dist', destination: undefined, baseDir: '/ext', outputDir: '/out', appDirectory: '/ext'}, {stdout: mockStdout}, ) @@ -83,7 +85,7 @@ describe('copySourceEntry', () => { // When const result = await copySourceEntry( - {source: 'README.md', destination: undefined, baseDir: '/ext', outputDir: '/out'}, + {source: 'README.md', destination: undefined, baseDir: '/ext', outputDir: '/out', appDirectory: '/ext'}, {stdout: mockStdout}, ) @@ -103,7 +105,7 @@ describe('copySourceEntry', () => { // When const result = await copySourceEntry( - {source: 'dist', destination: 'vendor/dist', baseDir: '/ext', outputDir: '/out'}, + {source: 'dist', destination: 'vendor/dist', baseDir: '/ext', outputDir: '/out', appDirectory: '/ext'}, {stdout: mockStdout}, ) @@ -124,7 +126,7 @@ describe('copySourceEntry', () => { // When const result = await copySourceEntry( - {source: 'theme', destination: undefined, baseDir: '/ext', outputDir: '/out'}, + {source: 'theme', destination: undefined, baseDir: '/ext', outputDir: '/out', appDirectory: '/ext'}, {stdout: mockStdout}, ) @@ -147,6 +149,7 @@ describe('copySourceEntry', () => { destination: 'assets/icons/icon.png', baseDir: '/ext', outputDir: '/out', + appDirectory: '/ext', }, {stdout: mockStdout}, ) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts index f573338b74b..f24efe0ba03 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts @@ -1,3 +1,4 @@ +import {assertPathWithinAppDir} from './assert-path-within-app.js' import {joinPath, dirname, basename, relativePath} from '@shopify/cli-kit/node/path' import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs' @@ -13,11 +14,13 @@ export async function copySourceEntry( destination: string | undefined baseDir: string outputDir: string + appDirectory: string }, options: {stdout: NodeJS.WritableStream}, ): Promise<{filesCopied: number; outputPaths: string[]}> { - const {source, destination, baseDir, outputDir} = config + const {source, destination, baseDir, outputDir, appDirectory} = config const sourcePath = joinPath(baseDir, source) + assertPathWithinAppDir(sourcePath, appDirectory, source) if (!(await fileExists(sourcePath))) { throw new Error(`Source does not exist: ${sourcePath}`) } diff --git a/packages/app/src/cli/services/bundle.test.ts b/packages/app/src/cli/services/bundle.test.ts index 0adf8cbe87a..cb86fbad9b4 100644 --- a/packages/app/src/cli/services/bundle.test.ts +++ b/packages/app/src/cli/services/bundle.test.ts @@ -1,9 +1,11 @@ -import {writeManifestToBundle, compressBundle, BUNDLE_EXCLUSION_PATTERNS} from './bundle.js' +import {writeManifestToBundle, compressBundle, uploadToGCS, BUNDLE_EXCLUSION_PATTERNS} from './bundle.js' import {AppInterface} from '../models/app/app.js' import {describe, test, expect, vi} from 'vitest' import {joinPath} from '@shopify/cli-kit/node/path' import {inTemporaryDirectory, mkdir, writeFile, readFile} from '@shopify/cli-kit/node/fs' import {brotliCompress, zip} from '@shopify/cli-kit/node/archiver' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' vi.mock('@shopify/cli-kit/node/archiver', () => { return { @@ -12,6 +14,11 @@ vi.mock('@shopify/cli-kit/node/archiver', () => { } }) +vi.mock('@shopify/cli-kit/node/http', async (importActual) => { + const actual: any = await importActual() + return {...actual, fetch: vi.fn()} +}) + describe('writeManifestToBundle', () => { test('writes manifest.json to the specified directory', async () => { await inTemporaryDirectory(async (tmpDir) => { @@ -192,3 +199,42 @@ describe('compressBundle', () => { ) }) }) + +describe('uploadToGCS', () => { + test('uploads the bundle when it is under the size limit', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const bundlePath = joinPath(tmpDir, 'bundle.zip') + await writeFile(bundlePath, 'small contents') + vi.mocked(fetch).mockResolvedValue({} as never) + + // When + await uploadToGCS('https://signed.example/upload', bundlePath) + + // Then + expect(fetch).toHaveBeenCalledWith( + 'https://signed.example/upload', + expect.objectContaining({method: 'put'}), + 'slow-request', + ) + }) + }) + + test('aborts with a helpful error when the bundle exceeds the 100 MB upload limit', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given — write a sparse-ish file larger than 100 MB without allocating it fully + const bundlePath = joinPath(tmpDir, 'huge.zip') + const oneMb = Buffer.alloc(1024 * 1024, 'x') + // 101 MB to exceed the cap + await writeFile(bundlePath, Buffer.concat(Array.from({length: 101}, () => oneMb))) + vi.mocked(fetch).mockResolvedValue({} as never) + + // When / Then + await expect(uploadToGCS('https://signed.example/upload', bundlePath)).rejects.toThrow(AbortError) + await expect(uploadToGCS('https://signed.example/upload', bundlePath)).rejects.toThrow( + /exceeds the 100 MB upload limit/, + ) + expect(fetch).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/services/bundle.ts b/packages/app/src/cli/services/bundle.ts index f849da2a77d..aff2ebe159a 100644 --- a/packages/app/src/cli/services/bundle.ts +++ b/packages/app/src/cli/services/bundle.ts @@ -5,10 +5,14 @@ import {MinimalAppIdentifiers} from '../models/organization.js' import {joinPath} from '@shopify/cli-kit/node/path' import {brotliCompress, zip} from '@shopify/cli-kit/node/archiver' import {formData, fetch} from '@shopify/cli-kit/node/http' -import {readFileSync} from '@shopify/cli-kit/node/fs' +import {fileSize, readFileSync} from '@shopify/cli-kit/node/fs' import {AbortError} from '@shopify/cli-kit/node/error' import {writeFile} from 'fs/promises' +const MEGABYTE = 1024 * 1024 +const MAX_BUNDLE_SIZE_MB = 100 +const MAX_BUNDLE_SIZE_BYTES = MAX_BUNDLE_SIZE_MB * MEGABYTE + export async function writeManifestToBundle(appManifest: AppManifest, bundlePath: string) { const manifestPath = joinPath(bundlePath, 'manifest.json') await writeFile(manifestPath, JSON.stringify(appManifest, null, 2)) @@ -31,6 +35,14 @@ export async function compressBundle(inputDirectory: string, outputPath: string, * @param filePath - The path to the file */ export async function uploadToGCS(signedURL: string, filePath: string) { + const size = await fileSize(filePath) + if (size > MAX_BUNDLE_SIZE_BYTES) { + const humanSize = `${(size / MEGABYTE).toFixed(2)} MB` + throw new AbortError( + `Your app bundle exceeds the ${MAX_BUNDLE_SIZE_MB} MB upload limit (it is ${humanSize}).`, + `Check the asset paths in your extension configuration — a misconfigured source can pull in much more than intended. Exclude large files or directories from your bundle, then try again.`, + ) + } const form = formData() const buffer = readFileSync(filePath) form.append('my_upload', buffer) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 2e689898ed0..206d63ed971 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -405,6 +405,7 @@ describe('getExtensionAssetMiddleware()', () => { baseDir: extDir, outputDir, context: {extension, options: {stdout: {write: vi.fn()}}} as any, + appDirectory: tmpDir, }) const flattened = buildResult.pathMap.get('../tools.json') as string From 5a9a7402d90ecd2fb317bef1797a1f49433707a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 14 May 2026 11:17:40 +0200 Subject: [PATCH 2/4] Resolve symlinks in asset path guard so symlinked-out dirs are caught --- .../assert-path-within-app.test.ts | 90 +++++++++++++++---- .../include-assets/assert-path-within-app.ts | 26 ++++-- .../steps/include-assets/copy-by-pattern.ts | 4 +- .../include-assets/copy-config-key-entry.ts | 2 +- .../steps/include-assets/copy-source-entry.ts | 2 +- 5 files changed, 98 insertions(+), 26 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts index 3c90b3f9954..4a20a7bd92c 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts @@ -1,30 +1,90 @@ import {assertPathWithinAppDir} from './assert-path-within-app.js' import {AbortError} from '@shopify/cli-kit/node/error' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {describe, expect, test} from 'vitest' +import {symlink} from 'fs/promises' describe('assertPathWithinAppDir', () => { - test('allows a path inside the app directory', () => { - expect(() => - assertPathWithinAppDir('/app/extensions/ext-a/icon.png', '/app', 'extensions/ext-a/icon.png'), - ).not.toThrow() + test('allows a path inside the app directory', async () => { + await inTemporaryDirectory(async (appDir) => { + const inside = joinPath(appDir, 'extensions', 'ext-a') + await mkdir(inside) + await writeFile(joinPath(inside, 'icon.png'), 'x') + await expect( + assertPathWithinAppDir(joinPath(inside, 'icon.png'), appDir, 'extensions/ext-a/icon.png'), + ).resolves.toBeUndefined() + }) }) - test('allows the app directory itself', () => { - expect(() => assertPathWithinAppDir('/app', '/app', '.')).not.toThrow() + test('allows the app directory itself', async () => { + await inTemporaryDirectory(async (appDir) => { + await expect(assertPathWithinAppDir(appDir, appDir, '.')).resolves.toBeUndefined() + }) }) - test('rejects a relative path that escapes the app directory via ..', () => { - expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow(AbortError) - expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow( - /resolves outside the app directory/, - ) + test('rejects a path that resolves outside the app directory via ..', async () => { + await inTemporaryDirectory(async (parent) => { + const appDir = joinPath(parent, 'app') + await mkdir(appDir) + const outside = joinPath(parent, 'outside.json') + await writeFile(outside, '{}') + await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow(AbortError) + await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow( + /resolves outside the app directory/, + ) + }) }) - test('rejects an absolute path that points outside the app directory', () => { - expect(() => assertPathWithinAppDir('/Users/me', '/app', '/Users/me')).toThrow(AbortError) + test('rejects a symlink whose target is outside the app directory', async () => { + await inTemporaryDirectory(async (parent) => { + const appDir = joinPath(parent, 'app') + await mkdir(appDir) + const outsideDir = joinPath(parent, 'home', 'big-folder') + await mkdir(outsideDir) + await writeFile(joinPath(outsideDir, 'huge.bin'), 'x') + + // Inside the app dir, but the symlink points outside. + const symlinkInApp = joinPath(appDir, 'assets') + await symlink(outsideDir, symlinkInApp) + + await expect(assertPathWithinAppDir(symlinkInApp, appDir, 'assets')).rejects.toThrow(AbortError) + }) + }) + + test('allows an in-tree symlink (e.g. pnpm-style links staying inside the app)', async () => { + await inTemporaryDirectory(async (appDir) => { + const realTarget = joinPath(appDir, 'shared') + await mkdir(realTarget) + const linkPath = joinPath(appDir, 'extensions', 'ext-a-assets') + await mkdir(joinPath(appDir, 'extensions')) + await symlink(realTarget, linkPath) + + await expect(assertPathWithinAppDir(linkPath, appDir, 'extensions/ext-a-assets')).resolves.toBeUndefined() + }) + }) + + test('does not false-positive on macOS-style symlinked temp dirs (both sides realpath’d)', async () => { + // inTemporaryDirectory on macOS returns a /var/folders/... path whose + // realpath is /private/var/folders/.... If only the source were realpath’d + // the check would treat the temp dir as outside itself. + await inTemporaryDirectory(async (appDir) => { + const inside = joinPath(appDir, 'src') + await mkdir(inside) + await writeFile(joinPath(inside, 'schema.json'), '{}') + await expect( + assertPathWithinAppDir(joinPath(inside, 'schema.json'), appDir, 'src/schema.json'), + ).resolves.toBeUndefined() + }) }) - test('includes the original config value in the error message for debuggability', () => { - expect(() => assertPathWithinAppDir('/other', '/app', '~/anywhere')).toThrow(/Asset path '~\/anywhere'/) + test('includes the original config value in the error for debuggability', async () => { + await inTemporaryDirectory(async (parent) => { + const appDir = joinPath(parent, 'app') + await mkdir(appDir) + const outside = joinPath(parent, 'leak') + await writeFile(outside, '') + await expect(assertPathWithinAppDir(outside, appDir, '~/anywhere')).rejects.toThrow(/Asset path '~\/anywhere'/) + }) }) }) diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts index 732011a309b..5afcf3419b5 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts @@ -1,23 +1,33 @@ import {relativePath, isAbsolutePath} from '@shopify/cli-kit/node/path' +import {fileRealPath} from '@shopify/cli-kit/node/fs' import {AbortError} from '@shopify/cli-kit/node/error' /** - * Throws if `resolvedPath` is not inside `appDirectory`. + * Throws if `resolvedPath` is not inside `appDirectory` after symlink resolution. * * Guards against accidental misuse where an extension config points an asset - * source outside the app folder (e.g. `source = "../../"` or an absolute home - * directory path). Adversarial bypass via symlinks is out of scope — server-side - * size enforcement is the real boundary; this check is a fast-fail for DX. + * source outside the app folder — either via `..`/absolute paths or by + * symlinking an in-app directory to somewhere else (e.g. a home directory). + * + * Both sides are realpath'd before comparison so macOS temp paths + * (`/var/folders` → `/private/var/folders`) and pnpm-style in-tree symlinks + * don't trip a false positive. * * `configValue` is the raw value from configuration used in the error message - * so the user can locate the offending entry. + * so the user can locate the offending entry. Caller must ensure `resolvedPath` + * exists on disk — `fileRealPath` throws on missing paths. */ -export function assertPathWithinAppDir(resolvedPath: string, appDirectory: string, configValue: string): void { - const relative = relativePath(appDirectory, resolvedPath) +export async function assertPathWithinAppDir( + resolvedPath: string, + appDirectory: string, + configValue: string, +): Promise { + const [realSource, realAppDir] = await Promise.all([fileRealPath(resolvedPath), fileRealPath(appDirectory)]) + const relative = relativePath(realAppDir, realSource) if (relative.startsWith('..') || isAbsolutePath(relative)) { throw new AbortError( `Asset path '${configValue}' resolves outside the app directory.`, - `Asset sources must be inside the app folder. Resolved to: ${resolvedPath}`, + `Asset sources must be inside the app folder. Resolved to: ${realSource}`, ) } } diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts index 7dfa4ea65e4..4b15be38ad9 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts @@ -17,7 +17,6 @@ export async function copyByPattern( options: {stdout: NodeJS.WritableStream}, ): Promise<{filesCopied: number; outputPaths: string[]}> { const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config - assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue) const files = await glob(patterns, { absolute: true, cwd: sourceDir, @@ -27,6 +26,9 @@ export async function copyByPattern( if (files.length === 0) { return {filesCopied: 0, outputPaths: []} } + // Realpath on sourceDir would throw on a missing dir; we only get here when + // glob found files, so sourceDir exists. + await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue) await mkdir(outputDir) 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 49143be9560..c46e20a8e2c 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 @@ -69,7 +69,6 @@ export async function copyConfigKeyEntry(config: { /* eslint-disable no-await-in-loop */ for (const sourcePath of uniquePaths) { const fullPath = joinPath(baseDir, sourcePath) - assertPathWithinAppDir(fullPath, appDirectory, sourcePath) const exists = await fileExists(fullPath) if (!exists) { throw new Error( @@ -77,6 +76,7 @@ export async function copyConfigKeyEntry(config: { .value, ) } + await assertPathWithinAppDir(fullPath, appDirectory, sourcePath) const sourceIsDir = await isDirectory(fullPath) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts index f24efe0ba03..6aa75a9823b 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts @@ -20,10 +20,10 @@ export async function copySourceEntry( ): Promise<{filesCopied: number; outputPaths: string[]}> { const {source, destination, baseDir, outputDir, appDirectory} = config const sourcePath = joinPath(baseDir, source) - assertPathWithinAppDir(sourcePath, appDirectory, source) if (!(await fileExists(sourcePath))) { throw new Error(`Source does not exist: ${sourcePath}`) } + await assertPathWithinAppDir(sourcePath, appDirectory, source) const sourceIsDir = await isDirectory(sourcePath) From 6e2ab4c8ef6b652a2ec06acc54d464133b090e96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 14 May 2026 16:27:28 +0200 Subject: [PATCH 3/4] Address PR review feedback - Check `..` as a path segment (not prefix) in assertPathWithinAppDir so a sibling named `..cache` isn't false-rejected. - Round up the displayed size in the bundle limit error via Math.ceil so a size just over the cap can't display as the cap. - Mock fileSize in the over-limit test to avoid allocating ~101MB in CI memory. - Move the app-directory boundary check before glob in copy-by-pattern so an out-of-app sourceDir fails fast; preserve missing-dir behavior via fileExists short-circuit. --- .../include-assets/assert-path-within-app.test.ts | 11 +++++++++++ .../include-assets/assert-path-within-app.ts | 6 +++++- .../steps/include-assets/copy-by-pattern.test.ts | 1 + .../build/steps/include-assets/copy-by-pattern.ts | 14 ++++++++++---- packages/app/src/cli/services/bundle.test.ts | 15 ++++++++++----- packages/app/src/cli/services/bundle.ts | 3 ++- 6 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts index 4a20a7bd92c..c6f2634c5c8 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts @@ -78,6 +78,17 @@ describe('assertPathWithinAppDir', () => { }) }) + test('allows a sibling whose name starts with two dots (e.g. ..cache)', async () => { + await inTemporaryDirectory(async (appDir) => { + const dotdotDir = joinPath(appDir, '..cache') + await mkdir(dotdotDir) + await writeFile(joinPath(dotdotDir, 'file.txt'), 'x') + await expect( + assertPathWithinAppDir(joinPath(dotdotDir, 'file.txt'), appDir, '..cache/file.txt'), + ).resolves.toBeUndefined() + }) + }) + test('includes the original config value in the error for debuggability', async () => { await inTemporaryDirectory(async (parent) => { const appDir = joinPath(parent, 'app') diff --git a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts index 5afcf3419b5..f875e599ff3 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts @@ -24,7 +24,11 @@ export async function assertPathWithinAppDir( ): Promise { const [realSource, realAppDir] = await Promise.all([fileRealPath(resolvedPath), fileRealPath(appDirectory)]) const relative = relativePath(realAppDir, realSource) - if (relative.startsWith('..') || isAbsolutePath(relative)) { + // Check `..` as a path segment, not just a prefix. A sibling-style name like + // `..cache` is a valid in-app entry whose relative path begins with `..` but + // does not escape. + const firstSegment = relative.split(/[/\\]/, 1)[0] + if (firstSegment === '..' || isAbsolutePath(relative)) { throw new AbortError( `Asset path '${configValue}' resolves outside the app directory.`, `Asset sources must be inside the app folder. Resolved to: ${realSource}`, diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts index 9e4a47b1e0e..6a3e4b81df7 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts @@ -9,6 +9,7 @@ describe('copyByPattern', () => { beforeEach(() => { mockStdout = {write: vi.fn()} + vi.mocked(fs.fileExists).mockResolvedValue(true) }) test('copies matched files preserving relative paths', async () => { diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts index 4b15be38ad9..0493973a56f 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts @@ -1,6 +1,6 @@ import {assertPathWithinAppDir} from './assert-path-within-app.js' import {joinPath, dirname, relativePath} from '@shopify/cli-kit/node/path' -import {glob, copyFile, mkdir} from '@shopify/cli-kit/node/fs' +import {glob, copyFile, mkdir, fileExists} from '@shopify/cli-kit/node/fs' /** * Pattern strategy: glob-based file selection. @@ -17,6 +17,15 @@ export async function copyByPattern( options: {stdout: NodeJS.WritableStream}, ): Promise<{filesCopied: number; outputPaths: string[]}> { const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config + + // Validate the boundary up front, before touching the filesystem. Realpath + // would throw on a missing sourceDir, so preserve the existing "missing dir + // = no files" behavior by short-circuiting first. + if (!(await fileExists(sourceDir))) { + return {filesCopied: 0, outputPaths: []} + } + await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue) + const files = await glob(patterns, { absolute: true, cwd: sourceDir, @@ -26,9 +35,6 @@ export async function copyByPattern( if (files.length === 0) { return {filesCopied: 0, outputPaths: []} } - // Realpath on sourceDir would throw on a missing dir; we only get here when - // glob found files, so sourceDir exists. - await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue) await mkdir(outputDir) diff --git a/packages/app/src/cli/services/bundle.test.ts b/packages/app/src/cli/services/bundle.test.ts index cb86fbad9b4..35fc7679335 100644 --- a/packages/app/src/cli/services/bundle.test.ts +++ b/packages/app/src/cli/services/bundle.test.ts @@ -2,7 +2,7 @@ import {writeManifestToBundle, compressBundle, uploadToGCS, BUNDLE_EXCLUSION_PAT import {AppInterface} from '../models/app/app.js' import {describe, test, expect, vi} from 'vitest' import {joinPath} from '@shopify/cli-kit/node/path' -import {inTemporaryDirectory, mkdir, writeFile, readFile} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, mkdir, writeFile, readFile, fileSize} from '@shopify/cli-kit/node/fs' import {brotliCompress, zip} from '@shopify/cli-kit/node/archiver' import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' @@ -19,6 +19,11 @@ vi.mock('@shopify/cli-kit/node/http', async (importActual) => { return {...actual, fetch: vi.fn()} }) +vi.mock('@shopify/cli-kit/node/fs', async (importActual) => { + const actual: any = await importActual() + return {...actual, fileSize: vi.fn(actual.fileSize)} +}) + describe('writeManifestToBundle', () => { test('writes manifest.json to the specified directory', async () => { await inTemporaryDirectory(async (tmpDir) => { @@ -222,11 +227,11 @@ describe('uploadToGCS', () => { test('aborts with a helpful error when the bundle exceeds the 100 MB upload limit', async () => { await inTemporaryDirectory(async (tmpDir) => { - // Given — write a sparse-ish file larger than 100 MB without allocating it fully + // Given — mock the reported size so CI doesn't have to allocate 101 MB on disk. const bundlePath = joinPath(tmpDir, 'huge.zip') - const oneMb = Buffer.alloc(1024 * 1024, 'x') - // 101 MB to exceed the cap - await writeFile(bundlePath, Buffer.concat(Array.from({length: 101}, () => oneMb))) + await writeFile(bundlePath, 'placeholder') + const oneHundredOneMb = 101 * 1024 * 1024 + vi.mocked(fileSize).mockResolvedValueOnce(oneHundredOneMb).mockResolvedValueOnce(oneHundredOneMb) vi.mocked(fetch).mockResolvedValue({} as never) // When / Then diff --git a/packages/app/src/cli/services/bundle.ts b/packages/app/src/cli/services/bundle.ts index aff2ebe159a..bd853c31753 100644 --- a/packages/app/src/cli/services/bundle.ts +++ b/packages/app/src/cli/services/bundle.ts @@ -37,7 +37,8 @@ export async function compressBundle(inputDirectory: string, outputPath: string, export async function uploadToGCS(signedURL: string, filePath: string) { const size = await fileSize(filePath) if (size > MAX_BUNDLE_SIZE_BYTES) { - const humanSize = `${(size / MEGABYTE).toFixed(2)} MB` + // Round up so a size that barely exceeds the cap never displays as the cap. + const humanSize = `${(Math.ceil((size / MEGABYTE) * 100) / 100).toFixed(2)} MB` throw new AbortError( `Your app bundle exceeds the ${MAX_BUNDLE_SIZE_MB} MB upload limit (it is ${humanSize}).`, `Check the asset paths in your extension configuration — a misconfigured source can pull in much more than intended. Exclude large files or directories from your bundle, then try again.`, From 2acfce3fc8d41dc209ac4086828ff2eb14286e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 14 May 2026 17:15:54 +0200 Subject: [PATCH 4/4] Fix include-assets-step tests after copy-by-pattern pre-glob check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new fileExists short-circuit in copyByPattern broke the auto-mocked pattern tests at the step level (fileExists returns undefined → early return). Add a beforeEach default for the pattern-entries block, and switch the pattern-only manifest test from a flat false to an implementation that returns true for the sourceDir path. --- .../services/build/steps/include-assets-step.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index 765bb34955b..e493f046643 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -552,6 +552,11 @@ describe('executeIncludeAssetsStep', () => { }) describe('pattern entries', () => { + beforeEach(() => { + // copyByPattern now short-circuits if sourceDir doesn't exist; default true here. + vi.mocked(fs.fileExists).mockResolvedValue(true) + }) + test('copies files matching include patterns', async () => { // Given vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png', '/test/extension/public/style.css']) @@ -943,11 +948,13 @@ describe('executeIncludeAssetsStep', () => { }) test('writes manifest.json with files array when generatesAssetsManifest is true and only pattern inclusions exist', async () => { - // Given — pattern entries contribute output paths to the manifest "files" array + // Given — pattern entries contribute output paths to the manifest "files" array. + // sourceDir must exist for copyByPattern's pre-glob fileExists check to pass; + // everything else can read false (the parent beforeEach default). vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png']) vi.mocked(fs.copyFile).mockResolvedValue() vi.mocked(fs.mkdir).mockResolvedValue() - vi.mocked(fs.fileExists).mockResolvedValue(false) + vi.mocked(fs.fileExists).mockImplementation(async (path) => String(path) === '/test/extension/public') vi.mocked(fs.writeFile).mockResolvedValue() const step: LifecycleStep = {