Skip to content
Open
89 changes: 87 additions & 2 deletions commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
Expand Down Expand Up @@ -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)
Comment thread
upsun-dispatch[bot] marked this conversation as resolved.
}

// 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")
Comment thread
pjcdawkins marked this conversation as resolved.
}

// 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.
Expand All @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions commands/auth_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package commands

import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"

"github.com/platformsh/platformify/vendorization"
Expand Down Expand Up @@ -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())
}
1 change: 1 addition & 0 deletions commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions internal/legacy/legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
19 changes: 16 additions & 3 deletions legacy/src/Service/Url.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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];
}
Expand All @@ -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
*/
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}
}
Loading
Loading