diff --git a/cli/azd/pkg/update/msi_windows.go b/cli/azd/pkg/update/msi_windows.go index 95d2c3d8d9f..2ee6d4bc94c 100644 --- a/cli/azd/pkg/update/msi_windows.go +++ b/cli/azd/pkg/update/msi_windows.go @@ -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} } diff --git a/cli/azd/pkg/update/msi_windows_test.go b/cli/azd/pkg/update/msi_windows_test.go index 9516908e503..693344ec0e4 100644 --- a/cli/azd/pkg/update/msi_windows_test.go +++ b/cli/azd/pkg/update/msi_windows_test.go @@ -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 @@ -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", @@ -77,6 +69,10 @@ func TestBuildInstallScriptArgs(t *testing.T) { "-Command", installScriptURL, "-Version 'stable'", + "Remove-Item", + }, + wantNotContains: []string{ + "-InstallFolder", "-SkipVerify", }, }, @@ -89,6 +85,11 @@ func TestBuildInstallScriptArgs(t *testing.T) { "-Command", installScriptURL, "-Version 'daily'", + "-InstallFolder", + expectedDir, + "Remove-Item", + }, + wantNotContains: []string{ "-SkipVerify", }, }, @@ -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",