From 4886930b4f7002caef69d252406a30e13d14f0c9 Mon Sep 17 00:00:00 2001 From: KIRAN Date: Fri, 27 Mar 2026 17:28:48 +0530 Subject: [PATCH 1/5] fix(@angular/build): prevent deleting parent directories of project root Previously, the output path validation only checked for an exact match against the project root. This allowed ancestor directories (e.g. ../ or ../../) to be used as the output path, which would silently delete all their contents including source files. Use path.relative() to detect when the output path is the project root or any ancestor of it, and throw a descriptive error in both cases. Fixes #6485 --- .../build/src/utils/delete-output-dir.ts | 9 ++- .../build/src/utils/delete-output-dir_spec.ts | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 packages/angular/build/src/utils/delete-output-dir_spec.ts diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts index 45084760793d..b84c6209c754 100644 --- a/packages/angular/build/src/utils/delete-output-dir.ts +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -7,7 +7,7 @@ */ import { readdir, rm } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { join, relative, resolve } from 'node:path'; /** * Delete an output directory, but error out if it's the root of the project. @@ -18,8 +18,11 @@ export async function deleteOutputDir( emptyOnlyDirectories?: string[], ): Promise { const resolvedOutputPath = resolve(root, outputPath); - if (resolvedOutputPath === root) { - throw new Error('Output path MUST not be project root directory!'); + const relativePath = relative(resolvedOutputPath, root); + if (!relativePath || !relativePath.startsWith('..')) { + throw new Error( + `Output path "${resolvedOutputPath}" MUST not be the project root directory or a parent of it.`, + ); } const directoriesToEmpty = emptyOnlyDirectories diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts new file mode 100644 index 000000000000..e5c8ed3dad64 --- /dev/null +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { deleteOutputDir } from './delete-output-dir'; + +describe('deleteOutputDir', () => { + let root: string; + + beforeEach(async () => { + // Use a unique temp directory for each test + const { mkdtemp } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + root = await mkdtemp(join(tmpdir(), 'ng-test-')); + }); + + it('should throw when output path is the project root', async () => { + await expectAsync(deleteOutputDir(root, '.')).toBeRejectedWithError( + /MUST not be the project root directory or a parent of it/, + ); + }); + + it('should throw when output path is a parent of the project root', async () => { + await expectAsync(deleteOutputDir(root, '..')).toBeRejectedWithError( + /MUST not be the project root directory or a parent of it/, + ); + }); + + it('should throw when output path is a grandparent of the project root', async () => { + await expectAsync(deleteOutputDir(root, '../..')).toBeRejectedWithError( + /MUST not be the project root directory or a parent of it/, + ); + }); + + it('should not throw when output path is a child of the project root', async () => { + const outputDir = join(root, 'dist'); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, 'old-file.txt'), 'content'); + + await expectAsync(deleteOutputDir(root, 'dist')).toBeResolved(); + }); + + it('should delete contents of a valid output directory', async () => { + const outputDir = join(root, 'dist'); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, 'old-file.txt'), 'content'); + + await deleteOutputDir(root, 'dist'); + + const { readdir } = await import('node:fs/promises'); + const entries = await readdir(outputDir); + expect(entries.length).toBe(0); + }); + + it('should not throw when output directory does not exist', async () => { + await expectAsync(deleteOutputDir(root, 'nonexistent')).toBeResolved(); + }); +}); From 6c745ccb202a5d06c038ddc6a3f1317bc4ed1230 Mon Sep 17 00:00:00 2001 From: KIRAN Date: Fri, 27 Mar 2026 17:51:11 +0530 Subject: [PATCH 2/5] fixup! fix(@angular/build): prevent deleting parent directories of project root --- packages/angular/build/src/utils/delete-output-dir_spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts index e5c8ed3dad64..e1fc7b51eec4 100644 --- a/packages/angular/build/src/utils/delete-output-dir_spec.ts +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.dev/license */ -import { mkdir, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { deleteOutputDir } from './delete-output-dir'; @@ -14,9 +15,6 @@ describe('deleteOutputDir', () => { let root: string; beforeEach(async () => { - // Use a unique temp directory for each test - const { mkdtemp } = await import('node:fs/promises'); - const { tmpdir } = await import('node:os'); root = await mkdtemp(join(tmpdir(), 'ng-test-')); }); @@ -53,7 +51,6 @@ describe('deleteOutputDir', () => { await deleteOutputDir(root, 'dist'); - const { readdir } = await import('node:fs/promises'); const entries = await readdir(outputDir); expect(entries.length).toBe(0); }); From a2b1fabab26449111761b0cbfe684b27d749178a Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:42:27 +0000 Subject: [PATCH 3/5] fixup! fix(@angular/build): prevent deleting parent directories of project root --- packages/angular/build/src/utils/delete-output-dir.ts | 2 +- packages/angular/build/src/utils/delete-output-dir_spec.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts index b84c6209c754..fd690a2615f1 100644 --- a/packages/angular/build/src/utils/delete-output-dir.ts +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -21,7 +21,7 @@ export async function deleteOutputDir( const relativePath = relative(resolvedOutputPath, root); if (!relativePath || !relativePath.startsWith('..')) { throw new Error( - `Output path "${resolvedOutputPath}" MUST not be the project root directory or a parent of it.`, + `Output path "${resolvedOutputPath}" MUST not be the project root directory or its parent.`, ); } diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts index e1fc7b51eec4..c9a672ef694e 100644 --- a/packages/angular/build/src/utils/delete-output-dir_spec.ts +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -20,19 +20,19 @@ describe('deleteOutputDir', () => { it('should throw when output path is the project root', async () => { await expectAsync(deleteOutputDir(root, '.')).toBeRejectedWithError( - /MUST not be the project root directory or a parent of it/, + /MUST not be the project root directory or its parent/, ); }); it('should throw when output path is a parent of the project root', async () => { await expectAsync(deleteOutputDir(root, '..')).toBeRejectedWithError( - /MUST not be the project root directory or a parent of it/, + /MUST not be the project root directory or its parent/, ); }); it('should throw when output path is a grandparent of the project root', async () => { await expectAsync(deleteOutputDir(root, '../..')).toBeRejectedWithError( - /MUST not be the project root directory or a parent of it/, + /MUST not be the project root directory or its parent/, ); }); From 9d3f5d4b34a40befdc461aa0e1e93e3383f44dcc Mon Sep 17 00:00:00 2001 From: KIRAN Date: Fri, 27 Mar 2026 20:01:39 +0530 Subject: [PATCH 4/5] fix(@angular/build): handle Windows absolute paths in output dir check --- packages/angular/build/src/utils/delete-output-dir.ts | 6 ++++-- packages/angular/build/src/utils/delete-output-dir_spec.ts | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts index fd690a2615f1..6dd4bb2fee41 100644 --- a/packages/angular/build/src/utils/delete-output-dir.ts +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -7,7 +7,7 @@ */ import { readdir, rm } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; +import { isAbsolute, join, relative, resolve } from 'node:path'; /** * Delete an output directory, but error out if it's the root of the project. @@ -19,7 +19,9 @@ export async function deleteOutputDir( ): Promise { const resolvedOutputPath = resolve(root, outputPath); const relativePath = relative(resolvedOutputPath, root); - if (!relativePath || !relativePath.startsWith('..')) { + // When relative() returns an absolute path, the paths are on different drives/roots + // (e.g. Windows drive letters or UNC paths), so it cannot be an ancestor. + if (!relativePath || (!isAbsolute(relativePath) && !relativePath.startsWith('..'))) { throw new Error( `Output path "${resolvedOutputPath}" MUST not be the project root directory or its parent.`, ); diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts index c9a672ef694e..9675fa1fafc9 100644 --- a/packages/angular/build/src/utils/delete-output-dir_spec.ts +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -58,4 +58,11 @@ describe('deleteOutputDir', () => { it('should not throw when output directory does not exist', async () => { await expectAsync(deleteOutputDir(root, 'nonexistent')).toBeResolved(); }); + + it('should not throw when output path is an absolute path outside the project', async () => { + const externalDir = await mkdtemp(join(tmpdir(), 'ng-test-external-')); + await writeFile(join(externalDir, 'old-file.txt'), 'content'); + + await expectAsync(deleteOutputDir(root, externalDir)).toBeResolved(); + }); }); From 3a6d97a73b283f888baaca31391887a995ee8679 Mon Sep 17 00:00:00 2001 From: KIRAN Date: Fri, 27 Mar 2026 21:50:33 +0530 Subject: [PATCH 5/5] fixup! fix(@angular/build): prevent deleting parent directories of project root --- .../angular/build/src/utils/delete-output-dir.ts | 13 +++++++------ .../build/src/utils/delete-output-dir_spec.ts | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts index 6dd4bb2fee41..863deb1c66e6 100644 --- a/packages/angular/build/src/utils/delete-output-dir.ts +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -7,7 +7,7 @@ */ import { readdir, rm } from 'node:fs/promises'; -import { isAbsolute, join, relative, resolve } from 'node:path'; +import { isAbsolute, join, resolve, sep } from 'node:path'; /** * Delete an output directory, but error out if it's the root of the project. @@ -18,12 +18,13 @@ export async function deleteOutputDir( emptyOnlyDirectories?: string[], ): Promise { const resolvedOutputPath = resolve(root, outputPath); - const relativePath = relative(resolvedOutputPath, root); - // When relative() returns an absolute path, the paths are on different drives/roots - // (e.g. Windows drive letters or UNC paths), so it cannot be an ancestor. - if (!relativePath || (!isAbsolute(relativePath) && !relativePath.startsWith('..'))) { + if (resolvedOutputPath === root) { + throw new Error('Output path MUST not be the workspace root directory.'); + } + + if (!isAbsolute(outputPath) && !resolvedOutputPath.startsWith(root + sep)) { throw new Error( - `Output path "${resolvedOutputPath}" MUST not be the project root directory or its parent.`, + `Output path "${resolvedOutputPath}" MUST not be a parent of the workspace root directory.`, ); } diff --git a/packages/angular/build/src/utils/delete-output-dir_spec.ts b/packages/angular/build/src/utils/delete-output-dir_spec.ts index 9675fa1fafc9..987105377e74 100644 --- a/packages/angular/build/src/utils/delete-output-dir_spec.ts +++ b/packages/angular/build/src/utils/delete-output-dir_spec.ts @@ -20,19 +20,19 @@ describe('deleteOutputDir', () => { it('should throw when output path is the project root', async () => { await expectAsync(deleteOutputDir(root, '.')).toBeRejectedWithError( - /MUST not be the project root directory or its parent/, + /MUST not be the workspace root directory/, ); }); it('should throw when output path is a parent of the project root', async () => { await expectAsync(deleteOutputDir(root, '..')).toBeRejectedWithError( - /MUST not be the project root directory or its parent/, + /MUST not be a parent of the workspace root directory/, ); }); it('should throw when output path is a grandparent of the project root', async () => { await expectAsync(deleteOutputDir(root, '../..')).toBeRejectedWithError( - /MUST not be the project root directory or its parent/, + /MUST not be a parent of the workspace root directory/, ); });