diff --git a/cli/azd/pkg/pipeline/pipeline.go b/cli/azd/pkg/pipeline/pipeline.go index fd6cd8fb23b..624459a5982 100644 --- a/cli/azd/pkg/pipeline/pipeline.go +++ b/cli/azd/pkg/pipeline/pipeline.go @@ -5,6 +5,7 @@ package pipeline import ( "context" + "encoding/json" "fmt" "maps" "os" @@ -231,9 +232,44 @@ func mergeProjectVariablesAndSecrets( } } + // Escape values for safe transmission to pipeline providers. + // This ensures that values containing JSON-like content (e.g., `["api://..."]`) + // are properly escaped (e.g., `[\"api://...\"]`) before being sent to GitHub Actions or Azure DevOps. + // Without this, the remote pipeline may incorrectly parse the value as JSON instead of treating it as a string. + escapeValuesForPipeline(variables) + escapeValuesForPipeline(secrets) + return variables, secrets, nil } +// escapeValuesForPipeline applies JSON escaping to values to ensure they are correctly +// interpreted as strings by pipeline providers (GitHub Actions, Azure DevOps). +// +// When a value contains special characters (e.g., quotes, backslashes, brackets), it needs +// to be escaped before being sent to the remote pipeline. This function uses JSON marshaling +// to properly escape the value, then strips the outer quotes added by marshaling. +// +// Example: the value `["api://guid"]` becomes `[\"api://guid\"]` after escaping. +func escapeValuesForPipeline(values map[string]string) { + for key, value := range values { + // Use JSON marshaling to properly escape special characters + escapedBytes, err := json.Marshal(value) + if err != nil { + // If marshaling fails, keep the original value + continue + } + + escapedStr := string(escapedBytes) + // JSON marshaling wraps the string in quotes; remove them + // Example: json.Marshal("test") produces "\"test\"", we want just the inner content + if len(escapedStr) >= 2 && escapedStr[0] == '"' && escapedStr[len(escapedStr)-1] == '"' { + escapedStr = escapedStr[1 : len(escapedStr)-1] + } + + values[key] = escapedStr + } +} + const ( gitHubDisplayName string = "GitHub" gitHubCode = "github" diff --git a/cli/azd/pkg/pipeline/pipeline_test.go b/cli/azd/pkg/pipeline/pipeline_test.go index cab72f86112..6bd7e14e570 100644 --- a/cli/azd/pkg/pipeline/pipeline_test.go +++ b/cli/azd/pkg/pipeline/pipeline_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_ConfigOptions_SecretsAndVars(t *testing.T) { @@ -46,3 +47,108 @@ func Test_ConfigOptions_SecretsAndVars(t *testing.T) { assert.Equal(t, expectedVariables, variables) assert.Equal(t, expectedSecrets, secrets) } + +// Test_ConfigOptions_EscapedValues tests that JSON-escaped string values are preserved +// when merging project variables and secrets. +// This addresses the issue where values like `["api://..."]` need to be escaped +// to `[\"api://...\"]` when sent to remote pipelines to prevent them from being +// interpreted as JSON arrays instead of strings. +func Test_ConfigOptions_EscapedValues(t *testing.T) { + projectVariables := []string{"AzureAd_TokenValidationParameters_ValidAudiences"} + projectSecrets := []string{} + + initialVariables := map[string]string{} + initialSecrets := map[string]string{} + + // This simulates a value that is read from config.json. + // After JSON unmarshaling, the value `"[\"api://...\"]"` becomes `["api://..."]` (backslashes consumed) + // We need to re-escape it before sending to the pipeline so it's treated as a string, not an array + env := map[string]string{ + "AzureAd_TokenValidationParameters_ValidAudiences": "[\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\"]", + } + + variables, secrets, err := mergeProjectVariablesAndSecrets( + projectVariables, projectSecrets, initialVariables, initialSecrets, nil, env) + require.NoError(t, err) + + // After escaping, the value should have backslashes to prevent JSON parsing in the pipeline + // The value becomes: [\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\"] + expectedVariables := map[string]string{ + "AzureAd_TokenValidationParameters_ValidAudiences": "[\\\"api://e935a748-8b59-4c26-a59c-9bcc83f5ab57\\\"]", + } + expectedSecrets := map[string]string{} + + assert.Equal(t, expectedVariables, variables) + assert.Equal(t, expectedSecrets, secrets) +} + +// Test_ConfigOptions_SimpleValues tests that simple string values are properly escaped +func Test_ConfigOptions_SimpleValues(t *testing.T) { + projectVariables := []string{"SIMPLE_VAR", "VAR_WITH_QUOTES"} + projectSecrets := []string{} + + initialVariables := map[string]string{} + initialSecrets := map[string]string{} + + env := map[string]string{ + "SIMPLE_VAR": "simple-value", + "VAR_WITH_QUOTES": "value with \"quotes\"", + } + + variables, secrets, err := mergeProjectVariablesAndSecrets( + projectVariables, projectSecrets, initialVariables, initialSecrets, nil, env) + require.NoError(t, err) + + // Simple values remain mostly the same, quotes get escaped + expectedVariables := map[string]string{ + "SIMPLE_VAR": "simple-value", + "VAR_WITH_QUOTES": "value with \\\"quotes\\\"", + } + expectedSecrets := map[string]string{} + + assert.Equal(t, expectedVariables, variables) + assert.Equal(t, expectedSecrets, secrets) +} + +// Test_escapeValuesForPipeline tests the escape function directly +func Test_escapeValuesForPipeline(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "JSON array string", + input: "[\"api://guid\"]", + expected: "[\\\"api://guid\\\"]", + }, + { + name: "Simple string", + input: "simple", + expected: "simple", + }, + { + name: "String with quotes", + input: "value with \"quotes\"", + expected: "value with \\\"quotes\\\"", + }, + { + name: "String with backslashes", + input: "path\\to\\file", + expected: "path\\\\to\\\\file", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + values := map[string]string{"test": tt.input} + escapeValuesForPipeline(values) + assert.Equal(t, tt.expected, values["test"]) + }) + } +}