|
1 | 1 | import {assertPathWithinAppDir} from './assert-path-within-app.js' |
2 | 2 | import {AbortError} from '@shopify/cli-kit/node/error' |
| 3 | +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' |
| 4 | +import {joinPath} from '@shopify/cli-kit/node/path' |
3 | 5 | import {describe, expect, test} from 'vitest' |
| 6 | +import {symlink} from 'fs/promises' |
4 | 7 |
|
5 | 8 | describe('assertPathWithinAppDir', () => { |
6 | | - test('allows a path inside the app directory', () => { |
7 | | - expect(() => |
8 | | - assertPathWithinAppDir('/app/extensions/ext-a/icon.png', '/app', 'extensions/ext-a/icon.png'), |
9 | | - ).not.toThrow() |
| 9 | + test('allows a path inside the app directory', async () => { |
| 10 | + await inTemporaryDirectory(async (appDir) => { |
| 11 | + const inside = joinPath(appDir, 'extensions', 'ext-a') |
| 12 | + await mkdir(inside) |
| 13 | + await writeFile(joinPath(inside, 'icon.png'), 'x') |
| 14 | + await expect( |
| 15 | + assertPathWithinAppDir(joinPath(inside, 'icon.png'), appDir, 'extensions/ext-a/icon.png'), |
| 16 | + ).resolves.toBeUndefined() |
| 17 | + }) |
10 | 18 | }) |
11 | 19 |
|
12 | | - test('allows the app directory itself', () => { |
13 | | - expect(() => assertPathWithinAppDir('/app', '/app', '.')).not.toThrow() |
| 20 | + test('allows the app directory itself', async () => { |
| 21 | + await inTemporaryDirectory(async (appDir) => { |
| 22 | + await expect(assertPathWithinAppDir(appDir, appDir, '.')).resolves.toBeUndefined() |
| 23 | + }) |
14 | 24 | }) |
15 | 25 |
|
16 | | - test('rejects a relative path that escapes the app directory via ..', () => { |
17 | | - expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow(AbortError) |
18 | | - expect(() => assertPathWithinAppDir('/other/secret.env', '/app', '../other/secret.env')).toThrow( |
19 | | - /resolves outside the app directory/, |
20 | | - ) |
| 26 | + test('rejects a path that resolves outside the app directory via ..', async () => { |
| 27 | + await inTemporaryDirectory(async (parent) => { |
| 28 | + const appDir = joinPath(parent, 'app') |
| 29 | + await mkdir(appDir) |
| 30 | + const outside = joinPath(parent, 'outside.json') |
| 31 | + await writeFile(outside, '{}') |
| 32 | + await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow(AbortError) |
| 33 | + await expect(assertPathWithinAppDir(outside, appDir, '../outside.json')).rejects.toThrow( |
| 34 | + /resolves outside the app directory/, |
| 35 | + ) |
| 36 | + }) |
21 | 37 | }) |
22 | 38 |
|
23 | | - test('rejects an absolute path that points outside the app directory', () => { |
24 | | - expect(() => assertPathWithinAppDir('/Users/me', '/app', '/Users/me')).toThrow(AbortError) |
| 39 | + test('rejects a symlink whose target is outside the app directory', async () => { |
| 40 | + await inTemporaryDirectory(async (parent) => { |
| 41 | + const appDir = joinPath(parent, 'app') |
| 42 | + await mkdir(appDir) |
| 43 | + const outsideDir = joinPath(parent, 'home', 'big-folder') |
| 44 | + await mkdir(outsideDir) |
| 45 | + await writeFile(joinPath(outsideDir, 'huge.bin'), 'x') |
| 46 | + |
| 47 | + // Inside the app dir, but the symlink points outside. |
| 48 | + const symlinkInApp = joinPath(appDir, 'assets') |
| 49 | + await symlink(outsideDir, symlinkInApp) |
| 50 | + |
| 51 | + await expect(assertPathWithinAppDir(symlinkInApp, appDir, 'assets')).rejects.toThrow(AbortError) |
| 52 | + }) |
| 53 | + }) |
| 54 | + |
| 55 | + test('allows an in-tree symlink (e.g. pnpm-style links staying inside the app)', async () => { |
| 56 | + await inTemporaryDirectory(async (appDir) => { |
| 57 | + const realTarget = joinPath(appDir, 'shared') |
| 58 | + await mkdir(realTarget) |
| 59 | + const linkPath = joinPath(appDir, 'extensions', 'ext-a-assets') |
| 60 | + await mkdir(joinPath(appDir, 'extensions')) |
| 61 | + await symlink(realTarget, linkPath) |
| 62 | + |
| 63 | + await expect(assertPathWithinAppDir(linkPath, appDir, 'extensions/ext-a-assets')).resolves.toBeUndefined() |
| 64 | + }) |
| 65 | + }) |
| 66 | + |
| 67 | + test('does not false-positive on macOS-style symlinked temp dirs (both sides realpath’d)', async () => { |
| 68 | + // inTemporaryDirectory on macOS returns a /var/folders/... path whose |
| 69 | + // realpath is /private/var/folders/.... If only the source were realpath’d |
| 70 | + // the check would treat the temp dir as outside itself. |
| 71 | + await inTemporaryDirectory(async (appDir) => { |
| 72 | + const inside = joinPath(appDir, 'src') |
| 73 | + await mkdir(inside) |
| 74 | + await writeFile(joinPath(inside, 'schema.json'), '{}') |
| 75 | + await expect( |
| 76 | + assertPathWithinAppDir(joinPath(inside, 'schema.json'), appDir, 'src/schema.json'), |
| 77 | + ).resolves.toBeUndefined() |
| 78 | + }) |
25 | 79 | }) |
26 | 80 |
|
27 | | - test('includes the original config value in the error message for debuggability', () => { |
28 | | - expect(() => assertPathWithinAppDir('/other', '/app', '~/anywhere')).toThrow(/Asset path '~\/anywhere'/) |
| 81 | + test('includes the original config value in the error for debuggability', async () => { |
| 82 | + await inTemporaryDirectory(async (parent) => { |
| 83 | + const appDir = joinPath(parent, 'app') |
| 84 | + await mkdir(appDir) |
| 85 | + const outside = joinPath(parent, 'leak') |
| 86 | + await writeFile(outside, '') |
| 87 | + await expect(assertPathWithinAppDir(outside, appDir, '~/anywhere')).rejects.toThrow(/Asset path '~\/anywhere'/) |
| 88 | + }) |
29 | 89 | }) |
30 | 90 | }) |
0 commit comments