Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions cli/azd/pkg/pipeline/pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package pipeline

import (
"context"
"encoding/json"
"fmt"
"maps"
"os"
Expand Down Expand Up @@ -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"
Expand Down
106 changes: 106 additions & 0 deletions cli/azd/pkg/pipeline/pipeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_ConfigOptions_SecretsAndVars(t *testing.T) {
Expand Down Expand Up @@ -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"])
})
}
}
Loading