diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 5f299e8bb91..88eb3a4c5b7 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -9,7 +9,9 @@ import ( "errors" "fmt" "io" + "maps" "os" + "regexp" "slices" "strings" "time" @@ -1263,17 +1265,48 @@ 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.\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" + + "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, } } type envGetValuesFlags struct { internal.EnvFlag global *internal.GlobalCommandOptions + export bool + shell string } -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. "+ + "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 } @@ -1305,6 +1338,39 @@ 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( + "--export and --output are mutually exclusive: %w", + internal.ErrInvalidFlagCombination, + ), + Suggestion: "Use '--export' without '--output', " + + "or remove '--export' to use '--output json'.", + } + } + + 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 @@ -1338,9 +1404,130 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, fmt.Errorf("ensuring environment exists: %w", err) } + if eg.flags.export { + if shell == "pwsh" { + return nil, writePwshExportedEnv( + env.Dotenv(), eg.writer, os.Stderr, + ) + } + return nil, writeExportedEnv( + env.Dotenv(), eg.writer, os.Stderr, + ) + } + 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`, +) + +// 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_]*$`) + +// 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, and carriage returns escaped. Newlines use +// ANSI-C quoting ($'...') to ensure correct round-tripping through eval. +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( + warnWriter, + "warning: skipping key %q "+ + "(not a valid shell identifier)\n", + key, + ) + continue + } + + val := values[key] + + // 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`) + 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) + } + + if _, err := io.WriteString(writer, line); err != nil { + return err + } + } + return nil +} + +// pwshEscaper escapes characters that are special inside PowerShell +// 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, 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( + warnWriter, + "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 new file mode 100644 index 00000000000..7ab71a9ca72 --- /dev/null +++ b/cli/azd/cmd/env_get_values_test.go @@ -0,0 +1,357 @@ +// 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 + shell string + expected string + }{ + { + name: "export basic values", + envVars: map[string]string{ + "FOO": "bar", + "BAZ": "qux", + }, + export: true, + shell: "bash", + 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, + shell: "bash", + 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, + shell: "bash", + 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, + shell: "bash", + 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, + shell: "bash", + 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, + shell: "bash", + 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{ + "CR_VALUE": "line1\rline2", + }, + export: true, + shell: "bash", + 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, + shell: "bash", + 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, + 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 { + 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, + shell: tt.shell, + }, + } + + _, err = action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, tt.expected, buf.String()) + }) + } +} + +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()) + + 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, + shell: "bash", + }, + } + + _, err = action.Run(t.Context()) + require.Error(t, err) + require.Contains( + 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", + ) +} + +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", + ) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..55d14be6ae6 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1917,6 +1917,19 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--export'], + 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 7c8296ba56d..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,6 +6,8 @@ Usage Flags -e, --environment string : The name of the environment to use. + --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. diff --git a/cli/azd/pkg/azdext/mcp_security_test.go b/cli/azd/pkg/azdext/mcp_security_test.go index d4d568c41dc..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,25 +320,12 @@ func TestMCPSecurityFluentBuilder(t *testing.T) { RedactHeaders("Authorization"). ValidatePathsWithinBase("/tmp") - if policy == nil { - t.Fatal("fluent builder should return non-nil policy") - } - - 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) { 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) } }