From d16ddc1fb90420eb2f1f4ddad6ed6ca7f7664ffc Mon Sep 17 00:00:00 2001 From: aws-amplify-bot Date: Thu, 6 Aug 2026 19:20:47 -0400 Subject: [PATCH 1/5] fix: bundling obeys symlinks --- .../aws-cdk-lib/core/lib/asset-staging.ts | 63 ++++++++-- .../aws-cdk-lib/core/lib/fs/fingerprint.ts | 10 +- packages/aws-cdk-lib/core/lib/fs/utils.ts | 6 + .../aws-cdk-lib/core/test/fs/utils.test.ts | 28 +++++ .../aws-cdk-lib/core/test/staging.test.ts | 119 +++++++++++++++++- 5 files changed, 210 insertions(+), 16 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index 37c8cee5ddf93..89d0a11c1be88 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -2,22 +2,24 @@ import * as crypto from 'crypto'; import * as path from 'path'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; +import { Annotations } from './annotations'; import type { AssetOptions } from './assets'; import { AssetHashType, FileAssetPackaging } from './assets'; import type { BundlingOptions } from './bundling'; import { BundlingFileAccess, BundlingOutput, PERF_BUNDLING_SRC_SYM } from './bundling'; import { AssumptionError, ValidationError } from './errors'; import type { FingerprintOptions } from './fs'; -import { FileSystem } from './fs'; +import { FileSystem, SymlinkFollowMode } from './fs'; import { clearLargeFileFingerprintCache } from './fs/fingerprint'; +import { isInternalPath, resolveLinkTarget } from './fs/utils'; import { Names } from './names'; import { AssetBundlingVolumeCopy, AssetBundlingBindMount } from './private/asset-staging'; import { Cache } from './private/cache'; import { stackOf, stageOf } from './private/core-construct-finders'; -import type { Stack } from './stack'; -import * as cxapi from '../../cx-api'; import { lit } from './private/literal-string'; import { profileSpan } from './private/perf'; +import type { Stack } from './stack'; +import * as cxapi from '../../cx-api'; const ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip', '.jar', '.tar', '.tgz']; @@ -355,7 +357,7 @@ export class AssetStaging extends Construct { // Check bundling output content and determine if we will need to archive const bundlingOutputType = bundling.outputType ?? BundlingOutput.AUTO_DISCOVER; - const bundledAsset = determineBundledAsset(this, bundleDir, bundlingOutputType); + const bundledAsset = determineBundledAsset(this, bundleDir, bundlingOutputType, this.fingerprintOptions.follow); // Calculate assetHash afterwards if we still must assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundledAsset.path); @@ -623,7 +625,7 @@ function sanitizeHashValue(key: string, value: any): any { /** * Returns the single archive file of a directory or undefined */ -function findSingleFile(scope: Construct, directory: string, archiveOnly: boolean): string | undefined { +function findSingleFile(scope: Construct, directory: string, archiveOnly: boolean, follow?: SymlinkFollowMode): string | undefined { if (!fs.existsSync(directory)) { throw new ValidationError(lit`DirectoryDoesNotExist`, `Directory ${directory} does not exist.`, scope); } @@ -636,6 +638,28 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea if (content.length === 1) { const file = path.join(directory, content[0]); const extension = getExtension(content[0]).toLowerCase(); + + // Use lstat so we can detect a symbolic link. Depending on the follow mode + // a symlink must not be followed. Since it is the only entry in the bundling + // output directory, dropping it would leave no usable output, so we warn and + // fail rather than silently zipping a directory that only holds a link we + // were told not to follow. + const stat = fs.lstatSync(file); + if (stat.isSymbolicLink() && !shouldFollowBundledSymlink(directory, file, follow)) { + const mode = follow ?? SymlinkFollowMode.EXTERNAL; + Annotations.of(scope).addWarningV2( + '@aws-cdk/core:bundlingOutputExternalSymlink', + `Bundling output '${file}' is a symbolic link that is not followed under symlink follow mode '${mode}' and will not be used as a single-file asset.`, + ); + throw new ValidationError( + lit`BundlingOutputUnfollowedSymlink`, + `bundling output ${JSON.stringify(file)} is a symbolic link that is not followed under symlink follow mode ${JSON.stringify(mode)}, leaving no usable bundling output; set \`follow\` to a mode that follows this link or emit a regular file`, + scope, + ); + } + + // `statSync` follows the link so symlinks that resolve to a regular file + // (internal links, or external links under ALWAYS/EXTERNAL) are still valid. if (fs.statSync(file).isFile() && (!archiveOnly || ARCHIVE_EXTENSIONS.includes(extension))) { return file; } @@ -644,6 +668,31 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea return undefined; } +/** + * Whether a symbolic link produced as bundling output should be followed when + * classifying and staging it as a single-file asset. + * + * Mirrors the symlink follow semantics used elsewhere: ALWAYS follows everything, + * EXTERNAL and BLOCK_EXTERNAL depend on whether the target is inside the output + * directory, and NEVER follows nothing. A dangling or unresolvable link is never + * followed. + */ +function shouldFollowBundledSymlink(directory: string, file: string, follow?: SymlinkFollowMode): boolean { + const mode = follow ?? SymlinkFollowMode.EXTERNAL; + if (mode === SymlinkFollowMode.NEVER) { + return false; + } + + const resolvedTarget = resolveLinkTarget(file, fs.readlinkSync(file)); + if (mode === SymlinkFollowMode.ALWAYS) { + return true; + } + + const internal = isInternalPath(path.resolve(directory), resolvedTarget); + // EXTERNAL follows only external links; BLOCK_EXTERNAL follows only internal links. + return mode === SymlinkFollowMode.EXTERNAL ? !internal : internal; +} + interface BundledAsset { path: string; packaging: FileAssetPackaging; @@ -654,8 +703,8 @@ interface BundledAsset { * Returns the bundled asset to use based on the content of the bundle directory * and the type of output. */ -function determineBundledAsset(scope: Construct, bundleDir: string, outputType: BundlingOutput): BundledAsset { - const archiveFile = findSingleFile(scope, bundleDir, outputType !== BundlingOutput.SINGLE_FILE); +function determineBundledAsset(scope: Construct, bundleDir: string, outputType: BundlingOutput, follow?: SymlinkFollowMode): BundledAsset { + const archiveFile = findSingleFile(scope, bundleDir, outputType !== BundlingOutput.SINGLE_FILE, follow); // auto-discover means that if there is an archive file, we take it as the // bundle, otherwise, we will archive here. diff --git a/packages/aws-cdk-lib/core/lib/fs/fingerprint.ts b/packages/aws-cdk-lib/core/lib/fs/fingerprint.ts index f8ff46a0403c7..02fffd0e8f753 100644 --- a/packages/aws-cdk-lib/core/lib/fs/fingerprint.ts +++ b/packages/aws-cdk-lib/core/lib/fs/fingerprint.ts @@ -5,7 +5,7 @@ import { FingerprintDiskCache } from './fingerprint-disk-cache'; import { IgnoreStrategy } from './ignore'; import type { FingerprintOptions } from './options'; import { IgnoreMode, SymlinkFollowMode } from './options'; -import { isInternalPath } from './utils'; +import { isInternalPath, resolveLinkTarget } from './utils'; import { UnscopedValidationError } from '../errors'; import { lit } from '../private/literal-string'; @@ -98,12 +98,6 @@ export function fingerprint(fileOrDirectory: string, options: FingerprintOptions } } - function _resolveLinkTarget(realPath: string, linkTarget: string): string { - return path.isAbsolute(linkTarget) - ? path.resolve(linkTarget) - : path.resolve(path.dirname(realPath), linkTarget); - } - // --- Core traversal --- function _processDirectory(symbolicPath: string, realPath: string) { @@ -131,7 +125,7 @@ export function fingerprint(fileOrDirectory: string, options: FingerprintOptions function _processSymlink(symbolicPath: string, realPath: string) { const linkTarget = fs.readlinkSync(realPath); - const resolvedLinkTarget = _resolveLinkTarget(realPath, linkTarget); + const resolvedLinkTarget = resolveLinkTarget(realPath, linkTarget); if (!_shouldFollowLink(resolvedLinkTarget)) { // Not following — hash the link target string itself diff --git a/packages/aws-cdk-lib/core/lib/fs/utils.ts b/packages/aws-cdk-lib/core/lib/fs/utils.ts index b2f944fa17db1..02618495defef 100644 --- a/packages/aws-cdk-lib/core/lib/fs/utils.ts +++ b/packages/aws-cdk-lib/core/lib/fs/utils.ts @@ -35,3 +35,9 @@ export function shouldFollow(mode: SymlinkFollowMode, sourceRoot: string, realPa export function isInternalPath(rootPath: string, targetPath: string): boolean { return rootPath === targetPath || targetPath.startsWith(rootPath + path.sep); } + +export function resolveLinkTarget(realPath: string, linkTarget: string): string { + return path.isAbsolute(linkTarget) + ? path.resolve(linkTarget) + : path.resolve(path.dirname(realPath), linkTarget); +} diff --git a/packages/aws-cdk-lib/core/test/fs/utils.test.ts b/packages/aws-cdk-lib/core/test/fs/utils.test.ts index 1b4b1fc66b8cd..62b9bf9a83be8 100644 --- a/packages/aws-cdk-lib/core/test/fs/utils.test.ts +++ b/packages/aws-cdk-lib/core/test/fs/utils.test.ts @@ -205,4 +205,32 @@ describe('utils', () => { expect(util.isInternalPath(root, path.resolve(path.join('source', 'elsewhere', 'file.txt')))).toEqual(false); }); }); + + describe('resolveLinkTarget', () => { + test('an absolute link target is resolved as-is', () => { + const realPath = path.join('source', 'root', 'link'); + const linkTarget = path.resolve(path.join('somewhere', 'else', 'referent')); + + expect(util.resolveLinkTarget(realPath, linkTarget)).toEqual(path.resolve(linkTarget)); + }); + + test('a relative link target is resolved against the directory of the link', () => { + const realPath = path.join('source', 'root', 'link'); + const linkTarget = 'referent'; + + // Resolved relative to the link's directory ('source/root'), not the cwd. + expect(util.resolveLinkTarget(realPath, linkTarget)).toEqual( + path.resolve(path.join('source', 'root'), 'referent'), + ); + }); + + test('a relative link target with parent segments is normalized', () => { + const realPath = path.join('source', 'root', 'nested', 'link'); + const linkTarget = path.join('..', 'sibling', 'referent'); + + expect(util.resolveLinkTarget(realPath, linkTarget)).toEqual( + path.resolve(path.join('source', 'root', 'sibling', 'referent')), + ); + }); + }); }); diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index f723fb672ced4..3b0e29e2b8002 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -5,10 +5,11 @@ import * as path from 'path'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import fs from 'fs-extra'; import sinon from 'sinon'; +import { Annotations, Match } from '../../assertions'; import { FileAssetPackaging } from '../../cloud-assembly-schema'; import * as cxapi from '../../cx-api'; import type { BundlingOptions } from '../lib'; -import { App, AssetHashType, AssetStaging, DockerImage, BundlingOutput, FileSystem, Stack, NestedStack, Stage, BundlingFileAccess } from '../lib'; +import { App, AssetHashType, AssetStaging, DockerImage, BundlingOutput, FileSystem, Stack, NestedStack, Stage, BundlingFileAccess, SymlinkFollowMode } from '../lib'; const STUB_INPUT_FILE = '/tmp/docker-stub.input'; const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; @@ -1629,6 +1630,122 @@ describe('staging', () => { expect(staging.packaging).toEqual(FileAssetPackaging.FILE); expect(staging.isArchive).toEqual(false); }); + + describe('bundling output that is a single symbolic link', () => { + const SYMLINK_WARNING = 'is a symbolic link that is not followed under symlink follow mode'; + const SYMLINK_THROW = /is a symbolic link that is not followed under symlink follow mode .* leaving no usable bundling output/; + + // Local bundling lets us write an arbitrary output (here: a single symbolic + // link) into the bundling output directory, which is exactly what + // `findSingleFile` inspects when deciding on a single-file asset. + function bundleWithSymlink(stack: Stack, opts: { + linkTarget: string; + follow?: SymlinkFollowMode; + outputType?: BundlingOutput; + }) { + return new AssetStaging(stack, 'Asset', { + sourcePath: path.join(__dirname, 'fs', 'fixtures', 'test1'), + follow: opts.follow, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SUCCESS], + outputType: opts.outputType, + local: { + tryBundle(outputDir: string): boolean { + fs.symlinkSync(opts.linkTarget, path.join(outputDir, 'link')); + return true; + }, + }, + }, + }); + } + + test.each([ + [SymlinkFollowMode.EXTERNAL, undefined], // EXTERNAL is also the default when `follow` is unset + [SymlinkFollowMode.EXTERNAL, SymlinkFollowMode.EXTERNAL], + [undefined, SymlinkFollowMode.ALWAYS], + ])('follows an external symlink under mode %s and uses it as a single-file asset', (_label, follow) => { + // GIVEN + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-target-follow-')); + const externalFile = path.join(externalDir, 'referent.txt'); + fs.writeFileSync(externalFile, 'referent'); + + try { + const app = new App(); + const stack = new Stack(app, 'stack'); + + // WHEN + const staging = bundleWithSymlink(stack, { + linkTarget: externalFile, + follow, + outputType: BundlingOutput.SINGLE_FILE, + }); + + // THEN - the external link is followed, so it is a valid single-file asset + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + Annotations.fromStack(stack).hasNoWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); + } finally { + fs.removeSync(externalDir); + } + }); + + test.each([ + ['NEVER', SymlinkFollowMode.NEVER, undefined], + ['NEVER (SINGLE_FILE)', SymlinkFollowMode.NEVER, BundlingOutput.SINGLE_FILE], + ['BLOCK_EXTERNAL (external target)', SymlinkFollowMode.BLOCK_EXTERNAL, undefined], + ])('drops the un-followed symlink and fails under mode %s', (_label, follow, outputType) => { + // GIVEN + const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-target-drop-')); + const externalFile = path.join(externalDir, 'referent.txt'); + fs.writeFileSync(externalFile, 'referent'); + + try { + const app = new App(); + const stack = new Stack(app, 'stack'); + + // WHEN / THEN - the only bundling output is a link we must not follow. + // It is dropped rather than zipped into the directory, which leaves no + // usable output, so synthesis fails. + expect(() => bundleWithSymlink(stack, { + linkTarget: externalFile, + follow, + outputType, + })).toThrow(SYMLINK_THROW); + + // AND the warning explaining why is attached to the construct + Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); + } finally { + fs.removeSync(externalDir); + } + }); + + test('EXTERNAL does not follow an internal symlink and fails', () => { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + + // WHEN / THEN - target is an absolute path inside the bundling output + // directory, so it is classified as internal and NOT followed under + // EXTERNAL mode. The link is dropped, leaving no usable output. + expect(() => new AssetStaging(stack, 'Asset', { + sourcePath: path.join(__dirname, 'fs', 'fixtures', 'test1'), + follow: SymlinkFollowMode.EXTERNAL, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SUCCESS], + local: { + tryBundle(outputDir: string): boolean { + fs.symlinkSync(path.join(outputDir, 'referent.txt'), path.join(outputDir, 'link')); + return true; + }, + }, + }, + })).toThrow(SYMLINK_THROW); + + Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); + }); + }); }); describe('staging with docker cp', () => { From 86f9ec3ca291f99834ade6d3d8e391fed8aff003 Mon Sep 17 00:00:00 2001 From: aws-amplify-bot Date: Fri, 7 Aug 2026 13:53:28 -0400 Subject: [PATCH 2/5] only block-external gets new behavior --- .../aws-cdk-lib/core/lib/asset-staging.ts | 49 ++++++------------- .../aws-cdk-lib/core/test/staging.test.ts | 41 ++-------------- 2 files changed, 18 insertions(+), 72 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index 89d0a11c1be88..a831ea5bc3feb 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -2,12 +2,11 @@ import * as crypto from 'crypto'; import * as path from 'path'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; -import { Annotations } from './annotations'; import type { AssetOptions } from './assets'; import { AssetHashType, FileAssetPackaging } from './assets'; import type { BundlingOptions } from './bundling'; import { BundlingFileAccess, BundlingOutput, PERF_BUNDLING_SRC_SYM } from './bundling'; -import { AssumptionError, ValidationError } from './errors'; +import { AssumptionError, UnscopedValidationError, ValidationError } from './errors'; import type { FingerprintOptions } from './fs'; import { FileSystem, SymlinkFollowMode } from './fs'; import { clearLargeFileFingerprintCache } from './fs/fingerprint'; @@ -639,27 +638,17 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea const file = path.join(directory, content[0]); const extension = getExtension(content[0]).toLowerCase(); - // Use lstat so we can detect a symbolic link. Depending on the follow mode - // a symlink must not be followed. Since it is the only entry in the bundling - // output directory, dropping it would leave no usable output, so we warn and - // fail rather than silently zipping a directory that only holds a link we - // were told not to follow. + // Depending on the follow mode a symlink must not be followed. Throw an + // error if we cannot follow the symlink because we are using a mode + // that blocks certain symlinks from being followed. const stat = fs.lstatSync(file); if (stat.isSymbolicLink() && !shouldFollowBundledSymlink(directory, file, follow)) { - const mode = follow ?? SymlinkFollowMode.EXTERNAL; - Annotations.of(scope).addWarningV2( - '@aws-cdk/core:bundlingOutputExternalSymlink', - `Bundling output '${file}' is a symbolic link that is not followed under symlink follow mode '${mode}' and will not be used as a single-file asset.`, - ); - throw new ValidationError( - lit`BundlingOutputUnfollowedSymlink`, - `bundling output ${JSON.stringify(file)} is a symbolic link that is not followed under symlink follow mode ${JSON.stringify(mode)}, leaving no usable bundling output; set \`follow\` to a mode that follows this link or emit a regular file`, - scope, + throw new UnscopedValidationError( + lit`BundlingOutputSymlinkForbidden`, + `bundling output ${JSON.stringify(file)} is a symbolic link that is forbidden due to follow mode ${JSON.stringify(follow ?? SymlinkFollowMode.EXTERNAL)}. Set \`follow\` to a mode that will follow symlinks (ALWAYS or EXTERNAL) or emit a regular file`, ); } - // `statSync` follows the link so symlinks that resolve to a regular file - // (internal links, or external links under ALWAYS/EXTERNAL) are still valid. if (fs.statSync(file).isFile() && (!archiveOnly || ARCHIVE_EXTENSIONS.includes(extension))) { return file; } @@ -669,28 +658,20 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea } /** - * Whether a symbolic link produced as bundling output should be followed when - * classifying and staging it as a single-file asset. + * Whether a symbolic link produced as bundling output should be followed + * and staging it as a single-file asset. * - * Mirrors the symlink follow semantics used elsewhere: ALWAYS follows everything, - * EXTERNAL and BLOCK_EXTERNAL depend on whether the target is inside the output - * directory, and NEVER follows nothing. A dangling or unresolvable link is never - * followed. + * Currently we only reject a symlink as something we should not follow + * when using BLOCK_EXTERNAL and the symlink is not an internal path */ function shouldFollowBundledSymlink(directory: string, file: string, follow?: SymlinkFollowMode): boolean { const mode = follow ?? SymlinkFollowMode.EXTERNAL; - if (mode === SymlinkFollowMode.NEVER) { - return false; - } - const resolvedTarget = resolveLinkTarget(file, fs.readlinkSync(file)); - if (mode === SymlinkFollowMode.ALWAYS) { - return true; - } - const internal = isInternalPath(path.resolve(directory), resolvedTarget); - // EXTERNAL follows only external links; BLOCK_EXTERNAL follows only internal links. - return mode === SymlinkFollowMode.EXTERNAL ? !internal : internal; + if (mode == SymlinkFollowMode.BLOCK_EXTERNAL && !internal) { + return false; + } + return true; } interface BundledAsset { diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index 3b0e29e2b8002..98db9c523bf0e 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -5,7 +5,6 @@ import * as path from 'path'; import { testDeprecated } from '@aws-cdk/cdk-build-tools'; import fs from 'fs-extra'; import sinon from 'sinon'; -import { Annotations, Match } from '../../assertions'; import { FileAssetPackaging } from '../../cloud-assembly-schema'; import * as cxapi from '../../cx-api'; import type { BundlingOptions } from '../lib'; @@ -1632,8 +1631,7 @@ describe('staging', () => { }); describe('bundling output that is a single symbolic link', () => { - const SYMLINK_WARNING = 'is a symbolic link that is not followed under symlink follow mode'; - const SYMLINK_THROW = /is a symbolic link that is not followed under symlink follow mode .* leaving no usable bundling output/; + const SYMLINK_THROW = /is a symbolic link that is forbidden due to follow mode .*/; // Local bundling lets us write an arbitrary output (here: a single symbolic // link) into the bundling output directory, which is exactly what @@ -1663,7 +1661,8 @@ describe('staging', () => { test.each([ [SymlinkFollowMode.EXTERNAL, undefined], // EXTERNAL is also the default when `follow` is unset [SymlinkFollowMode.EXTERNAL, SymlinkFollowMode.EXTERNAL], - [undefined, SymlinkFollowMode.ALWAYS], + [SymlinkFollowMode.ALWAYS, SymlinkFollowMode.ALWAYS], + [SymlinkFollowMode.NEVER, SymlinkFollowMode.NEVER], ])('follows an external symlink under mode %s and uses it as a single-file asset', (_label, follow) => { // GIVEN const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-target-follow-')); @@ -1684,15 +1683,12 @@ describe('staging', () => { // THEN - the external link is followed, so it is a valid single-file asset expect(staging.packaging).toEqual(FileAssetPackaging.FILE); expect(staging.isArchive).toEqual(false); - Annotations.fromStack(stack).hasNoWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); } finally { fs.removeSync(externalDir); } }); test.each([ - ['NEVER', SymlinkFollowMode.NEVER, undefined], - ['NEVER (SINGLE_FILE)', SymlinkFollowMode.NEVER, BundlingOutput.SINGLE_FILE], ['BLOCK_EXTERNAL (external target)', SymlinkFollowMode.BLOCK_EXTERNAL, undefined], ])('drops the un-followed symlink and fails under mode %s', (_label, follow, outputType) => { // GIVEN @@ -1705,46 +1701,15 @@ describe('staging', () => { const stack = new Stack(app, 'stack'); // WHEN / THEN - the only bundling output is a link we must not follow. - // It is dropped rather than zipped into the directory, which leaves no - // usable output, so synthesis fails. expect(() => bundleWithSymlink(stack, { linkTarget: externalFile, follow, outputType, })).toThrow(SYMLINK_THROW); - - // AND the warning explaining why is attached to the construct - Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); } finally { fs.removeSync(externalDir); } }); - - test('EXTERNAL does not follow an internal symlink and fails', () => { - // GIVEN - const app = new App(); - const stack = new Stack(app, 'stack'); - - // WHEN / THEN - target is an absolute path inside the bundling output - // directory, so it is classified as internal and NOT followed under - // EXTERNAL mode. The link is dropped, leaving no usable output. - expect(() => new AssetStaging(stack, 'Asset', { - sourcePath: path.join(__dirname, 'fs', 'fixtures', 'test1'), - follow: SymlinkFollowMode.EXTERNAL, - bundling: { - image: DockerImage.fromRegistry('alpine'), - command: [DockerStubCommand.SUCCESS], - local: { - tryBundle(outputDir: string): boolean { - fs.symlinkSync(path.join(outputDir, 'referent.txt'), path.join(outputDir, 'link')); - return true; - }, - }, - }, - })).toThrow(SYMLINK_THROW); - - Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp(SYMLINK_WARNING)); - }); }); }); From b47c825195c81ed3994c96c86adae199ecd0ce58 Mon Sep 17 00:00:00 2001 From: aws-amplify-bot Date: Fri, 7 Aug 2026 20:28:25 -0400 Subject: [PATCH 3/5] tree walking --- .../aws-cdk-lib/core/lib/asset-staging.ts | 68 +++--- .../test/fs/fixtures/test1/subdir4/file4.txt | 1 + .../fs/fixtures/test1/subdir4/local-link4.txt | 1 + .../aws-cdk-lib/core/test/staging.test.ts | 217 +++++++++++------- 4 files changed, 166 insertions(+), 121 deletions(-) create mode 100644 packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/file4.txt create mode 120000 packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/local-link4.txt diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index a831ea5bc3feb..20d6406f8c572 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -10,7 +10,6 @@ import { AssumptionError, UnscopedValidationError, ValidationError } from './err import type { FingerprintOptions } from './fs'; import { FileSystem, SymlinkFollowMode } from './fs'; import { clearLargeFileFingerprintCache } from './fs/fingerprint'; -import { isInternalPath, resolveLinkTarget } from './fs/utils'; import { Names } from './names'; import { AssetBundlingVolumeCopy, AssetBundlingBindMount } from './private/asset-staging'; import { Cache } from './private/cache'; @@ -19,6 +18,7 @@ import { lit } from './private/literal-string'; import { profileSpan } from './private/perf'; import type { Stack } from './stack'; import * as cxapi from '../../cx-api'; +import { isInternalPath, resolveLinkTarget } from './fs/utils'; const ARCHIVE_EXTENSIONS = ['.tar.gz', '.zip', '.jar', '.tar', '.tgz']; @@ -182,6 +182,11 @@ export class AssetStaging extends Construct { throw new ValidationError(lit`CannotFindAsset`, `Cannot find asset at ${this.sourcePath}`, this); } + // look for invalid (external symlinks) + if (props.follow == SymlinkFollowMode.BLOCK_EXTERNAL) { + findInvalidSymlinks(this.sourcePath); + } + this._sourceStats = fs.statSync(this.sourcePath); const outdir = stageOf(this)?.assetOutdir; @@ -356,7 +361,7 @@ export class AssetStaging extends Construct { // Check bundling output content and determine if we will need to archive const bundlingOutputType = bundling.outputType ?? BundlingOutput.AUTO_DISCOVER; - const bundledAsset = determineBundledAsset(this, bundleDir, bundlingOutputType, this.fingerprintOptions.follow); + const bundledAsset = determineBundledAsset(this, bundleDir, bundlingOutputType); // Calculate assetHash afterwards if we still must assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundledAsset.path); @@ -574,6 +579,31 @@ function determineHashType(scope: Construct, assetHashType?: AssetHashType, cust return hashType; } +/** + * Walk the directory tree, throw if we find external symlinks + * @param root true root of the directory + * @param subRoot used for walking subdirectories + */ +function findInvalidSymlinks(root: string, subRoot: string = root) { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const childPath = path.join(subRoot, entry.name); + if (entry.isSymbolicLink()) { + // we check whether this is internal or external, throw on external + const linkPath = fs.readlinkSync(childPath); + const resolvedPath = resolveLinkTarget(childPath, linkPath); + if (!isInternalPath(root, resolvedPath)) { + throw new UnscopedValidationError( + lit`BundlingFileSymlinkForbidden`, + `The file ${resolvedPath} is a symbolic link that is forbidden due to follow mode internal-only. Set \`follow\` to a mode that will follow symlinks (ALWAYS or EXTERNAL) or emit a regular file`, + ); + } + } else if (entry.isDirectory()) { + findInvalidSymlinks(root, childPath); + } + } +} + /** * Calculates a cache key from the props. Normalize by sorting keys. */ @@ -624,7 +654,7 @@ function sanitizeHashValue(key: string, value: any): any { /** * Returns the single archive file of a directory or undefined */ -function findSingleFile(scope: Construct, directory: string, archiveOnly: boolean, follow?: SymlinkFollowMode): string | undefined { +function findSingleFile(scope: Construct, directory: string, archiveOnly: boolean): string | undefined { if (!fs.existsSync(directory)) { throw new ValidationError(lit`DirectoryDoesNotExist`, `Directory ${directory} does not exist.`, scope); } @@ -638,17 +668,6 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea const file = path.join(directory, content[0]); const extension = getExtension(content[0]).toLowerCase(); - // Depending on the follow mode a symlink must not be followed. Throw an - // error if we cannot follow the symlink because we are using a mode - // that blocks certain symlinks from being followed. - const stat = fs.lstatSync(file); - if (stat.isSymbolicLink() && !shouldFollowBundledSymlink(directory, file, follow)) { - throw new UnscopedValidationError( - lit`BundlingOutputSymlinkForbidden`, - `bundling output ${JSON.stringify(file)} is a symbolic link that is forbidden due to follow mode ${JSON.stringify(follow ?? SymlinkFollowMode.EXTERNAL)}. Set \`follow\` to a mode that will follow symlinks (ALWAYS or EXTERNAL) or emit a regular file`, - ); - } - if (fs.statSync(file).isFile() && (!archiveOnly || ARCHIVE_EXTENSIONS.includes(extension))) { return file; } @@ -657,23 +676,6 @@ function findSingleFile(scope: Construct, directory: string, archiveOnly: boolea return undefined; } -/** - * Whether a symbolic link produced as bundling output should be followed - * and staging it as a single-file asset. - * - * Currently we only reject a symlink as something we should not follow - * when using BLOCK_EXTERNAL and the symlink is not an internal path - */ -function shouldFollowBundledSymlink(directory: string, file: string, follow?: SymlinkFollowMode): boolean { - const mode = follow ?? SymlinkFollowMode.EXTERNAL; - const resolvedTarget = resolveLinkTarget(file, fs.readlinkSync(file)); - const internal = isInternalPath(path.resolve(directory), resolvedTarget); - if (mode == SymlinkFollowMode.BLOCK_EXTERNAL && !internal) { - return false; - } - return true; -} - interface BundledAsset { path: string; packaging: FileAssetPackaging; @@ -684,8 +686,8 @@ interface BundledAsset { * Returns the bundled asset to use based on the content of the bundle directory * and the type of output. */ -function determineBundledAsset(scope: Construct, bundleDir: string, outputType: BundlingOutput, follow?: SymlinkFollowMode): BundledAsset { - const archiveFile = findSingleFile(scope, bundleDir, outputType !== BundlingOutput.SINGLE_FILE, follow); +function determineBundledAsset(scope: Construct, bundleDir: string, outputType: BundlingOutput): BundledAsset { + const archiveFile = findSingleFile(scope, bundleDir, outputType !== BundlingOutput.SINGLE_FILE); // auto-discover means that if there is an archive file, we take it as the // bundle, otherwise, we will archive here. diff --git a/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/file4.txt b/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/file4.txt new file mode 100644 index 0000000000000..eed67803db0d2 --- /dev/null +++ b/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/file4.txt @@ -0,0 +1 @@ +file4 \ No newline at end of file diff --git a/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/local-link4.txt b/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/local-link4.txt new file mode 120000 index 0000000000000..7fd9270e4704f --- /dev/null +++ b/packages/aws-cdk-lib/core/test/fs/fixtures/test1/subdir4/local-link4.txt @@ -0,0 +1 @@ +file4.txt \ No newline at end of file diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index 98db9c523bf0e..f0dfba553971c 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -28,7 +28,7 @@ enum DockerStubCommand { } const FIXTURE_TEST1_DIR = path.join(__dirname, 'fs', 'fixtures', 'test1'); -const FIXTURE_TEST1_HASH = '2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'; +const FIXTURE_TEST1_HASH = '0ed6a91d0269df25c265bdeb5c55dca958a86769bcc97e406a3d16ba1a08985a'; const FIXTURE_TARBALL = path.join(__dirname, 'fs', 'fixtures.tar.gz'); const NOT_ARCHIVED_ZIP_TXT_HASH = '95c924c84f5d023be4edee540cb2cb401a49f115d01ed403b288f6cb412771df'; const ARCHIVE_TARBALL_TEST_HASH = '3e948ff54a277d6001e2452fdbc4a9ef61f916ff662ba5e05ece1e2ec6dec9f5'; @@ -276,7 +276,7 @@ describe('staging', () => { // THEN expect(withoutExtra.assetHash).not.toEqual(withExtra.assetHash); expect(withoutExtra.assetHash).toEqual(FIXTURE_TEST1_HASH); - expect(withExtra.assetHash).toEqual('c95c915a5722bb9019e2c725d11868e5a619b55f36172f76bcbcaa8bb2d10c5f'); + expect(withExtra.assetHash).toEqual('546e4a1731df2753503162a5a260ae037df45ce6dc49f0a98711f5824cddec02'); }); test('can specify extra asset salt via context key', () => { @@ -320,7 +320,7 @@ describe('staging', () => { `run --rm ${USER_ARG} -v /input:/asset-input:${delegated} -v /output:/asset-output:${delegated} -w /asset-input alpine DOCKER_STUB_SUCCESS`, ); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', + 'asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -353,7 +353,7 @@ describe('staging', () => { const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', + 'asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -362,13 +362,13 @@ describe('staging', () => { 'validation-report.json', ]); - expect(asset.assetHash).toEqual('b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4'); + expect(asset.assetHash).toEqual('73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c'); expect(asset.sourcePath).toEqual(directory); const resolvedStagePath = asset.relativeStagedPath(stack); // absolute path ending with bundling dir expect(path.isAbsolute(resolvedStagePath)).toEqual(true); - expect(new RegExp('asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4$').test(resolvedStagePath)).toEqual(true); + expect(new RegExp('asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c$').test(resolvedStagePath)).toEqual(true); }); test('bundler reuses its output when it can', () => { @@ -404,7 +404,7 @@ describe('staging', () => { ); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', + 'asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -502,8 +502,8 @@ describe('staging', () => { ); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', // 'Asset' - 'asset.e80bb8f931b87e84975de193f5a7ecddd7558d3caf3d35d3a536d9ae6539234f', // 'AssetWithDifferentBundlingOptions' + 'asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c', // 'Asset' + 'asset.c16eb04e5e6f7a2e62afaf27abee04bc36da8999efc73d6853ac3dcdd04b6a98', // 'AssetWithDifferentBundlingOptions' 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -552,7 +552,7 @@ describe('staging', () => { ); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.2de2347dd01e3f43a463652635acaae09539cdf32769d9a60ac0ad4622b1e943', // 'Asset' + 'asset.feebd77844651944d530c7600e6f3dc440a9bad66abf76ab95a0a7d168aabca0', // 'Asset' 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -688,7 +688,7 @@ describe('staging', () => { expect(appAssembly.directory).toEqual(app2Assembly.directory); expect(fs.readdirSync(appAssembly.directory)).toEqual([ - 'asset.b1e32e86b3523f2fa512eb99180ee2975a50a4439e63e8badd153f2a68d61aa4', + 'asset.73f25aa93681e01831ecafe334b79916f3cead51b5bc3cadbfc4459dbafd4a3c', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -790,7 +790,7 @@ describe('staging', () => { expect(appAssembly.directory).toEqual(app2Assembly.directory); expect(fs.readdirSync(appAssembly.directory)).toEqual([ - 'asset.ec1d4062c578dacd630d64166a7d1efcd472e570e085a63f8857f6c674491bac', + 'asset.2a30c05d99c7036854ab5df0a01e8fbf1fad277598e2b0b6a2a29c7e09ebb4dc', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1388,8 +1388,8 @@ describe('staging', () => { // THEN const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48', // this is the bundle dir - 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48.zip', + 'asset.ab484104b6aae238176ac35262399d0db8aee2c7d385b27a10f8e0722b3512a9', // this is the bundle dir + 'asset.ab484104b6aae238176ac35262399d0db8aee2c7d385b27a10f8e0722b3512a9.zip', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1397,7 +1397,7 @@ describe('staging', () => { 'tree.json', 'validation-report.json', ]); - expect(fs.readdirSync(path.join(assembly.directory, 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48'))).toEqual([ + expect(fs.readdirSync(path.join(assembly.directory, 'asset.ab484104b6aae238176ac35262399d0db8aee2c7d385b27a10f8e0722b3512a9'))).toEqual([ 'test.zip', // bundle dir with "touched" bundled output file ]); expect(staging.packaging).toEqual(FileAssetPackaging.FILE); @@ -1469,7 +1469,7 @@ describe('staging', () => { // THEN const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.86ec07746e1d859290cfd8b9c648e581555649c75f51f741f11e22cab6775abc', + 'asset.7c7d7f5e01d066e4167fee3b098f209de7f45e1be53b77e2b757df73a749f1ea', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1583,8 +1583,8 @@ describe('staging', () => { // THEN const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.ef734136dc22840a94140575a2f98cbc061074e09535589d1cd2c11a4ac2fd75', - 'asset.ef734136dc22840a94140575a2f98cbc061074e09535589d1cd2c11a4ac2fd75_noext', + 'asset.390ed165e2a0a8741f7c86d1c9cd5c0c5aa251e234f5785cda765b25611e1df4', + 'asset.390ed165e2a0a8741f7c86d1c9cd5c0c5aa251e234f5785cda765b25611e1df4_noext', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1633,82 +1633,122 @@ describe('staging', () => { describe('bundling output that is a single symbolic link', () => { const SYMLINK_THROW = /is a symbolic link that is forbidden due to follow mode .*/; - // Local bundling lets us write an arbitrary output (here: a single symbolic - // link) into the bundling output directory, which is exactly what - // `findSingleFile` inspects when deciding on a single-file asset. - function bundleWithSymlink(stack: Stack, opts: { - linkTarget: string; - follow?: SymlinkFollowMode; - outputType?: BundlingOutput; - }) { - return new AssetStaging(stack, 'Asset', { - sourcePath: path.join(__dirname, 'fs', 'fixtures', 'test1'), - follow: opts.follow, + test.each([ + [undefined], // EXTERNAL is also the default when `follow` is unset + [SymlinkFollowMode.EXTERNAL], + [SymlinkFollowMode.ALWAYS], + [SymlinkFollowMode.NEVER], + ])('follows an external symlink under mode %s and uses it as a single-file asset', (follow) => { + // GIVEN + const app = new App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + const staging = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.OUTPUT, + follow, bundling: { image: DockerImage.fromRegistry('alpine'), - command: [DockerStubCommand.SUCCESS], - outputType: opts.outputType, - local: { - tryBundle(outputDir: string): boolean { - fs.symlinkSync(opts.linkTarget, path.join(outputDir, 'link')); - return true; - }, - }, + command: [DockerStubCommand.SINGLE_FILE], + outputType: BundlingOutput.SINGLE_FILE, }, }); - } - test.each([ - [SymlinkFollowMode.EXTERNAL, undefined], // EXTERNAL is also the default when `follow` is unset - [SymlinkFollowMode.EXTERNAL, SymlinkFollowMode.EXTERNAL], - [SymlinkFollowMode.ALWAYS, SymlinkFollowMode.ALWAYS], - [SymlinkFollowMode.NEVER, SymlinkFollowMode.NEVER], - ])('follows an external symlink under mode %s and uses it as a single-file asset', (_label, follow) => { + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + }); + + test('fails under mode BLOCK_EXTERNAL if there is a symlink in the directory being bundled and AssetHashType is Source', () => { // GIVEN - const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-target-follow-')); - const externalFile = path.join(externalDir, 'referent.txt'); - fs.writeFileSync(externalFile, 'referent'); - - try { - const app = new App(); - const stack = new Stack(app, 'stack'); - - // WHEN - const staging = bundleWithSymlink(stack, { - linkTarget: externalFile, - follow, + const app = new App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN - we should throw because there is an external symlink in the /test1 fixture + expect(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.SOURCE, + follow: SymlinkFollowMode.BLOCK_EXTERNAL, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_FILE], outputType: BundlingOutput.SINGLE_FILE, - }); + }, + })).toThrow(SYMLINK_THROW); + }); - // THEN - the external link is followed, so it is a valid single-file asset - expect(staging.packaging).toEqual(FileAssetPackaging.FILE); - expect(staging.isArchive).toEqual(false); - } finally { - fs.removeSync(externalDir); - } + test('fails under mode BLOCK_EXTERNAL if there is a symlink in the directory being bundled and AssetHashType is Output', () => { + // GIVEN + const app = new App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN - we should throw no matter the Asset Hash Type + expect(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.OUTPUT, + follow: SymlinkFollowMode.BLOCK_EXTERNAL, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_FILE], + outputType: BundlingOutput.SINGLE_FILE, + }, + })).toThrow(SYMLINK_THROW); }); - test.each([ - ['BLOCK_EXTERNAL (external target)', SymlinkFollowMode.BLOCK_EXTERNAL, undefined], - ])('drops the un-followed symlink and fails under mode %s', (_label, follow, outputType) => { + test('fails under mode BLOCK_EXTERNAL if there is a symlink, using more complicated directory layout', () => { // GIVEN - const externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ext-target-drop-')); - const externalFile = path.join(externalDir, 'referent.txt'); - fs.writeFileSync(externalFile, 'referent'); - - try { - const app = new App(); - const stack = new Stack(app, 'stack'); - - // WHEN / THEN - the only bundling output is a link we must not follow. - expect(() => bundleWithSymlink(stack, { - linkTarget: externalFile, - follow, - outputType, - })).toThrow(SYMLINK_THROW); - } finally { - fs.removeSync(externalDir); - } + const app = new App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'symlinks'); + + // WHEN - we should throw no matter the Asset Hash Type + expect(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.OUTPUT, + follow: SymlinkFollowMode.BLOCK_EXTERNAL, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_FILE], + outputType: BundlingOutput.SINGLE_FILE, + }, + })).toThrow(SYMLINK_THROW); + }); + + test('does not fail if there is a local link', () => { + // GIVEN + const app = new App({ context: { [cxapi.NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: false } }); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1', 'subdir4'); + + // WHEN - we should throw no matter the Asset Hash Type + const staging = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + assetHashType: AssetHashType.OUTPUT, + follow: SymlinkFollowMode.BLOCK_EXTERNAL, + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_FILE], + outputType: BundlingOutput.SINGLE_FILE, + }, + }); + + const assembly = app.synth(); + + expect(staging.packaging).toEqual(FileAssetPackaging.FILE); + expect(staging.isArchive).toEqual(false); + + expect(fs.readdirSync(assembly.directory)).toEqual([ + 'asset.1e4cb66c62741f7b9a5dbb596b25efe18984ba847949c85108f1e5f661ba0152.txt', + 'cdk.out', + 'manifest.json', + 'stack.metadata.json', + 'stack.template.json', + 'tree.json', + 'validation-report.json', + ]); }); }); }); @@ -1753,8 +1793,8 @@ describe('staging with docker cp', () => { // THEN const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.0ec371a2022d29dfd83f5df104e0f01b34233a4e3e839c3c4ec62008f0b9a0e8', // this is the bundle dir - 'asset.0ec371a2022d29dfd83f5df104e0f01b34233a4e3e839c3c4ec62008f0b9a0e8.zip', + 'asset.c0a5fa22d478764f48802d4ff41174892273b02445015e8e8e08a9596792550c', // this is the bundle dir + 'asset.c0a5fa22d478764f48802d4ff41174892273b02445015e8e8e08a9596792550c.zip', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1762,7 +1802,7 @@ describe('staging with docker cp', () => { 'tree.json', 'validation-report.json', ]); - expect(fs.readdirSync(path.join(assembly.directory, 'asset.0ec371a2022d29dfd83f5df104e0f01b34233a4e3e839c3c4ec62008f0b9a0e8'))).toEqual([ + expect(fs.readdirSync(path.join(assembly.directory, 'asset.c0a5fa22d478764f48802d4ff41174892273b02445015e8e8e08a9596792550c'))).toEqual([ 'test.zip', // bundle dir with "touched" bundled output file ]); expect(staging.packaging).toEqual(FileAssetPackaging.FILE); @@ -1802,8 +1842,8 @@ describe('staging with docker cp', () => { // THEN const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ - 'asset.93bd4079bff7440a725991ecf249416ae9ad73cb639f4a8d9e8f3ad8d491e89f', - 'asset.93bd4079bff7440a725991ecf249416ae9ad73cb639f4a8d9e8f3ad8d491e89f_noext', + 'asset.4697ea6b345c96a20246f80b874dfd6d640c6d0fd9c097d02ede1f45d37e1732', + 'asset.4697ea6b345c96a20246f80b874dfd6d640c6d0fd9c097d02ede1f45d37e1732_noext', 'cdk.out', 'manifest.json', 'stack.metadata.json', @@ -1836,6 +1876,7 @@ describe('staging with docker cp', () => { // THEN const assembly = app.synth(); + expect(fs.readdirSync(assembly.directory)).toEqual([ 'asset.53a51b4c68874a8e831e24e8982120be2a608f50b2e05edb8501143b3305baa8', 'asset.53a51b4c68874a8e831e24e8982120be2a608f50b2e05edb8501143b3305baa8_noext', From b6fab9464441e2eef3f76e6a780d37edbdb83659 Mon Sep 17 00:00:00 2001 From: aws-amplify-bot Date: Fri, 7 Aug 2026 21:16:46 -0400 Subject: [PATCH 4/5] fixtures --- packages/aws-cdk-lib/core/lib/asset-staging.ts | 2 +- .../aws-cdk-lib/core/test/fs/fixtures.tar.gz | Bin 1328 -> 714 bytes packages/aws-cdk-lib/core/test/staging.test.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/aws-cdk-lib/core/lib/asset-staging.ts b/packages/aws-cdk-lib/core/lib/asset-staging.ts index 20d6406f8c572..9db51fdb9f073 100644 --- a/packages/aws-cdk-lib/core/lib/asset-staging.ts +++ b/packages/aws-cdk-lib/core/lib/asset-staging.ts @@ -182,7 +182,7 @@ export class AssetStaging extends Construct { throw new ValidationError(lit`CannotFindAsset`, `Cannot find asset at ${this.sourcePath}`, this); } - // look for invalid (external symlinks) + // look for invalid (external) symlinks if (props.follow == SymlinkFollowMode.BLOCK_EXTERNAL) { findInvalidSymlinks(this.sourcePath); } diff --git a/packages/aws-cdk-lib/core/test/fs/fixtures.tar.gz b/packages/aws-cdk-lib/core/test/fs/fixtures.tar.gz index 50f5ba0f4a259052109afd4e7aae10087655fec8..bcc1f2f12fdab3a49de234bbf2fa608cd2e131ec 100644 GIT binary patch literal 714 zcmV;*0yX^~iwFP!000001MQpbj-oIah55Z#!407F`+D3FGh`7rQYX&cZworQe26%0 z$u8%~Bp7FsR!`6So>FF)pY`dORqMzfsX+;0uS5xQf4veB#syVE3c{k4P$_8?3y*X9 z;#BF)F^;3+BhLzb82r1j-~XfRuKHJ>`ywxY`B(@~=`Sb~TuCad{#-B){X@{zKRfH} zSZ<1Bn;#SV*0;x~^;hb7|5M79@jqRy);g;+Sy!i@W;PB!ZWrj^(!V?$_eT9)USunM z)(*z70!z+^^{28`{}ngOB~BfTJ<7uM*Y9~1o1abG=pI|$f6P5A5Q?tah#WN zBQb*i(&+j>FU>;8-gM$D2u|<+f=Ju{-ABTZneis)e|Ek^4yDtacemNL$ltHnO`$3X2l0kEt+GR@!2VtnzSuE*YCAN(qbX zHk)ZF**+APXtgO5R^#JgFd0(17-PQfy=NzX?fLUO@AIB>e(#}k^(YA={7?#Yro1zD z$n%qIodh0lwB9Wu>is%KgI=MB!D^4RyIiedd$S_r+f%{U40VQI>!=)4&btQY{G08# zAv>E)xRMPl=rRRY@F^b_@wqHk))Wly(f8E=YW_UKI54YdA| zYfmeTxnVQ$ag{_gU_W@j&ZoTN^fpdO%_DB_k*2F1dq6(lQGp~Ax615d7CK$R`(k?+ z`MYuipr5cNUr~m)Uz^)nx0zz`8X>&uw>i!`S~UN9KW6%^Y#0r8+gZ#`;3I`#flbscjxaEao5&| z5ZsN$w}n8Hs_!8n4I`BuP6*-ad@R2OQht04caq8jE=TpNSplmFgrPl;1Y_{Sl9UmM zln11x0LM3jL}?B)9MANmq9^=>RHTR-UwERvv-6f5Y0b?7b;|{y36JCG2=Hcs61af* z%htal1^O4RgTS`v6uZ#%X7TlKtVrD-)MT){7Cb=^$2=X+dqMcS_bX6+$}GZgo=18U z5=mAz$TRXti<;&mb-<{$g^)fa$F|+xdh^Ea_7)k3>3q?Ny+#Z~TRA_rl28cnbOn~} zFGdfxL%4r8y0h;xy8y0O?Jk`Y1)W`Ix-^Z$YCdSQ(eA^Q)!J`a;d%{ZoA(RYjwFcNDs7Vn&=Qq&5;m*Xhi{Rxt%5E=Tt z6=hHR4vNv*E--t`$^{|s=$lhGz0sv*t4xaeeYPypGT++n^<&3(LL~cd9HnbbAOWut zd1fOScB8pHl?W+W*|LZih-1Ac^U5MeRY)=X^jx<=j(Be&r917xi;QDAi=nGN?N6fO zyBqt5!Ghr%XMUd)D3QXer52(nCkwiMGKA!f#1a{c|C|)^|lH%#d^m z9+$18GqUDEr8JraW2O>$DMd@H-HS;Ke~um2d`&!wIpBk87R^A3hU>{ldKtVlnpQD_ zu<6xS$q*g77KDa^S?C(O%*MdfeUzy9Aj{DhPC-s(H&`S#dJmDD;1r-n8(_$+`{Eb- zy(H0=Cx)7yYG;_}whfS?US?&f&2W_V0aZo{F1%<>Ca(>|m&owJZz;6DaJn?#O4E-U YcO-O8v?>3u=H(>Sn%Y=MsiaW;12WR+@Bjb+ diff --git a/packages/aws-cdk-lib/core/test/staging.test.ts b/packages/aws-cdk-lib/core/test/staging.test.ts index f0dfba553971c..7e47a4eab29d2 100644 --- a/packages/aws-cdk-lib/core/test/staging.test.ts +++ b/packages/aws-cdk-lib/core/test/staging.test.ts @@ -228,7 +228,7 @@ describe('staging', () => { const assembly = app.synth(); expect(fs.readdirSync(assembly.directory)).toEqual([ `asset.${FIXTURE_TEST1_HASH}`, - 'asset.af10ac04b3b607b0f8659c8f0cee8c343025ee75baf0b146f10f0e5311d2c46b.tar.gz', + 'asset.39def77ac40e423b62f5529cfb8c9ae54b9d6ecd344f49770f27bc81fac36a6c.tar.gz', 'cdk.out', 'manifest.json', 'stack.metadata.json', From 79adea51a37fb528a754a75bb5942208cd4ff2cc Mon Sep 17 00:00:00 2001 From: aws-amplify-bot Date: Fri, 7 Aug 2026 21:54:21 -0400 Subject: [PATCH 5/5] fix test --- packages/aws-cdk-lib/core/test/fs/fs-copy.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/aws-cdk-lib/core/test/fs/fs-copy.test.ts b/packages/aws-cdk-lib/core/test/fs/fs-copy.test.ts index 720c82c7cb8bd..387b983f8eb11 100644 --- a/packages/aws-cdk-lib/core/test/fs/fs-copy.test.ts +++ b/packages/aws-cdk-lib/core/test/fs/fs-copy.test.ts @@ -29,6 +29,9 @@ describe('fs copy', () => { ' .hidden', ' subdir3 (D)', ' file3.txt', + 'subdir4 (D)', + ' file4.txt', + ' local-link4.txt => file4.txt', ]); });