From d8797c2a1fbaab307bba5f083fa3daf58162f904 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 9 Oct 2026 17:36:36 +1100 Subject: [PATCH 1/3] Stop treating WSL as a build server --- src/DiffEngine/BuildServerDetector.cs | 1 - src/Directory.Build.props | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/DiffEngine/BuildServerDetector.cs b/src/DiffEngine/BuildServerDetector.cs index d0e92fd24..485c70da0 100644 --- a/src/DiffEngine/BuildServerDetector.cs +++ b/src/DiffEngine/BuildServerDetector.cs @@ -65,7 +65,6 @@ static BuildServerDetector() IsMyGet || IsGoDc || IsDocker || - IsWsl || IsAppVeyor || IsBitbucketPipelines; } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0d39a80b3..69ab8b7d9 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,7 +1,7 @@ - 20.7.2 + 20.8.0 1.0.0 Testing, Snapshot, Diff, Compare Launches diff tools based on file extensions. Designed to be consumed by snapshot testing libraries. From 7b841dc854e2212f8c7342314e35caca11644983 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 9 Oct 2026 17:41:53 +1100 Subject: [PATCH 2/3] Document detecting a build that runs under WSL --- readme.md | 28 ++++++++++++++++++++++++++++ readme.source.md | 27 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/readme.md b/readme.md index e355c757f..1d62ff03b 100644 --- a/readme.md +++ b/readme.md @@ -44,6 +44,7 @@ DiffEngine manages launching and cleanup of diff tools. It is designed to be use * [Closing a tool](#closing-a-tool) * [File type detection](#file-type-detection) * [BuildServerDetector](#buildserverdetector) + * [WSL](#wsl) * [Override in tests](#override-in-tests) * [Automatic AI detection](#automatic-ai-detection) * [Programmatic usage](#programmatic-usage) @@ -193,6 +194,33 @@ var isBitbucketPipelines = BuildServerDetector.IsBitbucketPipelines; +### WSL + +Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. + +A build that runs its tests inside WSL from a Windows build agent is the case to watch. The variables the build server sets exist on the Windows side, and WSL only passes across the ones named in [WSLENV](https://learn.microsoft.com/en-us/windows/wsl/filesystems#share-environment-variables-between-windows-and-wsl-with-wslenv). So inside WSL nothing is detected, and diff tools are launched on the build agent. + +To have such a build detected, either name the build server's variable in `WSLENV` on the Windows side, for example on Azure DevOps: + +``` +set WSLENV=TF_BUILD:%WSLENV% +``` + +or set one of the detected variables on the command that starts the tests: + +``` +wsl -- env TF_BUILD=True dotnet test +``` + +To treat every WSL run as a build server, in a test: + +```cs +BuildServerDetector.Detected = BuildServerDetector.Detected || BuildServerDetector.IsWsl; +``` + +That value is scoped to the current async context, as described below. + + ### Override in tests `BuildServerDetector.Detected` can be set at test time. The value is stored in an `AsyncLocal`, so it is scoped to the current async context and does not leak to other threads or tests running in parallel. diff --git a/readme.source.md b/readme.source.md index 23206ecf6..9ac3d547b 100644 --- a/readme.source.md +++ b/readme.source.md @@ -89,6 +89,33 @@ There are also individual properties to check for each specific build system snippet: BuildServerDetectorProps +### WSL + +Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. + +A build that runs its tests inside WSL from a Windows build agent is the case to watch. The variables the build server sets exist on the Windows side, and WSL only passes across the ones named in [WSLENV](https://learn.microsoft.com/en-us/windows/wsl/filesystems#share-environment-variables-between-windows-and-wsl-with-wslenv). So inside WSL nothing is detected, and diff tools are launched on the build agent. + +To have such a build detected, either name the build server's variable in `WSLENV` on the Windows side, for example on Azure DevOps: + +``` +set WSLENV=TF_BUILD:%WSLENV% +``` + +or set one of the detected variables on the command that starts the tests: + +``` +wsl -- env TF_BUILD=True dotnet test +``` + +To treat every WSL run as a build server, in a test: + +```cs +BuildServerDetector.Detected = BuildServerDetector.Detected || BuildServerDetector.IsWsl; +``` + +That value is scoped to the current async context, as described below. + + ### Override in tests `BuildServerDetector.Detected` can be set at test time. The value is stored in an `AsyncLocal`, so it is scoped to the current async context and does not leak to other threads or tests running in parallel. From 16e428755ad77a6ef7d99f41612777b3aec957bd Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 10 Oct 2026 12:53:53 +1100 Subject: [PATCH 3/3] Launch Windows diff tools from inside WSL A tool with no copy in the distribution is resolved from its Windows definition and started through WSL's interop, with both files passed as Windows paths. Such a tool is found again by its command line and is never killed from here, since the process this side holds is WSL's stand-in for it. DiffEngine_WslWindowsTools=false turns it off. --- claude.md | 1 + docs/diff-tool.md | 25 ++ docs/mdsource/diff-tool.source.md | 25 ++ readme.md | 2 +- readme.source.md | 2 +- src/DiffEngine.Tests/WslHostTests.cs | 255 +++++++++++++++ src/DiffEngine.Tests/WslInteropTests.cs | 75 +++++ src/DiffEngine.Tests/WslPathsTests.cs | 133 ++++++++ src/DiffEngine/DiffRunner.cs | 17 +- src/DiffEngine/DiffRunner_Kill.cs | 9 + src/DiffEngine/DiffTools_Add.cs | 2 +- src/DiffEngine/OsSettingsResolver.cs | 137 +++++++- src/DiffEngine/Process/LinuxOsxProcess.cs | 11 +- src/DiffEngine/ResolvedTool.cs | 22 ++ src/DiffEngine/Tray/PendingFiles.cs | 2 +- src/DiffEngine/Wsl/WslHost.cs | 380 ++++++++++++++++++++++ src/DiffEngine/Wsl/WslInterop.cs | 159 +++++++++ src/DiffEngine/Wsl/WslPaths.cs | 231 +++++++++++++ 18 files changed, 1476 insertions(+), 12 deletions(-) create mode 100644 src/DiffEngine.Tests/WslHostTests.cs create mode 100644 src/DiffEngine.Tests/WslInteropTests.cs create mode 100644 src/DiffEngine.Tests/WslPathsTests.cs create mode 100644 src/DiffEngine/Wsl/WslHost.cs create mode 100644 src/DiffEngine/Wsl/WslInterop.cs create mode 100644 src/DiffEngine/Wsl/WslPaths.cs diff --git a/claude.md b/claude.md index b1d840054..826937871 100644 --- a/claude.md +++ b/claude.md @@ -822,6 +822,7 @@ the comment there about not caching "nothing staged" asks for. ### Key Patterns - Tool discovery uses wildcard path matching (`WildcardFileFinder`) to find executables in common install locations. A wildcard whose matches are all version-named folders takes the highest version; anything else takes the most recently written +- Inside WSL, which `BuildServerDetector.IsWsl` reports and `Detected` does not count as a build server, a tool with no copy in the distribution is resolved from its Windows definition and started through WSL's interop (`Wsl/`: `WslInterop`, `WslHost`, `WslPaths`; `OsSettingsResolver.TryFindOnHost`). The host's variables come from one `cmd.exe /u /c set`, its drives from `/proc/mounts` and the name of the distribution's share from one `wslpath -w /`. Paths are translated in process, not by `wslpath` per path, because `ResolvedTool.GetArguments` runs for every failing pair, opened or not. A program on the host's PATH is found from one listing of each host directory on it (`WslHost.TryFindOnPath`), a stat on a Windows drive from inside WSL being most of a millisecond. What this side holds for such a tool is WSL's `/init` standing in for it: `ps` lists it as `/init ` (`WslInterop.StripProxy`), ending it does not close a windowed tool, so `ResolvedTool.CanKill` is false and `DiffRunner.Kill` leaves it alone, and it ends with the terminal session while the window stays. It is started with all three streams redirected and closed (`WslInterop.Start`), or it would hold the pipe `dotnet test` reads. Not offered: the viewer (the host's loopback is not the distribution's under WSL's default networking), a Windows executable that is a `.cmd`, and Vim and Neovim. `DiffEngine_WslWindowsTools=false` turns it off. None of it can run in CI: `WslHostTests` stand a temp directory in for the drive, and the launch itself was run by hand in an Ubuntu distribution under WSL 2, with Beyond Compare, WinMerge and P4Merge - Tool order can be customized via `DiffEngine_ToolOrder` environment variable - `DisabledChecker` respects `DiffEngine_Disabled` env var - `ViewerClient` remembers a port found unowned for ten minutes (`RecheckUnownedAfter`), and the library's telling sends - settle, retire, move, delete, the first inline or diff send - skip the connect while that stands. A refused loopback connection costs two seconds on Windows (firewall stealth mode drops the reset), and a green run settles once per inline verification, which was six minutes for a class of 188 inline tests. Probes (`IsOwned`), the hosts and `InlineQueueClient` always ask and correct the memory; so does `SettleAppliedInline`, being one send per accept. Asking, on Windows, is the operating system's listener table first (`ListenerTable`, shared with `PiperClient`): no listener on the port means nobody to connect to, said without the two seconds, and a listener or a table that cannot be read leaves the connect to answer. So the first telling send of a test process, the launch gate's probe and each of its polls no longer wait to be refused. By port alone, whichever address, since the table is only believed when it says nobody is there. Not for a port that accepted a connection in the last second (`TrustOwnerFor`), because reading the table is reading every connection the machine has, and a run of settles to a live owner would pay more for each than the connect costs. The table is Windows' listeners alone, by `GetExtendedTcpTable` (`ListenerTable.Listeners`), falling back to .NET's list of every connection where that call is not there: 0.32 ms beside 3,000 connections where it was 13. What the table found empty is asked about again after a second (`RecheckUnlistedAfter`), so a tray started after a test process is found; the ten minutes is only for what a connect found. And only a refusal or an unanswered connect is remembered as nobody being there (`NobodyThere`). A viewer launch that failed gives its `MaxInstance` slot back diff --git a/docs/diff-tool.md b/docs/diff-tool.md index bc10e93f1..6bd2e9569 100644 --- a/docs/diff-tool.md +++ b/docs/diff-tool.md @@ -137,6 +137,31 @@ File | Settings | Manage Layers | This computer | Edit Layer | Build, Execution, Disable R# orphaned processes detection +## Windows Subsystem for Linux + +A test run inside a [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) distribution can use a diff tool installed on Windows, as well as one installed in the distribution. + +For each tool the distribution is searched first. A tool not found there is looked for on Windows: in the directories listed for it under [Supported Tools](#supported-tools), then on the `PATH`, which WSL extends with the Windows one. It is started through WSL's [interoperability with Windows](https://learn.microsoft.com/en-us/windows/wsl/filesystems#run-windows-tools-from-linux), and handed both files as Windows paths: + + * A file on a mounted drive by its drive letter: `/mnt/c/code/Tests.Method.received.txt` as `C:\code\Tests.Method.received.txt`. + * A file inside the distribution as a share: `/home/user/code/Tests.Method.received.txt` as `\\wsl.localhost\Ubuntu\home\user\code\Tests.Method.received.txt`. + +The [tool order](/docs/diff-tool.order.md) applies as it does anywhere else, so a Windows tool earlier in the order is used ahead of a tool in the distribution that is later in it. + +`DiffEngine_{ToolName}` can point at the Windows copy of a tool, written either as the distribution sees the directory (`/mnt/c/Program Files/WinMerge`) or as Windows does (`C:\Program Files\WinMerge`). + +What differs from a test run on Windows: + + * A Windows tool is not closed when its test passes. WSL runs a Windows program through a process of its own, and ending that process does not close the program's window. For the same reason a tool that does not auto refresh is given a second window when the same comparison fails again, where on Windows its window is replaced. + * Visual Studio Code is started by its `code` launcher, which WSL places on the `PATH`, and opens both files through its WSL extension. A tool that is a script on Windows (`.cmd`) cannot be started from a distribution. + * Vim and Neovim are only used from the distribution, since they run in the terminal they are started from. + * [DiffEngineTray](/docs/tray.md) is not supported from inside a distribution. + +To use only the tools in the distribution, set an environment variable `DiffEngine_WslWindowsTools` with the value `false`. + +A distribution is treated as a developer machine and not as a build server: see [BuildServerDetector](/readme.md#wsl). + + ## Supported Tools: Tools location is automatically detected. If a tool installed in a custom location, it can be manually configured using an environment variable, that points to the executable. The environment variable format is `DiffEngine_{ToolName}`. diff --git a/docs/mdsource/diff-tool.source.md b/docs/mdsource/diff-tool.source.md index 84acbf4fe..7b997c210 100644 --- a/docs/mdsource/diff-tool.source.md +++ b/docs/mdsource/diff-tool.source.md @@ -124,6 +124,31 @@ File | Settings | Manage Layers | This computer | Edit Layer | Build, Execution, Disable R# orphaned processes detection +## Windows Subsystem for Linux + +A test run inside a [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) distribution can use a diff tool installed on Windows, as well as one installed in the distribution. + +For each tool the distribution is searched first. A tool not found there is looked for on Windows: in the directories listed for it under [Supported Tools](#supported-tools), then on the `PATH`, which WSL extends with the Windows one. It is started through WSL's [interoperability with Windows](https://learn.microsoft.com/en-us/windows/wsl/filesystems#run-windows-tools-from-linux), and handed both files as Windows paths: + + * A file on a mounted drive by its drive letter: `/mnt/c/code/Tests.Method.received.txt` as `C:\code\Tests.Method.received.txt`. + * A file inside the distribution as a share: `/home/user/code/Tests.Method.received.txt` as `\\wsl.localhost\Ubuntu\home\user\code\Tests.Method.received.txt`. + +The [tool order](/docs/diff-tool.order.md) applies as it does anywhere else, so a Windows tool earlier in the order is used ahead of a tool in the distribution that is later in it. + +`DiffEngine_{ToolName}` can point at the Windows copy of a tool, written either as the distribution sees the directory (`/mnt/c/Program Files/WinMerge`) or as Windows does (`C:\Program Files\WinMerge`). + +What differs from a test run on Windows: + + * A Windows tool is not closed when its test passes. WSL runs a Windows program through a process of its own, and ending that process does not close the program's window. For the same reason a tool that does not auto refresh is given a second window when the same comparison fails again, where on Windows its window is replaced. + * Visual Studio Code is started by its `code` launcher, which WSL places on the `PATH`, and opens both files through its WSL extension. A tool that is a script on Windows (`.cmd`) cannot be started from a distribution. + * Vim and Neovim are only used from the distribution, since they run in the terminal they are started from. + * [DiffEngineTray](/docs/tray.md) is not supported from inside a distribution. + +To use only the tools in the distribution, set an environment variable `DiffEngine_WslWindowsTools` with the value `false`. + +A distribution is treated as a developer machine and not as a build server: see [BuildServerDetector](/readme.md#wsl). + + ## Supported Tools: Tools location is automatically detected. If a tool installed in a custom location, it can be manually configured using an environment variable, that points to the executable. The environment variable format is `DiffEngine_{ToolName}`. diff --git a/readme.md b/readme.md index 1d62ff03b..8994575e5 100644 --- a/readme.md +++ b/readme.md @@ -196,7 +196,7 @@ var isBitbucketPipelines = BuildServerDetector.IsBitbucketPipelines; ### WSL -Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. +Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. Diff tools are launched there, the ones installed on Windows included: see [Windows Subsystem for Linux](/docs/diff-tool.md#windows-subsystem-for-linux). A build that runs its tests inside WSL from a Windows build agent is the case to watch. The variables the build server sets exist on the Windows side, and WSL only passes across the ones named in [WSLENV](https://learn.microsoft.com/en-us/windows/wsl/filesystems#share-environment-variables-between-windows-and-wsl-with-wslenv). So inside WSL nothing is detected, and diff tools are launched on the build agent. diff --git a/readme.source.md b/readme.source.md index 9ac3d547b..a77c6c9b6 100644 --- a/readme.source.md +++ b/readme.source.md @@ -91,7 +91,7 @@ snippet: BuildServerDetectorProps ### WSL -Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. +Running under [WSL](https://learn.microsoft.com/en-us/windows/wsl/about) is not treated as a build server, since a WSL session is usually a developer machine. `BuildServerDetector.IsWsl` reports it, and `BuildServerDetector.Detected` ignores it. Diff tools are launched there, the ones installed on Windows included: see [Windows Subsystem for Linux](/docs/diff-tool.md#windows-subsystem-for-linux). A build that runs its tests inside WSL from a Windows build agent is the case to watch. The variables the build server sets exist on the Windows side, and WSL only passes across the ones named in [WSLENV](https://learn.microsoft.com/en-us/windows/wsl/filesystems#share-environment-variables-between-windows-and-wsl-with-wslenv). So inside WSL nothing is detected, and diff tools are launched on the build agent. diff --git a/src/DiffEngine.Tests/WslHostTests.cs b/src/DiffEngine.Tests/WslHostTests.cs new file mode 100644 index 000000000..d90bf4682 --- /dev/null +++ b/src/DiffEngine.Tests/WslHostTests.cs @@ -0,0 +1,255 @@ +/// +/// Finding a tool installed on Windows from inside a WSL distribution. +/// +/// No WSL is needed to run these, which is the point of taking what it +/// knows about the host as arguments. A directory under temp stands in for the drive the +/// distribution has mounted, so the lookups run against real files on whatever the tests are +/// running on. +/// +/// +[NotInParallel] +public class WslHostTests : + IDisposable +{ + // Forward slashes throughout, as every path inside a distribution has + readonly string drive = Path.Combine(Path.GetTempPath(), $"WslHostTests_{Guid.NewGuid():N}").Replace('\\', '/'); + + public void Dispose() + { + if (Directory.Exists(drive)) + { + Directory.Delete(drive, true); + } + } + + WslHost Host(params string[] pathDirectories) => + new( + new([new(drive, @"C:\")], @"\\wsl.localhost\Ubuntu"), + WslHost.ParseVariables( + """ + =C:=C:\Users\simon + LOCALAPPDATA=C:\Users\simon\AppData\Local + ProgramFiles=C:\Program Files + ProgramFiles(x86)=C:\Program Files (x86) + ProgramW6432=C:\Program Files + SystemRoot=C:\WINDOWS + USERPROFILE=C:\Users\simon + Odd=a=b + """), + pathDirectories); + + string Write(string relative) + { + var path = $"{drive}/{relative}"; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, ""); + return path; + } + + static OsSettings Settings(string exeName, params string[] directories) => + new(exeName, exeName, Arguments, directories); + + static LaunchArguments Arguments { get; } = new( + Left: (temp, target) => $"\"{target}\" \"{temp}\"", + Right: (temp, target) => $"\"{temp}\" \"{target}\""); + + /// + /// What cmd.exe /c set prints. A name is matched however it is cased, since + /// definitions write both %LocalAppData% and %LOCALAPPDATA%, and only the first + /// equals sign divides a line. + /// + [Test] + public async Task VariablesAreReadFromWhatSetPrints() + { + var variables = WslHost.ParseVariables("ProgramFiles=C:\\Program Files\r\n=C:=C:\\Users\r\nOdd=a=b\r\n\r\nno separator\r\n"); + + await Assert.That(variables.Count).IsEqualTo(2); + await Assert.That(variables["programfiles"]).IsEqualTo(@"C:\Program Files"); + await Assert.That(variables["Odd"]).IsEqualTo("a=b"); + } + + [Test] + [Arguments(@"%ProgramFiles%\Beyond Compare *\", @"C:\Program Files\Beyond Compare *\")] + [Arguments(@"%LocalAppData%\Programs\Tool\", @"C:\Users\simon\AppData\Local\Programs\Tool\")] + [Arguments(@"%USERPROFILE%\.dotnet\tools\", @"C:\Users\simon\.dotnet\tools\")] + [Arguments(@"%ProgramFiles(x86)%\Tool", @"C:\Program Files (x86)\Tool")] + [Arguments(@"C:\Tools\100%", @"C:\Tools\100%")] + public async Task ADirectoryIsExpandedWithTheHostsVariables(string directory, string expected) => + await Assert.That(Host().Expand(directory)).IsEqualTo(expected); + + /// + /// Not left as written, the way Environment.ExpandEnvironmentVariables leaves it: a + /// directory still holding a variable is not one that can be looked in. + /// + [Test] + public async Task ADirectoryNamingAVariableTheHostLacksIsDropped() + { + var host = Host(); + + await Assert.That(host.Expand(@"%NUGET_PACKAGES%\tool\")).IsNull(); + await Assert.That(host.SearchDirectories([@"%NUGET_PACKAGES%\tool\", @"Z:\tool\", @"%ProgramFiles%\Tool\"])) + .IsEquivalentTo([$"{drive}/Program Files/Tool/"]); + } + + [Test] + public async Task AToolIsFoundInItsWindowsDirectory() + { + var exe = Write("Program Files/Tool 5/Tool.exe"); + var settings = Settings("Tool.exe", @"%ProgramFiles%\Tool *\"); + + await Assert.That(OsSettingsResolver.TryFindOnHost(Host(), settings, out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(exe)); + } + + /// + /// The directory is searched as each of the three Program Files a Windows machine has, as it + /// is on Windows itself. + /// + [Test] + public async Task AToolIsFoundInThe32BitProgramFiles() + { + var exe = Write("Program Files (x86)/Tool/Tool.exe"); + var settings = Settings("Tool.exe", @"%ProgramFiles%\Tool\"); + + await Assert.That(OsSettingsResolver.TryFindOnHost(Host(), settings, out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(exe)); + } + + /// + /// A .cmd is a script for cmd.exe. The kernel hands WSL a Windows executable + /// and has nothing to do with a script, so a definition that names one is not found this way + /// even where the file is there. + /// + [Test] + public async Task AScriptIsNotATool() + { + Write("Program Files/Tool/tool.cmd"); + var settings = Settings("tool.cmd", @"%ProgramFiles%\Tool\"); + + await Assert.That(OsSettingsResolver.TryFindOnHost(Host($"{drive}/Program Files/Tool"), settings, out var path)).IsFalse(); + await Assert.That(path).IsNull(); + } + + [Test] + public async Task AToolNotInItsDirectoryIsFoundOnThePath() + { + var exe = Write("scoop/shims/tool.exe"); + var settings = Settings("Tool.exe", @"%ProgramFiles%\Tool\"); + var host = Host("/usr/bin", $"{drive}/empty", $"{drive}/scoop/shims/"); + + await Assert.That(OsSettingsResolver.TryFindOnHost(host, settings, out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(exe)); + } + + /// + /// A definition can name an executable to search directories for and a script to find on the + /// PATH, as Rider's does. The script is not looked for. + /// + [Test] + public async Task AScriptIsNotLookedForOnThePath() + { + Write("bin/tool.cmd"); + var settings = new OsSettings("tool64.exe", "tool.cmd", Arguments, @"%ProgramFiles%\Tool\"); + + await Assert.That(OsSettingsResolver.TryFindOnHost(Host($"{drive}/bin"), settings, out _)).IsFalse(); + } + + /// + /// The PATH is the distribution's, with the host's appended. Only the host's directories are + /// listed, the first to hold a name wins, and the Windows directory is passed over: it holds + /// most of the files on a PATH and no diff tool. + /// + [Test] + public async Task ThePathIsTheHostsDirectoriesInOrder() + { + var elsewhere = Path.Combine(Path.GetTempPath(), $"WslHostTests_{Guid.NewGuid():N}").Replace('\\', '/'); + Directory.CreateDirectory(elsewhere); + try + { + File.WriteAllText($"{elsewhere}/tool.exe", ""); + Write("WINDOWS/system32/tool.exe"); + Write("WINDOWS/other.exe"); + var first = Write("first/Tool.exe"); + Write("second/tool.exe"); + Write("second/notes.txt"); + var host = Host(elsewhere, $"{drive}/Windows/System32", $"{drive}/WINDOWS", $"{drive}/first", $"{drive}/second"); + + await Assert.That(host.TryFindOnPath("TOOL.EXE", out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(first)); + await Assert.That(host.TryFindOnPath("other.exe", out _)).IsFalse(); + await Assert.That(host.TryFindOnPath("notes.txt", out _)).IsFalse(); + } + finally + { + Directory.Delete(elsewhere, true); + } + } + + /// + /// DiffEngine_Tool can name the Windows copy either way round: as the distribution + /// sees the directory, or as it is written on the host. + /// + [Test] + [Arguments(@"C:\Custom\Tool")] + [Arguments("C:/Custom/Tool")] + [Arguments(@"C:\Custom\Tool\Tool.exe")] + public async Task AVariableCanNameTheWindowsCopyAsTheHostWritesIt(string value) + { + var exe = Write("Custom/Tool/Tool.exe"); + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", value); + try + { + await Assert.That(OsSettingsResolver.TryFindOnHostForEnvironmentVariable(Host(), "WslHostTool", "Tool.exe", true, out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(exe)); + } + finally + { + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", null); + } + } + + [Test] + public async Task AVariableCanNameTheWindowsCopyAsTheDistributionSeesIt() + { + var exe = Write("Custom/Tool/Tool.exe"); + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", $"{drive}/Custom/Tool"); + try + { + await Assert.That(OsSettingsResolver.TryFindOnHostForEnvironmentVariable(Host(), "WslHostTool", "Tool.exe", true, out var path)).IsTrue(); + await Assert.That(Path.GetFullPath(path!)).IsEqualTo(Path.GetFullPath(exe)); + } + finally + { + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", null); + } + } + + /// + /// A variable that names nothing is an error only where no other definition will be asked. + /// A tool with a Linux definition may have it naming the Linux copy, and that lookup is the + /// one to say whether it is there. + /// + [Test] + public async Task AVariableNamingNothingThrowsOnlyWhenNothingElseWillLook() + { + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", @"C:\Nowhere"); + try + { + var host = Host(); + + await Assert.That(OsSettingsResolver.TryFindOnHostForEnvironmentVariable(host, "WslHostTool", "Tool.exe", false, out var path)).IsFalse(); + await Assert.That(path).IsNull(); + + var exception = Assert.Throws(() => OsSettingsResolver.TryFindOnHostForEnvironmentVariable(host, "WslHostTool", "Tool.exe", true, out _)); + await Assert.That(exception.Message).IsEqualTo(@"Could not find exe defined by DiffEngine_WslHostTool. Path: C:\Nowhere"); + } + finally + { + Environment.SetEnvironmentVariable("DiffEngine_WslHostTool", null); + } + } + + [Test] + public async Task WithNoVariableNothingIsFoundAndNothingThrown() => + await Assert.That(OsSettingsResolver.TryFindOnHostForEnvironmentVariable(Host(), "WslHostToolUnset", "Tool.exe", true, out _)).IsFalse(); +} diff --git a/src/DiffEngine.Tests/WslInteropTests.cs b/src/DiffEngine.Tests/WslInteropTests.cs new file mode 100644 index 000000000..e91ab5c53 --- /dev/null +++ b/src/DiffEngine.Tests/WslInteropTests.cs @@ -0,0 +1,75 @@ +public class WslInteropTests +{ + /// + /// A Windows tool started from inside WSL is listed by ps as WSL's own program, given + /// the tool's path twice and then its arguments. A running tool is found by the command it + /// was started with, so what ps says is brought back to that. These are lines as an + /// Ubuntu distribution listed them, less the process id. + /// + [Test] + [Arguments( + @"/init /mnt/c/Program Files/WinMerge/WinMergeU.exe /mnt/c/Program Files/WinMerge/WinMergeU.exe /u /wl /e \\wsl.localhost\Ubuntu\home\simon\a.received.txt \\wsl.localhost\Ubuntu\home\simon\a.verified.txt", + @"/mnt/c/Program Files/WinMerge/WinMergeU.exe /u /wl /e \\wsl.localhost\Ubuntu\home\simon\a.received.txt \\wsl.localhost\Ubuntu\home\simon\a.verified.txt")] + [Arguments( + @"/init /mnt/c/Program Files/Beyond Compare 5/BCompare.exe /mnt/c/Program Files/Beyond Compare 5/BCompare.exe /solo /leftreadonly /nobackups C:\code\a b.received.txt C:\code\a b.verified.txt", + @"/mnt/c/Program Files/Beyond Compare 5/BCompare.exe /solo /leftreadonly /nobackups C:\code\a b.received.txt C:\code\a b.verified.txt")] + [Arguments( + "/init /mnt/c/Windows/System32/PING.EXE /mnt/c/Windows/System32/PING.EXE -n 120 127.0.0.1", + "/mnt/c/Windows/System32/PING.EXE -n 120 127.0.0.1")] + [Arguments( + "/init /mnt/c/Tools/tool.exe /mnt/c/Tools/tool.exe", + "/mnt/c/Tools/tool.exe")] + public async Task TheProxyIsTakenOffAWindowsToolsCommand(string listed, string command) => + await Assert.That(WslInterop.StripProxy(listed)).IsEqualTo(command); + + /// + /// Everything else ps lists is left as it is: a native tool, WSL's own processes, and + /// anything that only begins the same way. + /// + [Test] + [Arguments("/usr/bin/meld /home/simon/a.received.txt /home/simon/a.verified.txt")] + [Arguments("/init")] + [Arguments("/init ")] + [Arguments("/init --debug")] + [Arguments("/init /mnt/c/Tools/tool.exe /mnt/c/Tools/other.exe left right")] + [Arguments("/init /mnt/c/Tools/tool /mnt/c/Tools/tool.exe left")] + [Arguments("/initial /a /a")] + public async Task AnythingElseIsLeftAlone(string listed) => + await Assert.That(WslInterop.StripProxy(listed)).IsEqualTo(listed); + + /// + /// The viewer is spoken to over a loopback port the host does not share with a + /// distribution, and the two editors run in a terminal a test process does not have. Every + /// other tool is offered, and so is one of the caller's own, which has no + /// . + /// + [Test] + public async Task EveryToolButTheViewerAndTheTerminalEditorsIsOffered() + { + var withheld = Definitions.Tools + .Select(_ => _.Tool) + .Where(_ => !WslInterop.Offers(_)) + .ToList(); + + await Assert.That(withheld).IsEquivalentTo([DiffTool.DiffEngineViewer, DiffTool.Vim, DiffTool.Neovim]); + await Assert.That(WslInterop.Offers(null)).IsTrue(); + } + + /// + /// A definition whose Windows executable is a script cannot be started from a distribution, + /// so it has to be reachable some other way. Which ones are is written down here, so adding + /// another is a decision rather than a tool that quietly never resolves inside WSL. + /// + [Test] + public async Task TheToolsDeclaredAsScriptsAreTheOnesKnownAbout() + { + var scripts = Definitions.Tools + .Where(_ => _.OsSupport.Windows is { } windows && + !windows.ExeName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + .Select(_ => _.Tool) + .ToList(); + + // Found by the launcher its Linux definition names, which WSL puts on the PATH + await Assert.That(scripts).IsEquivalentTo([DiffTool.VisualStudioCode]); + } +} diff --git a/src/DiffEngine.Tests/WslPathsTests.cs b/src/DiffEngine.Tests/WslPathsTests.cs new file mode 100644 index 000000000..240e2536f --- /dev/null +++ b/src/DiffEngine.Tests/WslPathsTests.cs @@ -0,0 +1,133 @@ +/// +/// A path inside a WSL distribution, written the way the Windows host needs it and back. +/// +/// The mount table is the text of /proc/mounts from an Ubuntu distribution under WSL 2, +/// with a WSL 1 drive, a drive mounted somewhere with a space in it and a share added. Its +/// backslashes are octal escapes, as that file writes them. +/// +/// +public class WslPathsTests +{ + const string mountTable = + """ + /dev/sdd / ext4 rw,relatime,discard,errors=remount-ro,data=ordered 0 0 + none /mnt/wsl tmpfs rw,relatime 0 0 + drivers /usr/lib/wsl/drivers 9p ro,nosuid,nodev,noatime,aname=drivers;fmask=222;dmask=222,cache=0x5,access=client,msize=65536,trans=fd,rfd=8,wfd=8 0 0 + C:\134 /mnt/c 9p rw,noatime,aname=drvfs;path=C:\;uid=1000;gid=1000;symlinkroot=/mnt/,cache=0x5,access=client,msize=65536,trans=fd,rfd=6,wfd=6 0 0 + D:\134 /mnt/d 9p rw,noatime,aname=drvfs;path=D:\;uid=1000;gid=1000;symlinkroot=/mnt/,cache=0x5,access=client,msize=65536,trans=fd,rfd=6,wfd=6 0 0 + E: /mnt/e drvfs rw,noatime,uid=1000,gid=1000,case=off 0 0 + F:\134 /mnt/my\040drive 9p rw,noatime,aname=drvfs;path=F:\;uid=1000;gid=1000 0 0 + \134\134server\134share /mnt/share 9p rw,noatime,aname=drvfs;path=UNC\server\share;uid=1000;gid=1000 0 0 + tmpfs /run tmpfs rw,nosuid,nodev,mode=755 0 0 + """; + + static WslPaths Paths() => + new(WslPaths.ParseMounts(mountTable), @"\\wsl.localhost\Ubuntu\"); + + [Test] + public async Task OnlyTheDrivesAreMounts() + { + var mounts = WslPaths.ParseMounts(mountTable); + + await Assert.That(mounts).IsEquivalentTo( + [ + new WslMount("/mnt/c", @"C:\"), + new WslMount("/mnt/d", @"D:\"), + new WslMount("/mnt/e", @"E:\"), + new WslMount("/mnt/my drive", @"F:\"), + new WslMount("/mnt/share", @"\\server\share\") + ]); + } + + [Test] + [Arguments("/mnt/c/Users/simon/project/Tests.Method.received.txt", @"C:\Users\simon\project\Tests.Method.received.txt")] + [Arguments("/mnt/c", @"C:\")] + [Arguments("/mnt/d/Code/a b.txt", @"D:\Code\a b.txt")] + [Arguments("/mnt/e/x.txt", @"E:\x.txt")] + [Arguments("/mnt/my drive/x.txt", @"F:\x.txt")] + [Arguments("/mnt/share/folder/x.txt", @"\\server\share\folder\x.txt")] + public async Task AFileOnADriveIsWrittenWithItsLetter(string linux, string windows) => + await Assert.That(Paths().ToWindows(linux)).IsEqualTo(windows); + + /// + /// Everything that is not on a drive is in the distribution, which the host reaches as a + /// share. That includes a directory whose name only begins as a mount point does. + /// + [Test] + [Arguments("/home/simon/project/Tests.Method.received.txt", @"\\wsl.localhost\Ubuntu\home\simon\project\Tests.Method.received.txt")] + [Arguments("/tmp/a b.txt", @"\\wsl.localhost\Ubuntu\tmp\a b.txt")] + [Arguments("/mnt/cache/x.txt", @"\\wsl.localhost\Ubuntu\mnt\cache\x.txt")] + [Arguments("/mnt/wsl/x.txt", @"\\wsl.localhost\Ubuntu\mnt\wsl\x.txt")] + public async Task AFileInTheDistributionIsWrittenAsAShare(string linux, string windows) => + await Assert.That(Paths().ToWindows(linux)).IsEqualTo(windows); + + /// + /// The share is named by whatever wslpath said, which on an older Windows is + /// \\wsl$, and it says it with or without the trailing separator. + /// + [Test] + public async Task TheShareIsWhateverTheHostCallsIt() + { + var paths = new WslPaths([], @"\\wsl$\Debian"); + + await Assert.That(paths.ToWindows("/home/x.txt")).IsEqualTo(@"\\wsl$\Debian\home\x.txt"); + } + + /// + /// A drive mounted beneath another mount is its own drive, whatever order the table lists + /// them in. + /// + [Test] + public async Task TheDeepestMountWins() + { + var paths = new WslPaths( + [ + new("/mnt/c", @"C:\"), + new("/mnt/c/data", @"G:\") + ], + @"\\wsl.localhost\Ubuntu"); + + await Assert.That(paths.ToWindows("/mnt/c/data/x.txt")).IsEqualTo(@"G:\x.txt"); + await Assert.That(paths.ToWindows("/mnt/c/other/x.txt")).IsEqualTo(@"C:\other\x.txt"); + } + + [Test] + [Arguments(@"C:\Program Files\Beyond Compare *\", "/mnt/c/Program Files/Beyond Compare */")] + [Arguments(@"c:\Users\simon\AppData\Local", "/mnt/c/Users/simon/AppData/Local")] + [Arguments("C:/Program Files/WinMerge", "/mnt/c/Program Files/WinMerge")] + [Arguments(@"C:\", "/mnt/c")] + [Arguments("C:", "/mnt/c")] + [Arguments(@"F:\tools", "/mnt/my drive/tools")] + [Arguments(@"\\server\share\tools", "/mnt/share/tools")] + public async Task AHostDirectoryIsWrittenAsItsMount(string windows, string linux) + { + await Assert.That(Paths().TryToLinux(windows, out var result)).IsTrue(); + await Assert.That(result).IsEqualTo(linux); + } + + /// + /// A drive that is not mounted cannot be looked in from here, and a path that is already a + /// Linux one is not a host directory at all. + /// + [Test] + [Arguments(@"Z:\tools")] + [Arguments("/mnt/c/Program Files")] + [Arguments(@"%ProgramFiles%\Tool")] + [Arguments("")] + public async Task APathOffTheMountsHasNoLinuxSpelling(string path) + { + await Assert.That(Paths().TryToLinux(path, out var result)).IsFalse(); + await Assert.That(result).IsNull(); + } + + [Test] + [Arguments("/mnt/c", true)] + [Arguments("/mnt/c/", true)] + [Arguments("/mnt/c/Program Files/Git/cmd", true)] + [Arguments("/mnt/my drive/bin/", true)] + [Arguments("/usr/bin", false)] + [Arguments("/mnt/cache/bin", false)] + [Arguments("/", false)] + public async Task ADirectoryIsOnTheHostWhenItIsUnderADrive(string directory, bool expected) => + await Assert.That(Paths().IsOnHost(directory)).IsEqualTo(expected); +} diff --git a/src/DiffEngine/DiffRunner.cs b/src/DiffEngine/DiffRunner.cs index 1c2918be0..cdb6764ac 100644 --- a/src/DiffEngine/DiffRunner.cs +++ b/src/DiffEngine/DiffRunner.cs @@ -409,7 +409,7 @@ internal static LaunchResult InnerLaunch( tool.CommandAndArguments(tempFile, targetFile, out var arguments, out var command); - var canKill = !tool.IsMdi; + var canKill = tool.CanKill; var replacing = false; if (ProcessCleanup.TryGetProcessInfo(command, out var processCommand)) { @@ -471,7 +471,7 @@ await PendingFiles.AddDerivedAsync(viewer, tempFile, targetFile, source, Cancel. tool.CommandAndArguments(tempFile, targetFile, out var arguments, out var command); - var canKill = !tool.IsMdi; + var canKill = tool.CanKill; var replacing = false; if (ProcessCleanup.TryGetProcessInfo(command, out var processCommand)) { @@ -596,6 +596,13 @@ internal static int LaunchProcess(ResolvedTool tool, string arguments) return WindowsProcess.StartInheritingNothing(tool.ExePath, arguments); } + // A Windows tool from inside WSL, which is started so that it holds nothing of the + // host's either: its window outlives the run by as long as it is left open + if (tool.IsWindowsProgramInWsl) + { + return WslInterop.Start(tool.ExePath, arguments); + } + var startInfo = new ProcessStartInfo(tool.ExePath, arguments) { // Given the full exe path is known we dont need UseShellExecute https://stackoverflow.com/a/5255335 @@ -629,11 +636,13 @@ Failed to launch diff tool. /// /// Closes the tool already showing this pair, and reports whether it did. An MDI tool hosts - /// every diff in one window, so there is nothing to close and nothing being replaced. + /// every diff in one window, so there is nothing to close and nothing being replaced. A + /// Windows tool started from WSL cannot be closed from here, so its window is left and the + /// pair gets another beside it. /// static bool KillIfNotMdi(ResolvedTool tool, string command) { - if (tool.IsMdi) + if (!tool.CanKill) { return false; } diff --git a/src/DiffEngine/DiffRunner_Kill.cs b/src/DiffEngine/DiffRunner_Kill.cs index 23875fce6..d437cdfd3 100644 --- a/src/DiffEngine/DiffRunner_Kill.cs +++ b/src/DiffEngine/DiffRunner_Kill.cs @@ -38,6 +38,15 @@ public static void Kill(string tempFile, string targetFile) return; } + // What could be ended here is WSL's stand-in for the tool, and a windowed tool outlives + // that. Ending it would only lose track of a window that is still open, and the next + // failing run for this pair would then open a second + if (diffTool.IsWindowsProgramInWsl) + { + Logging.Write($"DiffTool is a Windows program run from WSL so not killing. diffTool: {diffTool.ExePath}"); + return; + } + var command = diffTool.BuildCommand(tempFile, targetFile); ProcessCleanup.Kill(command); } diff --git a/src/DiffEngine/DiffTools_Add.cs b/src/DiffEngine/DiffTools_Add.cs index eaad1819d..d8b732f37 100644 --- a/src/DiffEngine/DiffTools_Add.cs +++ b/src/DiffEngine/DiffTools_Add.cs @@ -46,7 +46,7 @@ public static partial class DiffTools static ResolvedTool? AddTool(string name, DiffTool? diffTool, bool autoRefresh, bool isMdi, bool supportsText, bool requiresTarget, IEnumerable binaryExtensions, OsSupport osSupport, bool useShellExecute, bool createNoWindow, bool killLockingProcess = false) { - if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments, PreferredCopy(diffTool))) + if (!OsSettingsResolver.Resolve(name, osSupport, out var exePath, out var launchArguments, PreferredCopy(diffTool), WslInterop.Offers(diffTool))) { return null; } diff --git a/src/DiffEngine/OsSettingsResolver.cs b/src/DiffEngine/OsSettingsResolver.cs index 83adc6ff2..ba75db022 100644 --- a/src/DiffEngine/OsSettingsResolver.cs +++ b/src/DiffEngine/OsSettingsResolver.cs @@ -4,6 +4,11 @@ static class OsSettingsResolver { static string[] envPaths; + /// + /// The directories of PATH, in its order. + /// + internal static IReadOnlyList EnvPaths => envPaths; + static OsSettingsResolver() { // An unset PATH is a NullReferenceException in a static constructor, and so permanent @@ -60,14 +65,33 @@ internal static string[] ParsePath(string value, char separator) /// first copy of all when it accepts none - so it reorders what is found and never empties it. /// Not asked about a copy an environment variable names, which is somebody's explicit choice. /// + /// + /// Inside WSL the Windows definition is searched too, on the host's drives, after the Linux + /// one: a copy the distribution has needs no path translated and no process standing in for + /// it. windowsThroughWsl is false for a tool that cannot be run that way: see + /// . + /// /// public static bool Resolve( string tool, OsSupport osSupport, [NotNullWhen(true)] out string? path, [NotNullWhen(true)] out LaunchArguments? launchArguments, - Func? preferred = null) + Func? preferred = null, + bool windowsThroughWsl = true) { + var host = windowsThroughWsl ? WslInterop.Host : null; + + // Ahead of the Linux definition, which throws for a variable naming a directory that + // does not hold its own executable + if (host != null && + osSupport.Windows is { } named && + TryFindOnHostForEnvironmentVariable(host, tool, named.ExeName, osSupport.Linux == null, out path)) + { + launchArguments = named.LaunchArguments; + return true; + } + if (TryResolveForOs(tool, osSupport.Windows, out path, "WINDOWS", preferred)) { launchArguments = osSupport.Windows.LaunchArguments; @@ -86,11 +110,113 @@ public static bool Resolve( return true; } + if (host != null && + osSupport.Windows is { } windows && + TryFindOnHost(host, windows, out path)) + { + launchArguments = windows.LaunchArguments; + return true; + } + path = null; launchArguments = null; return false; } + /// + /// A Windows definition's executable, looked for from inside WSL: in its search directories + /// as the distribution sees them, then on the PATH, to which WSL appends the host's + /// (). + /// + /// Only an .exe. A .cmd is a script for cmd.exe, which the kernel has + /// nothing to start with. The two tools declared that way are reached otherwise: VS Code by + /// the launcher its Linux definition finds on that same PATH, and Rider by its + /// rider64.exe. + /// + /// + internal static bool TryFindOnHost(WslHost host, OsSettings windows, [NotNullWhen(true)] out string? path) + { + path = null; + if (!IsExe(windows.ExeName)) + { + return false; + } + + var directories = host.SearchDirectories(ExpandProgramFiles(windows.SearchDirectories)); + path = Installed(windows.ExeName, null, directories).FirstOrDefault(); + if (path != null) + { + return true; + } + + return IsExe(windows.PathCommandName) && + host.TryFindOnPath(windows.PathCommandName, out path); + } + + /// + /// for a Windows copy named from inside WSL. The + /// variable may give the directory either way round: as the distribution sees it, or as the + /// host does. + /// + /// A variable that names nothing is only an error here when says + /// no other definition will be asked. Where there is a Linux one, the variable may be naming + /// that copy, and the Linux lookup is the one to say it is not there. + /// + /// + internal static bool TryFindOnHostForEnvironmentVariable(WslHost host, string tool, string exeName, bool orThrow, [NotNullWhen(true)] out string? path) + { + path = null; + var environmentVariable = $"DiffEngine_{tool}"; + var value = Environment.GetEnvironmentVariable(environmentVariable); + if (value is null || + !IsExe(exeName)) + { + return false; + } + + if (TryFindAt(value, exeName, out path) || + (host.Paths.TryToLinux(value, out var linux) && TryFindAt(linux, exeName, out path))) + { + return true; + } + + if (orThrow) + { + throw new($"Could not find exe defined by {environmentVariable}. Path: {value}"); + } + + return false; + } + + /// + /// The executable a variable's value names: the file itself, or the directory it is in. + /// + static bool TryFindAt(string basePath, string exeName, [NotNullWhen(true)] out string? path) + { + if (basePath.EndsWith(exeName, StringComparison.OrdinalIgnoreCase) && + File.Exists(basePath)) + { + path = basePath; + return true; + } + + if (Directory.Exists(basePath)) + { + var candidate = Path.Combine(basePath, exeName); + if (File.Exists(candidate)) + { + path = candidate; + return true; + } + } + + path = null; + return false; + } + + static bool IsExe(string name) => + name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + static bool TryResolveForOs( string tool, [NotNullWhen(true)] OsSettings? os, @@ -191,9 +317,9 @@ static bool TryFindExe(string exeName, string pathCommandName, IEnumerable /// Every installed copy, in the order they are looked for: the search directories as written, - /// then PATH. + /// then PATH, unless there is no name to look for there. /// - static IEnumerable Installed(string exeName, string pathCommandName, IEnumerable searchDirectories) + static IEnumerable Installed(string exeName, string? pathCommandName, IEnumerable searchDirectories) { foreach (var directory in searchDirectories.Distinct()) { @@ -211,6 +337,11 @@ static IEnumerable Installed(string exeName, string pathCommandName, IEn } } + if (pathCommandName is null) + { + yield break; + } + foreach (var commandPath in InEnvPath(pathCommandName)) { yield return commandPath; diff --git a/src/DiffEngine/Process/LinuxOsxProcess.cs b/src/DiffEngine/Process/LinuxOsxProcess.cs index 10dfd8592..913ca8c5a 100644 --- a/src/DiffEngine/Process/LinuxOsxProcess.cs +++ b/src/DiffEngine/Process/LinuxOsxProcess.cs @@ -37,6 +37,9 @@ public static List FindAll(HashSet? candidateExeNames = return []; } + // Inside WSL a Windows tool is listed behind the program WSL runs it with. A tool is + // looked for by the command it was started with, which has nothing in front of it + var throughWsl = WslInterop.Host != null; var commands = new List(); using var reader = new StringReader(processList); reader.ReadLine(); @@ -47,7 +50,13 @@ public static List FindAll(HashSet? candidateExeNames = continue; } - commands.Add(processCommand!.Value); + var parsed = processCommand!.Value; + if (throughWsl) + { + parsed = new(WslInterop.StripProxy(parsed.Command), parsed.Process); + } + + commands.Add(parsed); } return commands; diff --git a/src/DiffEngine/ResolvedTool.cs b/src/DiffEngine/ResolvedTool.cs index 24f1f4de6..7b172aa64 100644 --- a/src/DiffEngine/ResolvedTool.cs +++ b/src/DiffEngine/ResolvedTool.cs @@ -13,8 +13,30 @@ internal void CommandAndArguments(string tempFile, string targetFile, out string public string BuildCommand(string tempFile, string targetFile) => $"\"{ExePath}\" {GetArguments(tempFile, targetFile)}"; + /// + /// Whether this is a Windows program being run from inside WSL: see . + /// + internal bool IsWindowsProgramInWsl => + WslInterop.IsWindowsProgram(ExePath); + + /// + /// Whether the window opened for a pair can be closed by ending the process started for it. + /// Not for an MDI tool, whose one window holds every pair, and not for a Windows tool started + /// from WSL, where that process is not the tool. + /// + internal bool CanKill => + !IsMdi && + !IsWindowsProgramInWsl; + public string GetArguments(string tempFile, string targetFile) { + // A program on the host is handed both files by the paths the host knows them by + if (IsWindowsProgramInWsl) + { + tempFile = WslInterop.ToWindows(tempFile); + targetFile = WslInterop.ToWindows(targetFile); + } + if (TargetPosition.TargetOnLeft) { return LaunchArguments.Left(tempFile, targetFile); diff --git a/src/DiffEngine/Tray/PendingFiles.cs b/src/DiffEngine/Tray/PendingFiles.cs index c450c50f5..ecc618429 100644 --- a/src/DiffEngine/Tray/PendingFiles.cs +++ b/src/DiffEngine/Tray/PendingFiles.cs @@ -394,7 +394,7 @@ public static (string arguments, bool canKill) RelaunchFor(ResolvedTool tool, st return (ViewerLauncher.DiffArguments(temp, target), false); } - return (tool.GetArguments(temp, target), !tool.IsMdi); + return (tool.GetArguments(temp, target), tool.CanKill); } /// diff --git a/src/DiffEngine/Wsl/WslHost.cs b/src/DiffEngine/Wsl/WslHost.cs new file mode 100644 index 000000000..2a99c0925 --- /dev/null +++ b/src/DiffEngine/Wsl/WslHost.cs @@ -0,0 +1,380 @@ +namespace DiffEngine; + +/// +/// The Windows machine a WSL distribution runs on, as far as finding a diff tool installed there +/// needs it: where its drives are mounted, and the variables its install directories are written +/// with. +/// +/// Those variables are the host's and not this process's. %ProgramFiles% and +/// %LocalAppData% do not exist inside a distribution, so they are asked of the host once, +/// by running its cmd.exe, which a distribution can do for the same reason it can run the +/// tool. +/// +/// +class WslHost +{ + // Each program is run once a process. Long enough for a machine under load, and short enough + // that one which never answers does not hold up the first verification for good + const int timeout = 5000; + + readonly IReadOnlyDictionary variables; + readonly Lazy> programsOnPath; + + /// How a path here is written on the host, and the reverse. + /// The host's environment variables. + /// + /// This process's PATH. WSL appends the host's to it, so a program on the host's is found in + /// one of these. + /// + public WslHost(WslPaths paths, IReadOnlyDictionary variables, IReadOnlyList pathDirectories) + { + Paths = paths; + this.variables = variables; + programsOnPath = new(() => ListPrograms(pathDirectories)); + } + + public WslPaths Paths { get; } + + /// + /// A Windows program on the PATH, by its file name. + /// + /// Answered from one listing of each of the host's directories on the PATH, made the first + /// time anything is asked for. The ordinary lookup asks each directory about each name, and + /// a question put to a Windows drive from inside WSL takes most of a millisecond: thirty + /// directories and twenty tools were over half a second of every test run, where listing + /// them once is a tenth of that. + /// + /// + public bool TryFindOnPath(string name, [NotNullWhen(true)] out string? path) => + programsOnPath.Value.TryGetValue(name, out path); + + /// + /// Every .exe in the host's directories on the PATH, the first of each name winning + /// as it does for the PATH itself. Names are matched without regard to case, as the host + /// matches them. + /// + /// Not the Windows directory or anything in it. No diff tool is installed there, and it + /// holds most of the files on a PATH. + /// + /// + Dictionary ListPrograms(IReadOnlyList directories) + { + var programs = new Dictionary(StringComparer.OrdinalIgnoreCase); + string? systemRoot = null; + if (variables.TryGetValue("SystemRoot", out var windowsDirectory)) + { + Paths.TryToLinux(windowsDirectory, out systemRoot); + } + + foreach (var directory in directories) + { + if (!Paths.IsOnHost(directory) || + IsWithin(directory, systemRoot)) + { + continue; + } + + try + { + foreach (var file in Directory.EnumerateFiles(directory)) + { + if (!file.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var name = Path.GetFileName(file); + if (!programs.ContainsKey(name)) + { + programs[name] = file; + } + } + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + // A PATH entry that is not there, or cannot be read, holds nothing to find + } + } + + return programs; + } + + static bool IsWithin(string directory, string? parent) + { + if (parent is null) + { + return false; + } + + var trimmed = directory.TrimEnd('/'); + // A Windows drive ignores case, and a PATH spells this directory several ways + return trimmed.StartsWith(parent, StringComparison.OrdinalIgnoreCase) && + (trimmed.Length == parent.Length || trimmed[parent.Length] == '/'); + } + + /// + /// The directories a Windows definition searches, as paths this process can look in. + /// + /// One written with a variable the host does not define, or naming a drive that is not + /// mounted, is left out: there is nowhere here to look for it. + /// + /// + public IEnumerable SearchDirectories(IEnumerable directories) + { + foreach (var directory in directories) + { + if (Expand(directory) is { } expanded && + Paths.TryToLinux(expanded, out var linux)) + { + yield return linux; + } + } + } + + /// + /// with each %NAME% replaced by the host's value for it, + /// or null when the host has none. + /// + internal string? Expand(string directory) + { + var builder = new StringBuilder(directory.Length); + var position = 0; + while (position < directory.Length) + { + var start = directory.IndexOf('%', position); + if (start < 0) + { + break; + } + + var end = directory.IndexOf('%', start + 1); + if (end < 0) + { + break; + } + + var name = directory.Substring(start + 1, end - start - 1); + if (!variables.TryGetValue(name, out var value)) + { + return null; + } + + builder.Append(directory, position, start - position); + builder.Append(value); + position = end + 1; + } + + builder.Append(directory, position, directory.Length - position); + return builder.ToString(); + } + + /// + /// What cmd.exe /c set printed, as names and values. Names are matched without regard + /// to case, as Windows matches them: definitions write both %LocalAppData% and + /// %LOCALAPPDATA%. + /// + public static Dictionary ParseVariables(string output) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var line in output.Split('\n')) + { + var trimmed = line.TrimEnd('\r'); + var separator = trimmed.IndexOf('='); + // Not at 0: cmd keeps a current directory per drive in variables named "=C:" + if (separator < 1) + { + continue; + } + + result[trimmed.Substring(0, separator)] = trimmed.Substring(separator + 1); + } + + return result; + } + + /// + /// The host of this process, or null when it has none it can start a program on: not Linux, + /// not inside WSL, turned off with DiffEngine_WslWindowsTools, or WSL's own switch for + /// running Windows programs is off. + /// + /// Never throws. This runs from the type initializer of DiffTools, where a throw is a + /// TypeInitializationException for every later use in the process. + /// + /// + public static WslHost? Detect() + { + try + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || + !BuildServerDetector.IsWsl || + TurnedOff() || + !RunsWindowsPrograms()) + { + return null; + } + + var mounts = WslPaths.ParseMounts(File.ReadAllText("/proc/mounts")); + // No drive, so no directory a tool could be installed in is reachable from here + if (mounts.Count == 0) + { + return null; + } + + var root = DistributionRoot(); + if (root is null) + { + return null; + } + + return new(new(mounts, root), ReadVariables(mounts), OsSettingsResolver.EnvPaths); + } + catch (Exception exception) + { + Trace.WriteLine($"DiffEngine: Could not read the WSL host. Windows diff tools will not be offered. {exception.Message}"); + return null; + } + } + + static bool TurnedOff() + { + var value = Environment.GetEnvironmentVariable(WslInterop.Variable); + return value != null && + bool.TryParse(value, out var enabled) && + !enabled; + } + + /// + /// Whether the kernel hands a Windows executable to WSL to run. That is a binfmt_misc + /// registration, absent when interop is turned off in wsl.conf or .wslconfig, + /// and named differently by a distribution that registers it after systemd has started. + /// + static bool RunsWindowsPrograms() + { + foreach (var name in new[] { "WSLInterop", "WSLInterop-late" }) + { + var file = $"/proc/sys/fs/binfmt_misc/{name}"; + if (File.Exists(file) && + File.ReadLines(file).FirstOrDefault() == "enabled") + { + return true; + } + } + + return false; + } + + /// + /// What the host calls the distribution's /. Asked of wslpath rather than + /// built from the distribution's name, because the share is \\wsl.localhost on a + /// current Windows and \\wsl$ on an older one, and only WSL knows which. + /// + static string? DistributionRoot() + { + if (TryFind("wslpath", ["/usr/bin/wslpath", "/bin/wslpath"], out var wslpath) && + Run(wslpath, "-w /", "/", Encoding.UTF8) is { } output) + { + var root = output.Trim(); + if (root.StartsWith(@"\\", StringComparison.Ordinal)) + { + return root; + } + } + + var distribution = Environment.GetEnvironmentVariable("WSL_DISTRO_NAME"); + if (string.IsNullOrEmpty(distribution)) + { + return null; + } + + return $@"\\wsl.localhost\{distribution}"; + } + + /// + /// The host's environment, or nothing when it could not be asked. Nothing still leaves a + /// tool on the PATH to be found, since WSL appends the host's PATH to the distribution's. + /// + static Dictionary ReadVariables(List mounts) + { + var candidates = mounts + .Select(_ => $"{_.MountPoint}/Windows/System32/cmd.exe") + .ToArray(); + if (!TryFind("cmd.exe", candidates, out var cmd)) + { + return ParseVariables(""); + } + + // /u for UTF-16, since the default is the console's code page and a user directory need + // not fit in it. Started in its own directory: given one inside the distribution, cmd + // says at length that it cannot use it + var output = Run(cmd, "/u /c set", Path.GetDirectoryName(cmd)!, Encoding.Unicode); + return ParseVariables(output ?? ""); + } + + static bool TryFind(string name, string[] candidates, [NotNullWhen(true)] out string? path) + { + if (OsSettingsResolver.TryFindInEnvPath(name, out path)) + { + return true; + } + + path = candidates.FirstOrDefault(File.Exists); + return path != null; + } + + /// + /// What a program printed, or null when it did not start, failed, or did not finish in time. + /// + static string? Run(string file, string arguments, string directory, Encoding encoding) + { + using var process = new Process + { + StartInfo = new(file, arguments) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = encoding, + WorkingDirectory = directory + } + }; + try + { + process.Start(); + } + catch (Exception exception) + when (exception is System.ComponentModel.Win32Exception or InvalidOperationException) + { + return null; + } + + process.StandardInput.Close(); + var output = process.StandardOutput.ReadToEndAsync(); + // Read so that a program with a lot to say there is not left waiting on a full pipe + var error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(timeout)) + { + try + { + process.Kill(); + } + catch (Exception exception) + when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) + { + // Gone between the wait and the kill + } + + return null; + } + + if (process.ExitCode != 0 || + !Task.WaitAll([output, error], timeout)) + { + return null; + } + + return output.Result; + } +} diff --git a/src/DiffEngine/Wsl/WslInterop.cs b/src/DiffEngine/Wsl/WslInterop.cs new file mode 100644 index 000000000..e72268e20 --- /dev/null +++ b/src/DiffEngine/Wsl/WslInterop.cs @@ -0,0 +1,159 @@ +namespace DiffEngine; + +/// +/// Windows diff tools, for a test process running inside WSL. +/// +/// A distribution can start a Windows executable: the kernel recognises the file and hands it to +/// WSL, which runs it on the host. So a tool installed on Windows is offered when the +/// distribution has no copy of its own, found through its Windows definition +/// () and handed the two files by the paths the host knows them +/// by (). +/// +/// +/// What this side holds for a tool started that way is not the tool. It is a process of WSL's +/// that stands in for it, and three things follow. It is listed by ps with WSL's own +/// program in front of the tool's command line, which takes off so a +/// running tool is still found by the command it was started with. Ending it does not close a +/// windowed tool, so such a tool is never killed from here: it is not closed when its test +/// passes, and one that does not refresh gets a second window rather than a replacement. And it +/// ends with the terminal session that started it, while the window stays. +/// +/// +static class WslInterop +{ + /// + /// Set to false to leave Windows tools out and offer only what the distribution has. + /// + internal const string Variable = "DiffEngine_WslWindowsTools"; + + static readonly Lazy host = new(WslHost.Detect); + static readonly ConcurrentDictionary programs = new(StringComparer.Ordinal); + + /// + /// The Windows machine this process can start a tool on, or null: see + /// . + /// + public static WslHost? Host => host.Value; + + /// + /// Whether a Windows copy of a tool is looked for at all. + /// + /// Not the viewer. It is not handed a pair on a command line and left to it: it is spoken to + /// over a loopback port, and the host's loopback is not the distribution's. Not the editors + /// that run in the terminal they were started from either, since a test process has none to + /// give a Windows console program. + /// + /// + public static bool Offers(DiffTool? tool) => + tool is not (DiffTool.DiffEngineViewer or DiffTool.Vim or DiffTool.Neovim); + + /// + /// Whether starting this file here starts a program on the Windows host. + /// + /// Asked of the file, by the two bytes the kernel itself goes by, rather than of how the tool + /// was resolved: one registered with a path of the caller's own is as much a Windows program + /// as one found through a definition, and is no more able to open a Linux path. + /// + /// + public static bool IsWindowsProgram(string exePath) => + Host != null && + programs.GetOrAdd(exePath, HasWindowsHeader); + + static bool HasWindowsHeader(string path) + { + try + { + using var stream = File.OpenRead(path); + return stream.ReadByte() == 'M' && + stream.ReadByte() == 'Z'; + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// A file's path as a program on the host has to be given it. + /// + public static string ToWindows(string path) => + Host!.Paths.ToWindows(Path.GetFullPath(path)); + + /// + /// Starts a Windows tool and returns the id of the process that stands in for it here. + /// + /// Nothing of the test host's is handed over, for the reason + /// gives: a child holding the pipe dotnet test reads the host's output from keeps the + /// run from returning until it exits, and this one lives as long as the tool's window. All + /// three streams are redirected and this side of them closed. A windowed tool has nothing to + /// say on them. One that does write ends its stand-in by doing so, which costs only finding + /// the tool again later, since its window stays. + /// + /// + /// Started in the tool's own directory rather than the host's, which the arguments no longer + /// depend on: both paths are absolute by the time they are Windows ones. + /// + /// + public static int Start(string exePath, string arguments) + { + var startInfo = new ProcessStartInfo(exePath, arguments) + { + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(exePath) ?? "" + }; + using var process = Process.Start(startInfo); + if (process == null) + { + throw new($"No process was started for {exePath}"); + } + + process.StandardInput.Close(); + process.StandardOutput.Close(); + process.StandardError.Close(); + return process.Id; + } + + /// + /// The command line a Windows tool was started with, from the one ps lists for it. + /// + /// WSL registers its own /init as the interpreter for Windows executables, and asks + /// the kernel to keep the caller's first argument. So the process is /init, given the + /// path the kernel resolved and then everything the caller passed, which begins with that + /// same path: /init /mnt/c/Tool/tool.exe /mnt/c/Tool/tool.exe left right. What follows + /// the first copy is the command DiffEngine built. + /// + /// + /// The path can hold spaces, and nothing in the line says where it ends, so it is found as + /// the text that is there twice over. + /// + /// + public static string StripProxy(string command) + { + const string proxy = "/init "; + if (!command.StartsWith(proxy, StringComparison.Ordinal)) + { + return command; + } + + var rest = command.Substring(proxy.Length); + var space = rest.IndexOf(' '); + while (space > 0) + { + var path = rest.Substring(0, space); + var after = rest.Substring(space + 1); + if (after.StartsWith(path, StringComparison.Ordinal) && + (after.Length == path.Length || after[path.Length] == ' ')) + { + return after; + } + + space = rest.IndexOf(' ', space + 1); + } + + return command; + } +} diff --git a/src/DiffEngine/Wsl/WslPaths.cs b/src/DiffEngine/Wsl/WslPaths.cs new file mode 100644 index 000000000..1348137b9 --- /dev/null +++ b/src/DiffEngine/Wsl/WslPaths.cs @@ -0,0 +1,231 @@ +namespace DiffEngine; + +/// +/// The two spellings of a path inside a WSL distribution: the Linux one a test process has, and +/// the Windows one a program on the host has to be handed. +/// +/// A Windows drive is mounted into the distribution, at /mnt/c unless configured +/// otherwise, and a file under it is C:\... to the host. Everything else is in the +/// distribution's own file system, which the host reaches as a share: +/// \\wsl.localhost\Ubuntu\home\.... +/// +/// +/// Worked out here rather than by running wslpath per path. A command line is built for +/// every failing pair, whether or not a window ends up being opened for it, so a process a +/// path would be a thousand of them for a run with five hundred failures. What is needed is +/// read once instead: the drive mounts from /proc/mounts, and what the host calls the +/// distribution's root. +/// +/// +class WslPaths +{ + // Longest first, so a drive mounted beneath another mount is found before its parent + readonly List byMountPoint; + readonly List byWindowsRoot; + readonly string distributionRoot; + + /// The Windows drives mounted in the distribution. + /// + /// What the host calls the distribution's /, as wslpath -w / prints it. + /// + public WslPaths(IEnumerable mounts, string distributionRoot) + { + var list = mounts.ToList(); + byMountPoint = list + .OrderByDescending(_ => _.MountPoint.Length) + .ToList(); + byWindowsRoot = list + .OrderByDescending(_ => _.WindowsRoot.Length) + .ToList(); + this.distributionRoot = distributionRoot.TrimEnd('\\'); + } + + /// + /// The path as the host writes it. is absolute. + /// + public string ToWindows(string path) + { + foreach (var mount in byMountPoint) + { + if (TryRelative(path, mount.MountPoint, out var relative)) + { + return mount.WindowsRoot + relative.Replace('/', '\\'); + } + } + + return distributionRoot + path.Replace('/', '\\'); + } + + /// + /// The path as the distribution writes it, for one on a drive that is mounted. Anything else, + /// a path that is already a Linux one among them, has no answer. + /// + public bool TryToLinux(string path, [NotNullWhen(true)] out string? linux) + { + var normalized = path.Replace('/', '\\'); + foreach (var mount in byWindowsRoot) + { + var root = mount.WindowsRoot; + if (normalized.StartsWith(root, StringComparison.OrdinalIgnoreCase)) + { + var relative = normalized.Substring(root.Length).Replace('\\', '/'); + linux = relative.Length == 0 ? mount.MountPoint : $"{mount.MountPoint}/{relative}"; + return true; + } + + // The drive with nothing after it: "C:" + if (string.Equals(normalized, root.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase)) + { + linux = mount.MountPoint; + return true; + } + } + + linux = null; + return false; + } + + /// + /// Whether a path here is on one of the host's drives rather than in the distribution. + /// + public bool IsOnHost(string path) + { + var trimmed = path.Length > 1 ? path.TrimEnd('/') : path; + foreach (var mount in byMountPoint) + { + if (TryRelative(trimmed, mount.MountPoint, out _)) + { + return true; + } + } + + return false; + } + + /// + /// The mount point itself, or something beneath it. Not /mnt/cache for /mnt/c, + /// which only begins the same way. + /// + static bool TryRelative(string path, string mountPoint, out string relative) + { + if (path == mountPoint) + { + relative = ""; + return true; + } + + if (path.Length > mountPoint.Length && + path[mountPoint.Length] == '/' && + path.StartsWith(mountPoint, StringComparison.Ordinal)) + { + relative = path.Substring(mountPoint.Length + 1); + return true; + } + + relative = ""; + return false; + } + + /// + /// The Windows drives in the text of /proc/mounts. + /// + /// A drive is a drvfs mount under WSL 1, and under WSL 2 a 9p one whose options + /// name drvfs. Either way the first field is what was mounted, C:\ or a share, with + /// the backslash written as an octal escape as every field's awkward characters are. + /// + /// + public static List ParseMounts(string content) + { + var mounts = new List(); + foreach (var line in content.Split('\n')) + { + var fields = line.Trim().Split(' '); + if (fields.Length < 4) + { + continue; + } + + var type = fields[2]; + if (type != "drvfs" && + !(type == "9p" && fields[3].Contains("aname=drvfs"))) + { + continue; + } + + var source = Unescape(fields[0]); + if (!IsWindowsRoot(source)) + { + continue; + } + + var mountPoint = Unescape(fields[1]); + if (mountPoint.Length > 1) + { + mountPoint = mountPoint.TrimEnd('/'); + } + + mounts.Add(new(mountPoint, source.TrimEnd('\\') + '\\')); + } + + return mounts; + } + + /// + /// A drive, with or without its separator, or a share. + /// + static bool IsWindowsRoot(string source) + { + if (source.StartsWith(@"\\", StringComparison.Ordinal)) + { + return source.Length > 2; + } + + return source.Length >= 2 && + char.IsLetter(source[0]) && + source[1] == ':'; + } + + /// + /// A field of /proc/mounts with its octal escapes read back: a space is \040 + /// and a backslash \134. + /// + static string Unescape(string field) + { + if (field.IndexOf('\\') < 0) + { + return field; + } + + var builder = new StringBuilder(field.Length); + for (var index = 0; index < field.Length; index++) + { + var current = field[index]; + if (current == '\\' && + index + 3 < field.Length && + IsOctal(field[index + 1]) && + IsOctal(field[index + 2]) && + IsOctal(field[index + 3])) + { + var value = (field[index + 1] - '0') * 64 + + (field[index + 2] - '0') * 8 + + (field[index + 3] - '0'); + builder.Append((char) value); + index += 3; + continue; + } + + builder.Append(current); + } + + return builder.ToString(); + } + + static bool IsOctal(char value) => + value is >= '0' and <= '7'; +} + +/// +/// One Windows drive as a distribution sees it: where it is mounted, without a trailing +/// separator, and what the host calls it, with one. +/// +readonly record struct WslMount(string MountPoint, string WindowsRoot);