From 1c975a20057d61201972daedf58876d621a2ef27 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:23:14 +0100 Subject: [PATCH 1/7] feat(auth): open the login URL in a Windows browser from WSL On WSL there is usually no DISPLAY and no xdg-open, so the login prompt was not offered, and login printed the URL instead of opening it. Under WSL, URLs are now opened with wslview if it is installed, or else with Windows' rundll32.exe (as on Windows), found in PATH or in its default location. The local server's 127.0.0.1 address works from Windows, as WSL forwards localhost. Co-Authored-By: Claude Opus 5.5 --- commands/auth.go | 34 ++++++++++++++++++++++++++++++++-- commands/auth_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 9c953a731..349717ce6 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -272,12 +272,38 @@ func withLogin(cmd *cobra.Command, cnf *config.Config, m *auth.Manager, fn func( return fn() } -// hasDisplay matches the legacy CLI's Url::hasDisplay(). +// hasDisplay matches the legacy CLI's Url::hasDisplay(), and also counts WSL, where a Windows browser can be used. func hasDisplay() bool { if d := os.Getenv("DISPLAY"); d != "" { return d != "none" } - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || isWSL() +} + +// isWSL reports whether this is Linux in the Windows Subsystem for Linux. +func isWSL() bool { + if runtime.GOOS != "linux" { + return false + } + if os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSL_INTEROP") != "" { + return true + } + b, err := os.ReadFile("/proc/sys/kernel/osrelease") + return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") +} + +// wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. +func wslBrowserCommand() []string { + if p, err := exec.LookPath("wslview"); err == nil { + return []string{p} + } + // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). + for _, p := range []string{"rundll32.exe", "/mnt/c/Windows/System32/rundll32.exe"} { + if p, err := exec.LookPath(p); err == nil { + return []string{p, "url.dll,FileProtocolHandler"} + } + } + return nil } // browserCommand returns the command to open URLs, or nil if none should be used. @@ -295,6 +321,10 @@ func browserCommand(browserOption string) []string { return []string{"rundll32", "url.dll,FileProtocolHandler"} case runtime.GOOS == "darwin": return []string{"open"} + case isWSL(): + if c := wslBrowserCommand(); c != nil { + return c + } } for _, b := range []string{"xdg-open", "gnome-open"} { if _, err := exec.LookPath(b); err == nil { diff --git a/commands/auth_test.go b/commands/auth_test.go index ed7878146..7f59839af 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -1,6 +1,9 @@ package commands import ( + "os" + "path/filepath" + "runtime" "testing" "github.com/platformsh/platformify/vendorization" @@ -47,3 +50,30 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.Nil(t, browserCommand("0")) assert.NotPanics(t, func() { browserCommand(" ") }) } + +func TestBrowserCommand_WSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.True(t, isWSL()) + assert.True(t, hasDisplay(), "a Windows browser can be used without a display") + assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) + assert.True(t, canOpenURLs("")) +} + +func TestIsWSL_NotWSL(t *testing.T) { + if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { + t.Skip("running in WSL") + } + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSL_INTEROP", "") + assert.False(t, isWSL()) +} From 80b7a16b0224b5d77f9568f11dcf7d07ea0fcd0b Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:24:20 +0100 Subject: [PATCH 2/7] test: skip the non-WSL check on any WSL kernel Co-Authored-By: Claude Opus 5.5 --- commands/auth_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commands/auth_test.go b/commands/auth_test.go index 7f59839af..ee4027123 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/platformsh/platformify/vendorization" @@ -70,7 +71,7 @@ func TestBrowserCommand_WSL(t *testing.T) { } func TestIsWSL_NotWSL(t *testing.T) { - if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { + if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") } t.Setenv("WSL_DISTRO_NAME", "") From 6f9762c9bc78a4738b416565d88ed23d28963379 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:43:18 +0100 Subject: [PATCH 3/7] fix(auth): only treat WSL as having a display if a Windows browser is found WSL counted as having a display even without a Windows opener, e.g. with interop disabled, or in a Docker Desktop container, which shares the WSL kernel. The CLI then offered a browser login with no display, and xdg-open could start a terminal browser or fail. Co-Authored-By: Claude Opus 5.5 --- commands/auth.go | 7 ++++--- commands/auth_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 349717ce6..87d38a56f 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -272,15 +272,16 @@ func withLogin(cmd *cobra.Command, cnf *config.Config, m *auth.Manager, fn func( return fn() } -// hasDisplay matches the legacy CLI's Url::hasDisplay(), and also counts WSL, where a Windows browser can be used. +// hasDisplay matches the legacy CLI's Url::hasDisplay(). It also counts WSL, if a Windows browser can be opened. func hasDisplay() bool { if d := os.Getenv("DISPLAY"); d != "" { return d != "none" } - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || isWSL() + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || (isWSL() && wslBrowserCommand() != nil) } -// isWSL reports whether this is Linux in the Windows Subsystem for Linux. +// isWSL reports whether this is Linux in the Windows Subsystem for Linux. This includes Docker Desktop's containers, +// which share its kernel, but have no Windows browser: see wslBrowserCommand. func isWSL() bool { if runtime.GOOS != "linux" { return false diff --git a/commands/auth_test.go b/commands/auth_test.go index ee4027123..6296fa6b4 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -70,6 +70,23 @@ func TestBrowserCommand_WSL(t *testing.T) { assert.True(t, canOpenURLs("")) } +// TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker +// Desktop container (which shares the WSL kernel). +func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + if _, err := os.Stat("/mnt/c/Windows/System32/rundll32.exe"); err == nil { + t.Skip("a Windows opener exists") + } + t.Setenv("PATH", t.TempDir()) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.False(t, hasDisplay()) + assert.False(t, canOpenURLs("")) +} + func TestIsWSL_NotWSL(t *testing.T) { if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") From cfa244c7bbc73f8bd5e43078fb0d41c1d473b26a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 07:59:29 +0100 Subject: [PATCH 4/7] feat(legacy): open URLs in a Windows browser from WSL On WSL, the legacy CLI found no display or xdg-open, so legacy commands did not offer "Log in via a browser?" (with or without Go auth), and the PHP login printed the URL instead of opening it. Match the Go detection: under WSL (from WSL_DISTRO_NAME/WSL_INTEROP or the kernel release), count as having a display if a Windows opener is found, and open URLs with wslview, or else rundll32.exe from PATH or /mnt/c/Windows/System32. The lookup only runs on WSL, when a URL is to be opened. Co-Authored-By: Claude Opus 5.5 --- legacy/src/Service/Url.php | 24 +++++++- legacy/src/Util/OsUtil.php | 15 +++++ legacy/tests/Service/UrlTest.php | 96 ++++++++++++++++++++++++++++++++ legacy/tests/Util/OsUtilTest.php | 22 ++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 legacy/tests/Service/UrlTest.php diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index d4deb738d..36e157a64 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -84,6 +84,8 @@ public function openUrl(string $url, bool $print = true): bool if (OsUtil::isWindows() && $browser === 'start') { // The start command needs an extra (title) argument. $args = [$browser, '', $url]; + } elseif (str_ends_with($browser, 'rundll32.exe')) { + $args = [$browser, 'url.dll,FileProtocolHandler', $url]; } else { $args = [$browser, $url]; } @@ -100,7 +102,7 @@ public function openUrl(string $url, bool $print = true): bool } /** - * Check for a display (if not on Windows or OS X). + * Check for a display (if not on Windows or OS X). WSL counts, if a Windows browser can be opened. * * @return bool */ @@ -110,7 +112,7 @@ public function hasDisplay(): bool return getenv('DISPLAY') !== 'none'; } - return OsUtil::isWindows() || OsUtil::isOsX(); + return OsUtil::isWindows() || OsUtil::isOsX() || (OsUtil::isWsl() && $this->getWslBrowser() !== false); } /** @@ -151,6 +153,9 @@ private function getDefaultBrowser(): string|false if (OsUtil::isOsX()) { return 'open'; } + if (OsUtil::isWsl() && ($browser = $this->getWslBrowser()) !== false) { + return $browser; + } $browsers = ['xdg-open', 'gnome-open']; foreach ($browsers as $browser) { if ($this->shell->commandExists($browser)) { @@ -160,4 +165,19 @@ private function getDefaultBrowser(): string|false return false; } + + /** + * Finds a command to open URLs in a Windows browser from WSL. + */ + private function getWslBrowser(): string|false + { + // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). + foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { + if ($this->shell->commandExists($browser)) { + return $browser; + } + } + + return false; + } } diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index cdb128e7c..853b06830 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -30,6 +30,21 @@ public static function isLinux(): bool return stripos(PHP_OS, 'Linux') !== false; } + /** + * Checks for Linux in the Windows Subsystem for Linux (or a Docker Desktop container, which shares its kernel). + */ + public static function isWsl(): bool + { + if (!self::isLinux()) { + return false; + } + if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { + return true; + } + + return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; + } + /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php new file mode 100644 index 000000000..66be911db --- /dev/null +++ b/legacy/tests/Service/UrlTest.php @@ -0,0 +1,96 @@ + */ + private array $env = []; + + private Shell&MockObject $shell; + + protected function setUp(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + foreach (['DISPLAY', 'WSL_DISTRO_NAME', 'WSL_INTEROP'] as $name) { + $this->env[$name] = getenv($name); + } + putenv('DISPLAY'); + putenv('WSL_INTEROP'); + putenv('WSL_DISTRO_NAME=Ubuntu'); + } + + protected function tearDown(): void + { + foreach ($this->env as $name => $value) { + putenv($value === false ? $name : "$name=$value"); + } + } + + /** + * @param string[] $commands + */ + private function urlService(array $commands): Url + { + $this->shell = $this->createMock(Shell::class); + $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); + $definition = new InputDefinition(); + Url::configureInput($definition); + + return new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + } + + public function testWslOpensUrlsWithRundll32(): void + { + $url = $this->urlService(['rundll32.exe']); + $this->assertTrue($url->hasDisplay()); + $this->assertTrue($url->canOpenUrls()); + + $this->shell->expects($this->once()) + ->method('execute') + ->with(['rundll32.exe', 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslFindsRundll32OutsidePath(): void + { + $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; + $url = $this->urlService([$rundll32]); + $this->shell->expects($this->once()) + ->method('execute') + ->with([$rundll32, 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslPrefersWslview(): void + { + $url = $this->urlService(['wslview', 'rundll32.exe']); + $this->shell->expects($this->once()) + ->method('execute') + ->with(['wslview', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslWithoutOpener(): void + { + $url = $this->urlService([]); + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + } +} diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index 39bf511e2..0a097262b 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -24,4 +24,26 @@ public function testEscapePosixShellArg(): void OsUtil::escapePosixShellArg("No it isn't"), ); } + + public function testIsWsl(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + if (stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false) { + $this->markTestSkipped('running in WSL'); + } + $distro = getenv('WSL_DISTRO_NAME'); + $interop = getenv('WSL_INTEROP'); + try { + putenv('WSL_DISTRO_NAME'); + putenv('WSL_INTEROP'); + $this->assertFalse(OsUtil::isWsl()); + putenv('WSL_DISTRO_NAME=Ubuntu'); + $this->assertTrue(OsUtil::isWsl()); + } finally { + putenv($distro === false ? 'WSL_DISTRO_NAME' : "WSL_DISTRO_NAME=$distro"); + putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); + } + } } From 14230946b05f8f1d61da597fa2c3cb927c6f08df Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 08:36:57 +0100 Subject: [PATCH 5/7] fix(auth): only use a Windows opener from WSL when interop is enabled With WSL interop off, rundll32.exe still exists under /mnt/c, so the CLI offered "Log in via a browser?", but running it fails (exec format error). Require WSL's binfmt_misc interop entry (WSLInterop or WSLInterop-late) to be enabled before using wslview or rundll32.exe, in Go and in the legacy CLI. Checked on WSL1: the entry exists and is "enabled" with interop on, and is missing with interop off. Co-Authored-By: Claude Opus 5.5 --- commands/auth.go | 20 ++++++++++++++++++++ commands/auth_test.go | 31 +++++++++++++++++++++++++++++++ legacy/src/Service/Url.php | 9 +++++++++ legacy/src/Util/OsUtil.php | 15 +++++++++++++++ legacy/tests/Service/UrlTest.php | 23 +++++++++++++++++++++-- legacy/tests/Util/OsUtilTest.php | 18 ++++++++++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 87d38a56f..ffe2e7d8a 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -10,6 +10,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "runtime" "slices" "strings" @@ -293,8 +294,27 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } +// wslBinfmtDir is where WSL registers Windows interop (overridden in tests). +var wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + +// wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or +// WSLInterop-late, and is missing or disabled when interop is off. +func wslInteropEnabled() bool { + paths, _ := filepath.Glob(filepath.Join(wslBinfmtDir, "WSLInterop*")) + for _, p := range paths { + if b, err := os.ReadFile(p); err == nil && strings.HasPrefix(string(b), "enabled") { + return true + } + } + return false +} + // wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. func wslBrowserCommand() []string { + // The openers run Windows programs. + if !wslInteropEnabled() { + return nil + } if p, err := exec.LookPath("wslview"); err == nil { return []string{p} } diff --git a/commands/auth_test.go b/commands/auth_test.go index 6296fa6b4..8e924c39d 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -52,10 +52,22 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.NotPanics(t, func() { browserCommand(" ") }) } +// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop; an empty state leaves it out. +func setWSLInterop(t *testing.T, state string) { + dir := t.TempDir() + if state != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) + } + orig := wslBinfmtDir + wslBinfmtDir = dir + t.Cleanup(func() { wslBinfmtDir = orig }) +} + func TestBrowserCommand_WSL(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } + setWSLInterop(t, "enabled") dir := t.TempDir() rundll := filepath.Join(dir, "rundll32.exe") require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub @@ -87,6 +99,25 @@ func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { assert.False(t, canOpenURLs("")) } +// TestBrowserCommand_WSLInteropOff checks that a Windows opener is not used when interop is off, as it cannot run. +func TestBrowserCommand_WSLInteropOff(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + for _, state := range []string{"", "disabled"} { + setWSLInterop(t, state) + assert.False(t, hasDisplay(), "interop state %q", state) + assert.False(t, canOpenURLs(""), "interop state %q", state) + } +} + func TestIsWSL_NotWSL(t *testing.T) { if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index 36e157a64..cd0b287d9 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -166,11 +166,20 @@ private function getDefaultBrowser(): string|false return false; } + protected function isWslInteropEnabled(): bool + { + return OsUtil::isWslInteropEnabled(); + } + /** * Finds a command to open URLs in a Windows browser from WSL. */ private function getWslBrowser(): string|false { + // The openers run Windows programs. + if (!$this->isWslInteropEnabled()) { + return false; + } // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { if ($this->shell->commandExists($browser)) { diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index 853b06830..b745d8cb6 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -45,6 +45,21 @@ public static function isWsl(): bool return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; } + /** + * Checks whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or WSLInterop-late, and is + * missing or disabled when interop is off. + */ + public static function isWslInteropEnabled(string $binfmtDir = '/proc/sys/fs/binfmt_misc'): bool + { + foreach (glob($binfmtDir . '/WSLInterop*') ?: [] as $path) { + if (str_starts_with((string) @file_get_contents($path), 'enabled')) { + return true; + } + } + + return false; + } + /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index 66be911db..f190717c0 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -11,7 +11,9 @@ use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; class UrlTest extends TestCase { @@ -43,14 +45,24 @@ protected function tearDown(): void /** * @param string[] $commands */ - private function urlService(array $commands): Url + private function urlService(array $commands, bool $interop = true): Url { $this->shell = $this->createMock(Shell::class); $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); $definition = new InputDefinition(); Url::configureInput($definition); - return new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + return new class ($interop, $this->shell, new ArrayInput([], $definition), new BufferedOutput()) extends Url { + public function __construct(private readonly bool $interop, Shell $shell, InputInterface $input, OutputInterface $output) + { + parent::__construct($shell, $input, $output); + } + + protected function isWslInteropEnabled(): bool + { + return $this->interop; + } + }; } public function testWslOpensUrlsWithRundll32(): void @@ -93,4 +105,11 @@ public function testWslWithoutOpener(): void $this->assertFalse($url->hasDisplay()); $this->assertFalse($url->canOpenUrls()); } + + public function testWslInteropOff(): void + { + $url = $this->urlService(['wslview', 'rundll32.exe'], false); + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + } } diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index 0a097262b..e78041726 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -46,4 +46,22 @@ public function testIsWsl(): void putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); } } + + public function testIsWslInteropEnabled(): void + { + $dir = sys_get_temp_dir() . '/binfmt-' . bin2hex(random_bytes(4)); + mkdir($dir); + try { + $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); + file_put_contents($dir . '/WSLInterop', "disabled\ninterpreter /init\n"); + $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); + file_put_contents($dir . '/WSLInterop-late', "enabled\ninterpreter /init\n"); + $this->assertTrue(OsUtil::isWslInteropEnabled($dir)); + } finally { + foreach (glob($dir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($dir); + } + } } From eec79197dd101d6ec05cc529fc58d09235483686 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 09:46:10 +0100 Subject: [PATCH 6/7] fix(auth): detect WSL2 with interop disabled, and pass the opener to PHP WSL2 keeps the WSLInterop binfmt_misc entry enabled when wsl.conf sets [interop] enabled=false, so the CLI offered a browser login and then waited about 10 seconds for rundll32.exe to fail. Also check wsl.conf. So that the detection lives in one place, the Go wrapper passes the WSL opener to the legacy CLI in WSL_BROWSER (always set, empty when there is none), and the PHP Url service uses it instead of its own WSL checks. Checked on Windows 11 with WSL1 (VirtualBox) and WSL2 (QEMU/KVM): with interop on, legacy commands offer the prompt and open Edge; with it off, there's no prompt and login prints the URL at once. Co-Authored-By: Claude Opus 5.5 --- commands/auth.go | 44 +++++++++++++++++++-- commands/auth_test.go | 36 +++++++++++++++-- commands/root.go | 1 + internal/legacy/legacy.go | 4 ++ legacy/src/Service/Url.php | 26 +++---------- legacy/src/Util/OsUtil.php | 30 --------------- legacy/tests/Service/UrlTest.php | 66 ++++++++++---------------------- legacy/tests/Util/OsUtilTest.php | 40 ------------------- 8 files changed, 104 insertions(+), 143 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index ffe2e7d8a..289c5a7dd 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -294,16 +294,41 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } -// wslBinfmtDir is where WSL registers Windows interop (overridden in tests). -var wslBinfmtDir = "/proc/sys/fs/binfmt_misc" +// WSL's binfmt_misc directory and config file (overridden in tests). +var ( + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" +) // wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or -// WSLInterop-late, and is missing or disabled when interop is off. +// WSLInterop-late. WSL1 removes it when interop is off, but WSL2 keeps it enabled, so wsl.conf is checked too. func wslInteropEnabled() bool { paths, _ := filepath.Glob(filepath.Join(wslBinfmtDir, "WSLInterop*")) for _, p := range paths { if b, err := os.ReadFile(p); err == nil && strings.HasPrefix(string(b), "enabled") { - return true + return !wslConfDisablesInterop() + } + } + return false +} + +// wslConfDisablesInterop reports whether wsl.conf sets "enabled = false" in its [interop] section. +func wslConfDisablesInterop() bool { + b, err := os.ReadFile(wslConfPath) + if err != nil { + return false + } + var section string + for line := range strings.Lines(string(b)) { + line, _, _ = strings.Cut(line, "#") + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) + continue + } + key, value, ok := strings.Cut(line, "=") + if ok && section == "interop" && strings.EqualFold(strings.TrimSpace(key), "enabled") { + return strings.EqualFold(strings.TrimSpace(value), "false") } } return false @@ -327,6 +352,17 @@ func wslBrowserCommand() []string { return nil } +// wslBrowser returns the program to open URLs in a Windows browser, if this is WSL, for the legacy CLI. +func wslBrowser() string { + if !isWSL() { + return "" + } + if c := wslBrowserCommand(); c != nil { + return c[0] + } + return "" +} + // browserCommand returns the command to open URLs, or nil if none should be used. func browserCommand(browserOption string) []string { switch { diff --git a/commands/auth_test.go b/commands/auth_test.go index 8e924c39d..cb22bc769 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -58,9 +58,37 @@ func setWSLInterop(t *testing.T, state string) { if state != "" { require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) } - orig := wslBinfmtDir - wslBinfmtDir = dir - t.Cleanup(func() { wslBinfmtDir = orig }) + origDir, origConf := wslBinfmtDir, wslConfPath + wslBinfmtDir, wslConfPath = dir, filepath.Join(dir, "wsl.conf") + t.Cleanup(func() { wslBinfmtDir, wslConfPath = origDir, origConf }) +} + +// TestWSLInteropEnabled covers WSL2, which keeps the binfmt_misc entry enabled when wsl.conf disables interop. +func TestWSLInteropEnabled(t *testing.T) { + cases := []struct { + name string + binfmt string + wslConf string + want bool + }{ + {name: "enabled", binfmt: "enabled", want: true}, + {name: "no entry", want: false}, + {name: "entry disabled", binfmt: "disabled", want: false}, + {name: "wsl.conf disables", binfmt: "enabled", wslConf: "[boot]\nsystemd=true\n[interop]\nenabled=false\n"}, + {name: "wsl.conf spacing and case", binfmt: "enabled", wslConf: "[Interop]\n Enabled = False # off\n"}, + {name: "wsl.conf enables", binfmt: "enabled", wslConf: "[interop]\nenabled=true\n", want: true}, + {name: "other section", binfmt: "enabled", wslConf: "[automount]\nenabled=false\n", want: true}, + {name: "comment", binfmt: "enabled", wslConf: "[interop]\n# enabled=false\n", want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setWSLInterop(t, c.binfmt) + if c.wslConf != "" { + require.NoError(t, os.WriteFile(wslConfPath, []byte(c.wslConf), 0o600)) + } + assert.Equal(t, c.want, wslInteropEnabled()) + }) + } } func TestBrowserCommand_WSL(t *testing.T) { @@ -77,6 +105,7 @@ func TestBrowserCommand_WSL(t *testing.T) { t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) + assert.Equal(t, rundll, wslBrowser()) assert.True(t, hasDisplay(), "a Windows browser can be used without a display") assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) @@ -114,6 +143,7 @@ func TestBrowserCommand_WSLInteropOff(t *testing.T) { for _, state := range []string{"", "disabled"} { setWSLInterop(t, state) assert.False(t, hasDisplay(), "interop state %q", state) + assert.Empty(t, wslBrowser(), "interop state %q", state) assert.False(t, canOpenURLs(""), "interop state %q", state) } } diff --git a/commands/root.go b/commands/root.go index 9541880fe..b9db9f259 100644 --- a/commands/root.go +++ b/commands/root.go @@ -345,6 +345,7 @@ func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io Version: config.Version, DebugLogFunc: debugLogf, DisableInteraction: viper.GetBool("no-interaction"), + WSLBrowser: wslBrowser(), Stdout: stdout, Stderr: stderr, Stdin: stdin, diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 354eda692..680d248cb 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -38,6 +38,8 @@ type CLIWrapper struct { DisableInteraction bool ForceColor bool DebugLogFunc func(string, ...any) + // WSLBrowser is a command to open URLs in a Windows browser from WSL, or empty. + WSLBrowser string initOnce sync.Once _cacheDir string @@ -150,6 +152,8 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { if exe, err := os.Executable(); err == nil { cmd.Env = append(cmd.Env, envPrefix+"WRAPPER_EXECUTABLE="+exe) } + // The legacy CLI uses this to open URLs from WSL. It is always set, so the user's environment can't override it. + cmd.Env = append(cmd.Env, envPrefix+"WSL_BROWSER="+c.WSLBrowser) if c.DisableInteraction { cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") } diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index cd0b287d9..8a55bb9c6 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -16,7 +16,7 @@ class Url implements InputConfiguringInterface { protected OutputInterface $stdErr; - public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output) + public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output, protected Config $config) { $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() @@ -112,7 +112,7 @@ public function hasDisplay(): bool return getenv('DISPLAY') !== 'none'; } - return OsUtil::isWindows() || OsUtil::isOsX() || (OsUtil::isWsl() && $this->getWslBrowser() !== false); + return OsUtil::isWindows() || OsUtil::isOsX() || $this->getWslBrowser() !== false; } /** @@ -153,7 +153,7 @@ private function getDefaultBrowser(): string|false if (OsUtil::isOsX()) { return 'open'; } - if (OsUtil::isWsl() && ($browser = $this->getWslBrowser()) !== false) { + if (($browser = $this->getWslBrowser()) !== false) { return $browser; } $browsers = ['xdg-open', 'gnome-open']; @@ -166,27 +166,11 @@ private function getDefaultBrowser(): string|false return false; } - protected function isWslInteropEnabled(): bool - { - return OsUtil::isWslInteropEnabled(); - } - /** - * Finds a command to open URLs in a Windows browser from WSL. + * Returns the command to open URLs in a Windows browser from WSL, which the Go wrapper finds, or false. */ private function getWslBrowser(): string|false { - // The openers run Windows programs. - if (!$this->isWslInteropEnabled()) { - return false; - } - // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). - foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { - if ($this->shell->commandExists($browser)) { - return $browser; - } - } - - return false; + return getenv($this->config->getStr('application.env_prefix') . 'WSL_BROWSER') ?: false; } } diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index b745d8cb6..cdb128e7c 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -30,36 +30,6 @@ public static function isLinux(): bool return stripos(PHP_OS, 'Linux') !== false; } - /** - * Checks for Linux in the Windows Subsystem for Linux (or a Docker Desktop container, which shares its kernel). - */ - public static function isWsl(): bool - { - if (!self::isLinux()) { - return false; - } - if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { - return true; - } - - return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; - } - - /** - * Checks whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or WSLInterop-late, and is - * missing or disabled when interop is off. - */ - public static function isWslInteropEnabled(string $binfmtDir = '/proc/sys/fs/binfmt_misc'): bool - { - foreach (glob($binfmtDir . '/WSLInterop*') ?: [] as $path) { - if (str_starts_with((string) @file_get_contents($path), 'enabled')) { - return true; - } - } - - return false; - } - /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index f190717c0..9b9d43e0a 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -6,14 +6,13 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\Shell; use Platformsh\Cli\Service\Url; use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Output\OutputInterface; class UrlTest extends TestCase { @@ -27,12 +26,11 @@ protected function setUp(): void if (!OsUtil::isLinux()) { $this->markTestSkipped('WSL is Linux'); } - foreach (['DISPLAY', 'WSL_DISTRO_NAME', 'WSL_INTEROP'] as $name) { + foreach (['DISPLAY', 'MOCK_CLI_WSL_BROWSER'] as $name) { $this->env[$name] = getenv($name); } putenv('DISPLAY'); - putenv('WSL_INTEROP'); - putenv('WSL_DISTRO_NAME=Ubuntu'); + putenv('MOCK_CLI_WSL_BROWSER'); } protected function tearDown(): void @@ -42,46 +40,29 @@ protected function tearDown(): void } } - /** - * @param string[] $commands - */ - private function urlService(array $commands, bool $interop = true): Url + private function urlService(): Url { $this->shell = $this->createMock(Shell::class); - $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); + $this->shell->method('commandExists')->willReturn(false); $definition = new InputDefinition(); Url::configureInput($definition); - return new class ($interop, $this->shell, new ArrayInput([], $definition), new BufferedOutput()) extends Url { - public function __construct(private readonly bool $interop, Shell $shell, InputInterface $input, OutputInterface $output) - { - parent::__construct($shell, $input, $output); - } - - protected function isWslInteropEnabled(): bool - { - return $this->interop; - } - }; + return new Url( + $this->shell, + new ArrayInput([], $definition), + new BufferedOutput(), + new Config([], __DIR__ . '/../data/mock-cli-config.yaml'), + ); } public function testWslOpensUrlsWithRundll32(): void { - $url = $this->urlService(['rundll32.exe']); + $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; + putenv('MOCK_CLI_WSL_BROWSER=' . $rundll32); + $url = $this->urlService(); $this->assertTrue($url->hasDisplay()); $this->assertTrue($url->canOpenUrls()); - $this->shell->expects($this->once()) - ->method('execute') - ->with(['rundll32.exe', 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) - ->willReturn(''); - $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); - } - - public function testWslFindsRundll32OutsidePath(): void - { - $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; - $url = $this->urlService([$rundll32]); $this->shell->expects($this->once()) ->method('execute') ->with([$rundll32, 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) @@ -89,26 +70,21 @@ public function testWslFindsRundll32OutsidePath(): void $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslPrefersWslview(): void + public function testWslOpensUrlsWithWslview(): void { - $url = $this->urlService(['wslview', 'rundll32.exe']); + putenv('MOCK_CLI_WSL_BROWSER=/usr/bin/wslview'); + $url = $this->urlService(); $this->shell->expects($this->once()) ->method('execute') - ->with(['wslview', 'http://127.0.0.1:5000']) + ->with(['/usr/bin/wslview', 'http://127.0.0.1:5000']) ->willReturn(''); $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslWithoutOpener(): void - { - $url = $this->urlService([]); - $this->assertFalse($url->hasDisplay()); - $this->assertFalse($url->canOpenUrls()); - } - - public function testWslInteropOff(): void + public function testNoWslBrowser(): void { - $url = $this->urlService(['wslview', 'rundll32.exe'], false); + putenv('MOCK_CLI_WSL_BROWSER='); + $url = $this->urlService(); $this->assertFalse($url->hasDisplay()); $this->assertFalse($url->canOpenUrls()); } diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index e78041726..39bf511e2 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -24,44 +24,4 @@ public function testEscapePosixShellArg(): void OsUtil::escapePosixShellArg("No it isn't"), ); } - - public function testIsWsl(): void - { - if (!OsUtil::isLinux()) { - $this->markTestSkipped('WSL is Linux'); - } - if (stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false) { - $this->markTestSkipped('running in WSL'); - } - $distro = getenv('WSL_DISTRO_NAME'); - $interop = getenv('WSL_INTEROP'); - try { - putenv('WSL_DISTRO_NAME'); - putenv('WSL_INTEROP'); - $this->assertFalse(OsUtil::isWsl()); - putenv('WSL_DISTRO_NAME=Ubuntu'); - $this->assertTrue(OsUtil::isWsl()); - } finally { - putenv($distro === false ? 'WSL_DISTRO_NAME' : "WSL_DISTRO_NAME=$distro"); - putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); - } - } - - public function testIsWslInteropEnabled(): void - { - $dir = sys_get_temp_dir() . '/binfmt-' . bin2hex(random_bytes(4)); - mkdir($dir); - try { - $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); - file_put_contents($dir . '/WSLInterop', "disabled\ninterpreter /init\n"); - $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); - file_put_contents($dir . '/WSLInterop-late', "enabled\ninterpreter /init\n"); - $this->assertTrue(OsUtil::isWslInteropEnabled($dir)); - } finally { - foreach (glob($dir . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($dir); - } - } } From b23b2beebd00db93258dfaa088afa190ab34689d Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 10:06:51 +0100 Subject: [PATCH 7/7] perf(auth): check System32 for rundll32.exe before searching PATH in WSL The wrapper looks up the WSL opener for every legacy command. In WSL2, PATH includes Windows directories on a slow mount: searching it for wslview (usually missing) and rundll32.exe took about 70ms, against 6ms for checking /mnt/c/Windows/System32/rundll32.exe. Check that path first, fall back to PATH, and drop wslview, which rundll32.exe makes unneeded. Co-Authored-By: Claude Opus 5.5 --- commands/auth.go | 14 ++++---- commands/auth_test.go | 58 +++++++++++++++++++++----------- legacy/tests/Service/UrlTest.php | 11 ------ 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 289c5a7dd..a637cdcfe 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -294,10 +294,11 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } -// WSL's binfmt_misc directory and config file (overridden in tests). +// WSL's binfmt_misc directory, config file, and the usual path to rundll32.exe (overridden in tests). var ( - wslBinfmtDir = "/proc/sys/fs/binfmt_misc" - wslConfPath = "/etc/wsl.conf" + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" + wslRundll32Path = "/mnt/c/Windows/System32/rundll32.exe" ) // wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or @@ -340,11 +341,8 @@ func wslBrowserCommand() []string { if !wslInteropEnabled() { return nil } - if p, err := exec.LookPath("wslview"); err == nil { - return []string{p} - } - // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). - for _, p := range []string{"rundll32.exe", "/mnt/c/Windows/System32/rundll32.exe"} { + // Searching PATH is slow under WSL2 (tens of ms, as it has Windows directories), so the usual path is tried first. + for _, p := range []string{wslRundll32Path, "rundll32.exe"} { if p, err := exec.LookPath(p); err == nil { return []string{p, "url.dll,FileProtocolHandler"} } diff --git a/commands/auth_test.go b/commands/auth_test.go index cb22bc769..2ac89a037 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -52,15 +52,22 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.NotPanics(t, func() { browserCommand(" ") }) } -// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop; an empty state leaves it out. -func setWSLInterop(t *testing.T, state string) { +// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop (an empty state leaves it out), wsl.conf, and the +// System32 rundll32.exe (missing). It returns the paths for wsl.conf and rundll32.exe. +func setWSLInterop(t *testing.T, state string) (conf, rundll string) { dir := t.TempDir() if state != "" { require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) } - origDir, origConf := wslBinfmtDir, wslConfPath - wslBinfmtDir, wslConfPath = dir, filepath.Join(dir, "wsl.conf") - t.Cleanup(func() { wslBinfmtDir, wslConfPath = origDir, origConf }) + origDir, origConf, origRundll := wslBinfmtDir, wslConfPath, wslRundll32Path + wslBinfmtDir, wslConfPath, wslRundll32Path = dir, filepath.Join(dir, "wsl.conf"), filepath.Join(dir, "rundll32.exe") + t.Cleanup(func() { wslBinfmtDir, wslConfPath, wslRundll32Path = origDir, origConf, origRundll }) + return wslConfPath, wslRundll32Path +} + +// writeStub writes an executable stub. +func writeStub(t *testing.T, path string) { + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub } // TestWSLInteropEnabled covers WSL2, which keeps the binfmt_misc entry enabled when wsl.conf disables interop. @@ -82,9 +89,9 @@ func TestWSLInteropEnabled(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - setWSLInterop(t, c.binfmt) + conf, _ := setWSLInterop(t, c.binfmt) if c.wslConf != "" { - require.NoError(t, os.WriteFile(wslConfPath, []byte(c.wslConf), 0o600)) + require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) } assert.Equal(t, c.want, wslInteropEnabled()) }) @@ -95,31 +102,45 @@ func TestBrowserCommand_WSL(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - setWSLInterop(t, "enabled") + _, system32 := setWSLInterop(t, "enabled") + writeStub(t, system32) dir := t.TempDir() - rundll := filepath.Join(dir, "rundll32.exe") - require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + writeStub(t, filepath.Join(dir, "rundll32.exe")) t.Setenv("PATH", dir) t.Setenv("DISPLAY", "") t.Setenv("WSL_INTEROP", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) - assert.Equal(t, rundll, wslBrowser()) + assert.Equal(t, system32, wslBrowser(), "System32 is checked before PATH") assert.True(t, hasDisplay(), "a Windows browser can be used without a display") - assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) + assert.Equal(t, []string{system32, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) } +// TestBrowserCommand_WSLRundll32InPath checks the PATH fallback, e.g. when the Windows drive is not mounted at /mnt/c. +func TestBrowserCommand_WSLRundll32InPath(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + setWSLInterop(t, "enabled") + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + writeStub(t, rundll) + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.Equal(t, rundll, wslBrowser()) +} + // TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker // Desktop container (which shares the WSL kernel). func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - if _, err := os.Stat("/mnt/c/Windows/System32/rundll32.exe"); err == nil { - t.Skip("a Windows opener exists") - } + setWSLInterop(t, "enabled") t.Setenv("PATH", t.TempDir()) t.Setenv("DISPLAY", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") @@ -133,15 +154,12 @@ func TestBrowserCommand_WSLInteropOff(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - dir := t.TempDir() - rundll := filepath.Join(dir, "rundll32.exe") - require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub - t.Setenv("PATH", dir) t.Setenv("DISPLAY", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") for _, state := range []string{"", "disabled"} { - setWSLInterop(t, state) + _, rundll := setWSLInterop(t, state) + writeStub(t, rundll) assert.False(t, hasDisplay(), "interop state %q", state) assert.Empty(t, wslBrowser(), "interop state %q", state) assert.False(t, canOpenURLs(""), "interop state %q", state) diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index 9b9d43e0a..98d2598e3 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -70,17 +70,6 @@ public function testWslOpensUrlsWithRundll32(): void $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslOpensUrlsWithWslview(): void - { - putenv('MOCK_CLI_WSL_BROWSER=/usr/bin/wslview'); - $url = $this->urlService(); - $this->shell->expects($this->once()) - ->method('execute') - ->with(['/usr/bin/wslview', 'http://127.0.0.1:5000']) - ->willReturn(''); - $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); - } - public function testNoWslBrowser(): void { putenv('MOCK_CLI_WSL_BROWSER=');