diff --git a/commands/auth.go b/commands/auth.go index 9c953a731..a637cdcfe 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -10,6 +10,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "runtime" "slices" "strings" @@ -272,12 +273,92 @@ 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(). 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" + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || (isWSL() && wslBrowserCommand() != nil) +} + +// 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 + } + 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") +} + +// 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" + wslRundll32Path = "/mnt/c/Windows/System32/rundll32.exe" +) + +// wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or +// 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 !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 +} + +// 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 + } + // 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"} + } + } + 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. @@ -295,6 +376,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..2ac89a037 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -1,6 +1,10 @@ package commands import ( + "os" + "path/filepath" + "runtime" + "strings" "testing" "github.com/platformsh/platformify/vendorization" @@ -47,3 +51,126 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.Nil(t, browserCommand("0")) assert.NotPanics(t, func() { browserCommand(" ") }) } + +// 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, 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. +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) { + conf, _ := setWSLInterop(t, c.binfmt) + if c.wslConf != "" { + require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) + } + assert.Equal(t, c.want, wslInteropEnabled()) + }) + } +} + +func TestBrowserCommand_WSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + _, system32 := setWSLInterop(t, "enabled") + writeStub(t, system32) + dir := t.TempDir() + 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, system32, wslBrowser(), "System32 is checked before PATH") + assert.True(t, hasDisplay(), "a Windows browser can be used without a display") + 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") + } + setWSLInterop(t, "enabled") + t.Setenv("PATH", t.TempDir()) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.False(t, hasDisplay()) + 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") + } + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + for _, state := range []string{"", "disabled"} { + _, 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) + } +} + +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") + } + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSL_INTEROP", "") + assert.False(t, isWSL()) +} 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 d4deb738d..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() @@ -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() || $this->getWslBrowser() !== false; } /** @@ -151,6 +153,9 @@ private function getDefaultBrowser(): string|false if (OsUtil::isOsX()) { return 'open'; } + if (($browser = $this->getWslBrowser()) !== false) { + return $browser; + } $browsers = ['xdg-open', 'gnome-open']; foreach ($browsers as $browser) { if ($this->shell->commandExists($browser)) { @@ -160,4 +165,12 @@ private function getDefaultBrowser(): string|false return false; } + + /** + * Returns the command to open URLs in a Windows browser from WSL, which the Go wrapper finds, or false. + */ + private function getWslBrowser(): string|false + { + return getenv($this->config->getStr('application.env_prefix') . 'WSL_BROWSER') ?: false; + } } diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php new file mode 100644 index 000000000..98d2598e3 --- /dev/null +++ b/legacy/tests/Service/UrlTest.php @@ -0,0 +1,80 @@ + */ + private array $env = []; + + private Shell&MockObject $shell; + + protected function setUp(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + foreach (['DISPLAY', 'MOCK_CLI_WSL_BROWSER'] as $name) { + $this->env[$name] = getenv($name); + } + putenv('DISPLAY'); + putenv('MOCK_CLI_WSL_BROWSER'); + } + + protected function tearDown(): void + { + foreach ($this->env as $name => $value) { + putenv($value === false ? $name : "$name=$value"); + } + } + + private function urlService(): Url + { + $this->shell = $this->createMock(Shell::class); + $this->shell->method('commandExists')->willReturn(false); + $definition = new InputDefinition(); + Url::configureInput($definition); + + return new Url( + $this->shell, + new ArrayInput([], $definition), + new BufferedOutput(), + new Config([], __DIR__ . '/../data/mock-cli-config.yaml'), + ); + } + + public function testWslOpensUrlsWithRundll32(): void + { + $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, 'url.dll,FileProtocolHandler', '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='); + $url = $this->urlService(); + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + } +}