From fd6936f85a7407f606615e14031464c7ef07e0da Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Thu, 26 Mar 2026 23:45:29 -0700 Subject: [PATCH 01/10] feat: add --export flag to azd env get-values for shell sourcing Add --export flag that outputs environment variables in shell-ready format (export KEY="VALUE" for bash/zsh). This enables easy shell integration: eval "$(azd env get-values --export)" Escapes backslashes, double quotes, dollar signs, backticks, newlines, and carriage returns for safe eval usage. Returns error when combined with --output flag (mutually exclusive). Fixes #4384 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 60 +++++- cli/azd/cmd/env_get_values_test.go | 187 ++++++++++++++++++ cli/azd/cmd/testdata/TestFigSpec.ts | 4 + .../TestUsage-azd-env-get-values.snap | 1 + 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 cli/azd/cmd/env_get_values_test.go diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 5f299e8bb91..e56fa331cf8 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "slices" "strings" @@ -1263,17 +1264,32 @@ func newEnvGetValuesCmd() *cobra.Command { return &cobra.Command{ Use: "get-values", Short: "Get all environment values.", - Args: cobra.NoArgs, + Long: "Get all environment values.\n\n" + + "Use --export to output in shell-ready format " + + "(export KEY=\"VALUE\").\n" + + "This enables shell integration:\n\n" + + " eval \"$(azd env get-values --export)\"", + Args: cobra.NoArgs, } } type envGetValuesFlags struct { internal.EnvFlag global *internal.GlobalCommandOptions + export bool } -func (eg *envGetValuesFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { +func (eg *envGetValuesFlags) Bind( + local *pflag.FlagSet, + global *internal.GlobalCommandOptions, +) { eg.EnvFlag.Bind(local, global) + local.BoolVar( + &eg.export, + "export", + false, + "Output in shell-ready format (export KEY=\"VALUE\").", + ) eg.global = global } @@ -1305,6 +1321,13 @@ func newEnvGetValuesAction( } func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) { + if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat { + return nil, fmt.Errorf( + "--export and --output are mutually exclusive: %w", + internal.ErrInvalidFlagCombination, + ) + } + name, err := eg.azdCtx.GetDefaultEnvironmentName() if err != nil { return nil, err @@ -1338,9 +1361,42 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, fmt.Errorf("ensuring environment exists: %w", err) } + if eg.flags.export { + return nil, writeExportedEnv( + env.Dotenv(), eg.writer, + ) + } + return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil) } +// writeExportedEnv writes environment variables in shell-ready +// format (export KEY="VALUE") to the given writer. Values are +// double-quoted with embedded backslashes, double quotes, dollar +// signs, backticks, newlines, and carriage returns escaped. +func writeExportedEnv( + values map[string]string, + writer io.Writer, +) error { + keys := slices.Sorted(maps.Keys(values)) + for _, key := range keys { + val := values[key] + escaped := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `$`, `\$`, + "`", "\\`", + "\n", `\n`, + "\r", `\r`, + ).Replace(val) + line := fmt.Sprintf("export %s=\"%s\"\n", key, escaped) + if _, err := io.WriteString(writer, line); err != nil { + return err + } + } + return nil +} + func newEnvGetValueFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envGetValueFlags { flags := &envGetValueFlags{} flags.Bind(cmd.Flags(), global) diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go new file mode 100644 index 00000000000..486e45f3aa5 --- /dev/null +++ b/cli/azd/cmd/env_get_values_test.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestEnvGetValuesExport(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + export bool + expected string + }{ + { + name: "export basic values", + envVars: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export BAZ=\"qux\"\n" + + "export FOO=\"bar\"\n", + }, + { + name: "export values with special characters", + envVars: map[string]string{ + "CONN": `host="localhost" pass=$ecret`, + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export CONN=" + + `"host=\"localhost\" pass=\$ecret"` + + "\n", + }, + { + name: "export empty value", + envVars: map[string]string{ + "EMPTY": "", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export EMPTY=\"\"\n", + }, + { + name: "export values with newlines", + envVars: map[string]string{ + "MULTILINE": "line1\nline2\nline3", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export MULTILINE=\"line1\\nline2\\nline3\"\n", + }, + { + name: "export values with backslashes", + envVars: map[string]string{ + "WIN_PATH": `C:\path\to\dir`, + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export WIN_PATH=\"C:\\\\path\\\\to\\\\dir\"\n", + }, + { + name: "export values with backticks and command substitution", + envVars: map[string]string{ + "DANGEROUS": "value with `backticks` and $(command)", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n", + }, + { + name: "export values with carriage returns", + envVars: map[string]string{ + "CR_VALUE": "line1\rline2", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export CR_VALUE=\"line1\\rline2\"\n", + }, + { + name: "no export outputs dotenv format", + envVars: map[string]string{ + "KEY": "value", + }, + export: false, + expected: "AZURE_ENV_NAME=\"test\"\n" + + "KEY=\"value\"\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockContext := mocks.NewMockContext( + t.Context(), + ) + + azdCtx := azdcontext.NewAzdContextWithDirectory( + t.TempDir(), + ) + err := azdCtx.SetProjectState( + azdcontext.ProjectState{ + DefaultEnvironment: "test", + }, + ) + require.NoError(t, err) + + testEnv := environment.New("test") + for k, v := range tt.envVars { + testEnv.DotenvSet(k, v) + } + + envMgr := &mockenv.MockEnvManager{} + envMgr.On( + "Get", mock.Anything, "test", + ).Return(testEnv, nil) + + var buf bytes.Buffer + formatter, err := output.NewFormatter("dotenv") + require.NoError(t, err) + + action := &envGetValuesAction{ + azdCtx: azdCtx, + console: mockContext.Console, + envManager: envMgr, + formatter: formatter, + writer: &buf, + flags: &envGetValuesFlags{ + global: &internal.GlobalCommandOptions{}, + export: tt.export, + }, + } + + _, err = action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, tt.expected, buf.String()) + }) + } +} + +func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + + azdCtx := azdcontext.NewAzdContextWithDirectory( + t.TempDir(), + ) + err := azdCtx.SetProjectState( + azdcontext.ProjectState{ + DefaultEnvironment: "test", + }, + ) + require.NoError(t, err) + + formatter, err := output.NewFormatter("json") + require.NoError(t, err) + + var buf bytes.Buffer + action := &envGetValuesAction{ + azdCtx: azdCtx, + console: mockContext.Console, + formatter: formatter, + writer: &buf, + flags: &envGetValuesFlags{ + global: &internal.GlobalCommandOptions{}, + export: true, + }, + } + + _, err = action.Run(t.Context()) + require.Error(t, err) + require.Contains( + t, err.Error(), "mutually exclusive", + ) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..df26a55d9c6 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1917,6 +1917,10 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--export'], + description: 'Output in shell-ready format (export KEY="VALUE").', + }, ], }, { diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap index 7c8296ba56d..c52d2910775 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap @@ -6,6 +6,7 @@ Usage Flags -e, --environment string : The name of the environment to use. + --export : Output in shell-ready format (export KEY="VALUE"). Global Flags -C, --cwd string : Sets the current working directory. From 75be893c78054cd8be834ea3bff03f602160824e Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 30 Mar 2026 10:04:38 -0400 Subject: [PATCH 02/10] fix: shell-safe export with newline roundtrip and key validation - Use ANSI-C quoting ($'...') for values containing newlines so \n roundtrips correctly through eval. - Skip keys that are not valid shell identifiers to prevent injection. - Lift strings.NewReplacer out of the loop (allocated once). - Add test for invalid key skipping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 40 ++++++++++++++++++++++-------- cli/azd/cmd/env_get_values_test.go | 15 ++++++++++- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index e56fa331cf8..139c9edd9d3 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -11,6 +11,7 @@ import ( "io" "maps" "os" + "regexp" "slices" "strings" "time" @@ -1373,23 +1374,42 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e // writeExportedEnv writes environment variables in shell-ready // format (export KEY="VALUE") to the given writer. Values are // double-quoted with embedded backslashes, double quotes, dollar -// signs, backticks, newlines, and carriage returns escaped. +// signs, backticks, and carriage returns escaped. Newlines use +// ANSI-C quoting ($'...') to ensure correct roundtripping through eval. func writeExportedEnv( values map[string]string, writer io.Writer, ) error { + escaper := strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `$`, `\$`, + "`", "\\`", + "\r", `\r`, + ) + + // Valid shell identifier: starts with letter or underscore, then alphanumerics/underscores + validKey := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + keys := slices.Sorted(maps.Keys(values)) for _, key := range keys { + if !validKey.MatchString(key) { + continue + } + val := values[key] - escaped := strings.NewReplacer( - `\`, `\\`, - `"`, `\"`, - `$`, `\$`, - "`", "\\`", - "\n", `\n`, - "\r", `\r`, - ).Replace(val) - line := fmt.Sprintf("export %s=\"%s\"\n", key, escaped) + escaped := escaper.Replace(val) + + // Use $'...' quoting for values containing newlines so \n is + // interpreted as an actual newline by the shell. + var line string + if strings.Contains(val, "\n") { + escaped = strings.ReplaceAll(escaped, "\n", `\n`) + line = fmt.Sprintf("export %s=$'%s'\n", key, strings.ReplaceAll(escaped, `'`, `\'`)) + } else { + line = fmt.Sprintf("export %s=\"%s\"\n", key, escaped) + } + if _, err := io.WriteString(writer, line); err != nil { return err } diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go index 486e45f3aa5..822d6c5659e 100644 --- a/cli/azd/cmd/env_get_values_test.go +++ b/cli/azd/cmd/env_get_values_test.go @@ -62,7 +62,7 @@ func TestEnvGetValuesExport(t *testing.T) { }, export: true, expected: "export AZURE_ENV_NAME=\"test\"\n" + - "export MULTILINE=\"line1\\nline2\\nline3\"\n", + "export MULTILINE=$'line1\\nline2\\nline3'\n", }, { name: "export values with backslashes", @@ -100,6 +100,19 @@ func TestEnvGetValuesExport(t *testing.T) { expected: "AZURE_ENV_NAME=\"test\"\n" + "KEY=\"value\"\n", }, + { + name: "export skips invalid shell keys", + envVars: map[string]string{ + "VALID_KEY": "ok", + "bad;key": "injected", + "has spaces": "nope", + "_UNDERSCORE": "fine", + }, + export: true, + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export VALID_KEY=\"ok\"\n" + + "export _UNDERSCORE=\"fine\"\n", + }, } for _, tt := range tests { From 34939fb5ab3dc983e7fa7406e9073f882f121c1b Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 30 Mar 2026 10:07:52 -0400 Subject: [PATCH 03/10] fix: cspell roundtripping -> round-tripping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 139c9edd9d3..929c2d65eae 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1375,7 +1375,7 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e // format (export KEY="VALUE") to the given writer. Values are // double-quoted with embedded backslashes, double quotes, dollar // signs, backticks, and carriage returns escaped. Newlines use -// ANSI-C quoting ($'...') to ensure correct roundtripping through eval. +// ANSI-C quoting ($'...') to ensure correct round-tripping through eval. func writeExportedEnv( values map[string]string, writer io.Writer, From 9b3872f8b4634d613a863ea67a85428684ff2014 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 30 Mar 2026 15:11:00 -0400 Subject: [PATCH 04/10] fix: lift shellEscaper and validShellKey to package-level vars Avoids re-allocating strings.NewReplacer and regexp.MustCompile on every call to writeExportedEnv. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 929c2d65eae..85780c43695 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1371,6 +1371,21 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil) } +// shellEscaper escapes characters that are special inside double-quoted +// shell strings: backslashes, double quotes, dollar signs, backticks, +// and carriage returns. Built once at package level to avoid re-allocation. +var shellEscaper = strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `$`, `\$`, + "`", "\\`", + "\r", `\r`, +) + +// validShellKey matches valid POSIX shell identifiers: +// starts with a letter or underscore, followed by alphanumerics or underscores. +var validShellKey = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + // writeExportedEnv writes environment variables in shell-ready // format (export KEY="VALUE") to the given writer. Values are // double-quoted with embedded backslashes, double quotes, dollar @@ -1380,25 +1395,14 @@ func writeExportedEnv( values map[string]string, writer io.Writer, ) error { - escaper := strings.NewReplacer( - `\`, `\\`, - `"`, `\"`, - `$`, `\$`, - "`", "\\`", - "\r", `\r`, - ) - - // Valid shell identifier: starts with letter or underscore, then alphanumerics/underscores - validKey := regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) - keys := slices.Sorted(maps.Keys(values)) for _, key := range keys { - if !validKey.MatchString(key) { + if !validShellKey.MatchString(key) { continue } val := values[key] - escaped := escaper.Replace(val) + escaped := shellEscaper.Replace(val) // Use $'...' quoting for values containing newlines so \n is // interpreted as an actual newline by the shell. From 13e31bc5549d3376050400acce9f8070b7916935 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 31 Mar 2026 13:40:50 -0400 Subject: [PATCH 05/10] Address review feedback on --export flag - Fix CR-only values to use ANSI-C quoting for correct roundtrip - Document POSIX shell limitation in help text - Emit stderr warning for skipped non-shell-safe keys - Use ErrorWithSuggestion for --export/--output conflict Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 37 ++++++++++++++----- cli/azd/cmd/env_get_values_test.go | 2 +- cli/azd/cmd/testdata/TestFigSpec.ts | 2 +- .../TestUsage-azd-env-get-values.snap | 2 +- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 85780c43695..e2fd44ffc8e 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1266,10 +1266,18 @@ func newEnvGetValuesCmd() *cobra.Command { Use: "get-values", Short: "Get all environment values.", Long: "Get all environment values.\n\n" + - "Use --export to output in shell-ready format " + - "(export KEY=\"VALUE\").\n" + + "Use --export to output in shell-ready " + + "POSIX format (export KEY=\"VALUE\").\n" + "This enables shell integration:\n\n" + - " eval \"$(azd env get-values --export)\"", + " eval \"$(azd env get-values --export)\"\n\n" + + "The output uses POSIX shell syntax " + + "compatible with bash, zsh, and ksh.\n" + + "Values containing newlines or carriage " + + "returns use $'...' (ANSI-C) quoting,\n" + + "which requires bash, zsh, or ksh.\n\n" + + "For PowerShell, use:\n\n" + + " azd env get-values --output json " + + "| ConvertFrom-Json", Args: cobra.NoArgs, } } @@ -1289,7 +1297,8 @@ func (eg *envGetValuesFlags) Bind( &eg.export, "export", false, - "Output in shell-ready format (export KEY=\"VALUE\").", + "Output in POSIX shell-ready format "+ + "(export KEY=\"VALUE\").", ) eg.global = global } @@ -1323,10 +1332,14 @@ func newEnvGetValuesAction( func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) { if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat { - return nil, fmt.Errorf( - "--export and --output are mutually exclusive: %w", - internal.ErrInvalidFlagCombination, - ) + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "--export and --output are mutually exclusive: %w", + internal.ErrInvalidFlagCombination, + ), + Suggestion: "Use '--export' without '--output', " + + "or remove '--export' to use '--output json'.", + } } name, err := eg.azdCtx.GetDefaultEnvironmentName() @@ -1398,6 +1411,12 @@ func writeExportedEnv( keys := slices.Sorted(maps.Keys(values)) for _, key := range keys { if !validShellKey.MatchString(key) { + fmt.Fprintf( + os.Stderr, + "warning: skipping key %q "+ + "(not a valid shell identifier)\n", + key, + ) continue } @@ -1407,7 +1426,7 @@ func writeExportedEnv( // Use $'...' quoting for values containing newlines so \n is // interpreted as an actual newline by the shell. var line string - if strings.Contains(val, "\n") { + if strings.Contains(val, "\n") || strings.Contains(val, "\r") { escaped = strings.ReplaceAll(escaped, "\n", `\n`) line = fmt.Sprintf("export %s=$'%s'\n", key, strings.ReplaceAll(escaped, `'`, `\'`)) } else { diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go index 822d6c5659e..2f4f8c5ff21 100644 --- a/cli/azd/cmd/env_get_values_test.go +++ b/cli/azd/cmd/env_get_values_test.go @@ -89,7 +89,7 @@ func TestEnvGetValuesExport(t *testing.T) { }, export: true, expected: "export AZURE_ENV_NAME=\"test\"\n" + - "export CR_VALUE=\"line1\\rline2\"\n", + "export CR_VALUE=$'line1\\rline2'\n", }, { name: "no export outputs dotenv format", diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index df26a55d9c6..9f0b4132063 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1919,7 +1919,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--export'], - description: 'Output in shell-ready format (export KEY="VALUE").', + description: 'Output in POSIX shell-ready format (export KEY="VALUE").', }, ], }, diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap index c52d2910775..12b56aeb31d 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap @@ -6,7 +6,7 @@ Usage Flags -e, --environment string : The name of the environment to use. - --export : Output in shell-ready format (export KEY="VALUE"). + --export : Output in POSIX shell-ready format (export KEY="VALUE"). Global Flags -C, --cwd string : Sets the current working directory. From dfea26db0640e1cbbce654796052b92371954467 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 31 Mar 2026 15:54:53 -0400 Subject: [PATCH 06/10] Add PowerShell export support via --shell pwsh flag Extends the --export flag with a --shell option (bash | pwsh) so PowerShell users can export env vars natively: azd env get-values --export --shell pwsh | Invoke-Expression PowerShell output uses $env:KEY = "VALUE" syntax with proper backtick escaping for special characters. Includes tests for basic values, special characters, backticks, newlines, empty values, and invalid shell validation. Updates command snapshots for the new --shell flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 88 ++++++++++++--- cli/azd/cmd/env_get_values_test.go | 100 ++++++++++++++++++ cli/azd/cmd/testdata/TestFigSpec.ts | 11 +- .../TestUsage-azd-env-get-values.snap | 3 +- 4 files changed, 187 insertions(+), 15 deletions(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index e2fd44ffc8e..ce3cc181582 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1266,18 +1266,18 @@ func newEnvGetValuesCmd() *cobra.Command { Use: "get-values", Short: "Get all environment values.", Long: "Get all environment values.\n\n" + - "Use --export to output in shell-ready " + - "POSIX format (export KEY=\"VALUE\").\n" + - "This enables shell integration:\n\n" + + "Use --export to output in shell-ready format.\n" + + "Use --shell to select the syntax " + + "(bash or pwsh, default: bash).\n\n" + + "Bash/zsh/ksh (POSIX):\n\n" + " eval \"$(azd env get-values --export)\"\n\n" + - "The output uses POSIX shell syntax " + - "compatible with bash, zsh, and ksh.\n" + - "Values containing newlines or carriage " + - "returns use $'...' (ANSI-C) quoting,\n" + - "which requires bash, zsh, or ksh.\n\n" + - "For PowerShell, use:\n\n" + - " azd env get-values --output json " + - "| ConvertFrom-Json", + "PowerShell:\n\n" + + " azd env get-values --export --shell pwsh " + + "| Invoke-Expression\n\n" + + "POSIX output uses $'...' (ANSI-C) quoting " + + "for values containing newlines\n" + + "or carriage returns, " + + "which requires bash, zsh, or ksh.", Args: cobra.NoArgs, } } @@ -1286,6 +1286,7 @@ type envGetValuesFlags struct { internal.EnvFlag global *internal.GlobalCommandOptions export bool + shell string } func (eg *envGetValuesFlags) Bind( @@ -1297,8 +1298,14 @@ func (eg *envGetValuesFlags) Bind( &eg.export, "export", false, - "Output in POSIX shell-ready format "+ - "(export KEY=\"VALUE\").", + "Output in shell-ready format. "+ + "Use --shell to select the shell syntax (default: bash).", + ) + local.StringVar( + &eg.shell, + "shell", + "bash", + "Shell syntax for --export output: bash (POSIX) or pwsh (PowerShell).", ) eg.global = global } @@ -1342,6 +1349,17 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e } } + shell := strings.ToLower(eg.flags.shell) + if eg.flags.export && shell != "bash" && shell != "pwsh" { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "unsupported shell %q for --export: %w", + eg.flags.shell, internal.ErrInvalidFlagCombination, + ), + Suggestion: "Use '--shell bash' (default) or '--shell pwsh'.", + } + } + name, err := eg.azdCtx.GetDefaultEnvironmentName() if err != nil { return nil, err @@ -1376,6 +1394,11 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e } if eg.flags.export { + if shell == "pwsh" { + return nil, writePwshExportedEnv( + env.Dotenv(), eg.writer, + ) + } return nil, writeExportedEnv( env.Dotenv(), eg.writer, ) @@ -1440,6 +1463,45 @@ func writeExportedEnv( return nil } +// pwshEscaper escapes characters that are special inside PowerShell +// double-quoted strings: backticks, double quotes, and dollar signs. +var pwshEscaper = strings.NewReplacer( + "`", "``", + `"`, "`\"", + `$`, "`$", +) + +// writePwshExportedEnv writes environment variables in PowerShell format +// ($env:KEY = "VALUE") to the given writer. Values are double-quoted with +// embedded backticks, double quotes, and dollar signs escaped using the +// PowerShell backtick escape character. +func writePwshExportedEnv( + values map[string]string, + writer io.Writer, +) error { + keys := slices.Sorted(maps.Keys(values)) + for _, key := range keys { + if !validShellKey.MatchString(key) { + fmt.Fprintf( + os.Stderr, + "warning: skipping key %q "+ + "(not a valid shell identifier)\n", + key, + ) + continue + } + + val := values[key] + escaped := pwshEscaper.Replace(val) + line := fmt.Sprintf("$env:%s = \"%s\"\n", key, escaped) + + if _, err := io.WriteString(writer, line); err != nil { + return err + } + } + return nil +} + func newEnvGetValueFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envGetValueFlags { flags := &envGetValueFlags{} flags.Bind(cmd.Flags(), global) diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go index 2f4f8c5ff21..c9da69c7b4e 100644 --- a/cli/azd/cmd/env_get_values_test.go +++ b/cli/azd/cmd/env_get_values_test.go @@ -22,6 +22,7 @@ func TestEnvGetValuesExport(t *testing.T) { name string envVars map[string]string export bool + shell string expected string }{ { @@ -31,6 +32,7 @@ func TestEnvGetValuesExport(t *testing.T) { "BAZ": "qux", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export BAZ=\"qux\"\n" + "export FOO=\"bar\"\n", @@ -41,6 +43,7 @@ func TestEnvGetValuesExport(t *testing.T) { "CONN": `host="localhost" pass=$ecret`, }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export CONN=" + `"host=\"localhost\" pass=\$ecret"` + @@ -52,6 +55,7 @@ func TestEnvGetValuesExport(t *testing.T) { "EMPTY": "", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export EMPTY=\"\"\n", }, @@ -61,6 +65,7 @@ func TestEnvGetValuesExport(t *testing.T) { "MULTILINE": "line1\nline2\nline3", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export MULTILINE=$'line1\\nline2\\nline3'\n", }, @@ -70,6 +75,7 @@ func TestEnvGetValuesExport(t *testing.T) { "WIN_PATH": `C:\path\to\dir`, }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export WIN_PATH=\"C:\\\\path\\\\to\\\\dir\"\n", }, @@ -79,6 +85,7 @@ func TestEnvGetValuesExport(t *testing.T) { "DANGEROUS": "value with `backticks` and $(command)", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n", }, @@ -88,6 +95,7 @@ func TestEnvGetValuesExport(t *testing.T) { "CR_VALUE": "line1\rline2", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export CR_VALUE=$'line1\\rline2'\n", }, @@ -97,6 +105,7 @@ func TestEnvGetValuesExport(t *testing.T) { "KEY": "value", }, export: false, + shell: "bash", expected: "AZURE_ENV_NAME=\"test\"\n" + "KEY=\"value\"\n", }, @@ -109,10 +118,63 @@ func TestEnvGetValuesExport(t *testing.T) { "_UNDERSCORE": "fine", }, export: true, + shell: "bash", expected: "export AZURE_ENV_NAME=\"test\"\n" + "export VALID_KEY=\"ok\"\n" + "export _UNDERSCORE=\"fine\"\n", }, + { + name: "pwsh export basic values", + envVars: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + export: true, + shell: "pwsh", + expected: "$env:AZURE_ENV_NAME = \"test\"\n" + + "$env:BAZ = \"qux\"\n" + + "$env:FOO = \"bar\"\n", + }, + { + name: "pwsh export special characters", + envVars: map[string]string{ + "CONN": `host="localhost" pass=$ecret`, + }, + export: true, + shell: "pwsh", + expected: "$env:AZURE_ENV_NAME = \"test\"\n" + + "$env:CONN = \"host=`\"localhost`\" pass=`$ecret\"\n", + }, + { + name: "pwsh export with backticks", + envVars: map[string]string{ + "CMD": "value with `backtick`", + }, + export: true, + shell: "pwsh", + expected: "$env:AZURE_ENV_NAME = \"test\"\n" + + "$env:CMD = \"value with ``backtick``\"\n", + }, + { + name: "pwsh export with newlines", + envVars: map[string]string{ + "MULTILINE": "line1\nline2", + }, + export: true, + shell: "pwsh", + expected: "$env:AZURE_ENV_NAME = \"test\"\n" + + "$env:MULTILINE = \"line1\nline2\"\n", + }, + { + name: "pwsh export empty value", + envVars: map[string]string{ + "EMPTY": "", + }, + export: true, + shell: "pwsh", + expected: "$env:AZURE_ENV_NAME = \"test\"\n" + + "$env:EMPTY = \"\"\n", + }, } for _, tt := range tests { @@ -154,6 +216,7 @@ func TestEnvGetValuesExport(t *testing.T) { flags: &envGetValuesFlags{ global: &internal.GlobalCommandOptions{}, export: tt.export, + shell: tt.shell, }, } @@ -189,6 +252,7 @@ func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) { flags: &envGetValuesFlags{ global: &internal.GlobalCommandOptions{}, export: true, + shell: "bash", }, } @@ -198,3 +262,39 @@ func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) { t, err.Error(), "mutually exclusive", ) } + +func TestEnvGetValuesExportInvalidShell(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + + azdCtx := azdcontext.NewAzdContextWithDirectory( + t.TempDir(), + ) + err := azdCtx.SetProjectState( + azdcontext.ProjectState{ + DefaultEnvironment: "test", + }, + ) + require.NoError(t, err) + + formatter, err := output.NewFormatter("dotenv") + require.NoError(t, err) + + var buf bytes.Buffer + action := &envGetValuesAction{ + azdCtx: azdCtx, + console: mockContext.Console, + formatter: formatter, + writer: &buf, + flags: &envGetValuesFlags{ + global: &internal.GlobalCommandOptions{}, + export: true, + shell: "fish", + }, + } + + _, err = action.Run(t.Context()) + require.Error(t, err) + require.Contains( + t, err.Error(), "unsupported shell", + ) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 9f0b4132063..55d14be6ae6 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1919,7 +1919,16 @@ const completionSpec: Fig.Spec = { }, { name: ['--export'], - description: 'Output in POSIX shell-ready format (export KEY="VALUE").', + description: 'Output in shell-ready format. Use --shell to select the shell syntax (default: bash).', + }, + { + name: ['--shell'], + description: 'Shell syntax for --export output: bash (POSIX) or pwsh (PowerShell).', + args: [ + { + name: 'shell', + }, + ], }, ], }, diff --git a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap index 12b56aeb31d..efe44982b03 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-env-get-values.snap @@ -6,7 +6,8 @@ Usage Flags -e, --environment string : The name of the environment to use. - --export : Output in POSIX shell-ready format (export KEY="VALUE"). + --export : Output in shell-ready format. Use --shell to select the shell syntax (default: bash). + --shell string : Shell syntax for --export output: bash (POSIX) or pwsh (PowerShell). Global Flags -C, --cwd string : Sets the current working directory. From 49107250aa93ef832aacc6fd76c7421ddf26a031 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 1 Apr 2026 10:39:23 -0400 Subject: [PATCH 07/10] Fix shell escaping, PowerShell newlines, stderr abstraction, and --shell validation - Use separate ANSI-C escaper for $'...' quoting path so $ and backtick don't get extra backslashes. - Add newline/CR escaping to pwshEscaper using backtick sequences. - Accept warnWriter parameter instead of using os.Stderr directly. - Reject --shell (non-default) without --export. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env.go | 46 +++++++++++++++++++++++------- cli/azd/cmd/env_get_values_test.go | 38 +++++++++++++++++++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index ce3cc181582..88eb3a4c5b7 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1338,6 +1338,18 @@ func newEnvGetValuesAction( } func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) { + shell := strings.ToLower(eg.flags.shell) + if !eg.flags.export && shell != "bash" { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "--shell requires --export: %w", + internal.ErrInvalidFlagCombination, + ), + Suggestion: "Use '--export --shell pwsh' together, " + + "or remove '--shell' when not using '--export'.", + } + } + if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat { return nil, &internal.ErrorWithSuggestion{ Err: fmt.Errorf( @@ -1349,7 +1361,6 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e } } - shell := strings.ToLower(eg.flags.shell) if eg.flags.export && shell != "bash" && shell != "pwsh" { return nil, &internal.ErrorWithSuggestion{ Err: fmt.Errorf( @@ -1396,11 +1407,11 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e if eg.flags.export { if shell == "pwsh" { return nil, writePwshExportedEnv( - env.Dotenv(), eg.writer, + env.Dotenv(), eg.writer, os.Stderr, ) } return nil, writeExportedEnv( - env.Dotenv(), eg.writer, + env.Dotenv(), eg.writer, os.Stderr, ) } @@ -1418,6 +1429,14 @@ var shellEscaper = strings.NewReplacer( "\r", `\r`, ) +// ansiCEscaper escapes characters for ANSI-C $'...' quoting. +// In ANSI-C quoting, only backslash and single quote are special; +// dollar signs and backticks are literal. +var ansiCEscaper = strings.NewReplacer( + `\`, `\\`, + `'`, `\'`, +) + // validShellKey matches valid POSIX shell identifiers: // starts with a letter or underscore, followed by alphanumerics or underscores. var validShellKey = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) @@ -1430,12 +1449,13 @@ var validShellKey = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) func writeExportedEnv( values map[string]string, writer io.Writer, + warnWriter io.Writer, ) error { keys := slices.Sorted(maps.Keys(values)) for _, key := range keys { if !validShellKey.MatchString(key) { fmt.Fprintf( - os.Stderr, + warnWriter, "warning: skipping key %q "+ "(not a valid shell identifier)\n", key, @@ -1444,15 +1464,17 @@ func writeExportedEnv( } val := values[key] - escaped := shellEscaper.Replace(val) // Use $'...' quoting for values containing newlines so \n is // interpreted as an actual newline by the shell. var line string if strings.Contains(val, "\n") || strings.Contains(val, "\r") { + escaped := ansiCEscaper.Replace(val) escaped = strings.ReplaceAll(escaped, "\n", `\n`) - line = fmt.Sprintf("export %s=$'%s'\n", key, strings.ReplaceAll(escaped, `'`, `\'`)) + escaped = strings.ReplaceAll(escaped, "\r", `\r`) + line = fmt.Sprintf("export %s=$'%s'\n", key, escaped) } else { + escaped := shellEscaper.Replace(val) line = fmt.Sprintf("export %s=\"%s\"\n", key, escaped) } @@ -1464,26 +1486,30 @@ func writeExportedEnv( } // pwshEscaper escapes characters that are special inside PowerShell -// double-quoted strings: backticks, double quotes, and dollar signs. +// double-quoted strings: backticks, double quotes, dollar signs, +// newlines, and carriage returns. var pwshEscaper = strings.NewReplacer( "`", "``", `"`, "`\"", `$`, "`$", + "\n", "`n", + "\r", "`r", ) // writePwshExportedEnv writes environment variables in PowerShell format // ($env:KEY = "VALUE") to the given writer. Values are double-quoted with -// embedded backticks, double quotes, and dollar signs escaped using the -// PowerShell backtick escape character. +// embedded backticks, double quotes, dollar signs, newlines, and carriage +// returns escaped using the PowerShell backtick escape character. func writePwshExportedEnv( values map[string]string, writer io.Writer, + warnWriter io.Writer, ) error { keys := slices.Sorted(maps.Keys(values)) for _, key := range keys { if !validShellKey.MatchString(key) { fmt.Fprintf( - os.Stderr, + warnWriter, "warning: skipping key %q "+ "(not a valid shell identifier)\n", key, diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go index c9da69c7b4e..a78470b77f2 100644 --- a/cli/azd/cmd/env_get_values_test.go +++ b/cli/azd/cmd/env_get_values_test.go @@ -163,7 +163,7 @@ func TestEnvGetValuesExport(t *testing.T) { export: true, shell: "pwsh", expected: "$env:AZURE_ENV_NAME = \"test\"\n" + - "$env:MULTILINE = \"line1\nline2\"\n", + "$env:MULTILINE = \"line1`nline2\"\n", }, { name: "pwsh export empty value", @@ -298,3 +298,39 @@ func TestEnvGetValuesExportInvalidShell(t *testing.T) { t, err.Error(), "unsupported shell", ) } + +func TestEnvGetValuesShellWithoutExport(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + + azdCtx := azdcontext.NewAzdContextWithDirectory( + t.TempDir(), + ) + err := azdCtx.SetProjectState( + azdcontext.ProjectState{ + DefaultEnvironment: "test", + }, + ) + require.NoError(t, err) + + formatter, err := output.NewFormatter("dotenv") + require.NoError(t, err) + + var buf bytes.Buffer + action := &envGetValuesAction{ + azdCtx: azdCtx, + console: mockContext.Console, + formatter: formatter, + writer: &buf, + flags: &envGetValuesFlags{ + global: &internal.GlobalCommandOptions{}, + export: false, + shell: "pwsh", + }, + } + + _, err = action.Run(t.Context()) + require.Error(t, err) + require.Contains( + t, err.Error(), "--shell requires --export", + ) +} From 54363159b146ae8214934d923b5f4484d48b713c Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 1 Apr 2026 11:16:17 -0400 Subject: [PATCH 08/10] Fix staticcheck SA5011 false positives in tests Use switch statement for TestPtr to keep nil-check and dereference in the same branch. Add nolint directive for TestMCPSecurityFluentBuilder where t.Fatal guarantees non-nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdext/mcp_security_test.go | 1 + cli/azd/pkg/ux/ux_additional_test.go | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/cli/azd/pkg/azdext/mcp_security_test.go b/cli/azd/pkg/azdext/mcp_security_test.go index d4d568c41dc..678b5744907 100644 --- a/cli/azd/pkg/azdext/mcp_security_test.go +++ b/cli/azd/pkg/azdext/mcp_security_test.go @@ -322,6 +322,7 @@ func TestMCPSecurityFluentBuilder(t *testing.T) { t.Fatal("fluent builder should return non-nil policy") } + //nolint:staticcheck // SA5011 false positive: t.Fatal above guarantees non-nil if !policy.blockMetadata { t.Error("blockMetadata should be true") } diff --git a/cli/azd/pkg/ux/ux_additional_test.go b/cli/azd/pkg/ux/ux_additional_test.go index b77e390729c..e81ae449b6a 100644 --- a/cli/azd/pkg/ux/ux_additional_test.go +++ b/cli/azd/pkg/ux/ux_additional_test.go @@ -40,16 +40,19 @@ func TestConsoleWidth_empty_COLUMNS_uses_default(t *testing.T) { func TestPtr(t *testing.T) { intVal := 42 p := Ptr(intVal) - if p == nil { + switch { + case p == nil: t.Fatal("Ptr should return non-nil pointer") - } - if *p != 42 { + case *p != 42: t.Fatalf("*Ptr(42) = %d, want 42", *p) } strVal := "hello" sp := Ptr(strVal) - if *sp != "hello" { + switch { + case sp == nil: + t.Fatal("Ptr should return non-nil pointer for string") + case *sp != "hello": t.Fatalf("*Ptr(hello) = %q, want hello", *sp) } } From 318b41574dd464d03777e8db995a5a9902ff2152 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 1 Apr 2026 11:20:31 -0400 Subject: [PATCH 09/10] Add regression tests for ANSI-C escaping and warning output - Add combined newline + single-quote test case for ANSI-C path. - Add direct unit test verifying warnWriter receives skip warnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/env_get_values_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cli/azd/cmd/env_get_values_test.go b/cli/azd/cmd/env_get_values_test.go index a78470b77f2..7ab71a9ca72 100644 --- a/cli/azd/cmd/env_get_values_test.go +++ b/cli/azd/cmd/env_get_values_test.go @@ -89,6 +89,14 @@ func TestEnvGetValuesExport(t *testing.T) { expected: "export AZURE_ENV_NAME=\"test\"\n" + "export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n", }, + { + name: "export value with newline and single quotes", + envVars: map[string]string{"MIXED": "it's\nmultiline"}, + export: true, + shell: "bash", + expected: "export AZURE_ENV_NAME=\"test\"\n" + + "export MIXED=$'it\\'s\\nmultiline'\n", + }, { name: "export values with carriage returns", envVars: map[string]string{ @@ -227,6 +235,19 @@ func TestEnvGetValuesExport(t *testing.T) { } } +func TestWriteExportedEnvWarnings(t *testing.T) { + var out, warn bytes.Buffer + err := writeExportedEnv( + map[string]string{"bad;key": "val", "GOOD": "val"}, + &out, &warn, + ) + require.NoError(t, err) + require.Contains(t, warn.String(), "skipping key") + require.Contains(t, warn.String(), "bad;key") + require.Contains(t, out.String(), "GOOD") + require.NotContains(t, out.String(), "bad;key") +} + func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) From 0ce924f191b01f5d59ff4457d0ced19bff89fd7c Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 1 Apr 2026 11:36:38 -0400 Subject: [PATCH 10/10] Fix staticcheck SA5011: use require.NotNil for fluent builder test Replace manual nil-check + t.Fatal with require.NotNil which makes staticcheck aware the pointer is non-nil for subsequent accesses. Convert all manual assertions to require.True/require.Len. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azdext/mcp_security_test.go | 28 +++++++------------------ 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/cli/azd/pkg/azdext/mcp_security_test.go b/cli/azd/pkg/azdext/mcp_security_test.go index 678b5744907..1b2934e8551 100644 --- a/cli/azd/pkg/azdext/mcp_security_test.go +++ b/cli/azd/pkg/azdext/mcp_security_test.go @@ -12,6 +12,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestMCPSecurityCheckURL_BlocksMetadataEndpoints(t *testing.T) { @@ -318,26 +320,12 @@ func TestMCPSecurityFluentBuilder(t *testing.T) { RedactHeaders("Authorization"). ValidatePathsWithinBase("/tmp") - if policy == nil { - t.Fatal("fluent builder should return non-nil policy") - } - - //nolint:staticcheck // SA5011 false positive: t.Fatal above guarantees non-nil - if !policy.blockMetadata { - t.Error("blockMetadata should be true") - } - if !policy.blockPrivate { - t.Error("blockPrivate should be true") - } - if !policy.requireHTTPS { - t.Error("requireHTTPS should be true") - } - if !policy.IsHeaderBlocked("Authorization") { - t.Error("Authorization should be blocked") - } - if len(policy.allowedBasePaths) != 1 { - t.Errorf("expected 1 base path, got %d", len(policy.allowedBasePaths)) - } + require.NotNil(t, policy, "fluent builder should return non-nil policy") + require.True(t, policy.blockMetadata, "blockMetadata should be true") + require.True(t, policy.blockPrivate, "blockPrivate should be true") + require.True(t, policy.requireHTTPS, "requireHTTPS should be true") + require.True(t, policy.IsHeaderBlocked("Authorization"), "Authorization should be blocked") + require.Len(t, policy.allowedBasePaths, 1, "expected 1 base path") } func TestSSRFSafeRedirect_SchemeDowngrade(t *testing.T) {