diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index 58e46f6283e..9c012cb2a2a 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -280,6 +280,7 @@ stdlib stdouttrace STRINGSLICE struct +stretchr structs subst substr diff --git a/cli/azd/cmd/exec.go b/cli/azd/cmd/exec.go new file mode 100644 index 00000000000..8918319fd88 --- /dev/null +++ b/cli/azd/cmd/exec.go @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/cmd/actions" + "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/exec/scripting" + "github.com/azure/azure-dev/cli/azd/pkg/keyvault" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func execActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { + root.Add("exec", &actions.ActionDescriptorOptions{ + Command: newExecCmd(), + FlagsResolver: newExecFlags, + ActionResolver: newExecAction, + OutputFormats: []output.Format{output.NoneFormat}, + DefaultFormat: output.NoneFormat, + GroupingOptions: actions.CommandGroupOptions{ + RootLevelHelp: actions.CmdGroupManage, + }, + }) + return root +} + +func newExecCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "exec [command] [args...] [-- script-args...]", + Short: "Execute commands and scripts with azd environment context.", + Long: `Execute commands and scripts with full access to azd environment variables. + +Commands are run with the azd environment loaded into the child process. +Multiple arguments use direct process execution (no shell wrapping). +A single quoted argument uses shell inline execution. + +Examples: + azd exec python script.py # Direct exec (exact argv) + azd exec npm run dev # Direct exec (no shell) + azd exec -- python app.py --port 8000 # Direct exec with flags + azd exec 'echo $AZURE_ENV_NAME' # Inline via shell + azd exec ./setup.sh # Execute script file + azd exec --shell pwsh "Write-Host 'Hello'" # Inline PowerShell + azd exec ./build.sh -- --verbose # Script with args + azd exec -i ./init.sh # Interactive mode`, + Args: cobra.MinimumNArgs(1), + } + // Stop cobra from parsing flags after the first positional argument + // so that `azd exec python --version` passes --version to python. + cmd.Flags().SetInterspersed(false) + cmd.FParseErrWhitelist.UnknownFlags = true + return cmd +} + +type execFlags struct { + internal.EnvFlag + global *internal.GlobalCommandOptions + shell string + interactive bool +} + +func newExecFlags( + cmd *cobra.Command, global *internal.GlobalCommandOptions, +) *execFlags { + flags := &execFlags{} + flags.Bind(cmd.Flags(), global) + return flags +} + +func (f *execFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { + f.EnvFlag.Bind(local, global) + f.global = global + + local.StringVarP(&f.shell, "shell", "s", "", + "Shell to use (bash, sh, zsh, pwsh, powershell, cmd). "+ + "Auto-detected if not specified.") + local.BoolVarP(&f.interactive, "interactive", "i", false, + "Run in interactive mode (connect stdin)") +} + +type execAction struct { + env *environment.Environment + keyvaultService keyvault.KeyVaultService + flags *execFlags + args []string +} + +func newExecAction( + env *environment.Environment, + keyvaultService keyvault.KeyVaultService, + flags *execFlags, + args []string, +) actions.Action { + return &execAction{ + env: env, + keyvaultService: keyvaultService, + flags: flags, + args: args, + } +} + +// buildChildEnv creates a scoped environment for the child process. +// It starts from the current process env, then overlays azd environment +// variables. Key Vault references (akvs:// and @Microsoft.KeyVault(SecretUri=...)) +// are resolved transparently. We never call os.Setenv — secrets stay out of +// the parent process. +func (a *execAction) buildChildEnv(ctx context.Context) ([]string, error) { + childEnv := os.Environ() + subscriptionId := a.env.GetSubscriptionId() + for key, value := range a.env.Dotenv() { + resolved := value + if keyvault.IsSecretReference(value) { + secret, err := a.keyvaultService.SecretFromKeyVaultReference(ctx, value, subscriptionId) + if err != nil { + return nil, fmt.Errorf( + "resolving secret for %q: %w", key, err) + } + resolved = secret + } + childEnv = append(childEnv, key+"="+resolved) + } + return childEnv, nil +} + +func (a *execAction) Run(ctx context.Context) (*actions.ActionResult, error) { + childEnv, err := a.buildChildEnv(ctx) + if err != nil { + return nil, err + } + + scriptInput := a.args[0] + var scriptArgs []string + if len(a.args) > 1 { + scriptArgs = a.args[1:] + } + + exec, err := scripting.New(scripting.Config{ + Shell: a.flags.shell, + Interactive: a.flags.interactive, + Args: scriptArgs, + Env: childEnv, + }) + if err != nil { + return nil, fmt.Errorf("invalid configuration: %w", err) + } + + // Try file execution first; fall back based on argument shape. + if err := exec.Execute(ctx, scriptInput); err != nil { + if _, ok := errors.AsType[*scripting.ScriptNotFoundError](err); ok { + // Guard: if the input looks like a file path (has path separators + // or a known script extension), don't fall through to inline/direct + // execution — the user intended to run a file that doesn't exist. + if looksLikeFilePath(scriptInput) { + return nil, err + } + if len(scriptArgs) > 0 && a.flags.shell == "" { + err = exec.ExecuteDirect(ctx, scriptInput, scriptArgs) + } else { + err = exec.ExecuteInline(ctx, scriptInput) + } + } + if err != nil { + if execErr, ok := errors.AsType[*scripting.ExecutionError](err); ok { + return nil, &internal.ExitCodeError{ + ExitCode: execErr.ExitCode, + Err: err, + } + } + return nil, err + } + } + + return nil, nil +} + +// scriptExtensions lists file extensions that indicate a script file, +// used by looksLikeFilePath to prevent unsafe inline fallback. +var scriptExtensions = map[string]bool{ + ".sh": true, ".bash": true, ".zsh": true, + ".ps1": true, ".cmd": true, ".bat": true, + ".py": true, ".rb": true, ".pl": true, +} + +// looksLikeFilePath reports whether input appears to be a file path rather +// than a bare command or inline script. Used to prevent falling through to +// inline execution when a user typos a script name (F15 security fix). +func looksLikeFilePath(input string) bool { + if strings.ContainsAny(input, "/\\") { + return true + } + ext := strings.ToLower(filepath.Ext(input)) + return ext != "" && scriptExtensions[ext] +} diff --git a/cli/azd/cmd/exec_test.go b/cli/azd/cmd/exec_test.go new file mode 100644 index 00000000000..32eb261b0c3 --- /dev/null +++ b/cli/azd/cmd/exec_test.go @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "os/exec" + "path/filepath" + "runtime" + "strings" + "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/exec/scripting" + "github.com/azure/azure-dev/cli/azd/pkg/keyvault" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// envSliceToMap converts an env slice ([]string{"KEY=VALUE", ...}) to a map. +// When duplicate keys exist, the last value wins (matching exec.Cmd behavior). +func envSliceToMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, entry := range env { + k, v, _ := strings.Cut(entry, "=") + m[k] = v + } + return m +} + +// mockExecKeyVaultService implements keyvault.KeyVaultService for testing. +// Only SecretFromKeyVaultReference is wired; other methods panic if called. +type mockExecKeyVaultService struct { + secretFromKeyVaultRefFn func(ctx context.Context, ref string, defaultSubID string) (string, error) +} + +func (m *mockExecKeyVaultService) GetKeyVault( + context.Context, string, string, string, +) (*keyvault.KeyVault, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) GetKeyVaultSecret( + context.Context, string, string, string, +) (*keyvault.Secret, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) PurgeKeyVault(context.Context, string, string, string) error { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) ListSubscriptionVaults(context.Context, string) ([]keyvault.Vault, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) CreateVault( + context.Context, string, string, string, string, string, +) (keyvault.Vault, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) ListKeyVaultSecrets(context.Context, string, string) ([]string, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) CreateKeyVaultSecret( + context.Context, string, string, string, string, +) error { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) SecretFromAkvs(context.Context, string) (string, error) { + panic("not implemented") +} + +func (m *mockExecKeyVaultService) SecretFromKeyVaultReference( + ctx context.Context, ref string, defaultSubID string, +) (string, error) { + if m.secretFromKeyVaultRefFn != nil { + return m.secretFromKeyVaultRefFn(ctx, ref, defaultSubID) + } + return "", errors.New("mockExecKeyVaultService: secretFromKeyVaultRefFn not set") +} + +func TestExecAction_SetsEnvironmentVariables(t *testing.T) { + const key1 = "AZD_TEST_EXEC_VAR1" + const key2 = "AZD_TEST_EXEC_VAR2" + + env := environment.NewWithValues("test", map[string]string{ + key1: "value1", + key2: "value2", + }) + + kvMock := &mockExecKeyVaultService{ + secretFromKeyVaultRefFn: func(_ context.Context, _ string, _ string) (string, error) { + return "", errors.New("should not be called for plain values") + }, + } + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"go", "version"}, + } + + childEnv, err := action.buildChildEnv(t.Context()) + require.NoError(t, err) + + envMap := envSliceToMap(childEnv) + assert.Equal(t, "value1", envMap[key1]) + assert.Equal(t, "value2", envMap[key2]) +} + +func TestExecAction_ResolvesSecretReferences(t *testing.T) { + const secretKey = "AZD_TEST_EXEC_SECRET" //nolint:gosec // G101: test constant, not a credential + + secretRef := "akvs://sub-id/vault-name/secret-name" //nolint:gosec // G101: test fixture, not a credential + env := environment.NewWithValues("test", map[string]string{ + secretKey: secretRef, + }) + + kvMock := &mockExecKeyVaultService{ + secretFromKeyVaultRefFn: func(_ context.Context, ref string, _ string) (string, error) { + assert.Equal(t, secretRef, ref) + return "resolved-secret-value", nil + }, + } + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"go", "version"}, + } + + childEnv, err := action.buildChildEnv(t.Context()) + require.NoError(t, err) + + envMap := envSliceToMap(childEnv) + assert.Equal(t, "resolved-secret-value", envMap[secretKey]) +} + +func TestExecAction_SecretResolutionFailure(t *testing.T) { + const secretKey = "AZD_TEST_EXEC_SECRET_FAIL" //nolint:gosec // G101: test constant, not a credential + + secretRef := "akvs://sub-id/vault-name/secret-name" //nolint:gosec // G101: test fixture, not a credential + env := environment.NewWithValues("test", map[string]string{ + secretKey: secretRef, + }) + + kvMock := &mockExecKeyVaultService{ + secretFromKeyVaultRefFn: func(_ context.Context, _ string, _ string) (string, error) { + return "", errors.New("vault unavailable") + }, + } + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"go", "version"}, + } + + _, err := action.buildChildEnv(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "resolving secret") +} + +func TestExecAction_InvalidShell(t *testing.T) { + env := environment.NewWithValues("test", nil) + + kvMock := &mockExecKeyVaultService{} + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{ + global: &internal.GlobalCommandOptions{}, + shell: "invalid-shell", + }, + args: []string{"echo", "hello"}, + } + + _, err := action.Run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid configuration") +} + +func TestExecAction_DirectExecMode(t *testing.T) { + env := environment.NewWithValues("test", nil) + + kvMock := &mockExecKeyVaultService{} + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"go", "version"}, + } + + _, err := action.Run(t.Context()) + require.NoError(t, err) +} + +func TestNewExecCmd(t *testing.T) { + cmd := newExecCmd() + + assert.Equal(t, "exec [command] [args...] [-- script-args...]", cmd.Use) + assert.Contains(t, cmd.Short, "Execute commands") + require.NotNil(t, cmd.Args) + + // Args validator requires at least 1 argument. + assert.Error(t, cmd.Args(cmd, []string{})) + assert.NoError(t, cmd.Args(cmd, []string{"echo"})) + assert.NoError(t, cmd.Args(cmd, []string{"echo", "hello"})) + + // Verify SetInterspersed(false) was called by checking that the flags + // set does not have interspersed enabled. pflag exposes HasFlags but + // not interspersed directly; instead we verify the observable behavior + // by confirming that the whitelist and arg validator work correctly. + // The FParseErrWhitelist.UnknownFlags should be true. + assert.True(t, cmd.FParseErrWhitelist.UnknownFlags, + "unknown flags should be whitelisted so child flags are forwarded") +} + +func TestExecAction_ResolvesKeyVaultReferenceFormat(t *testing.T) { + const secretKey = "AZD_TEST_EXEC_KVREF" //nolint:gosec // G101: test constant, not a credential + + // @Microsoft.KeyVault reference format (used in Azure App Service settings). + secretRef := "@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/mysecret)" + env := environment.NewWithValues("test", map[string]string{ + secretKey: secretRef, + }) + + kvMock := &mockExecKeyVaultService{ + secretFromKeyVaultRefFn: func(_ context.Context, ref string, _ string) (string, error) { + assert.Equal(t, secretRef, ref) + return "kv-resolved-value", nil + }, + } + + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"go", "version"}, + } + + childEnv, err := action.buildChildEnv(t.Context()) + require.NoError(t, err) + + envMap := envSliceToMap(childEnv) + assert.Equal(t, "kv-resolved-value", envMap[secretKey]) +} + +func TestExecAction_ExitCodePropagation(t *testing.T) { + env := environment.NewWithValues("test", nil) + + // Build a tiny Go program that calls os.Exit(42) — this is portable across + // all platforms and gives us a real exit code to verify propagation. + exitBin := filepath.Join(t.TempDir(), "exit42") + if runtime.GOOS == "windows" { + exitBin += ".exe" + } + //nolint:gosec // G204: test builds a known fixture + buildCmd := exec.Command("go", "build", "-o", exitBin, "testdata/exit42.go") + buildCmd.Dir = "." + out, err := buildCmd.CombinedOutput() + require.NoError(t, err, "failed to build exit42: %s", string(out)) + + // Test exit code propagation through ExecuteDirect path by using the + // scripting package directly. This avoids the Execute() shell-dispatch + // path which doesn't handle raw binaries. + executor, err := scripting.New(scripting.Config{ + Interactive: false, + Env: env.Environ(), + }) + require.NoError(t, err) + + execErr := executor.ExecuteDirect(t.Context(), exitBin, []string{}) + require.Error(t, execErr) + + var scriptExecErr *scripting.ExecutionError + if errors.As(execErr, &scriptExecErr) { + assert.Equal(t, 42, scriptExecErr.ExitCode) + } else { + t.Fatalf("expected ExecutionError with exit code 42, got %T: %v", execErr, execErr) + } +} + +func TestLooksLikeFilePath(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"echo hello", false}, + {"go version", false}, + {"npm", false}, + {"./script.sh", true}, + {"scripts/deploy.sh", true}, + {"C:\\scripts\\deploy.ps1", true}, + {"deploy.sh", true}, + {"build.ps1", true}, + {"run.cmd", true}, + {"setup.bat", true}, + {"app.py", true}, + {"tool.rb", true}, + {"deploy.bash", true}, + {"config.zsh", true}, + {"mycommand", false}, + {"echo $HOME", false}, + {"ls -la", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.want, looksLikeFilePath(tt.input)) + }) + } +} + +func TestExecAction_FileNotFoundNoInlineFallback(t *testing.T) { + env := environment.NewWithValues("test", nil) + kvMock := &mockExecKeyVaultService{} + + // A non-existent file with a script extension should NOT fall through + // to inline execution — it should return ScriptNotFoundError. + action := &execAction{ + env: env, + keyvaultService: kvMock, + flags: &execFlags{global: &internal.GlobalCommandOptions{}}, + args: []string{"nonexistent.sh"}, + } + + _, err := action.Run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 2c84b24ca95..11d75f0ffbf 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -199,6 +199,7 @@ func newRootCmd( hooksActions(root) mcpActions(root) copilotActions(root) + execActions(root) // Create a FeatureManager for command-tree gating. // User config is loaded best-effort; env vars (AZD_ALPHA_ENABLE_TOOL) always work. diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 005a903c218..17ec06ee245 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2463,6 +2463,38 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['exec'], + description: 'Execute commands and scripts with azd environment context.', + options: [ + { + name: ['--interactive', '-i'], + description: 'Run in interactive mode (connect stdin)', + }, + { + name: ['--shell', '-s'], + description: 'Shell to use (bash, sh, zsh, pwsh, powershell, cmd). Auto-detected if not specified.', + args: [ + { + name: 'shell', + }, + ], + }, + ], + args: [ + { + name: 'command', + isOptional: true, + }, + { + name: 'args...', + isOptional: true, + }, + { + name: 'script-args...', + }, + ], + }, { name: ['extension', 'ext'], description: 'Manage azd extensions.', @@ -3919,6 +3951,10 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['exec'], + description: 'Execute commands and scripts with azd environment context.', + }, { name: ['extension', 'ext'], description: 'Manage azd extensions.', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-exec.snap b/cli/azd/cmd/testdata/TestUsage-azd-exec.snap new file mode 100644 index 00000000000..b5a800490ed --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-exec.snap @@ -0,0 +1,21 @@ + +Execute commands and scripts with azd environment context. + +Usage + azd exec [command] [args...] [-- script-args...] [flags] + +Flags + -e, --environment string : The name of the environment to use. + -i, --interactive : Run in interactive mode (connect stdin) + -s, --shell string : Shell to use (bash, sh, zsh, pwsh, powershell, cmd). Auto-detected if not specified. + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd exec in your web browser. + -h, --help : Gets help for exec. + --no-prompt : Runs without prompts. Uses existing values; fails if any required value or decision cannot be resolved automatically. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd.snap b/cli/azd/cmd/testdata/TestUsage-azd.snap index 47ab9e0419d..75b72817c42 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd.snap @@ -20,6 +20,7 @@ Commands completion : Generate shell completion scripts. config : Manage azd configurations (ex: default Azure subscription, location). env : Manage environments (ex: default environment, environment variables). + exec : Execute commands and scripts with azd environment context. show : Display information about your project and its resources. tool : Manage Azure development tools. version : Print the version number of Azure Developer CLI. diff --git a/cli/azd/cmd/testdata/exit42.go b/cli/azd/cmd/testdata/exit42.go new file mode 100644 index 00000000000..74f3a55d2d4 --- /dev/null +++ b/cli/azd/cmd/testdata/exit42.go @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build ignore + +package main + +import "os" + +func main() { + os.Exit(42) +} diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 595cbd719dc..41b26a3c236 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -59,6 +59,36 @@ integration. | `AZD_DEPLOY_{SERVICE}_SLOT_NAME` | Sets the App Service deployment slot target for a service. Replace `{SERVICE}` with the uppercase service name (hyphens become underscores). Set to `production` to deploy to the main app, or a slot name (e.g., `staging`). When slots exist and this is not set, `--no-prompt` mode fails with an error listing available targets. | | `AZD_DEPLOY_{SERVICE}_SKIP_STATUS_CHECK` | If `true`, skips runtime deployment status tracking for the named Linux App Service after zip deploy. Useful when the target web app is intentionally stopped. Parsed as a boolean (`true`/`false`/`1`/`0`). `{SERVICE}` follows the same naming rules as `AZD_DEPLOY_{SERVICE}_SLOT_NAME`. | +## azd exec + +The `azd exec` command runs commands and scripts with the active azd environment loaded into the child +process. All environment variables from the `.env` file (including provisioning outputs) are injected +automatically. Key Vault secret references (`akvs://` and `@Microsoft.KeyVault(SecretUri=...)`) are +resolved transparently before injection. + +### Execution Modes + +`azd exec` selects an execution mode based on the arguments provided: + +| Mode | Trigger | Example | +| --- | --- | --- | +| **Script file** | First argument is an existing file | `azd exec ./setup.sh` | +| **Direct exec** | Multiple arguments, no `--shell` flag | `azd exec python script.py` | +| **Shell inline** | Single argument, or `--shell` specified | `azd exec 'echo $AZURE_ENV_NAME'` | + +**Direct exec** passes the exact argument vector to the child process without shell wrapping, which +avoids quoting and escaping issues. **Shell inline** wraps the argument with the detected (or +specified) shell's `-c` flag. **Script file** detects the shell from the file extension (`.sh` → +bash, `.ps1` → pwsh, `.cmd`/`.bat` → cmd). + +### Flags + +| Flag | Description | +| --- | --- | +| `--shell`, `-s` | Shell to use (`bash`, `sh`, `zsh`, `pwsh`, `powershell`, `cmd`). Auto-detected if not specified. | +| `--interactive`, `-i` | Run in interactive mode (connects stdin to the child process). | +| `--environment`, `-e` | The azd environment to load. | + ## Extension Variables These variables are set and consumed by azd extension hosts (for example, IDE/editor integrations) diff --git a/cli/azd/internal/errors.go b/cli/azd/internal/errors.go index 2e0e4f15257..83f43741671 100644 --- a/cli/azd/internal/errors.go +++ b/cli/azd/internal/errors.go @@ -5,6 +5,7 @@ package internal import ( "errors" + "fmt" "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" ) @@ -121,3 +122,23 @@ var ( var ( ErrToolUpgradeFailed = errors.New("tool upgrade did not succeed") ) + +// ExitCodeError wraps an error with a specific process exit code. +// When returned from an action, main.go uses the exit code instead of the +// default exit code 1. This is used by `azd exec` to propagate the child +// process exit code without calling os.Exit inside the middleware chain. +type ExitCodeError struct { + ExitCode int + Err error +} + +func (e *ExitCodeError) Error() string { + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("exit code %d", e.ExitCode) +} + +func (e *ExitCodeError) Unwrap() error { + return e.Err +} diff --git a/cli/azd/main.go b/cli/azd/main.go index e104a12cab5..690bd2e82d8 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -7,6 +7,7 @@ package main import ( "context" + "errors" "fmt" "io" "log" @@ -141,7 +142,11 @@ func main() { } if cmdErr != nil { - os.Exit(1) + exitCode := 1 + if exitCodeErr, ok := errors.AsType[*internal.ExitCodeError](cmdErr); ok && exitCodeErr.ExitCode != 0 { + exitCode = exitCodeErr.ExitCode + } + os.Exit(exitCode) } } diff --git a/cli/azd/pkg/exec/scripting/command_builder.go b/cli/azd/pkg/exec/scripting/command_builder.go new file mode 100644 index 00000000000..de9bb9e2854 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/command_builder.go @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "context" + "os/exec" + "strings" +) + +func (e *Executor) buildCommand( + ctx context.Context, shell, scriptOrPath string, isInline bool, +) *exec.Cmd { + var cmdArgs []string + skipAppendArgs := false + useCmdLineOverride := false + cmdWrapOuter := false + + shellLower := strings.ToLower(shell) + shellBin := shell + if IsSupportedShell(shellLower) { + shellBin = shellLower + } + + switch shellLower { + case "bash", "sh", "zsh": + if isInline { + cmdArgs = []string{shellBin, "-c", scriptOrPath, "--"} + } else { + cmdArgs = []string{shellBin, scriptOrPath} + } + case "pwsh", "powershell": + if isInline { + cmdArgs = []string{ + shellBin, "-Command", + e.buildPowerShellInlineCommand(scriptOrPath), + } + skipAppendArgs = true + } else { + cmdArgs = []string{shellBin, "-File", scriptOrPath} + } + case "cmd": + useCmdLineOverride = true + if isInline { + cmdArgs = []string{shellBin, "/c", scriptOrPath} + cmdWrapOuter = false + } else { + escaped := strings.ReplaceAll(scriptOrPath, `"`, `""`) + // Neutralize %VAR% expansion — cmd.exe expands environment variables + // in the path before executing, which is a security risk (CVE-2024-24576 class). + escaped = strings.ReplaceAll(escaped, "%", "%%") + cmdArgs = []string{shellBin, "/c", `"` + escaped + `"`} + cmdWrapOuter = true + } + default: + if isInline { + cmdArgs = []string{shell, "-c", scriptOrPath, "--"} + } else { + cmdArgs = []string{shell, scriptOrPath} + } + } + + if !skipAppendArgs && len(e.config.Args) > 0 { + if useCmdLineOverride { + for _, arg := range e.config.Args { + cmdArgs = append(cmdArgs, quoteCmdArg(arg)) + } + } else { + cmdArgs = append(cmdArgs, e.config.Args...) + } + } + + cmd := exec.CommandContext(ctx, cmdArgs[0], cmdArgs[1:]...) //nolint:gosec + if useCmdLineOverride { + setCmdLineOverride(cmd, cmdArgs, cmdWrapOuter) + } + return cmd +} + +// stripControlChars removes all ASCII control characters (0x00–0x1F, 0x7F) +// from s. Tab (0x09) is included because cmd.exe treats it as whitespace +// that can break argument boundaries. +func stripControlChars(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7F { + return -1 + } + return r + }, s) +} + +// quoteCmdArg quotes a single argument for cmd.exe. +func quoteCmdArg(arg string) string { + if arg == "" { + return `""` + } + cleaned := stripControlChars(arg) + escaped := strings.ReplaceAll(cleaned, `"`, `""`) + // Neutralize %VAR% expansion — cmd.exe expands environment variables + // even inside double quotes. + escaped = strings.ReplaceAll(escaped, "%", "%%") + if strings.ContainsAny(escaped, " \t&|<>^%\"") { + return `"` + escaped + `"` + } + return escaped +} + +func (e *Executor) buildPowerShellInlineCommand(scriptOrPath string) string { + if len(e.config.Args) == 0 { + return scriptOrPath + } + + quotedArgs := make([]string, len(e.config.Args)) + for i, arg := range e.config.Args { + quotedArgs[i] = quotePowerShellArg(arg) + } + + return strings.Join(append([]string{scriptOrPath}, quotedArgs...), " ") +} + +func quotePowerShellArg(arg string) string { + if arg == "" { + return "''" + } + return "'" + strings.ReplaceAll(arg, "'", "''") + "'" +} diff --git a/cli/azd/pkg/exec/scripting/command_builder_notwindows.go b/cli/azd/pkg/exec/scripting/command_builder_notwindows.go new file mode 100644 index 00000000000..171178c2417 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/command_builder_notwindows.go @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build !windows + +package scripting + +import "os/exec" + +func setCmdLineOverride(_ *exec.Cmd, _ []string, _ bool) {} diff --git a/cli/azd/pkg/exec/scripting/command_builder_test.go b/cli/azd/pkg/exec/scripting/command_builder_test.go new file mode 100644 index 00000000000..1ef71db79ee --- /dev/null +++ b/cli/azd/pkg/exec/scripting/command_builder_test.go @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestQuoteCmdArg(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + {"empty string", "", `""`}, + {"simple arg", "hello", "hello"}, + {"arg with spaces", "hello world", `"hello world"`}, + {"arg with ampersand", "a&b", `"a&b"`}, + {"arg with pipe", "a|b", `"a|b"`}, + {"arg with angle brackets", "", `""`}, + {"arg with caret", "a^b", `"a^b"`}, + {"arg with percent", "%PATH%", `"%%PATH%%"`}, + {"safe path", `C:\scripts\run.bat`, `C:\scripts\run.bat`}, + { + "path with spaces", + `C:\my scripts\run.bat`, + `"C:\my scripts\run.bat"`, + }, + { + "embedded double quote", + `he said "hello"`, + `"he said ""hello"""`, + }, + { + "injection via embedded quotes", + `a" & calc & "`, + `"a"" & calc & """`, + }, + { + "fake pre-quoted injection", + `"safe" & calc & "x"`, + `"""safe"" & calc & ""x"""`, + }, + {"newline stripped", "a\nb", "ab"}, + {"CR stripped", "a\rb", "ab"}, + {"null stripped", "a\x00b", "ab"}, + {"VT stripped", "a\x0Bb", "ab"}, + {"FF stripped", "a\x0Cb", "ab"}, + {"Ctrl-Z stripped", "a\x1Ab", "ab"}, + {"ESC stripped", "a\x1Bb", "ab"}, + {"newline with metachar", "a\n&b", `"a&b"`}, + {"tab character", "a\tb", "ab"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := quoteCmdArg(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestQuotePowerShellArg(t *testing.T) { + tests := []struct { + name string + arg string + want string + }{ + {"empty string", "", "''"}, + {"simple arg", "hello", "'hello'"}, + {"arg with single quote", "it's", "'it''s'"}, + {"multiple quotes", "a'b'c", "'a''b''c'"}, + {"arg with spaces", "hello world", "'hello world'"}, + {"double dash flag", "--skip-sync", "'--skip-sync'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := quotePowerShellArg(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildPowerShellInlineCommand(t *testing.T) { + t.Run("no args returns script as-is", func(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + got := e.buildPowerShellInlineCommand("Get-Date") + assert.Equal(t, "Get-Date", got) + }) + + t.Run("with args joins and quotes", func(t *testing.T) { + e, err := New(Config{Args: []string{"arg1", "it's"}}) + require.NoError(t, err) + got := e.buildPowerShellInlineCommand("cmd") + assert.Equal(t, "cmd 'arg1' 'it''s'", got) + }) +} + +func TestBuildCommand_BashInline(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand(t.Context(), "bash", "echo hi", true) + require.NotNil(t, cmd) + assert.Equal(t, "bash", cmd.Args[0]) + assert.Equal(t, "-c", cmd.Args[1]) + assert.Equal(t, "echo hi", cmd.Args[2]) + assert.Equal(t, "--", cmd.Args[3]) +} + +func TestBuildCommand_BashFile(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "bash", "/path/to/script.sh", false, + ) + require.NotNil(t, cmd) + assert.Equal(t, "bash", cmd.Args[0]) + assert.Equal(t, "/path/to/script.sh", cmd.Args[1]) +} + +func TestBuildCommand_PwshInline(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "pwsh", "Write-Host 'hi'", true, + ) + require.NotNil(t, cmd) + assert.Equal(t, "pwsh", cmd.Args[0]) + assert.Equal(t, "-Command", cmd.Args[1]) + assert.Equal(t, "Write-Host 'hi'", cmd.Args[2]) +} + +func TestBuildCommand_PwshFile(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "pwsh", "/path/to/script.ps1", false, + ) + require.NotNil(t, cmd) + assert.Equal(t, "pwsh", cmd.Args[0]) + assert.Equal(t, "-File", cmd.Args[1]) + assert.Equal(t, "/path/to/script.ps1", cmd.Args[2]) +} + +func TestBuildCommand_CmdInline(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "cmd", "echo hello", true, + ) + require.NotNil(t, cmd) + assert.Equal(t, "cmd", cmd.Args[0]) + assert.Equal(t, "/c", cmd.Args[1]) + assert.Equal(t, "echo hello", cmd.Args[2]) +} + +func TestBuildCommand_CmdFile(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "cmd", `C:\test.bat`, false, + ) + require.NotNil(t, cmd) + assert.Equal(t, "cmd", cmd.Args[0]) + assert.Equal(t, "/c", cmd.Args[1]) + assert.Equal(t, `"C:\test.bat"`, cmd.Args[2]) +} + +func TestBuildCommand_WithArgs(t *testing.T) { + e, err := New(Config{Args: []string{"arg1", "arg2"}}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "bash", "/path/to/script.sh", false, + ) + require.NotNil(t, cmd) + assert.True(t, slices.Contains(cmd.Args, "arg1")) + assert.True(t, slices.Contains(cmd.Args, "arg2")) +} + +func TestBuildCommand_PwshInline_SkipsArgs(t *testing.T) { + e, err := New(Config{Args: []string{"a1"}}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "pwsh", "Get-Date", true, + ) + require.NotNil(t, cmd) + // Args should be baked into the -Command string, not appended + assert.Len(t, cmd.Args, 3) + assert.Contains(t, cmd.Args[2], "'a1'") +} + +func TestBuildCommand_DefaultShell(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), "unknown-shell", "echo hi", true, + ) + require.NotNil(t, cmd) + // Unknown shells fall through to default case + assert.Equal(t, "unknown-shell", cmd.Args[0]) + assert.Equal(t, "-c", cmd.Args[1]) +} + +func TestBuildCommand_ShellCaseNormalization(t *testing.T) { + tests := []struct { + shell string + wantFirst string + }{ + {"BASH", "bash"}, + {"SH", "sh"}, + {"ZSH", "zsh"}, + {"PWSH", "pwsh"}, + {"CMD", "cmd"}, + } + + for _, tt := range tests { + t.Run(tt.shell, func(t *testing.T) { + e, err := New(Config{Shell: tt.shell}) + require.NoError(t, err) + + cmd := e.buildCommand( + t.Context(), tt.shell, "test.sh", false, + ) + require.NotNil(t, cmd) + assert.Equal(t, tt.wantFirst, cmd.Args[0]) + }) + } +} diff --git a/cli/azd/pkg/exec/scripting/command_builder_windows.go b/cli/azd/pkg/exec/scripting/command_builder_windows.go new file mode 100644 index 00000000000..981630d71cb --- /dev/null +++ b/cli/azd/pkg/exec/scripting/command_builder_windows.go @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package scripting + +import ( + "os/exec" + "strings" + "syscall" +) + +func setCmdLineOverride(cmd *exec.Cmd, args []string, wrapOuter bool) { + payload := strings.Join(args[2:], " ") + var cmdLine string + if wrapOuter { + cmdLine = args[0] + " " + args[1] + ` "` + payload + `"` + } else { + cmdLine = args[0] + " " + args[1] + " " + payload + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: cmdLine, + } +} diff --git a/cli/azd/pkg/exec/scripting/errors.go b/cli/azd/pkg/exec/scripting/errors.go new file mode 100644 index 00000000000..00197d2f65e --- /dev/null +++ b/cli/azd/pkg/exec/scripting/errors.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import "fmt" + +// ValidationError indicates that input validation failed. +type ValidationError struct { + Field string + Reason string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error for %s: %s", e.Field, e.Reason) +} + +// ScriptNotFoundError indicates that a script file could not be found. +type ScriptNotFoundError struct { + Path string +} + +func (e *ScriptNotFoundError) Error() string { + return fmt.Sprintf("script not found: %s", e.Path) +} + +// InvalidShellError indicates that an invalid shell was specified. +type InvalidShellError struct { + Shell string +} + +func (e *InvalidShellError) Error() string { + return fmt.Sprintf( + "invalid shell: %s (valid: bash, sh, zsh, pwsh, powershell, cmd)", e.Shell) +} + +// ExecutionError indicates that script execution failed with a specific exit code. +type ExecutionError struct { + ExitCode int + Shell string + IsInline bool +} + +func (e *ExecutionError) Error() string { + if e.Shell == "" { + return fmt.Sprintf("command exited with code %d", e.ExitCode) + } + if e.IsInline { + return fmt.Sprintf( + "inline script exited with code %d (shell: %s)", e.ExitCode, e.Shell) + } + return fmt.Sprintf( + "script exited with code %d (shell: %s)", e.ExitCode, e.Shell) +} diff --git a/cli/azd/pkg/exec/scripting/errors_test.go b/cli/azd/pkg/exec/scripting/errors_test.go new file mode 100644 index 00000000000..012ce024eaa --- /dev/null +++ b/cli/azd/pkg/exec/scripting/errors_test.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidationError_Error(t *testing.T) { + err := &ValidationError{ + Field: "scriptPath", + Reason: "cannot be empty", + } + assert.Equal(t, + "validation error for scriptPath: cannot be empty", + err.Error(), + ) +} + +func TestScriptNotFoundError_Error(t *testing.T) { + err := &ScriptNotFoundError{Path: "missing.sh"} + assert.Equal(t, "script not found: missing.sh", err.Error()) +} + +func TestInvalidShellError_Error(t *testing.T) { + err := &InvalidShellError{Shell: "python"} + got := err.Error() + assert.Contains(t, got, "python") + assert.Contains(t, got, "invalid shell") +} + +func TestExecutionError_Error_NoShell(t *testing.T) { + err := &ExecutionError{ExitCode: 1, Shell: "", IsInline: false} + assert.Equal(t, "command exited with code 1", err.Error()) +} + +func TestExecutionError_Error_Inline(t *testing.T) { + err := &ExecutionError{ + ExitCode: 2, Shell: "bash", IsInline: true, + } + got := err.Error() + assert.Contains(t, got, "inline script") + assert.Contains(t, got, "2") + assert.Contains(t, got, "bash") +} + +func TestExecutionError_Error_Script(t *testing.T) { + err := &ExecutionError{ + ExitCode: 42, Shell: "pwsh", IsInline: false, + } + got := err.Error() + assert.Contains(t, got, "script exited with code 42") + assert.Contains(t, got, "pwsh") + assert.NotContains(t, got, "inline") +} diff --git a/cli/azd/pkg/exec/scripting/executor.go b/cli/azd/pkg/exec/scripting/executor.go new file mode 100644 index 00000000000..974efb36991 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/executor.go @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Config holds the configuration for script execution. +type Config struct { + Shell string + Interactive bool + Args []string + // Env overrides the child process environment. When set, the child process + // receives exactly these variables instead of inheriting os.Environ(). + // This prevents secrets from leaking into the parent process via os.Setenv. + Env []string +} + +// Validate checks if the Config has valid values. +func (c *Config) Validate() error { + if err := ValidateShell(c.Shell); err != nil { + return &InvalidShellError{Shell: c.Shell} + } + return nil +} + +// Executor executes scripts and commands with azd context. +type Executor struct { + config Config +} + +// New creates a new script executor with the given configuration. +func New(config Config) (*Executor, error) { + if err := config.Validate(); err != nil { + return nil, err + } + return &Executor{config: config}, nil +} + +// Execute runs a script file. +func (e *Executor) Execute(ctx context.Context, scriptPath string) error { + if scriptPath == "" { + return &ValidationError{Field: "scriptPath", Reason: "cannot be empty"} + } + + absPath, err := filepath.Abs(scriptPath) + if err != nil { + return &ValidationError{ + Field: "scriptPath", Reason: fmt.Sprintf("invalid path: %v", err), + } + } + + info, err := os.Stat(absPath) + if err != nil { + if os.IsNotExist(err) { + return &ScriptNotFoundError{Path: filepath.Base(absPath)} + } + return &ValidationError{ + Field: "scriptPath", Reason: fmt.Sprintf("cannot access: %v", err), + } + } + if info.IsDir() { + return &ValidationError{ + Field: "scriptPath", Reason: "must be a file, not a directory", + } + } + + shell := e.config.Shell + if shell == "" { + shell = DetectShellFromFile(absPath) + } + + workingDir := filepath.Dir(absPath) + return e.executeCommand(ctx, shell, workingDir, absPath, false) +} + +// ExecuteDirect runs a command directly without shell wrapping, preserving exact +// argv semantics. This is the preferred mode for "run a program with azd env". +func (e *Executor) ExecuteDirect(ctx context.Context, command string, args []string) error { + if command == "" { + return &ValidationError{Field: "command", Reason: "cannot be empty"} + } + + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + cmd := exec.CommandContext(ctx, command, args...) //nolint:gosec + cmd.Dir = workingDir + cmd.Env = e.childEnv() + if e.config.Interactive { + cmd.Stdin = os.Stdin + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if os.Getenv("AZD_DEBUG") == "true" { + quotedArgs := make([]string, len(args)) + for i, a := range args { + quotedArgs[i] = fmt.Sprintf("%q", a) + } + fmt.Fprintf(os.Stderr, + "Executing (direct): %s %s\n", command, strings.Join(quotedArgs, " ")) + fmt.Fprintf(os.Stderr, "Working directory: %q\n", workingDir) + } + + return e.runCommand(ctx, cmd, command, "", false) +} + +// ExecuteInline runs an inline script command. +func (e *Executor) ExecuteInline(ctx context.Context, scriptContent string) error { + if strings.TrimSpace(scriptContent) == "" || !containsPrintable(scriptContent) { + return &ValidationError{ + Field: "scriptContent", Reason: "must contain printable characters", + } + } + + shell := e.config.Shell + if shell == "" { + shell = DefaultShell() + } + + workingDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + return e.executeCommand(ctx, shell, workingDir, scriptContent, true) +} + +func (e *Executor) executeCommand( + ctx context.Context, shell, workingDir, scriptOrPath string, isInline bool, +) error { + cmd := e.buildCommand(ctx, shell, scriptOrPath, isInline) + cmd.Dir = workingDir + cmd.Env = e.childEnv() + + if e.config.Interactive { + cmd.Stdin = os.Stdin + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if os.Getenv("AZD_DEBUG") == "true" { + e.logDebugInfo(shell, workingDir, scriptOrPath, isInline, cmd.Args) + } + + return e.runCommand(ctx, cmd, scriptOrPath, shell, isInline) +} + +func (e *Executor) logDebugInfo( + shell, workingDir, scriptOrPath string, isInline bool, cmdArgs []string, +) { + if isInline { + fmt.Fprintf(os.Stderr, "Executing inline: %s\n", shell) + fmt.Fprintf(os.Stderr, "Script length: %d chars\n", len(scriptOrPath)) + } else if len(cmdArgs) > 0 { + quotedArgs := make([]string, len(cmdArgs)-1) + for i, a := range cmdArgs[1:] { + quotedArgs[i] = fmt.Sprintf("%q", a) + } + fmt.Fprintf(os.Stderr, + "Executing: %s %s\n", shell, strings.Join(quotedArgs, " ")) + } + fmt.Fprintf(os.Stderr, "Working directory: %q\n", workingDir) +} + +// childEnv returns the environment for the child process. If Config.Env is +// set, it is used directly; otherwise os.Environ() is used as a fallback. +func (e *Executor) childEnv() []string { + if len(e.config.Env) > 0 { + return e.config.Env + } + return os.Environ() +} + +// containsPrintable reports whether s contains at least one printable character +// (Unicode graphic or space). This rejects NUL-only and control-char-only input. +func containsPrintable(s string) bool { + for _, r := range s { + if r >= ' ' && r != 0x7F { + return true + } + } + return false +} + +func (e *Executor) runCommand( + ctx context.Context, cmd *exec.Cmd, scriptOrPath, shell string, isInline bool, +) error { + setupProcessTree(cmd, e.config.Interactive) + killTree, err := startProcessTree(cmd) + if err != nil { + return e.wrapError(err, scriptOrPath, shell, isInline) + } + + // Kill entire process tree on context cancellation. + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + killTree() + case <-done: + } + }() + + err = cmd.Wait() + close(done) + + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + return &ExecutionError{ + ExitCode: exitErr.ExitCode(), + Shell: shell, + IsInline: isInline, + } + } + return e.wrapError(err, scriptOrPath, shell, isInline) + } + return nil +} + +func (e *Executor) wrapError(err error, scriptOrPath, shell string, isInline bool) error { + if shell == "" { + return fmt.Errorf("failed to execute command %q: %w", scriptOrPath, err) + } + if isInline { + return fmt.Errorf( + "failed to execute inline script with shell %q: %w", shell, err) + } + return fmt.Errorf( + "failed to execute script %q with shell %q: %w", + filepath.Base(scriptOrPath), shell, err) +} diff --git a/cli/azd/pkg/exec/scripting/executor_test.go b/cli/azd/pkg/exec/scripting/executor_test.go new file mode 100644 index 00000000000..fe1bf405b20 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/executor_test.go @@ -0,0 +1,292 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew_ValidConfigs(t *testing.T) { + tests := []struct { + name string + config Config + }{ + {"empty config", Config{}}, + {"bash", Config{Shell: "bash"}}, + {"sh", Config{Shell: "sh"}}, + {"zsh", Config{Shell: "zsh"}}, + {"pwsh", Config{Shell: "pwsh"}}, + {"powershell", Config{Shell: "powershell"}}, + {"cmd", Config{Shell: "cmd"}}, + {"uppercase", Config{Shell: "BASH"}}, + {"with args", Config{Args: []string{"a", "b"}}}, + {"interactive", Config{Interactive: true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e, err := New(tt.config) + require.NoError(t, err) + require.NotNil(t, e) + }) + } +} + +func TestNew_InvalidShell(t *testing.T) { + e, err := New(Config{Shell: "python"}) + require.Error(t, err) + assert.Nil(t, e) + + _, ok := errors.AsType[*InvalidShellError](err) + assert.True(t, ok, "expected InvalidShellError, got %T", err) +} + +func TestExecute_EmptyPath(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + err = e.Execute(t.Context(), "") + require.Error(t, err) + + valErr, ok := errors.AsType[*ValidationError](err) + require.True(t, ok, "expected ValidationError, got %T", err) + assert.Equal(t, "scriptPath", valErr.Field) +} + +func TestExecute_NonexistentFile(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "no-such-file.sh") + err = e.Execute(t.Context(), path) + require.Error(t, err) + + _, ok := errors.AsType[*ScriptNotFoundError](err) + assert.True(t, ok, "expected ScriptNotFoundError, got %T", err) +} + +func TestExecute_DirectoryPath(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + err = e.Execute(t.Context(), t.TempDir()) + require.Error(t, err) + + valErr, ok := errors.AsType[*ValidationError](err) + require.True(t, ok, "expected ValidationError, got %T", err) + assert.True(t, + strings.Contains(valErr.Reason, "directory"), + "expected reason about directory, got %q", valErr.Reason, + ) +} + +func TestExecuteDirect_EmptyCommand(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + err = e.ExecuteDirect(t.Context(), "", nil) + require.Error(t, err) + + valErr, ok := errors.AsType[*ValidationError](err) + require.True(t, ok, "expected ValidationError, got %T", err) + assert.Equal(t, "command", valErr.Field) +} + +func TestExecuteInline_EmptyContent(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + err = e.ExecuteInline(t.Context(), "") + require.Error(t, err) + + _, ok := errors.AsType[*ValidationError](err) + assert.True(t, ok, "expected ValidationError, got %T", err) +} + +func TestExecuteInline_WhitespaceOnly(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + err = e.ExecuteInline(t.Context(), " \t ") + require.Error(t, err) + + _, ok := errors.AsType[*ValidationError](err) + assert.True(t, ok, "expected ValidationError, got %T", err) +} + +// --- Integration tests requiring real shells --- + +func TestExecuteDirect_GoVersion(t *testing.T) { + // "go" must be on PATH for this test + if _, err := os.Stat(goExePath()); err != nil { + t.Skip("go not available on PATH") + } + + e, err := New(Config{}) + require.NoError(t, err) + + err = e.ExecuteDirect(t.Context(), "go", []string{"version"}) + require.NoError(t, err) +} + +func TestExecute_ValidScript(t *testing.T) { + dir := t.TempDir() + var scriptPath string + + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "test.cmd") + require.NoError(t, os.WriteFile( + scriptPath, + []byte("@echo off\r\necho hello"), + 0o600, + )) + } else { + scriptPath = filepath.Join(dir, "test.sh") + //nolint:gosec // G306 test script needs execute permission + require.NoError(t, os.WriteFile( + scriptPath, + []byte("#!/bin/bash\necho hello"), + 0o700, + )) + } + + e, err := New(Config{}) + require.NoError(t, err) + require.NoError(t, e.Execute(t.Context(), scriptPath)) +} + +func TestExecuteInline_Valid(t *testing.T) { + shell := platformShell() + e, err := New(Config{Shell: shell}) + require.NoError(t, err) + + require.NoError(t, e.ExecuteInline(t.Context(), "echo hello")) +} + +func TestExecute_WithExplicitShell(t *testing.T) { + dir := t.TempDir() + var scriptPath, shell string + + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "test.ps1") + require.NoError(t, os.WriteFile( + scriptPath, + []byte("Write-Host 'hello'"), + 0o600, + )) + shell = "powershell" + } else { + scriptPath = filepath.Join(dir, "test.sh") + //nolint:gosec // G306 test script needs execute permission + require.NoError(t, os.WriteFile( + scriptPath, + []byte("#!/bin/bash\necho hello"), + 0o700, + )) + shell = "bash" + } + + e, err := New(Config{Shell: shell}) + require.NoError(t, err) + require.NoError(t, e.Execute(t.Context(), scriptPath)) +} + +func TestExecute_ScriptWithArgs(t *testing.T) { + dir := t.TempDir() + var scriptPath string + + if runtime.GOOS == "windows" { + scriptPath = filepath.Join(dir, "args.cmd") + require.NoError(t, os.WriteFile( + scriptPath, + []byte("@echo off\r\necho %1"), + 0o600, + )) + } else { + scriptPath = filepath.Join(dir, "args.sh") + //nolint:gosec // G306 test script needs execute permission + require.NoError(t, os.WriteFile( + scriptPath, + []byte("#!/bin/bash\necho $1"), + 0o700, + )) + } + + e, err := New(Config{Args: []string{"test-arg"}}) + require.NoError(t, err) + require.NoError(t, e.Execute(t.Context(), scriptPath)) +} + +func TestExecuteDirect_ExitCodePropagation(t *testing.T) { + e, err := New(Config{}) + require.NoError(t, err) + + var cmdErr error + if runtime.GOOS == "windows" { + cmdErr = e.ExecuteDirect( + t.Context(), "cmd", []string{"/c", "exit 42"}, + ) + } else { + cmdErr = e.ExecuteDirect( + t.Context(), "bash", []string{"-c", "exit 42"}, + ) + } + + require.Error(t, cmdErr) + execErr, ok := errors.AsType[*ExecutionError](cmdErr) + require.True(t, ok, "expected ExecutionError, got %T", cmdErr) + assert.Equal(t, 42, execErr.ExitCode) +} + +func TestExecuteInline_ExitCodePropagation(t *testing.T) { + shell := platformShell() + e, err := New(Config{Shell: shell}) + require.NoError(t, err) + + var script string + if runtime.GOOS == "windows" { + script = "exit 42" + } else { + script = "exit 42" + } + + cmdErr := e.ExecuteInline(t.Context(), script) + require.Error(t, cmdErr) + + execErr, ok := errors.AsType[*ExecutionError](cmdErr) + require.True(t, ok, "expected ExecutionError, got %T", cmdErr) + assert.Equal(t, 42, execErr.ExitCode) +} + +// --- helpers --- + +func platformShell() string { + if runtime.GOOS == "windows" { + return "cmd" + } + return "bash" +} + +func goExePath() string { + name := "go" + if runtime.GOOS == "windows" { + name = "go.exe" + } + + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { //nolint:gosec // G703: test helper, path from PATH env var + return p + } + } + return name +} diff --git a/cli/azd/pkg/exec/scripting/proctree_unix.go b/cli/azd/pkg/exec/scripting/proctree_unix.go new file mode 100644 index 00000000000..76c8d137bd3 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/proctree_unix.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build !windows + +package scripting + +import ( + "os/exec" + "syscall" +) + +// setupProcessTree configures the command to run in its own process group +// so the entire tree can be killed on cancellation. Interactive mode skips +// this because Setpgid breaks stdin forwarding (same as pkg/exec/CmdTree). +func setupProcessTree(cmd *exec.Cmd, interactive bool) { + if !interactive { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + } +} + +// startProcessTree starts the command and returns a kill function that +// sends SIGKILL to the entire process group. +func startProcessTree(cmd *exec.Cmd) (kill func(), _ error) { + if err := cmd.Start(); err != nil { + return func() {}, err + } + return func() { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + }, nil +} diff --git a/cli/azd/pkg/exec/scripting/proctree_windows.go b/cli/azd/pkg/exec/scripting/proctree_windows.go new file mode 100644 index 00000000000..65d94880ce4 --- /dev/null +++ b/cli/azd/pkg/exec/scripting/proctree_windows.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package scripting + +import ( + "log" + "os/exec" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// setupProcessTree merges CREATE_NEW_PROCESS_GROUP into the command's +// SysProcAttr (preserving CmdLine if already set by setCmdLineOverride). +// Interactive mode skips the flag because CREATE_NEW_PROCESS_GROUP disables +// Ctrl-C delivery to the child — matching the Unix behavior that skips Setpgid. +func setupProcessTree(cmd *exec.Cmd, interactive bool) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + if !interactive { + cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP + } +} + +// startProcessTree starts the command, creates a Windows Job Object with +// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, and assigns the process to it. +// The returned kill function terminates the entire job (all child processes). +func startProcessTree(cmd *exec.Cmd) (kill func(), _ error) { + if err := cmd.Start(); err != nil { + return func() {}, err + } + + // Best-effort Job Object setup — fall back to direct process kill. + fallbackKill := func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + } + + handle, err := windows.CreateJobObject(nil, nil) + if err != nil { + log.Printf("scripting: Job Object creation failed, falling back to direct kill: %s", err) + return fallbackKill, nil + } + + info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{ + BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{ + LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + } + //nolint:gosec // G103: unsafe.Pointer needed for Windows API + _, err = windows.SetInformationJobObject( + handle, + windows.JobObjectExtendedLimitInformation, + uintptr(unsafe.Pointer(&info)), + uint32(unsafe.Sizeof(info)), + ) + if err != nil { + log.Printf("scripting: Job Object configuration failed, falling back to direct kill: %s", err) + _ = windows.CloseHandle(handle) + return fallbackKill, nil + } + + //nolint:gosec // G115: int-to-uint32 conversion safe for PIDs + proc, err := windows.OpenProcess( + windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, + false, + uint32(cmd.Process.Pid), + ) + if err != nil { + log.Printf("scripting: Job Object process open failed, falling back to direct kill: %s", err) + _ = windows.CloseHandle(handle) + return fallbackKill, nil + } + + if err := windows.AssignProcessToJobObject(handle, proc); err != nil { + log.Printf("scripting: Job Object process assignment failed, falling back to direct kill: %s", err) + _ = windows.CloseHandle(proc) + _ = windows.CloseHandle(handle) + return fallbackKill, nil + } + _ = windows.CloseHandle(proc) + + return func() { + if err := windows.TerminateJobObject(handle, 1); err != nil { + log.Printf("scripting: failed to terminate job object: %s\n", err) + } + _ = windows.CloseHandle(handle) + }, nil +} diff --git a/cli/azd/pkg/exec/scripting/shell.go b/cli/azd/pkg/exec/scripting/shell.go new file mode 100644 index 00000000000..d7021b4469c --- /dev/null +++ b/cli/azd/pkg/exec/scripting/shell.go @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package scripting provides secure script and command execution with Azure context. +package scripting + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +const osWindows = "windows" + +// validShells is the canonical set of supported shell names (lowercase). +var validShells = map[string]bool{ + "bash": true, + "sh": true, + "zsh": true, + "pwsh": true, + "powershell": true, + "cmd": true, +} + +// IsSupportedShell returns whether the given shell name is a supported shell. +func IsSupportedShell(shell string) bool { + return validShells[strings.ToLower(shell)] +} + +// ValidateShell checks whether shell is a known, supported shell name. +// An empty string is considered valid (auto-detect). +func ValidateShell(shell string) error { + if shell == "" { + return nil + } + if !IsSupportedShell(shell) { + return fmt.Errorf("invalid shell %q: must be one of bash, sh, zsh, pwsh, powershell, cmd", shell) + } + return nil +} + +// DetectShellFromFile returns the appropriate shell for executing a script +// file based on its extension. +func DetectShellFromFile(filePath string) string { + ext := strings.ToLower(filepath.Ext(filePath)) + switch ext { + case ".sh", ".bash": + return "bash" + case ".zsh": + return "zsh" + case ".ps1": + return "pwsh" + case ".cmd", ".bat": + return "cmd" + default: + return DefaultShell() + } +} + +// DefaultShell returns the platform-appropriate default shell. +func DefaultShell() string { + if runtime.GOOS == osWindows { + return "powershell" + } + return "bash" +} diff --git a/cli/azd/pkg/exec/scripting/shell_test.go b/cli/azd/pkg/exec/scripting/shell_test.go new file mode 100644 index 00000000000..4a1912b87be --- /dev/null +++ b/cli/azd/pkg/exec/scripting/shell_test.go @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package scripting + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateShell(t *testing.T) { + tests := []struct { + shell string + wantErr bool + }{ + {"", false}, + {"bash", false}, + {"sh", false}, + {"zsh", false}, + {"pwsh", false}, + {"powershell", false}, + {"cmd", false}, + {"BASH", false}, + {"Pwsh", false}, + {"CMD", false}, + {"python", true}, + {"invalid", true}, + {"node", true}, + } + + for _, tt := range tests { + name := tt.shell + if name == "" { + name = "empty" + } + t.Run(name, func(t *testing.T) { + err := ValidateShell(tt.shell) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestDetectShellFromFile(t *testing.T) { + tests := []struct { + file string + want string + }{ + {"script.sh", "bash"}, + {"script.bash", "bash"}, + {"script.zsh", "zsh"}, + {"script.ps1", "pwsh"}, + {"script.cmd", "cmd"}, + {"script.bat", "cmd"}, + {"SCRIPT.SH", "bash"}, + {"SCRIPT.PS1", "pwsh"}, + } + + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + got := DetectShellFromFile(tt.file) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestDetectShellFromFile_UnknownExtension(t *testing.T) { + got := DetectShellFromFile("script.txt") + want := "bash" + if runtime.GOOS == osWindows { + want = "powershell" + } + assert.Equal(t, want, got) +} + +func TestDetectShellFromFile_NoExtension(t *testing.T) { + got := DetectShellFromFile("Makefile") + want := "bash" + if runtime.GOOS == osWindows { + want = "powershell" + } + assert.Equal(t, want, got) +} + +func TestDefaultShell(t *testing.T) { + got := DefaultShell() + if runtime.GOOS == osWindows { + assert.Equal(t, "powershell", got) + } else { + assert.Equal(t, "bash", got) + } +}