Skip to content

Commit 6e2ab4c

Browse files
committed
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.
1 parent 5a9a740 commit 6e2ab4c

6 files changed

Lines changed: 39 additions & 11 deletions

File tree

packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ describe('assertPathWithinAppDir', () => {
7878
})
7979
})
8080

81+
test('allows a sibling whose name starts with two dots (e.g. ..cache)', async () => {
82+
await inTemporaryDirectory(async (appDir) => {
83+
const dotdotDir = joinPath(appDir, '..cache')
84+
await mkdir(dotdotDir)
85+
await writeFile(joinPath(dotdotDir, 'file.txt'), 'x')
86+
await expect(
87+
assertPathWithinAppDir(joinPath(dotdotDir, 'file.txt'), appDir, '..cache/file.txt'),
88+
).resolves.toBeUndefined()
89+
})
90+
})
91+
8192
test('includes the original config value in the error for debuggability', async () => {
8293
await inTemporaryDirectory(async (parent) => {
8394
const appDir = joinPath(parent, 'app')

packages/app/src/cli/services/build/steps/include-assets/assert-path-within-app.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ export async function assertPathWithinAppDir(
2424
): Promise<void> {
2525
const [realSource, realAppDir] = await Promise.all([fileRealPath(resolvedPath), fileRealPath(appDirectory)])
2626
const relative = relativePath(realAppDir, realSource)
27-
if (relative.startsWith('..') || isAbsolutePath(relative)) {
27+
// Check `..` as a path segment, not just a prefix. A sibling-style name like
28+
// `..cache` is a valid in-app entry whose relative path begins with `..` but
29+
// does not escape.
30+
const firstSegment = relative.split(/[/\\]/, 1)[0]
31+
if (firstSegment === '..' || isAbsolutePath(relative)) {
2832
throw new AbortError(
2933
`Asset path '${configValue}' resolves outside the app directory.`,
3034
`Asset sources must be inside the app folder. Resolved to: ${realSource}`,

packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ describe('copyByPattern', () => {
99

1010
beforeEach(() => {
1111
mockStdout = {write: vi.fn()}
12+
vi.mocked(fs.fileExists).mockResolvedValue(true)
1213
})
1314

1415
test('copies matched files preserving relative paths', async () => {

packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {assertPathWithinAppDir} from './assert-path-within-app.js'
22
import {joinPath, dirname, relativePath} from '@shopify/cli-kit/node/path'
3-
import {glob, copyFile, mkdir} from '@shopify/cli-kit/node/fs'
3+
import {glob, copyFile, mkdir, fileExists} from '@shopify/cli-kit/node/fs'
44

55
/**
66
* Pattern strategy: glob-based file selection.
@@ -17,6 +17,15 @@ export async function copyByPattern(
1717
options: {stdout: NodeJS.WritableStream},
1818
): Promise<{filesCopied: number; outputPaths: string[]}> {
1919
const {sourceDir, outputDir, patterns, ignore, appDirectory, sourceDirConfigValue} = config
20+
21+
// Validate the boundary up front, before touching the filesystem. Realpath
22+
// would throw on a missing sourceDir, so preserve the existing "missing dir
23+
// = no files" behavior by short-circuiting first.
24+
if (!(await fileExists(sourceDir))) {
25+
return {filesCopied: 0, outputPaths: []}
26+
}
27+
await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue)
28+
2029
const files = await glob(patterns, {
2130
absolute: true,
2231
cwd: sourceDir,
@@ -26,9 +35,6 @@ export async function copyByPattern(
2635
if (files.length === 0) {
2736
return {filesCopied: 0, outputPaths: []}
2837
}
29-
// Realpath on sourceDir would throw on a missing dir; we only get here when
30-
// glob found files, so sourceDir exists.
31-
await assertPathWithinAppDir(sourceDir, appDirectory, sourceDirConfigValue)
3238

3339
await mkdir(outputDir)
3440

packages/app/src/cli/services/bundle.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {writeManifestToBundle, compressBundle, uploadToGCS, BUNDLE_EXCLUSION_PAT
22
import {AppInterface} from '../models/app/app.js'
33
import {describe, test, expect, vi} from 'vitest'
44
import {joinPath} from '@shopify/cli-kit/node/path'
5-
import {inTemporaryDirectory, mkdir, writeFile, readFile} from '@shopify/cli-kit/node/fs'
5+
import {inTemporaryDirectory, mkdir, writeFile, readFile, fileSize} from '@shopify/cli-kit/node/fs'
66
import {brotliCompress, zip} from '@shopify/cli-kit/node/archiver'
77
import {AbortError} from '@shopify/cli-kit/node/error'
88
import {fetch} from '@shopify/cli-kit/node/http'
@@ -19,6 +19,11 @@ vi.mock('@shopify/cli-kit/node/http', async (importActual) => {
1919
return {...actual, fetch: vi.fn()}
2020
})
2121

22+
vi.mock('@shopify/cli-kit/node/fs', async (importActual) => {
23+
const actual: any = await importActual()
24+
return {...actual, fileSize: vi.fn(actual.fileSize)}
25+
})
26+
2227
describe('writeManifestToBundle', () => {
2328
test('writes manifest.json to the specified directory', async () => {
2429
await inTemporaryDirectory(async (tmpDir) => {
@@ -222,11 +227,11 @@ describe('uploadToGCS', () => {
222227

223228
test('aborts with a helpful error when the bundle exceeds the 100 MB upload limit', async () => {
224229
await inTemporaryDirectory(async (tmpDir) => {
225-
// Given — write a sparse-ish file larger than 100 MB without allocating it fully
230+
// Given — mock the reported size so CI doesn't have to allocate 101 MB on disk.
226231
const bundlePath = joinPath(tmpDir, 'huge.zip')
227-
const oneMb = Buffer.alloc(1024 * 1024, 'x')
228-
// 101 MB to exceed the cap
229-
await writeFile(bundlePath, Buffer.concat(Array.from({length: 101}, () => oneMb)))
232+
await writeFile(bundlePath, 'placeholder')
233+
const oneHundredOneMb = 101 * 1024 * 1024
234+
vi.mocked(fileSize).mockResolvedValueOnce(oneHundredOneMb).mockResolvedValueOnce(oneHundredOneMb)
230235
vi.mocked(fetch).mockResolvedValue({} as never)
231236

232237
// When / Then

packages/app/src/cli/services/bundle.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export async function compressBundle(inputDirectory: string, outputPath: string,
3737
export async function uploadToGCS(signedURL: string, filePath: string) {
3838
const size = await fileSize(filePath)
3939
if (size > MAX_BUNDLE_SIZE_BYTES) {
40-
const humanSize = `${(size / MEGABYTE).toFixed(2)} MB`
40+
// Round up so a size that barely exceeds the cap never displays as the cap.
41+
const humanSize = `${(Math.ceil((size / MEGABYTE) * 100) / 100).toFixed(2)} MB`
4142
throw new AbortError(
4243
`Your app bundle exceeds the ${MAX_BUNDLE_SIZE_MB} MB upload limit (it is ${humanSize}).`,
4344
`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.`,

0 commit comments

Comments
 (0)