diff --git a/scripts/lib/install-lib.ps1 b/scripts/lib/install-lib.ps1 index 4823e5f..a602889 100644 --- a/scripts/lib/install-lib.ps1 +++ b/scripts/lib/install-lib.ps1 @@ -568,6 +568,22 @@ function Get-ProcessOwner { return $null } +function Get-ProcessImagePath { + # The process's own main-module path, or $null when it cannot be read. A protected process, or a + # 32-bit steam.exe read from 64-bit PowerShell, makes MainModule throw; then ask WMI, which does + # not need a handle on the process. Never throws. + param([Parameter(Mandatory = $true)]$Process) + try { + $f = $Process.MainModule.FileName + if ($f) { return $f } + } catch { } + try { + $cim = Get-CimInstance -ClassName Win32_Process -Filter "ProcessId = $($Process.Id)" -ErrorAction Stop + if ($cim -and $cim.ExecutablePath) { return $cim.ExecutablePath } + } catch { } + return $null +} + function Get-ProcessesUsingFolder { <# .SYNOPSIS @@ -575,7 +591,8 @@ function Get-ProcessesUsingFolder { .DESCRIPTION Protected processes and processes that exit while we look throw when their modules are read. Those are skipped, never fatal. Returns objects with Name, Id, SessionId, Owner, - Path (the file that put it on the list) and Role 'holder'. + Path (the file that put it on the list), ImagePath (the process's own exe, or $null when + unreadable) and Role 'holder'. Path may be a DLL; ImagePath is never a module. #> param([Parameter(Mandatory = $true)][string]$Folder) $prefix = [System.IO.Path]::GetFullPath($Folder).TrimEnd('\') + '\' @@ -601,7 +618,8 @@ function Get-ProcessesUsingFolder { if ($hit) { $found += [pscustomobject]@{ Name = $p.ProcessName; Id = $p.Id; SessionId = $p.SessionId - Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $hit; Role = 'holder' } + Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $hit + ImagePath = (Get-ProcessImagePath -Process $p); Role = 'holder' } } } return $found @@ -636,11 +654,11 @@ function Test-SteamIsolationBlocksUpgrade { $known = @($procs | ForEach-Object { $_.Id }) foreach ($p in @(Get-Process -Name 'steam', 'steamwebhelper' -ErrorAction SilentlyContinue)) { if (($sessions -contains $p.SessionId) -and -not ($known -contains $p.Id)) { - $path = $null - try { $path = $p.MainModule.FileName } catch { } + $path = Get-ProcessImagePath -Process $p $procs += [pscustomobject]@{ Name = $p.ProcessName; Id = $p.Id; SessionId = $p.SessionId - Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $path; Role = 'steam' } + Owner = (Get-ProcessOwner -ProcessId $p.Id); Path = $path + ImagePath = $path; Role = 'steam' } } } } @@ -659,7 +677,10 @@ function Format-SteamInUseMessage { $launcher = Join-Path $Check.Folder 'MultiSeat.SteamLauncher.exe' $steamExe = $null foreach ($p in @($Check.Processes)) { - if ($p.Name -eq 'steam' -and $p.Path) { $steamExe = $p.Path; break } + # ImagePath, not Path: for a holder, Path is the module that matched (the hook DLL). + if ($p.Name -eq 'steam' -and $p.ImagePath -and $p.ImagePath.EndsWith('steam.exe', [System.StringComparison]::OrdinalIgnoreCase)) { + $steamExe = $p.ImagePath; break + } } if (-not $steamExe) { $steamExe = '' } diff --git a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs index 19d82ab..08150ef 100644 --- a/src/MultiSeat.Tests/Scripts/InstallLibTests.cs +++ b/src/MultiSeat.Tests/Scripts/InstallLibTests.cs @@ -870,6 +870,80 @@ public void Preflight_names_a_running_process_whose_image_lives_in_steam_isolati Assert.Empty(after.GetProperty("Processes").EnumerateArray()); } + // -- The close-Steam command must name steam.exe, never the hook DLL (#126) --------------- + + private static string MessageFor(string engine, string processesLiteral, string folder) + { + var j = J(Ps(engine, + "$check = [pscustomobject]@{ Blocked = $true; Folder = " + Q(folder) + "; LockedFiles = @(); Processes = @(" + processesLiteral + ") }\n" + + "$r = [pscustomobject]@{ Message = (Format-SteamInUseMessage -Check $check) }\n")); + return j.GetProperty("Message").GetString()!; + } + + [Theory, MemberData(nameof(Engines))] + public void The_close_steam_command_names_steam_exe_when_the_holder_path_is_the_hook_dll(string engine) + { + const string steamExe = @"C:\Program Files (x86)\Steam\steam.exe"; + const string dir = @"C:\Program Files\MultiSeat\SteamIsolation"; + var msg = MessageFor(engine, + "[pscustomobject]@{ Name = 'steam'; Id = 10; SessionId = 2; Owner = 'PC\\gamer'; Role = 'holder'; " + + "Path = " + Q(dir + @"\MultiSeat.SteamHook.dll") + "; ImagePath = " + Q(steamExe) + " }", dir); + Assert.Contains("--steam \"" + steamExe + "\" --forward -- -shutdown", msg); + Assert.DoesNotContain("--steam \"" + dir + @"\MultiSeat.SteamHook.dll", msg); + } + + [Theory, MemberData(nameof(Engines))] + public void The_close_steam_command_keeps_the_placeholder_when_no_steam_image_is_known(string engine) + { + const string dir = @"C:\Program Files\MultiSeat\SteamIsolation"; + var msg = MessageFor(engine, + "[pscustomobject]@{ Name = 'steam'; Id = 10; SessionId = 2; Owner = $null; Role = 'holder'; " + + "Path = " + Q(dir + @"\MultiSeat.SteamHook.dll") + "; ImagePath = $null }", dir); + Assert.Contains("--steam \"\"", msg); + Assert.DoesNotContain("SteamHook.dll\" --forward", msg); + } + + [Theory, MemberData(nameof(Engines))] + public void The_close_steam_command_skips_a_steam_entry_whose_image_is_not_steam_exe(string engine) + { + // A first entry with only a DLL image must not win over a later entry with the real exe. + const string steamExe = @"D:\Games\Steam\steam.exe"; + const string dir = @"C:\Program Files\MultiSeat\SteamIsolation"; + var msg = MessageFor(engine, + "[pscustomobject]@{ Name = 'steam'; Id = 10; SessionId = 2; Owner = $null; Role = 'holder'; Path = 'x.dll'; ImagePath = " + Q(dir + @"\MultiSeat.SteamHook.dll") + " }, " + + "[pscustomobject]@{ Name = 'steam'; Id = 11; SessionId = 2; Owner = $null; Role = 'steam'; Path = " + Q(steamExe) + "; ImagePath = " + Q(steamExe) + " }", dir); + Assert.Contains("--steam \"" + steamExe + "\"", msg); + } + + [Theory, MemberData(nameof(Engines))] + public void Preflight_fills_ImagePath_with_the_real_exe_and_the_message_uses_it(string engine) + { + // A real process whose image is SteamIsolation\steam.exe. Path (the matching file) and + // ImagePath must both be that exe here, and the printed command must carry it. + using var t = new Temp(); + PutSteamIsolation(t.Root, "old"); + var fakeSteam = Path.Combine(t.Root, "SteamIsolation", "steam.exe"); + File.Copy(Path.Combine(Environment.SystemDirectory, "ping.exe"), fakeSteam, overwrite: true); + using var proc = Process.Start(new ProcessStartInfo(fakeSteam, "-n 120 127.0.0.1") + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true, + })!; + try + { + Thread.Sleep(500); + var j = J(Ps(engine, CheckCall(t.Root) + + "$r | Add-Member -NotePropertyName Message -NotePropertyValue (Format-SteamInUseMessage -Check $r)\n")); + var mine = j.GetProperty("Processes").EnumerateArray().Single(x => x.GetProperty("Id").GetInt32() == proc.Id); + Assert.Equal(fakeSteam, mine.GetProperty("ImagePath").GetString(), ignoreCase: true); + Assert.Contains("--steam \"" + fakeSteam + "\"", j.GetProperty("Message").GetString(), StringComparison.OrdinalIgnoreCase); + } + finally + { + try { proc.Kill(); proc.WaitForExit(10_000); } catch (InvalidOperationException) { } + } + } + private static string GateCall(string install, string marker) => "$r = Invoke-StopWhenNotBlocked -InstallDir " + Q(install) + " -Stop { Set-Content -LiteralPath " + Q(marker) + " -Value stopped }\n";