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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 20 additions & 23 deletions cli/azd/pkg/update/msi_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,35 +150,32 @@ func isStandardMSIInstall() error {
return nil
}

// versionFlag returns the install script parameter value for the given channel.
func versionFlag(channel Channel) string {
func escapeForPSSingleQuote(s string) string {
return strings.ReplaceAll(s, "'", "''")
}

// buildInstallScriptArgs constructs the PowerShell arguments to run install-azd.ps1.
// For all channels, the script is downloaded to a temp directory.
// For daily channel, an additional parameter (-InstallFolder) is passed
// to the script. The install folder is escaped for PowerShell single-quoted strings
// to handle paths containing apostrophes (e.g. O'Connor).
// Returns the arguments to pass to the "powershell" command.
func buildInstallScriptArgs(channel Channel) []string {
var scriptArgs string
switch channel {
case ChannelDaily:
return "daily"
case ChannelStable:
return "stable"
scriptArgs = fmt.Sprintf(" -Version 'daily' -InstallFolder '%s'",
escapeForPSSingleQuote(expectedPerUserInstallDir()))
default:
return "stable"
scriptArgs = " -Version 'stable'"
}
}

// buildInstallScriptArgs constructs the PowerShell arguments to download and run
// install-azd.ps1 with the appropriate -Version flag.
// The -SkipVerify flag is passed because Authenticode verification via
// Get-AuthenticodeSignature failed.
// The MSI is already downloaded over HTTPS from a Microsoft-controlled domain,
// so the transport-level integrity is sufficient.
// Returns the arguments to pass to the "powershell" command.
func buildInstallScriptArgs(channel Channel) []string {
version := versionFlag(channel)
// Download the script to a temp file, then invoke it with the appropriate -Version flag.
// Using -ExecutionPolicy Bypass ensures the script runs even if the system policy is restrictive.
script := fmt.Sprintf(
`$script = Join-Path $env:TEMP 'install-azd.ps1'; `+
`Invoke-RestMethod '%s' -OutFile $script; `+
`& $script -Version '%s' -SkipVerify; `+
`Remove-Item $script -Force -ErrorAction SilentlyContinue`,
installScriptURL, version,
"$tmpScript = Join-Path $env:TEMP 'azd-install.ps1'; "+
"Invoke-RestMethod '%s' -OutFile $tmpScript; "+
"& $tmpScript%s; "+
"Remove-Item $tmpScript -Force -ErrorAction SilentlyContinue",
installScriptURL, scriptArgs,
)
return []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
}
121 changes: 72 additions & 49 deletions cli/azd/pkg/update/msi_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import (
"github.com/stretchr/testify/require"
)

// NOTE: Automated testing of updateViaMSI invoking PowerShell with the correct
// arguments and isStandardMSIInstall succeeding on a standard path are limited
// because go test compiles to temp directories that never match the expected MSI
// install path (Programs\Azure Dev CLI), and backupCurrentExe cannot rename the
// running test binary. We rely on manual testing on actual per-user MSI installs
// to validate these code paths.

func TestExpectedPerUserInstallDir(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -42,31 +49,16 @@ func TestExpectedPerUserInstallDir(t *testing.T) {
}
}

func TestVersionFlag(t *testing.T) {
tests := []struct {
name string
channel Channel
want string
}{
{"stable channel", ChannelStable, "stable"},
{"daily channel", ChannelDaily, "daily"},
{"unknown defaults to stable", Channel("nightly"), "stable"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := versionFlag(tt.channel)
require.Equal(t, tt.want, got)
})
}
}

func TestBuildInstallScriptArgs(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
expectedDir := expectedPerUserInstallDir()

tests := []struct {
name string
channel Channel
// We check that certain substrings appear in the constructed args
wantContains []string
wantContains []string
wantNotContains []string
}{
{
name: "stable",
Expand All @@ -77,6 +69,10 @@ func TestBuildInstallScriptArgs(t *testing.T) {
"-Command",
installScriptURL,
"-Version 'stable'",
"Remove-Item",
},
wantNotContains: []string{
"-InstallFolder",
"-SkipVerify",
},
},
Expand All @@ -89,6 +85,11 @@ func TestBuildInstallScriptArgs(t *testing.T) {
"-Command",
installScriptURL,
"-Version 'daily'",
"-InstallFolder",
expectedDir,
"Remove-Item",
},
wantNotContains: []string{
"-SkipVerify",
},
},
Expand All @@ -105,52 +106,74 @@ func TestBuildInstallScriptArgs(t *testing.T) {
for _, s := range tt.wantContains {
require.Contains(t, joined, s, "expected args to contain %q", s)
}
for _, s := range tt.wantNotContains {
require.NotContains(t, joined, s, "expected args NOT to contain %q", s)
}
})
}
}

func TestEscapeForPSSingleQuote(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"no quotes", `C:\Users\testuser`, `C:\Users\testuser`},
{"single apostrophe", `C:\Users\O'Connor`, `C:\Users\O''Connor`},
{"multiple apostrophes", `C:\it's\a'path`, `C:\it''s\a''path`},
{"empty string", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, escapeForPSSingleQuote(tt.input))
})
}
}

func TestBuildInstallScriptArgs_ApostropheInPath(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\O'Connor\AppData\Local`)

args := buildInstallScriptArgs(ChannelDaily)
script := args[4]

// The apostrophe must be doubled for a valid PowerShell single-quoted string.
require.Contains(t, script, `O''Connor`)
// Must NOT contain unescaped apostrophe inside the -InstallFolder value.
require.NotContains(t, script, `-InstallFolder 'C:\Users\O'Connor`)
}

func TestBuildInstallScriptArgs_Structure(t *testing.T) {
t.Setenv("LOCALAPPDATA", `C:\Users\testuser\AppData\Local`)
expectedDir := expectedPerUserInstallDir()

args := buildInstallScriptArgs(ChannelStable)

// The args should be: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", <script>]
require.Equal(t, 5, len(args), "expected exactly 5 args")
require.Equal(t, "-NoProfile", args[0])
require.Equal(t, "-ExecutionPolicy", args[1])
require.Equal(t, "Bypass", args[2])
require.Equal(t, "-Command", args[3])

// The script (args[4]) should be a single string containing the full PowerShell pipeline
// Stable downloads to temp file — passes -Version 'stable' explicitly
script := args[4]
require.Contains(t, script, "Invoke-RestMethod")
require.Contains(t, script, installScriptURL)
require.Contains(t, script, "-SkipVerify")
require.Contains(t, script, "Remove-Item")
}

func TestIsStandardMSIInstall_StandardPath(t *testing.T) {
// Get the actual exe path and set LOCALAPPDATA so that
// expectedPerUserInstallDir() == filepath.Dir(exePath).
// expectedPerUserInstallDir = LOCALAPPDATA + \Programs\Azure Dev CLI
// So we need LOCALAPPDATA = filepath.Dir(exePath) stripped of "\Programs\Azure Dev CLI".
exePath, err := os.Executable()
require.NoError(t, err)
exePath, err = filepath.EvalSymlinks(exePath)
require.NoError(t, err)

actualDir := filepath.Dir(exePath)
suffix := filepath.Join("Programs", "Azure Dev CLI")
if !strings.HasSuffix(strings.ToLower(filepath.Clean(actualDir)), strings.ToLower(suffix)) {
// The test binary isn't in the expected suffix path (typical in CI/dev).
// Skip this test since we can't synthetically set LOCALAPPDATA to match.
t.Skipf("test binary dir %q does not end with %q; skipping standard-path test", actualDir, suffix)
}

localAppData := strings.TrimSuffix(filepath.Clean(actualDir), filepath.Clean(suffix))
localAppData = strings.TrimRight(localAppData, string(filepath.Separator))
t.Setenv("LOCALAPPDATA", localAppData)

err = isStandardMSIInstall()
require.NoError(t, err)
require.Contains(t, script, "-Version 'stable'")
require.NotContains(t, script, "-InstallFolder")

// Daily downloads to temp file with -Version 'daily'
argsDaily := buildInstallScriptArgs(ChannelDaily)
require.Equal(t, 5, len(argsDaily))
require.Equal(t, "Bypass", argsDaily[2])
scriptDaily := argsDaily[4]
require.Contains(t, scriptDaily, "Invoke-RestMethod")
require.Contains(t, scriptDaily, installScriptURL)
require.Contains(t, scriptDaily, "-Version 'daily'")
require.Contains(t, scriptDaily, "-InstallFolder")
require.Contains(t, scriptDaily, expectedDir)
require.Contains(t, scriptDaily, "Remove-Item")
}

func TestIsStandardMSIInstall_NonStandardPath(t *testing.T) {
Expand Down
Loading