From b651dd672dba7a0107814adf7c9ff7aa1b693b78 Mon Sep 17 00:00:00 2001 From: DavidWells Date: Fri, 2 Oct 2026 13:33:50 -0700 Subject: [PATCH] fix(preprocess): JSON objects in calls outside a variable stay text (VTL #set($m = {...})) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encodeJsonArgObjects encoded every name(... {json} ...) in a string, but decoding only happens when a variable resolves. A JSON object in a call outside any variable — an API Gateway VTL template's #set($mfaMap = { "SMS_MFA": "SMS" }) — became __JSON_B64__...__ in the resolved config. Now only calls inside a ${...} are encoded (brace-aware depth scan, so ${merge({"a":1})} still counts). Tests: encodeJsonArgObjects unit cases; serverlessGoldens apigw-vtl fixture (its leak check failed before the fix). Golden harness can keep backslashes so \n and \" in config values are compared exactly; BuildSpec goldens regenerated (backslashes only). --- .../src/utils/encoders/js-fixes.js | 29 ++++++++++++- .../src/utils/encoders/js-fixes.test.js | 11 +++++ .../configorama/tests/conformance/harness.js | 18 +++++--- .../serverlessGoldens/fixtures/apigw-vtl.yml | 42 +++++++++++++++++++ .../goldens/apigw-vtl.deployer.golden | 35 ++++++++++++++++ .../goldens/apigw-vtl.standalone.golden | 35 ++++++++++++++++ .../goldens/buildspec.deployer.golden | 2 +- .../goldens/buildspec.standalone.golden | 2 +- .../serverlessGoldens.test.js | 2 +- 9 files changed, 166 insertions(+), 10 deletions(-) create mode 100644 packages/configorama/tests/serverlessGoldens/fixtures/apigw-vtl.yml create mode 100644 packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.deployer.golden create mode 100644 packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.standalone.golden diff --git a/packages/configorama/src/utils/encoders/js-fixes.js b/packages/configorama/src/utils/encoders/js-fixes.js index 158a393..dd358a6 100644 --- a/packages/configorama/src/utils/encoders/js-fixes.js +++ b/packages/configorama/src/utils/encoders/js-fixes.js @@ -94,13 +94,40 @@ function hasEncodedJson(value) { * @param {string} [varPrefix] - the variable opening, e.g. `${` or `#{` * @returns {string} */ +/** + * Whether index sits inside a variable expression. Inside one every { } counts, so a JSON object + * argument (${merge({"a":1})}) doesn't end it early; outside, only varPrefix opens one. + * @param {string} str + * @param {number} index + * @param {string} varPrefix - the variable opening, ending in `{` + * @returns {boolean} + */ +function isInsideVariable(str, index, varPrefix) { + let depth = 0 + for (let i = 0; i < index; i++) { + if (depth === 0) { + if (str.startsWith(varPrefix, i)) { + depth = 1 + i += varPrefix.length - 1 + } + } else if (str[i] === '{') { + depth++ + } else if (str[i] === '}') { + depth-- + } + } + return depth > 0 +} + function encodeJsonArgObjects(str, varPrefix = '${') { if (!str || typeof str !== 'string' || str.indexOf('{') === -1) return str if (!varPrefix.endsWith('{')) return str // non-brace syntaxes (e.g. `$[`) don't conflict with JSON braces const preBrace = varPrefix.slice(0, -1).replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const guard = preBrace ? `(? { + return str.replace(pattern, (match, open, obj, close, offset) => { + // Only a call inside a variable is configorama syntax; elsewhere (VTL #set($m = {...})) it's text + if (!isInsideVariable(str, offset, varPrefix)) return match // file()/text() take a path, where { } are plain characters if (/^(?:file|text)\(/.test(open)) return match // A { inside a quoted string argument ("{" in eval, '{a},{b}' in split) is text, not JSON diff --git a/packages/configorama/src/utils/encoders/js-fixes.test.js b/packages/configorama/src/utils/encoders/js-fixes.test.js index c6ebdd5..fae1c5a 100644 --- a/packages/configorama/src/utils/encoders/js-fixes.test.js +++ b/packages/configorama/src/utils/encoders/js-fixes.test.js @@ -38,6 +38,17 @@ test('encodeJsonArgObjects - braces inside a quoted string argument are not JSON assert.is(encodeJsonArgObjects("${split('{a},{b}', ',')}"), "${split('{a},{b}', ',')}") }) +test('encodeJsonArgObjects - a call outside any variable is plain text (VTL #set($m = {...}))', () => { + assert.is(encodeJsonArgObjects('#set($m = { "a": "b" })'), '#set($m = { "a": "b" })') + const vtl = '#set($inputRoot = $input.path(\'$\'))\n#set($mfaMap = {\n "SMS_MFA": "SMS"\n})\n' + assert.is(encodeJsonArgObjects(vtl), vtl) +}) + +test('encodeJsonArgObjects - only the call inside a variable is encoded when both appear', () => { + const out = encodeJsonArgObjects('#set($m = {"a":1}) ${self:x | help({"k":2})}') + assert.ok(out.startsWith('#set($m = {"a":1}) ${self:x | help(__JSON_B64__'), out) +}) + test('encodeJsonArgObjects - file() and text() paths are not JSON', () => { assert.is(encodeJsonArgObjects('${file(./br/{x}.json):k}'), '${file(./br/{x}.json):k}') assert.is(encodeJsonArgObjects('${text(./{x}.txt)}'), '${text(./{x}.txt)}') diff --git a/packages/configorama/tests/conformance/harness.js b/packages/configorama/tests/conformance/harness.js index 2b108ff..96ec177 100644 --- a/packages/configorama/tests/conformance/harness.js +++ b/packages/configorama/tests/conformance/harness.js @@ -6,13 +6,19 @@ const assert = require('uvu/assert') const ROOT = path.resolve(__dirname, '../..') const GOLDEN_DIR = path.join(__dirname, 'goldens') -function canonicalize(value) { +/** + * Stable text for a golden: sorted JSON keys, LF endings, machine paths masked + * @param {any} value + * @param {{ keepBackslashes?: boolean }} [options] - keepBackslashes: leave \ as is (config values, + * where \n or \" is data) instead of turning Windows path separators into / + * @returns {string} + */ +function canonicalize(value, options = {}) { if (typeof value !== 'string') { value = JSON.stringify(value, stableJsonReplacer, 2) } - return String(value) - .replace(/\r\n/g, '\n') - .replace(/\\/g, '/') + const text = String(value).replace(/\r\n/g, '\n') + return (options.keepBackslashes ? text : text.replace(/\\/g, '/')) .replaceAll(ROOT, '') .replace(/\/Users\/[^/\n]+/g, '/Users/') .trimEnd() + '\n' @@ -30,9 +36,9 @@ function goldenPath(name, dir = GOLDEN_DIR) { return path.join(dir, `${name}.golden`) } -function assertGolden(name, actual, dir = GOLDEN_DIR) { +function assertGolden(name, actual, dir = GOLDEN_DIR, options = {}) { const expectedPath = goldenPath(name, dir) - const output = canonicalize(actual) + const output = canonicalize(actual, options) if (process.env.UPDATE_GOLDENS) { fs.mkdirSync(path.dirname(expectedPath), { recursive: true }) diff --git a/packages/configorama/tests/serverlessGoldens/fixtures/apigw-vtl.yml b/packages/configorama/tests/serverlessGoldens/fixtures/apigw-vtl.yml new file mode 100644 index 0000000..9ce6843 --- /dev/null +++ b/packages/configorama/tests/serverlessGoldens/fixtures/apigw-vtl.yml @@ -0,0 +1,42 @@ +# API Gateway mapping templates (Velocity): $vars, #set maps, #foreach, and JSON bodies are +# template text, not configorama syntax. Only ${self:...} refs inside are resolved. +service: proxy +provider: + name: aws + stage: ${opt:stage, 'dev'} +resources: + Resources: + GetUser: + Type: AWS::ApiGateway::Method + Properties: + Integration: + RequestTemplates: + application/json: | + #set($token = $input.params('Authorization').replace('Bearer ', '')) + { + "AccessToken": "$token" + } + IntegrationResponses: + - StatusCode: '200' + ResponseTemplates: + application/json: + Fn::If: + - CustomMapping + - | + #set($inputRoot = $input.path('$')) + #set($mfaMap = { + "SMS_MFA": "SMS", + "SOFTWARE_TOKEN_MFA": "TOKEN" + }) + #set($flags = { "stage": "${self:provider.stage}" }) + { + "username": "$inputRoot.Username", + "attributes": { + #foreach($attr in $inputRoot.UserAttributes) + "$attr.Name": "$attr.Value"#if($foreach.hasNext),#end + #end + }, + "mfaSetting": "$mfaMap.get($inputRoot.PreferredMfaSetting)", + "stage": "${self:provider.stage}" + } + - $input.json('$') diff --git a/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.deployer.golden b/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.deployer.golden new file mode 100644 index 0000000..bffa943 --- /dev/null +++ b/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.deployer.golden @@ -0,0 +1,35 @@ +{ + "provider": { + "name": "aws", + "stage": "acme-prod" + }, + "resources": { + "Resources": { + "GetUser": { + "Properties": { + "Integration": { + "IntegrationResponses": [ + { + "ResponseTemplates": { + "application/json": { + "Fn::If": [ + "CustomMapping", + "#set($inputRoot = $input.path('$'))\n#set($mfaMap = {\n \"SMS_MFA\": \"SMS\",\n \"SOFTWARE_TOKEN_MFA\": \"TOKEN\"\n})\n#set($flags = { \"stage\": \"acme-prod\" })\n{\n \"username\": \"$inputRoot.Username\",\n \"attributes\": {\n #foreach($attr in $inputRoot.UserAttributes)\n \"$attr.Name\": \"$attr.Value\"#if($foreach.hasNext),#end\n #end\n },\n \"mfaSetting\": \"$mfaMap.get($inputRoot.PreferredMfaSetting)\",\n \"stage\": \"acme-prod\"\n}\n", + "$input.json('$')" + ] + } + }, + "StatusCode": "200" + } + ], + "RequestTemplates": { + "application/json": "#set($token = $input.params('Authorization').replace('Bearer ', ''))\n{\n \"AccessToken\": \"$token\"\n}\n" + } + } + }, + "Type": "AWS::ApiGateway::Method" + } + } + }, + "service": "proxy" +} diff --git a/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.standalone.golden b/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.standalone.golden new file mode 100644 index 0000000..b81695e --- /dev/null +++ b/packages/configorama/tests/serverlessGoldens/goldens/apigw-vtl.standalone.golden @@ -0,0 +1,35 @@ +{ + "provider": { + "name": "aws", + "stage": "qa" + }, + "resources": { + "Resources": { + "GetUser": { + "Properties": { + "Integration": { + "IntegrationResponses": [ + { + "ResponseTemplates": { + "application/json": { + "Fn::If": [ + "CustomMapping", + "#set($inputRoot = $input.path('$'))\n#set($mfaMap = {\n \"SMS_MFA\": \"SMS\",\n \"SOFTWARE_TOKEN_MFA\": \"TOKEN\"\n})\n#set($flags = { \"stage\": \"qa\" })\n{\n \"username\": \"$inputRoot.Username\",\n \"attributes\": {\n #foreach($attr in $inputRoot.UserAttributes)\n \"$attr.Name\": \"$attr.Value\"#if($foreach.hasNext),#end\n #end\n },\n \"mfaSetting\": \"$mfaMap.get($inputRoot.PreferredMfaSetting)\",\n \"stage\": \"qa\"\n}\n", + "$input.json('$')" + ] + } + }, + "StatusCode": "200" + } + ], + "RequestTemplates": { + "application/json": "#set($token = $input.params('Authorization').replace('Bearer ', ''))\n{\n \"AccessToken\": \"$token\"\n}\n" + } + } + }, + "Type": "AWS::ApiGateway::Method" + } + } + }, + "service": "proxy" +} diff --git a/packages/configorama/tests/serverlessGoldens/goldens/buildspec.deployer.golden b/packages/configorama/tests/serverlessGoldens/goldens/buildspec.deployer.golden index 2c01d66..e5f0af7 100644 --- a/packages/configorama/tests/serverlessGoldens/goldens/buildspec.deployer.golden +++ b/packages/configorama/tests/serverlessGoldens/goldens/buildspec.deployer.golden @@ -9,7 +9,7 @@ "Properties": { "Name": "builder-acme-prod", "Source": { - "BuildSpec": "version: 0.2/nphases:/n build:/n commands:/n - test -n /"$REPOSITORY_URL/"/n - if [ -n /"$TOKEN/" ]; then git clone /"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}/" repo; else git clone /"$REPOSITORY_URL/" repo; fi/n - export ELAPSED=$(( $(date +%s) - START ))/n - echo /"stage acme-prod/"/n", + "BuildSpec": "version: 0.2\nphases:\n build:\n commands:\n - test -n \"$REPOSITORY_URL\"\n - if [ -n \"$TOKEN\" ]; then git clone \"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}\" repo; else git clone \"$REPOSITORY_URL\" repo; fi\n - export ELAPSED=$(( $(date +%s) - START ))\n - echo \"stage acme-prod\"\n", "Type": "NO_SOURCE" } }, diff --git a/packages/configorama/tests/serverlessGoldens/goldens/buildspec.standalone.golden b/packages/configorama/tests/serverlessGoldens/goldens/buildspec.standalone.golden index 8291525..722c655 100644 --- a/packages/configorama/tests/serverlessGoldens/goldens/buildspec.standalone.golden +++ b/packages/configorama/tests/serverlessGoldens/goldens/buildspec.standalone.golden @@ -9,7 +9,7 @@ "Properties": { "Name": "builder-qa", "Source": { - "BuildSpec": "version: 0.2/nphases:/n build:/n commands:/n - test -n /"$REPOSITORY_URL/"/n - if [ -n /"$TOKEN/" ]; then git clone /"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}/" repo; else git clone /"$REPOSITORY_URL/" repo; fi/n - export ELAPSED=$(( $(date +%s) - START ))/n - echo /"stage qa/"/n", + "BuildSpec": "version: 0.2\nphases:\n build:\n commands:\n - test -n \"$REPOSITORY_URL\"\n - if [ -n \"$TOKEN\" ]; then git clone \"https://x-access-token:${TOKEN}@${REPOSITORY_URL#https://}\" repo; else git clone \"$REPOSITORY_URL\" repo; fi\n - export ELAPSED=$(( $(date +%s) - START ))\n - echo \"stage qa\"\n", "Type": "NO_SOURCE" } }, diff --git a/packages/configorama/tests/serverlessGoldens/serverlessGoldens.test.js b/packages/configorama/tests/serverlessGoldens/serverlessGoldens.test.js index 91fec87..2c61c76 100644 --- a/packages/configorama/tests/serverlessGoldens/serverlessGoldens.test.js +++ b/packages/configorama/tests/serverlessGoldens/serverlessGoldens.test.js @@ -90,7 +90,7 @@ for (const file of fixtures) { } } assert.equal(resolvedSync, resolved, 'sync and async APIs resolve the same') - assertGolden(`${name}.${mode}`, resolved, GOLDENS) + assertGolden(`${name}.${mode}`, resolved, GOLDENS, { keepBackslashes: true }) }) } }