diff --git a/commands/auth.go b/commands/auth.go index 9c953a731..298e80c25 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,88 @@ 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 default automount root (overridden in tests). +var ( + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" + wslDefaultMountRoot = "/mnt/" +) + +// 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(wslConf string) 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 !strings.EqualFold(wslConfValue(wslConf, "interop", "enabled"), "false") + } + } + return false +} + +// wslConfValue returns the value of a key in a section of wsl.conf's contents, without surrounding quotes, or an +// empty string. +func wslConfValue(wslConf, section, key string) string { + var current string + for line := range strings.Lines(wslConf) { + line, _, _ = strings.Cut(line, "#") + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + current = strings.TrimSpace(line[1 : len(line)-1]) + continue + } + k, v, ok := strings.Cut(line, "=") + if ok && strings.EqualFold(current, section) && strings.EqualFold(strings.TrimSpace(k), key) { + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { + v = v[1 : len(v)-1] + } + return v + } + } + return "" +} + +// wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. +func wslBrowserCommand() []string { + b, _ := os.ReadFile(wslConfPath) + wslConf := string(b) + // The openers run Windows programs. + if !wslInteropEnabled(wslConf) { + return nil + } + root := wslConfValue(wslConf, "automount", "root") + if root == "" { + root = wslDefaultMountRoot + } + // Searching PATH is slow under WSL2 (tens of ms, as it has Windows directories), so it is the fallback. + for _, p := range []string{filepath.Join(root, "c", "Windows", "System32", "rundll32.exe"), "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 +372,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..a5fa9b705 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,183 @@ 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 (missing), +// and the default automount root. It returns the paths for wsl.conf and System32's rundll32.exe (missing). +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, origRoot := wslBinfmtDir, wslConfPath, wslDefaultMountRoot + wslBinfmtDir, wslConfPath, wslDefaultMountRoot = dir, filepath.Join(dir, "wsl.conf"), dir+"/" + t.Cleanup(func() { wslBinfmtDir, wslConfPath, wslDefaultMountRoot = origDir, origConf, origRoot }) + return wslConfPath, filepath.Join(dir, "c", "Windows", "System32", "rundll32.exe") +} + +// wslOpener returns the program wslBrowserCommand finds, or an empty string. +func wslOpener() string { + if c := wslBrowserCommand(); c != nil { + return c[0] + } + return "" +} + +// writeStub writes an executable stub, creating its directory. +func writeStub(t *testing.T, path string) { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) + 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 quoted", binfmt: "enabled", wslConf: "[interop]\nenabled = \"false\"\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) + assert.Equal(t, c.want, wslInteropEnabled(c.wslConf)) + }) + } +} + +func TestBrowserCommand_WSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + _, system32 := setWSLInterop(t, "enabled") + writeStub(t, system32) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.True(t, isWSL()) + assert.Equal(t, system32, wslOpener()) + 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("")) +} + +// TestWSLBrowser_MountRoot checks that rundll32.exe is found via wsl.conf's automount root, or else via PATH. +func TestWSLBrowser_MountRoot(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + custom := t.TempDir() + customRundll := filepath.Join(custom, "c", "Windows", "System32", "rundll32.exe") + cases := []struct { + name string + wslConf string + stub string // Where to write rundll32.exe: "default", "custom" (roots), "path", or nowhere. + want string // Where it is expected to be found, as above, or nowhere. + }{ + {name: "default root", stub: "default", want: "default"}, + {name: "custom root", wslConf: "[automount]\nroot = " + custom + "/\n", stub: "custom", want: "custom"}, + {name: "custom root without slash", wslConf: "[Automount]\nRoot=" + custom + "\n", stub: "custom", want: "custom"}, + {name: "custom root, quoted", wslConf: "[automount]\nroot = \"" + custom + "/\"\n", stub: "custom", want: "custom"}, + {name: "custom root, rundll32 in default root", wslConf: "[automount]\nroot=" + custom + "/\n", stub: "default"}, + {name: "rundll32 in PATH", stub: "path", want: "path"}, + {name: "no rundll32"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + conf, defaultRundll := setWSLInterop(t, "enabled") + if c.wslConf != "" { + require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) + } + require.NoError(t, os.RemoveAll(filepath.Join(custom, "c"))) + pathDir := t.TempDir() + t.Setenv("PATH", pathDir) + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + switch c.stub { + case "default": + writeStub(t, defaultRundll) + case "custom": + writeStub(t, customRundll) + case "path": + writeStub(t, filepath.Join(pathDir, "rundll32.exe")) + } + + switch c.want { + case "default": + assert.Equal(t, defaultRundll, wslOpener()) + case "custom": + assert.Equal(t, customRundll, wslOpener()) + case "path": + assert.Equal(t, filepath.Join(pathDir, "rundll32.exe"), wslOpener()) + default: + assert.Empty(t, wslOpener()) + assert.Nil(t, wslBrowserCommand()) + } + }) + } +} + +func TestWSLConfValue(t *testing.T) { + conf := "[boot]\nsystemd=true\nroot=/boot/\n\n[Automount] # mounts\n Root = /win/ # comment\n" + assert.Equal(t, "true", wslConfValue(conf, "boot", "systemd")) + assert.Equal(t, "/win/", wslConfValue(conf, "automount", "root")) + assert.Empty(t, wslConfValue(conf, "interop", "enabled")) + assert.Empty(t, wslConfValue("", "automount", "root")) + + quoted := "[a]\nd=\"x\"\ns='y'\nm=\"z'\nq=\"\n" + assert.Equal(t, "x", wslConfValue(quoted, "a", "d")) + assert.Equal(t, "y", wslConfValue(quoted, "a", "s")) + assert.Equal(t, "\"z'", wslConfValue(quoted, "a", "m")) + assert.Equal(t, "\"", wslConfValue(quoted, "a", "q")) +} + +// 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, wslOpener(), "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/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index d4deb738d..dbab9af51 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -16,6 +16,14 @@ class Url implements InputConfiguringInterface { protected OutputInterface $stdErr; + /** WSL's kernel release file, binfmt_misc directory, config file, and default automount root (overridden in tests). */ + public string $osReleasePath = '/proc/sys/kernel/osrelease'; + public string $wslBinfmtDir = '/proc/sys/fs/binfmt_misc'; + public string $wslConfPath = '/etc/wsl.conf'; + public string $wslDefaultMountRoot = '/mnt/'; + + private string|false|null $wslBrowser = null; + public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output) { $this->stdErr = $this->output instanceof ConsoleOutputInterface @@ -84,6 +92,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 +110,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 +120,7 @@ public function hasDisplay(): bool return getenv('DISPLAY') !== 'none'; } - return OsUtil::isWindows() || OsUtil::isOsX(); + return OsUtil::isWindows() || OsUtil::isOsX() || $this->getWslBrowser() !== false; } /** @@ -151,6 +161,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 +173,93 @@ private function getDefaultBrowser(): string|false return false; } + + /** + * Returns the command to open URLs in a Windows browser from WSL, or false. This matches the Go wrapper. + */ + private function getWslBrowser(): string|false + { + return $this->wslBrowser ??= $this->findWslBrowser(); + } + + private function findWslBrowser(): string|false + { + if (!OsUtil::isLinux() || !$this->isWsl()) { + return false; + } + $wslConf = (string) @file_get_contents($this->wslConfPath); + // The opener runs a Windows program. + if (!$this->wslInteropEnabled($wslConf)) { + return false; + } + $root = self::wslConfValue($wslConf, 'automount', 'root'); + if ($root === '') { + $root = $this->wslDefaultMountRoot; + } + $path = rtrim($root, '/') . '/c/Windows/System32/rundll32.exe'; + if (is_file($path) && is_executable($path)) { + return $path; + } + // Searching PATH is slow under WSL2 (it has Windows directories), so it is the fallback. + if ($this->shell->commandExists('rundll32.exe')) { + return 'rundll32.exe'; + } + + return false; + } + + /** + * Tests for WSL. This includes Docker Desktop's containers, which share its kernel, but have no Windows browser. + */ + private function isWsl(): bool + { + if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { + return true; + } + $release = @file_get_contents($this->osReleasePath); + + return $release !== false && stripos($release, 'microsoft') !== false; + } + + /** + * Tests 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. + */ + private function wslInteropEnabled(string $wslConf): bool + { + foreach (glob($this->wslBinfmtDir . '/WSLInterop*') ?: [] as $path) { + $contents = @file_get_contents($path); + if ($contents !== false && str_starts_with($contents, 'enabled')) { + return strcasecmp(self::wslConfValue($wslConf, 'interop', 'enabled'), 'false') !== 0; + } + } + + return false; + } + + /** + * Returns the value of a key in a section of wsl.conf's contents, without surrounding quotes, or an empty string. + */ + public static function wslConfValue(string $wslConf, string $section, string $key): string + { + $current = ''; + foreach (preg_split('/\r?\n/', $wslConf) ?: [] as $line) { + $line = trim(explode('#', $line, 2)[0]); + if (str_starts_with($line, '[') && str_ends_with($line, ']')) { + $current = trim(substr($line, 1, -1)); + continue; + } + $parts = explode('=', $line, 2); + if (count($parts) === 2 && strcasecmp($current, $section) === 0 && strcasecmp(trim($parts[0]), $key) === 0) { + $value = trim($parts[1]); + if (strlen($value) >= 2 && ($value[0] === '"' || $value[0] === "'") && $value[-1] === $value[0]) { + $value = substr($value, 1, -1); + } + + return $value; + } + } + + return ''; + } } diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php new file mode 100644 index 000000000..76aef3eae --- /dev/null +++ b/legacy/tests/Service/UrlTest.php @@ -0,0 +1,178 @@ + */ + private array $env = []; + + private string $dir; + + private Shell&MockObject $shell; + + protected function setUp(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + foreach (self::ENV as $name) { + $this->env[$name] = getenv($name); + putenv($name); + } + putenv('WSL_DISTRO_NAME=Ubuntu'); + $this->dir = sys_get_temp_dir() . '/cli-url-test-' . bin2hex(random_bytes(6)); + mkdir($this->dir . '/binfmt', 0o700, true); + } + + protected function tearDown(): void + { + foreach ($this->env as $name => $value) { + putenv($value === false ? $name : "$name=$value"); + } + if (isset($this->dir)) { + (new Filesystem())->remove($this->dir); + } + } + + private function urlService(bool $rundll32InPath = false): Url + { + $this->shell = $this->createMock(Shell::class); + $this->shell->method('commandExists')->willReturnCallback( + fn(string $command): bool => $rundll32InPath && $command === 'rundll32.exe', + ); + $definition = new InputDefinition(); + Url::configureInput($definition); + $url = new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + $url->osReleasePath = $this->dir . '/osrelease'; + $url->wslBinfmtDir = $this->dir . '/binfmt'; + $url->wslConfPath = $this->dir . '/wsl.conf'; + $url->wslDefaultMountRoot = $this->dir . '/mnt/'; + + return $url; + } + + private function setInterop(string $state): void + { + file_put_contents($this->dir . '/binfmt/WSLInterop', $state . "\ninterpreter /init\n"); + } + + private function writeStub(string $path): void + { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0o700, true); + } + file_put_contents($path, "#!/bin/sh\n"); + chmod($path, 0o700); + } + + private function defaultRundll32(): string + { + return $this->dir . '/mnt/c/Windows/System32/rundll32.exe'; + } + + public function testWslOpensUrlsWithRundll32(): void + { + $this->setInterop('enabled'); + $this->writeStub($this->defaultRundll32()); + $url = $this->urlService(); + $this->assertTrue($url->hasDisplay()); + $this->assertTrue($url->canOpenUrls()); + + $this->shell->expects($this->once()) + ->method('execute') + ->with([$this->defaultRundll32(), 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + /** + * @return array + */ + public static function wslBrowserCases(): array + { + $custom = "[automount]\nroot = %s/custom/\n"; + + return [ + 'default root' => ['enabled', '', 'mnt', false, 'mnt'], + 'custom root' => ['enabled', $custom, 'custom', false, 'custom'], + 'custom root, quoted, no slash' => ['enabled', "[Automount]\nRoot = \"%s/custom\" # comment\n", 'custom', false, 'custom'], + 'custom root, rundll32 in default root' => ['enabled', $custom, 'mnt', false, false], + 'PATH fallback' => ['enabled', '', '', true, 'rundll32.exe'], + 'no rundll32' => ['enabled', '', '', false, false], + 'no interop entry' => ['', '', 'mnt', true, false], + 'interop entry disabled' => ['disabled', '', 'mnt', true, false], + 'wsl.conf disables interop' => ['enabled', "[boot]\nsystemd=true\n[Interop]\n Enabled = 'False'\n", 'mnt', true, false], + 'wsl.conf enables interop' => ['enabled', "[interop]\nenabled=true\n", 'mnt', false, 'mnt'], + 'commented out' => ['enabled', "[interop]\n# enabled=false\n", 'mnt', false, 'mnt'], + ]; + } + + #[DataProvider('wslBrowserCases')] + public function testWslBrowser(string $interop, string $wslConf, string $stubRoot, bool $rundll32InPath, string|false $want): void + { + if ($interop !== '') { + $this->setInterop($interop); + } + if ($wslConf !== '') { + file_put_contents($this->dir . '/wsl.conf', sprintf($wslConf, $this->dir)); + } + if ($stubRoot !== '') { + $this->writeStub($this->dir . '/' . $stubRoot . '/c/Windows/System32/rundll32.exe'); + } + $url = $this->urlService($rundll32InPath); + if ($want === false) { + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + return; + } + $expected = $want === 'rundll32.exe' ? $want : $this->dir . '/' . $want . '/c/Windows/System32/rundll32.exe'; + $this->assertTrue($url->hasDisplay()); + $this->shell->expects($this->once()) + ->method('execute') + ->with([$expected, 'url.dll,FileProtocolHandler', 'https://example.com']) + ->willReturn(''); + $url->openUrl('https://example.com', false); + } + + public function testWslDetectedFromKernelRelease(): void + { + putenv('WSL_DISTRO_NAME'); + $this->setInterop('enabled'); + $this->writeStub($this->defaultRundll32()); + $url = $this->urlService(); + $this->assertFalse($url->hasDisplay(), 'not WSL'); + + file_put_contents($this->dir . '/osrelease', "5.15.167.4-Microsoft-standard-WSL2\n"); + $this->assertTrue($this->urlService()->hasDisplay()); + } + + public function testWslConfValue(): void + { + $conf = "[boot]\nsystemd=true\nroot=/boot/\n\n[Automount] # mounts\n Root = /win/ # comment\n" + . "[a]\nd=\"x\"\ns='y'\nm=\"z'\nq=\"\n"; + $this->assertSame('true', Url::wslConfValue($conf, 'boot', 'systemd')); + $this->assertSame('/win/', Url::wslConfValue($conf, 'automount', 'root')); + $this->assertSame('x', Url::wslConfValue($conf, 'a', 'd')); + $this->assertSame('y', Url::wslConfValue($conf, 'a', 's')); + $this->assertSame("\"z'", Url::wslConfValue($conf, 'a', 'm')); + $this->assertSame('"', Url::wslConfValue($conf, 'a', 'q')); + $this->assertSame('', Url::wslConfValue($conf, 'interop', 'enabled')); + $this->assertSame('', Url::wslConfValue('', 'automount', 'root')); + } +}