diff --git a/src/DiffEngine.Tests/Wow64CommandLineTests.cs b/src/DiffEngine.Tests/Wow64CommandLineTests.cs
new file mode 100644
index 00000000..98e5ac22
--- /dev/null
+++ b/src/DiffEngine.Tests/Wow64CommandLineTests.cs
@@ -0,0 +1,83 @@
+#if NET10_0
+///
+/// Reading the command line of a 32-bit process from this 64-bit one.
+///
+/// NtQueryInformationProcess with ProcessBasicInformation answers a 64-bit caller with the 64-bit
+/// PEB, even when the target is running under WOW64. Reading that with 32-bit offsets produced
+/// nothing, so every 32-bit diff tool - which is most of the %ProgramFiles(x86)% installs the
+/// resolver goes out of its way to find - had no command line: never seen as already running, and
+/// never killed. The 32-bit PEB has to be asked for by name.
+///
+///
+[NotInParallel]
+[RunOn(TUnit.Core.Enums.OS.Windows)]
+public class Wow64CommandLineTests
+{
+ [Test]
+ public async Task AThirtyTwoBitProcessHasAReadableCommandLine()
+ {
+ var wow = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.Windows),
+ "SysWOW64",
+ "cmd.exe");
+ if (!Environment.Is64BitProcess ||
+ !File.Exists(wow))
+ {
+ // A 32-bit host, or a Windows with no WOW64 layer. Nothing to say here
+ return;
+ }
+
+ // Distinctive, so this cannot match any other cmd on the machine
+ var marker = $"DiffEngineWow64Probe{Guid.NewGuid():N}";
+ var process = Process.Start(
+ new ProcessStartInfo
+ {
+ FileName = wow,
+ // Shaped like a diff tool invocation, because FindAll only keeps command
+ // lines with two file path arguments
+ Arguments = $"/c ping -n 30 127.0.0.1 >nul & rem C:\\probe\\{marker}.received.txt C:\\probe\\{marker}.verified.txt",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ })!;
+
+ try
+ {
+ await Assert.That(await WaitForCommandLine(marker)).IsTrue();
+ }
+ finally
+ {
+ try
+ {
+ if (!process.HasExited)
+ {
+ process.Kill();
+ }
+ }
+ catch
+ {
+ // Nothing useful to do if it has already gone
+ }
+
+ process.Dispose();
+ }
+ }
+
+ static async Task WaitForCommandLine(string marker)
+ {
+ for (var attempt = 0; attempt < 40; attempt++)
+ {
+ var found = WindowsProcess
+ .FindAll([with(StringComparer.OrdinalIgnoreCase), "cmd.exe"])
+ .Any(_ => _.Command.Contains(marker, StringComparison.Ordinal));
+ if (found)
+ {
+ return true;
+ }
+
+ await Task.Delay(250);
+ }
+
+ return false;
+ }
+}
+#endif
diff --git a/src/DiffEngine/Process/WindowsProcess.cs b/src/DiffEngine/Process/WindowsProcess.cs
index be506675..5711398d 100644
--- a/src/DiffEngine/Process/WindowsProcess.cs
+++ b/src/DiffEngine/Process/WindowsProcess.cs
@@ -29,6 +29,18 @@ private static partial int NtQueryInformationProcess(
int size,
out int returnLength);
+ ///
+ /// The same export, asked for a pointer sized answer rather than a struct: the ProcessWow64
+ /// Information class returns the address of a WOW64 target's 32-bit PEB.
+ ///
+ [LibraryImport("ntdll.dll", EntryPoint = "NtQueryInformationProcess")]
+ private static partial int NtQueryWow64Peb(
+ SafeProcessHandle handle,
+ int processInformationClass,
+ ref IntPtr info,
+ int size,
+ out int returnLength);
+
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool IsWow64Process(
@@ -89,6 +101,14 @@ static extern bool ReadProcessMemory(
IntPtr size,
out IntPtr bytesRead);
+ [DllImport("ntdll.dll", EntryPoint = "NtQueryInformationProcess")]
+ static extern int NtQueryWow64Peb(
+ SafeProcessHandle handle,
+ int processInformationClass,
+ ref IntPtr info,
+ int size,
+ out int returnLength);
+
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool IsWow64Process(SafeProcessHandle handle, out bool isWow64);
#endif
@@ -99,6 +119,12 @@ static extern bool ReadProcessMemory(
const int processTerminate = 0x0001;
const int processBasicInformation = 0;
+ ///
+ /// ProcessWow64Information. Returns the address of the 32-bit PEB for a WOW64 target, or zero
+ /// for one that is not running under WOW64.
+ ///
+ const int processWow64Information = 26;
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct PROCESSENTRY32W
{
@@ -298,9 +324,32 @@ static int FindDotSeparatedPath(CharSpan span)
return null;
}
- return Environment.Is64BitProcess && !isTarget32Bit
- ? ReadCommandLine64(handle, pbi.PebBaseAddress)
- : ReadCommandLine32(handle, pbi.PebBaseAddress);
+ if (!Environment.Is64BitProcess)
+ {
+ // A 32-bit caller cannot reach a 64-bit target's PEB, so there is nothing useful to
+ // read rather than something wrong to read
+ if (Environment.Is64BitOperatingSystem &&
+ !isTarget32Bit)
+ {
+ return null;
+ }
+
+ return ReadCommandLine32(handle, pbi.PebBaseAddress);
+ }
+
+ if (!isTarget32Bit)
+ {
+ return ReadCommandLine64(handle, pbi.PebBaseAddress);
+ }
+
+ // A WOW64 target seen from a 64-bit caller. ProcessBasicInformation answers with the
+ // 64-bit PEB the system keeps for it, and reading that with 32-bit offsets yields
+ // nothing usable - so every 32-bit diff tool, which is most of the %ProgramFiles(x86)%
+ // installs the resolver goes out of its way to find, had no command line at all: never
+ // detected as already running, and never killed. The 32-bit PEB has to be asked for
+ return TryGetWow64Peb(handle, out var wow64Peb)
+ ? ReadCommandLine32(handle, wow64Peb)
+ : null;
}
catch
{
@@ -308,6 +357,13 @@ static int FindDotSeparatedPath(CharSpan span)
}
}
+ static bool TryGetWow64Peb(SafeProcessHandle handle, out IntPtr peb)
+ {
+ peb = IntPtr.Zero;
+ return NtQueryWow64Peb(handle, processWow64Information, ref peb, IntPtr.Size, out _) == 0 &&
+ peb != IntPtr.Zero;
+ }
+
static string? ReadCommandLine64(SafeProcessHandle handle, IntPtr pebAddress)
{
// In 64-bit PEB, ProcessParameters is at offset 0x20