Skip to content

Commit 7d9ab2a

Browse files
authored
fix(core): detect and resolve stringified number tokens (#19578)
Number tokens are encoded as a range of very large negative numbers (for example: -1.888154589709072e+289). When these are naively stringified, the `resolve()` method doesn't recognize and translate them anymore, and these numbers end up in the target template in a confusing way. However, recognizing them is actually not that hard and can be done using a regex. We can then do the token resolution appropriately, making it so that construct authors do not have to call `Tokenization.stringifyNumber()` anymore in order to support stringification of number values. Fixes #19546, closes #19550. ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/master/INTEGRATION_TESTS.md)? * [ ] Did you use `cdk-integ` to deploy the infrastructure and generate the snapshot (i.e. `cdk-integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 2321ece commit 7d9ab2a

File tree

6 files changed

+96
-26
lines changed

6 files changed

+96
-26
lines changed

packages/@aws-cdk/aws-stepfunctions-tasks/test/batch/submit-job.test.ts

+27
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,33 @@ test('supports tokens', () => {
169169
});
170170
});
171171

172+
test('container overrides are tokens', () => {
173+
// WHEN
174+
const task = new BatchSubmitJob(stack, 'Task', {
175+
jobDefinitionArn: batchJobDefinition.jobDefinitionArn,
176+
jobName: 'JobName',
177+
jobQueueArn: batchJobQueue.jobQueueArn,
178+
containerOverrides: {
179+
memory: cdk.Size.mebibytes(sfn.JsonPath.numberAt('$.asdf')),
180+
},
181+
});
182+
183+
// THEN
184+
expect(stack.resolve(task.toStateJson())).toEqual({
185+
Type: 'Task',
186+
Resource: { 'Fn::Join': ['', ['arn:', { Ref: 'AWS::Partition' }, ':states:::batch:submitJob.sync']] },
187+
End: true,
188+
Parameters: {
189+
JobDefinition: { Ref: 'JobDefinition24FFE3ED' },
190+
JobName: 'JobName',
191+
JobQueue: { Ref: 'JobQueueEE3AD499' },
192+
ContainerOverrides: {
193+
ResourceRequirements: [{ 'Type': 'MEMORY', 'Value.$': '$.asdf' }],
194+
},
195+
},
196+
});
197+
});
198+
172199
test('supports passing task input into payload', () => {
173200
// WHEN
174201
const task = new BatchSubmitJob(stack, 'Task', {

packages/@aws-cdk/core/lib/private/cloudformation-lang.ts

+9-8
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ export class CloudFormationLang {
4343

4444
// Some case analysis to produce minimal expressions
4545
if (parts.length === 1) { return parts[0]; }
46-
if (parts.length === 2 && typeof parts[0] === 'string' && typeof parts[1] === 'string') {
47-
return parts[0] + parts[1];
46+
if (parts.length === 2 && isConcatable(parts[0]) && isConcatable(parts[1])) {
47+
return `${parts[0]}${parts[1]}`;
4848
}
4949

5050
// Otherwise return a Join intrinsic (already in the target document language to avoid taking
@@ -323,8 +323,8 @@ export function minimalCloudFormationJoin(delimiter: string, values: any[]): any
323323
const el = values[i];
324324
if (isSplicableFnJoinIntrinsic(el)) {
325325
values.splice(i, 1, ...el['Fn::Join'][1]);
326-
} else if (i > 0 && isPlainString(values[i - 1]) && isPlainString(values[i])) {
327-
values[i - 1] += delimiter + values[i];
326+
} else if (i > 0 && isConcatable(values[i - 1]) && isConcatable(values[i])) {
327+
values[i - 1] = `${values[i-1]}${delimiter}${values[i]}`;
328328
values.splice(i, 1);
329329
} else {
330330
i += 1;
@@ -333,10 +333,6 @@ export function minimalCloudFormationJoin(delimiter: string, values: any[]): any
333333

334334
return values;
335335

336-
function isPlainString(obj: any): boolean {
337-
return typeof obj === 'string' && !Token.isUnresolved(obj);
338-
}
339-
340336
function isSplicableFnJoinIntrinsic(obj: any): boolean {
341337
if (!isIntrinsic(obj)) { return false; }
342338
if (Object.keys(obj)[0] !== 'Fn::Join') { return false; }
@@ -351,6 +347,11 @@ export function minimalCloudFormationJoin(delimiter: string, values: any[]): any
351347
}
352348
}
353349

350+
function isConcatable(obj: any): boolean {
351+
return ['string', 'number'].includes(typeof obj) && !Token.isUnresolved(obj);
352+
}
353+
354+
354355
/**
355356
* Return whether the given value represents a CloudFormation intrinsic
356357
*/

packages/@aws-cdk/core/lib/private/encoding.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ const QUOTED_BEGIN_STRING_TOKEN_MARKER = regexQuote(BEGIN_STRING_TOKEN_MARKER);
1414
const QUOTED_BEGIN_LIST_TOKEN_MARKER = regexQuote(BEGIN_LIST_TOKEN_MARKER);
1515
const QUOTED_END_TOKEN_MARKER = regexQuote(END_TOKEN_MARKER);
1616

17-
const STRING_TOKEN_REGEX = new RegExp(`${QUOTED_BEGIN_STRING_TOKEN_MARKER}([${VALID_KEY_CHARS}]+)${QUOTED_END_TOKEN_MARKER}`, 'g');
17+
// Sometimes the number of digits is different
18+
export const STRINGIFIED_NUMBER_PATTERN = '-1\\.\\d{10,16}e\\+289';
19+
20+
const STRING_TOKEN_REGEX = new RegExp(`${QUOTED_BEGIN_STRING_TOKEN_MARKER}([${VALID_KEY_CHARS}]+)${QUOTED_END_TOKEN_MARKER}|(${STRINGIFIED_NUMBER_PATTERN})`, 'g');
1821
const LIST_TOKEN_REGEX = new RegExp(`${QUOTED_BEGIN_LIST_TOKEN_MARKER}([${VALID_KEY_CHARS}]+)${QUOTED_END_TOKEN_MARKER}`, 'g');
1922

2023
/**
@@ -52,7 +55,7 @@ export class TokenString {
5255
ret.addLiteral(this.str.substring(rest, m.index));
5356
}
5457

55-
ret.addToken(lookup(m[1]));
58+
ret.addToken(lookup(m[1] ?? m[2]));
5659

5760
rest = this.re.lastIndex;
5861
m = this.re.exec(this.str);
@@ -218,3 +221,12 @@ export function extractTokenDouble(encoded: number): number | undefined {
218221
return ints[0] + shl32(ints[1] & 0xFFFF);
219222
/* eslint-enable no-bitwise */
220223
}
224+
225+
const STRINGIFIED_NUMBER_REGEX = new RegExp(STRINGIFIED_NUMBER_PATTERN);
226+
227+
/**
228+
* Return whether the given string contains accidentally stringified number tokens
229+
*/
230+
export function stringContainsNumberTokens(x: string) {
231+
return !!x.match(STRINGIFIED_NUMBER_REGEX);
232+
}

packages/@aws-cdk/core/lib/private/token-map.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,12 @@ export class TokenMap {
177177

178178
private registerNumberKey(token: IResolvable): number {
179179
const counter = this.tokenCounter++;
180+
const dbl = createTokenDouble(counter);
181+
// Register in the number map, as well as a string representation of that token
182+
// in the string map.
180183
this.numberTokenMap.set(counter, token);
181-
return createTokenDouble(counter);
184+
this.stringTokenMap.set(`${dbl}`, token);
185+
return dbl;
182186
}
183187
}
184188

packages/@aws-cdk/core/lib/string-fragments.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { IFragmentConcatenator, IResolvable } from './resolvable';
2-
import { isResolvableObject } from './token';
2+
import { isResolvableObject, Token } from './token';
33

44
/**
55
* Result of the split of a string with Tokens
@@ -71,8 +71,10 @@ export class TokenizedStringFragments {
7171
const mapped = mapper.mapToken(f.token);
7272
if (isResolvableObject(mapped)) {
7373
ret.addToken(mapped);
74-
} else {
74+
} else if (Token.isUnresolved(mapped)) {
7575
ret.addIntrinsic(mapped);
76+
} else {
77+
ret.addLiteral(mapped);
7678
}
7779
break;
7880
case 'intrinsic':

packages/@aws-cdk/core/test/tokens.test.ts

+37-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Fn, isResolvableObject, Lazy, Stack, Token, Tokenization } from '../lib';
2-
import { createTokenDouble, extractTokenDouble } from '../lib/private/encoding';
1+
import { CfnResource, Fn, isResolvableObject, Lazy, Stack, Token, Tokenization } from '../lib';
2+
import { createTokenDouble, extractTokenDouble, stringContainsNumberTokens, STRINGIFIED_NUMBER_PATTERN } from '../lib/private/encoding';
33
import { Intrinsic } from '../lib/private/intrinsic';
44
import { findTokens } from '../lib/private/resolve';
55
import { IResolvable } from '../lib/resolvable';
@@ -482,15 +482,12 @@ describe('tokens', () => {
482482
expect(() => {
483483
resolve({ value: encoded[0] });
484484
}).toThrow(/Found an encoded list/);
485-
486-
487485
});
488486
});
489487

490488
describe('number encoding', () => {
491489
test('basic integer encoding works', () => {
492490
expect(16).toEqual(extractTokenDouble(createTokenDouble(16)));
493-
494491
});
495492

496493
test('arbitrary integers can be encoded, stringified, and recovered', () => {
@@ -504,16 +501,12 @@ describe('tokens', () => {
504501
const decoded = extractTokenDouble(roundtripped);
505502
expect(decoded).toEqual(x);
506503
}
507-
508-
509504
});
510505

511506
test('arbitrary numbers are correctly detected as not being tokens', () => {
512507
expect(undefined).toEqual(extractTokenDouble(0));
513508
expect(undefined).toEqual(extractTokenDouble(1243));
514509
expect(undefined).toEqual(extractTokenDouble(4835e+532));
515-
516-
517510
});
518511

519512
test('can number-encode and resolve Token objects', () => {
@@ -528,8 +521,42 @@ describe('tokens', () => {
528521
// THEN
529522
const resolved = resolve({ value: encoded });
530523
expect(resolved).toEqual({ value: 123 });
524+
});
531525

526+
test('regex detects all stringifications of encoded tokens', () => {
527+
expect(stringContainsNumberTokens(`${createTokenDouble(0)}`)).toBeTruthy();
528+
expect(stringContainsNumberTokens(`${createTokenDouble(Math.pow(2, 48) - 1)}`)).toBeTruthy(); // MAX_ENCODABLE_INTEGER
529+
expect(stringContainsNumberTokens('1234')).toBeFalsy();
530+
});
532531

532+
test('check that the first N encoded numbers can be detected', () => {
533+
const re = new RegExp(STRINGIFIED_NUMBER_PATTERN);
534+
// Ran this up to 1 million offline
535+
for (let i = 0; i < 1000; i++) {
536+
expect(`${createTokenDouble(i)}`).toMatch(re);
537+
}
538+
});
539+
540+
test('handle stringified number token', () => {
541+
// GIVEN
542+
const tok = `the answer is: ${Lazy.number({ produce: () => 86 })}`;
543+
544+
// THEN
545+
expect(resolve({ value: `${tok}` })).toEqual({
546+
value: 'the answer is: 86',
547+
});
548+
});
549+
550+
test('handle stringified number reference', () => {
551+
const stack = new Stack();
552+
const res = new CfnResource(stack, 'Resource', { type: 'My::Resource' });
553+
// GIVEN
554+
const tok = `the answer is: ${Token.asNumber(res.ref)}`;
555+
556+
// THEN
557+
expect(resolve({ value: `${tok}` })).toEqual({
558+
value: { 'Fn::Join': ['', ['the answer is: ', { Ref: 'Resource' }]] },
559+
});
533560
});
534561
});
535562

@@ -694,25 +721,21 @@ describe('tokens', () => {
694721
describe('stringifyNumber', () => {
695722
test('converts number to string', () => {
696723
expect(Tokenization.stringifyNumber(100)).toEqual('100');
697-
698724
});
699725

700726
test('converts tokenized number to string', () => {
701727
expect(resolve(Tokenization.stringifyNumber({
702728
resolve: () => 100,
703729
} as any))).toEqual('100');
704-
705730
});
706731

707732
test('string remains the same', () => {
708733
expect(Tokenization.stringifyNumber('123' as any)).toEqual('123');
709-
710734
});
711735

712736
test('Ref remains the same', () => {
713737
const val = { Ref: 'SomeLogicalId' };
714738
expect(Tokenization.stringifyNumber(val as any)).toEqual(val);
715-
716739
});
717740

718741
test('lazy Ref remains the same', () => {
@@ -791,3 +814,4 @@ function tokensThatResolveTo(value: any): Token[] {
791814
function resolve(x: any) {
792815
return new Stack().resolve(x);
793816
}
817+

0 commit comments

Comments
 (0)