Repository: zarf-dev/zarf (Go)

Issue:
Component actions cmd mutation on Windows fails
### Environment
Device and OS: Windows
App version: v0.54.0
Kubernetes distro being used: k3s
Other:

### Steps to reproduce
1. Create a package with an action cmd that includes multiple variables (e.g. `echo ${ZARF_VAR_ENV1} ${ZARF_VAR_ENV2} ${ZARF_CONST_ENV3}`)
2. Deploy package from a Windows terminal

### Expected result
Command is mutated to: `echo $Env:ZARF_VAR_ENV1 $Env:ZARF_VAR_ENV2 $Env:ZARF_CONST_ENV3` 

### Actual Result
Command is mutated to: `echo $Env:ZARF_VAR_ENV1 ${ZARF_VAR_ENV2} ${ZARF_CONST_ENV3}`

So only the first variable is replaced, AND only variables, not the constants (`ZARF_CONST_`)

### Visual Proof (screenshots, videos, text, etc)
N/A

### Severity/Priority
medium

### Additional Context
Add any other context or screenshots about the technical debt here.



Discussion:
Thanks for the issue (and PR) @koendelaat - we'll monitor the PR and provide any required feedback. 



Patch:
diff --git a/src/internal/packager2/actions/actions.go b/src/internal/packager2/actions/actions.go
index abe7d9158b..9e43985233 100644
--- a/src/internal/packager2/actions/actions.go
+++ b/src/internal/packager2/actions/actions.go
@@ -83,7 +83,7 @@ func runAction(ctx context.Context, basePath string, defaultCfg v1alpha1.ZarfCom
 	actionDefaults := actionGetCfg(ctx, defaultCfg, action, variableConfig.GetAllTemplates())
 	actionDefaults.Dir = filepath.Join(basePath, actionDefaults.Dir)
 
-	if cmd, err = actionCmdMutation(ctx, cmd, actionDefaults.Shell); err != nil {
+	if cmd, err = actionCmdMutation(ctx, cmd, actionDefaults.Shell, runtime.GOOS); err != nil {
 		l.Error("error mutating command", "cmd", cmdEscaped, "err", err.Error())
 	}
 
@@ -209,7 +209,7 @@ func convertWaitToCmd(_ context.Context, wait v1alpha1.ZarfComponentActionWait,
 }
 
 // Perform some basic string mutations to make commands more useful.
-func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell) (string, error) {
+func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell, goos string) (string, error) {
 	zarfCommand, err := utils.GetFinalExecutableCommand()
 	if err != nil {
 		return cmd, err
@@ -219,21 +219,27 @@ func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell
 	cmd = strings.ReplaceAll(cmd, "./zarf ", zarfCommand+" ")
 
 	// Make commands 'more' compatible with Windows OS PowerShell
-	if runtime.GOOS == "windows" && (exec.IsPowershell(shellPref.Windows) || shellPref.Windows == "") {
+	if goos == "windows" && (exec.IsPowershell(shellPref.Windows) || shellPref.Windows == "") {
 		// Replace "touch" with "New-Item" on Windows as it's a common command, but not POSIX so not aliased by M$.
 		// See https://mathieubuisson.github.io/powershell-linux-bash/ &
 		// http://web.cs.ucla.edu/~miryung/teaching/EE461L-Spring2012/labs/posix.html for more details.
 		cmd = regexp.MustCompile(`^touch `).ReplaceAllString(cmd, `New-Item `)
 
-		// Convert any ${ZARF_VAR_*} or $ZARF_VAR_* to ${env:ZARF_VAR_*} or $env:ZARF_VAR_* respectively (also TF_VAR_*).
+		// Convert any ${ZARF_VAR_*} or $ZARF_VAR_* to ${env:ZARF_VAR_*} or $env:ZARF_VAR_* respectively
+		// (also TF_VAR_* and ZARF_CONST_).
 		// https://regex101.com/r/xk1rkw/1
-		envVarRegex := regexp.MustCompile(`(?P<envIndicator>\${?(?P<varName>(ZARF|TF)_VAR_([a-zA-Z0-9_-])+)}?)`)
-		get, err := helpers.MatchRegex(envVarRegex, cmd)
-		if err == nil {
-			newCmd := strings.ReplaceAll(cmd, get("envIndicator"), fmt.Sprintf("$Env:%s", get("varName")))
+		envVarRegex := regexp.MustCompile(`(?P<envIndicator>\${?(?P<varName>(ZARF|TF)_(VAR|CONST)_([a-zA-Z0-9_-])+)}?)`)
+		getFunctions := MatchAllRegex(envVarRegex, cmd)
+
+		newCmd := cmd
+		for _, get := range getFunctions {
+			newCmd = strings.ReplaceAll(newCmd, get("envIndicator"), fmt.Sprintf("$Env:%s", get("varName")))
+
+		}
+		if newCmd != cmd {
 			logger.From(ctx).Debug("converted command", "cmd", cmd, "newCmd", newCmd)
-			cmd = newCmd
 		}
+		cmd = newCmd
 	}
 
 	return cmd, nil
@@ -298,3 +304,20 @@ func actionRun(ctx context.Context, cfg v1alpha1.ZarfComponentActionDefaults, cm
 	}
 	return stdout, stderr, err
 }
+
+// MatchAllRegex wraps a get function around each substring match, returning all matches.
+func MatchAllRegex(regex *regexp.Regexp, str string) []func(string) string {
+	// Validate the string.
+	matches := regex.FindAllStringSubmatch(str, -1)
+
+	// Parse the string into its components.
+	var funcs []func(string) string
+	for _, match := range matches {
+		funcs = append(funcs, func(name string) string {
+			return match[regex.SubexpIndex(name)]
+
+		})
+	}
+
+	return funcs
+}
diff --git a/src/pkg/packager/actions/actions.go b/src/pkg/packager/actions/actions.go
index cffcb8f7f0..630452d7a6 100644
--- a/src/pkg/packager/actions/actions.go
+++ b/src/pkg/packager/actions/actions.go
@@ -82,7 +82,7 @@ func runAction(ctx context.Context, defaultCfg v1alpha1.ZarfComponentActionDefau
 
 	actionDefaults := actionGetCfg(ctx, defaultCfg, action, variableConfig.GetAllTemplates())
 
-	if cmd, err = actionCmdMutation(ctx, cmd, actionDefaults.Shell); err != nil {
+	if cmd, err = actionCmdMutation(ctx, cmd, actionDefaults.Shell, runtime.GOOS); err != nil {
 		l.Error("error mutating command", "cmd", cmdEscaped, "err", err.Error())
 	}
 
@@ -209,7 +209,7 @@ func convertWaitToCmd(_ context.Context, wait v1alpha1.ZarfComponentActionWait,
 }
 
 // Perform some basic string mutations to make commands more useful.
-func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell) (string, error) {
+func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell, goos string) (string, error) {
 	zarfCommand, err := utils.GetFinalExecutableCommand()
 	if err != nil {
 		return cmd, err
@@ -219,21 +219,27 @@ func actionCmdMutation(ctx context.Context, cmd string, shellPref v1alpha1.Shell
 	cmd = strings.ReplaceAll(cmd, "./zarf ", zarfCommand+" ")
 
 	// Make commands 'more' compatible with Windows OS PowerShell
-	if runtime.GOOS == "windows" && (exec.IsPowershell(shellPref.Windows) || shellPref.Windows == "") {
+	if goos == "windows" && (exec.IsPowershell(shellPref.Windows) || shellPref.Windows == "") {
 		// Replace "touch" with "New-Item" on Windows as it's a common command, but not POSIX so not aliased by M$.
 		// See https://mathieubuisson.github.io/powershell-linux-bash/ &
 		// http://web.cs.ucla.edu/~miryung/teaching/EE461L-Spring2012/labs/posix.html for more details.
 		cmd = regexp.MustCompile(`^touch `).ReplaceAllString(cmd, `New-Item `)
 
-		// Convert any ${ZARF_VAR_*} or $ZARF_VAR_* to ${env:ZARF_VAR_*} or $env:ZARF_VAR_* respectively (also TF_VAR_*).
+		// Convert any ${ZARF_VAR_*} or $ZARF_VAR_* to ${env:ZARF_VAR_*} or $env:ZARF_VAR_* respectively
+		// (also TF_VAR_* and ZARF_CONST_).
 		// https://regex101.com/r/xk1rkw/1
-		envVarRegex := regexp.MustCompile(`(?P<envIndicator>\${?(?P<varName>(ZARF|TF)_VAR_([a-zA-Z0-9_-])+)}?)`)
-		get, err := helpers.MatchRegex(envVarRegex, cmd)
-		if err == nil {
-			newCmd := strings.ReplaceAll(cmd, get("envIndicator"), fmt.Sprintf("$Env:%s", get("varName")))
+		envVarRegex := regexp.MustCompile(`(?P<envIndicator>\${?(?P<varName>(ZARF|TF)_(VAR|CONST)_([a-zA-Z0-9_-])+)}?)`)
+		getFunctions := MatchAllRegex(envVarRegex, cmd)
+
+		newCmd := cmd
+		for _, get := range getFunctions {
+			newCmd = strings.ReplaceAll(newCmd, get("envIndicator"), fmt.Sprintf("$Env:%s", get("varName")))
+
+		}
+		if newCmd != cmd {
 			logger.From(ctx).Debug("converted command", "cmd", cmd, "newCmd", newCmd)
-			cmd = newCmd
 		}
+		cmd = newCmd
 	}
 
 	return cmd, nil
@@ -297,3 +303,20 @@ func actionRun(ctx context.Context, cfg v1alpha1.ZarfComponentActionDefaults, cm
 	}
 	return stdout, stderr, err
 }
+
+// MatchAllRegex wraps a get function around each substring match, returning all matches.
+func MatchAllRegex(regex *regexp.Regexp, str string) []func(string) string {
+	// Validate the string.
+	matches := regex.FindAllStringSubmatch(str, -1)
+
+	// Parse the string into its components.
+	var funcs []func(string) string
+	for _, match := range matches {
+		funcs = append(funcs, func(name string) string {
+			return match[regex.SubexpIndex(name)]
+
+		})
+	}
+
+	return funcs
+}


Test patch:
diff --git a/src/internal/packager2/actions/actions_test.go b/src/internal/packager2/actions/actions_test.go
new file mode 100644
index 0000000000..be77e39ee5
--- /dev/null
+++ b/src/internal/packager2/actions/actions_test.go
@@ -0,0 +1,99 @@
+package actions
+
+import (
+	"context"
+	"fmt"
+	"github.com/stretchr/testify/require"
+	"testing"
+
+	"github.com/zarf-dev/zarf/src/api/v1alpha1"
+	"github.com/zarf-dev/zarf/src/pkg/utils"
+)
+
+func Test_actionCmdMutation(t *testing.T) {
+	zarfCmd, _ := utils.GetFinalExecutableCommand()
+	tests := []struct {
+		name      string
+		cmd       string
+		shellPref v1alpha1.Shell
+		goos      string
+		want      string
+		wantErr   error
+	}{
+		{
+			name:      "linux without zarf",
+			cmd:       "echo \"this is zarf\"",
+			shellPref: v1alpha1.Shell{},
+			goos:      "linux",
+			want:      "echo \"this is zarf\"",
+			wantErr:   nil,
+		},
+		{
+			name:      "linux including zarf",
+			cmd:       "./zarf deploy",
+			shellPref: v1alpha1.Shell{},
+			goos:      "linux",
+			want:      fmt.Sprintf("%s deploy", zarfCmd),
+			wantErr:   nil,
+		},
+		{
+			name:      "windows including zarf",
+			cmd:       "./zarf deploy",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      fmt.Sprintf("%s deploy", zarfCmd),
+			wantErr:   nil,
+		},
+		{
+			name:      "windows env",
+			cmd:       "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_VAR_ENV1",
+			wantErr:   nil,
+		},
+		{
+			name: "windows env pwsh",
+			cmd:  "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{
+				Windows: "pwsh",
+			},
+			goos:    "windows",
+			want:    "echo $Env:ZARF_VAR_ENV1",
+			wantErr: nil,
+		},
+		{
+			name: "windows env powershell",
+			cmd:  "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{
+				Windows: "powershell",
+			},
+			goos:    "windows",
+			want:    "echo $Env:ZARF_VAR_ENV1",
+			wantErr: nil,
+		},
+		{
+			name:      "windows multiple env",
+			cmd:       "echo ${ZARF_VAR_ENV1} ${ZARF_VAR_ENV2}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_VAR_ENV1 $Env:ZARF_VAR_ENV2",
+			wantErr:   nil,
+		},
+		{
+			name:      "windows constants",
+			cmd:       "echo ${ZARF_CONST_ENV1}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_CONST_ENV1",
+			wantErr:   nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := actionCmdMutation(context.Background(), tt.cmd, tt.shellPref, tt.goos)
+			require.Equal(t, tt.wantErr, err)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
diff --git a/src/pkg/packager/actions/actions_test.go b/src/pkg/packager/actions/actions_test.go
new file mode 100644
index 0000000000..be77e39ee5
--- /dev/null
+++ b/src/pkg/packager/actions/actions_test.go
@@ -0,0 +1,99 @@
+package actions
+
+import (
+	"context"
+	"fmt"
+	"github.com/stretchr/testify/require"
+	"testing"
+
+	"github.com/zarf-dev/zarf/src/api/v1alpha1"
+	"github.com/zarf-dev/zarf/src/pkg/utils"
+)
+
+func Test_actionCmdMutation(t *testing.T) {
+	zarfCmd, _ := utils.GetFinalExecutableCommand()
+	tests := []struct {
+		name      string
+		cmd       string
+		shellPref v1alpha1.Shell
+		goos      string
+		want      string
+		wantErr   error
+	}{
+		{
+			name:      "linux without zarf",
+			cmd:       "echo \"this is zarf\"",
+			shellPref: v1alpha1.Shell{},
+			goos:      "linux",
+			want:      "echo \"this is zarf\"",
+			wantErr:   nil,
+		},
+		{
+			name:      "linux including zarf",
+			cmd:       "./zarf deploy",
+			shellPref: v1alpha1.Shell{},
+			goos:      "linux",
+			want:      fmt.Sprintf("%s deploy", zarfCmd),
+			wantErr:   nil,
+		},
+		{
+			name:      "windows including zarf",
+			cmd:       "./zarf deploy",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      fmt.Sprintf("%s deploy", zarfCmd),
+			wantErr:   nil,
+		},
+		{
+			name:      "windows env",
+			cmd:       "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_VAR_ENV1",
+			wantErr:   nil,
+		},
+		{
+			name: "windows env pwsh",
+			cmd:  "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{
+				Windows: "pwsh",
+			},
+			goos:    "windows",
+			want:    "echo $Env:ZARF_VAR_ENV1",
+			wantErr: nil,
+		},
+		{
+			name: "windows env powershell",
+			cmd:  "echo ${ZARF_VAR_ENV1}",
+			shellPref: v1alpha1.Shell{
+				Windows: "powershell",
+			},
+			goos:    "windows",
+			want:    "echo $Env:ZARF_VAR_ENV1",
+			wantErr: nil,
+		},
+		{
+			name:      "windows multiple env",
+			cmd:       "echo ${ZARF_VAR_ENV1} ${ZARF_VAR_ENV2}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_VAR_ENV1 $Env:ZARF_VAR_ENV2",
+			wantErr:   nil,
+		},
+		{
+			name:      "windows constants",
+			cmd:       "echo ${ZARF_CONST_ENV1}",
+			shellPref: v1alpha1.Shell{},
+			goos:      "windows",
+			want:      "echo $Env:ZARF_CONST_ENV1",
+			wantErr:   nil,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := actionCmdMutation(context.Background(), tt.cmd, tt.shellPref, tt.goos)
+			require.Equal(t, tt.wantErr, err)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}