Skip to content

Commit 58c30c0

Browse files
authored
fix(@angular/build): use isSubDirectory in normalizeAssetPatterns and ignore nested .gitkeep
- Replace `.startsWith(...)` prefix checks in `normalizeAssetPatterns` with `isSubDirectory(...)` so sibling directories sharing a prefix with `sourceRoot`, `projectRoot`, or `workspaceRoot` (such as `src-assets` when `sourceRoot` is `src`) are resolved against the proper parent directory instead of producing a `..` output path error. - Update `DEFAULT_ASSET_IGNORE` in `resolve-assets.ts` from `'.gitkeep'` to `'**/.gitkeep'` so nested `.gitkeep` files are also ignored, matching `'**/.DS_Store'` and `'**/Thumbs.db'`. - Validate that resolved asset `destination` paths in `resolveAssets` do not escape the output root via `..`.
1 parent 9e998c1 commit 58c30c0

3 files changed

Lines changed: 184 additions & 8 deletions

File tree

‎packages/angular/build/src/utils/normalize-asset-patterns.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ export function normalizeAssetPatterns(
5555
let root: string;
5656

5757
// Check if the string asset is within sourceRoot.
58-
if (resolvedAssetPath.startsWith(resolvedSourceRoot)) {
58+
if (isSubDirectory(resolvedSourceRoot, resolvedAssetPath)) {
5959
root = resolvedSourceRoot;
60-
} else if (resolvedAssetPath.startsWith(resolvedProjectRoot)) {
60+
} else if (isSubDirectory(resolvedProjectRoot, resolvedAssetPath)) {
6161
root = resolvedProjectRoot;
62-
} else if (resolvedAssetPath.startsWith(resolvedWorkspaceRoot)) {
62+
} else if (isSubDirectory(resolvedWorkspaceRoot, resolvedAssetPath)) {
6363
root = resolvedWorkspaceRoot;
6464
} else {
6565
throw new Error(`The ${assetPattern} asset path must be within the workspace root.`);
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import assert from 'node:assert';
10+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
11+
import path, { join } from 'node:path';
12+
import { normalizeAssetPatterns } from './normalize-asset-patterns';
13+
import { resolveAssets } from './resolve-assets';
14+
15+
describe('normalizeAssetPatterns', () => {
16+
let tempRoot: string;
17+
18+
beforeEach(async () => {
19+
const baseTmpDir = process.env['TEST_TMPDIR'];
20+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
21+
tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-assets-test-'));
22+
});
23+
24+
afterEach(async () => {
25+
if (tempRoot) {
26+
await rm(tempRoot, { recursive: true, force: true });
27+
}
28+
});
29+
30+
it('should return an empty array when given no asset patterns', () => {
31+
expect(normalizeAssetPatterns([], tempRoot, '', 'src')).toEqual([]);
32+
});
33+
34+
it('should normalize a string asset within sourceRoot relative to sourceRoot', async () => {
35+
await mkdir(join(tempRoot, 'src/assets'), { recursive: true });
36+
await writeFile(join(tempRoot, 'src/assets/logo.png'), 'PNG');
37+
38+
const result = normalizeAssetPatterns(
39+
['src/assets', 'src/assets/logo.png'],
40+
tempRoot,
41+
'',
42+
'src',
43+
);
44+
45+
expect(result).toEqual([
46+
{ glob: '**/*', input: 'src/assets', output: 'assets' },
47+
{ glob: 'logo.png', input: 'src/assets', output: 'assets' },
48+
]);
49+
});
50+
51+
it('should normalize a string asset in a sibling directory sharing a prefix with sourceRoot (e.g. src-assets)', async () => {
52+
await mkdir(join(tempRoot, 'src-assets'), { recursive: true });
53+
await writeFile(join(tempRoot, 'src-assets/logo.png'), 'PNG');
54+
55+
const result = normalizeAssetPatterns(
56+
['src-assets', 'src-assets/logo.png'],
57+
tempRoot,
58+
'',
59+
'src',
60+
);
61+
62+
expect(result).toEqual([
63+
{ glob: '**/*', input: 'src-assets', output: 'src-assets' },
64+
{ glob: 'logo.png', input: 'src-assets', output: 'src-assets' },
65+
]);
66+
});
67+
68+
it('should normalize a library string asset in a sibling directory sharing a prefix with projectSourceRoot', async () => {
69+
await mkdir(join(tempRoot, 'projects/lib/src-assets'), { recursive: true });
70+
await writeFile(join(tempRoot, 'projects/lib/src-assets/logo.png'), 'PNG');
71+
72+
const result = normalizeAssetPatterns(
73+
['projects/lib/src-assets', 'projects/lib/src-assets/logo.png'],
74+
tempRoot,
75+
'projects/lib',
76+
'projects/lib/src',
77+
);
78+
79+
expect(result).toEqual([
80+
{
81+
glob: '**/*',
82+
input: path.normalize('projects/lib/src-assets'),
83+
output: 'src-assets',
84+
},
85+
{
86+
glob: 'logo.png',
87+
input: path.normalize('projects/lib/src-assets'),
88+
output: 'src-assets',
89+
},
90+
]);
91+
});
92+
93+
it('should normalize a string asset in a sibling directory sharing a prefix with projectRoot relative to workspaceRoot', async () => {
94+
await mkdir(join(tempRoot, 'projects/lib-shared'), { recursive: true });
95+
await writeFile(join(tempRoot, 'projects/lib-shared/logo.png'), 'PNG');
96+
97+
const result = normalizeAssetPatterns(
98+
['projects/lib-shared/logo.png'],
99+
tempRoot,
100+
'projects/lib',
101+
'projects/lib/src',
102+
);
103+
104+
expect(result).toEqual([
105+
{
106+
glob: 'logo.png',
107+
input: path.normalize('projects/lib-shared'),
108+
output: path.normalize('projects/lib-shared'),
109+
},
110+
]);
111+
});
112+
113+
it('should throw when a string asset is in a sibling directory sharing a prefix with workspaceRoot', () => {
114+
const siblingDir = `../${path.basename(tempRoot)}-outside/logo.png`;
115+
116+
expect(() => normalizeAssetPatterns([siblingDir], tempRoot, '', 'src')).toThrowError(
117+
/asset path must be within the workspace root/,
118+
);
119+
});
120+
});
121+
122+
describe('resolveAssets', () => {
123+
let tempRoot: string;
124+
125+
beforeEach(async () => {
126+
const baseTmpDir = process.env['TEST_TMPDIR'];
127+
assert(baseTmpDir, 'TEST_TMPDIR is not set');
128+
tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-resolve-assets-test-'));
129+
});
130+
131+
afterEach(async () => {
132+
if (tempRoot) {
133+
await rm(tempRoot, { recursive: true, force: true });
134+
}
135+
});
136+
137+
it('should ignore both root and nested .gitkeep files by default', async () => {
138+
await mkdir(join(tempRoot, 'src/assets/nested/deep'), { recursive: true });
139+
await writeFile(join(tempRoot, 'src/assets/.gitkeep'), '');
140+
await writeFile(join(tempRoot, 'src/assets/nested/.gitkeep'), '');
141+
await writeFile(join(tempRoot, 'src/assets/nested/deep/.gitkeep'), '');
142+
await writeFile(join(tempRoot, 'src/assets/nested/deep/logo.png'), 'PNG');
143+
144+
const resolved = await resolveAssets(
145+
[{ glob: '**/*', input: 'src/assets', output: 'assets' }],
146+
tempRoot,
147+
);
148+
149+
expect(resolved).toEqual([
150+
{
151+
source: join(tempRoot, 'src/assets/nested/deep/logo.png'),
152+
destination: join('assets', 'nested/deep/logo.png'),
153+
},
154+
]);
155+
});
156+
157+
it('should throw when a resolved destination escapes the output root via ".." in glob', async () => {
158+
await mkdir(join(tempRoot, 'src'), { recursive: true });
159+
await writeFile(join(tempRoot, 'outside.txt'), 'secret');
160+
161+
await expectAsync(
162+
resolveAssets([{ glob: '../outside.txt', input: 'src', output: '.' }], tempRoot),
163+
).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.');
164+
165+
await expectAsync(
166+
resolveAssets([{ glob: '../*.txt', input: 'src', output: '.' }], tempRoot),
167+
).toBeRejectedWithError('An asset cannot be written to a location outside of the output path.');
168+
});
169+
});

‎packages/angular/build/src/utils/resolve-assets.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { isSubDirectory } from './path';
1515
/**
1616
* Default glob ignore patterns for assets.
1717
*/
18-
export const DEFAULT_ASSET_IGNORE = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const;
18+
export const DEFAULT_ASSET_IGNORE = ['**/.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const;
1919

2020
export async function resolveAssets(
2121
entries: {
@@ -63,10 +63,17 @@ export async function resolveAssets(
6363
followSymbolicLinks: entry.followSymlinks ?? false,
6464
});
6565

66-
return files.map((file) => ({
67-
source: path.join(cwd, file),
68-
destination: path.join(entry.output, entry.flatten ? path.basename(file) : file),
69-
}));
66+
return files.map((file) => {
67+
const destination = path.join(entry.output, entry.flatten ? path.basename(file) : file);
68+
if (destination === '..' || destination.startsWith(`..${path.sep}`)) {
69+
throw new Error('An asset cannot be written to a location outside of the output path.');
70+
}
71+
72+
return {
73+
source: path.join(cwd, file),
74+
destination,
75+
};
76+
});
7077
});
7178

7279
return resolvedEntries.flat();

0 commit comments

Comments
 (0)