Skip to content

Commit 9d8e038

Browse files
authored
Fix: Add JSON escape to variables before syncing to remote pipeline (#5568)
* Add JSON escape to variables before syncing to remote Add JSON escape to variables before syncing to remote Add JSON escape to variables before syncing to remote Add JSON escape to variables before syncing to remote Add JSON escape to variables before syncing to remote * Escape environment config values in pipeline manager before calling providers
1 parent 6adee29 commit 9d8e038

File tree

2 files changed

+142
-0
lines changed

2 files changed

+142
-0
lines changed

cli/azd/pkg/pipeline/pipeline.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package pipeline
55

66
import (
77
"context"
8+
"encoding/json"
89
"fmt"
910
"maps"
1011
"os"
@@ -231,9 +232,44 @@ func mergeProjectVariablesAndSecrets(
231232
}
232233
}
233234

235+
// Escape values for safe transmission to pipeline providers.
236+
// This ensures that values containing JSON-like content (e.g., `["api://..."]`)
237+
// are properly escaped (e.g., `[\"api://...\"]`) before being sent to GitHub Actions or Azure DevOps.
238+
// Without this, the remote pipeline may incorrectly parse the value as JSON instead of treating it as a string.
239+
escapeValuesForPipeline(variables)
240+
escapeValuesForPipeline(secrets)
241+
234242
return variables, secrets, nil
235243
}
236244

245+
// escapeValuesForPipeline applies JSON escaping to values to ensure they are correctly
246+
// interpreted as strings by pipeline providers (GitHub Actions, Azure DevOps).
247+
//
248+
// When a value contains special characters (e.g., quotes, backslashes, brackets), it needs
249+
// to be escaped before being sent to the remote pipeline. This function uses JSON marshaling
250+
// to properly escape the value, then strips the outer quotes added by marshaling.
251+
//
252+
// Example: the value `["api://guid"]` becomes `[\"api://guid\"]` after escaping.
253+
func escapeValuesForPipeline(values map[string]string) {
254+
for key, value := range values {
255+
// Use JSON marshaling to properly escape special characters
256+
escapedBytes, err := json.Marshal(value)
257+
if err != nil {
258+
// If marshaling fails, keep the original value
259+
continue
260+
}
261+
262+
escapedStr := string(escapedBytes)
263+
// JSON marshaling wraps the string in quotes; remove them
264+
// Example: json.Marshal("test") produces "\"test\"", we want just the inner content
265+
if len(escapedStr) >= 2 && escapedStr[0] == '"' && escapedStr[len(escapedStr)-1] == '"' {
266+
escapedStr = escapedStr[1 : len(escapedStr)-1]
267+
}
268+
269+
values[key] = escapedStr
270+
}
271+
}
272+
237273
const (
238274
gitHubDisplayName string = "GitHub"
239275
gitHubCode = "github"

cli/azd/pkg/pipeline/pipeline_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"testing"
77

88
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
910
)
1011

1112
func Test_ConfigOptions_SecretsAndVars(t *testing.T) {
@@ -46,3 +47,108 @@ func Test_ConfigOptions_SecretsAndVars(t *testing.T) {
4647
assert.Equal(t, expectedVariables, variables)
4748
assert.Equal(t, expectedSecrets, secrets)
4849
}
50+
51+
// Test_ConfigOptions_EscapedValues tests that JSON-escaped string values are preserved
52+
// when merging project variables and secrets.
53+
// This addresses the issue where values like `["api://..."]` need to be escaped
54+
// to `[\"api://...\"]` when sent to remote pipelines to prevent them from being
55+
// interpreted as JSON arrays instead of strings.
56+
func Test_ConfigOptions_EscapedValues(t *testing.T) {
57+
projectVariables := []string{"AzureAd_TokenValidationParameters_ValidAudiences"}
58+
projectSecrets := []string{}
59+
60+
initialVariables := map[string]string{}
61+
initialSecrets := map[string]string{}
62+
63+
// This simulates a value that is read from config.json.
64+
// After JSON unmarshaling, the value `"[\"api://...\"]"` becomes `["api://..."]` (backslashes consumed)
65+
// We need to re-escape it before sending to the pipeline so it's treated as a string, not an array
66+
env := map[string]string{
67+
"AzureAd_TokenValidationParameters_ValidAudiences": "[\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\"]",
68+
}
69+
70+
variables, secrets, err := mergeProjectVariablesAndSecrets(
71+
projectVariables, projectSecrets, initialVariables, initialSecrets, nil, env)
72+
require.NoError(t, err)
73+
74+
// After escaping, the value should have backslashes to prevent JSON parsing in the pipeline
75+
// The value becomes: [\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\"]
76+
expectedVariables := map[string]string{
77+
"AzureAd_TokenValidationParameters_ValidAudiences": "[\\\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\\\"]",
78+
}
79+
expectedSecrets := map[string]string{}
80+
81+
assert.Equal(t, expectedVariables, variables)
82+
assert.Equal(t, expectedSecrets, secrets)
83+
}
84+
85+
// Test_ConfigOptions_SimpleValues tests that simple string values are properly escaped
86+
func Test_ConfigOptions_SimpleValues(t *testing.T) {
87+
projectVariables := []string{"SIMPLE_VAR", "VAR_WITH_QUOTES"}
88+
projectSecrets := []string{}
89+
90+
initialVariables := map[string]string{}
91+
initialSecrets := map[string]string{}
92+
93+
env := map[string]string{
94+
"SIMPLE_VAR": "simple-value",
95+
"VAR_WITH_QUOTES": "value with \"quotes\"",
96+
}
97+
98+
variables, secrets, err := mergeProjectVariablesAndSecrets(
99+
projectVariables, projectSecrets, initialVariables, initialSecrets, nil, env)
100+
require.NoError(t, err)
101+
102+
// Simple values remain mostly the same, quotes get escaped
103+
expectedVariables := map[string]string{
104+
"SIMPLE_VAR": "simple-value",
105+
"VAR_WITH_QUOTES": "value with \\\"quotes\\\"",
106+
}
107+
expectedSecrets := map[string]string{}
108+
109+
assert.Equal(t, expectedVariables, variables)
110+
assert.Equal(t, expectedSecrets, secrets)
111+
}
112+
113+
// Test_escapeValuesForPipeline tests the escape function directly
114+
func Test_escapeValuesForPipeline(t *testing.T) {
115+
tests := []struct {
116+
name string
117+
input string
118+
expected string
119+
}{
120+
{
121+
name: "JSON array string",
122+
input: "[\"api://guid\"]",
123+
expected: "[\\\"api://guid\\\"]",
124+
},
125+
{
126+
name: "Simple string",
127+
input: "simple",
128+
expected: "simple",
129+
},
130+
{
131+
name: "String with quotes",
132+
input: "value with \"quotes\"",
133+
expected: "value with \\\"quotes\\\"",
134+
},
135+
{
136+
name: "String with backslashes",
137+
input: "path\\to\\file",
138+
expected: "path\\\\to\\\\file",
139+
},
140+
{
141+
name: "Empty string",
142+
input: "",
143+
expected: "",
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
values := map[string]string{"test": tt.input}
150+
escapeValuesForPipeline(values)
151+
assert.Equal(t, tt.expected, values["test"])
152+
})
153+
}
154+
}

0 commit comments

Comments
 (0)