diff --git a/.ci/compile.sh b/.ci/compile.sh index 63c027473..d60683b6e 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -459,6 +459,75 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi +if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then + echo "::group::Probe the installer's holder lookup" + # The working directory is still the build dir here: the package steps below are what + # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name + # build/build. Both paths are relative to the dir the probe was built into. + probe="./installer-holder-probe.exe" + if [[ ! -f $probe ]]; then + echo "::error file=$0::installer-holder-probe.exe was not built" + exit 1 + fi + # The probe exists to name a holder that a process-and-module scan cannot see: one + # that has a file open without having loaded it. Holding a temporary DLL with + # FileShare.None is exactly that case, so this asserts the reason the probe was + # added instead of only that the Restart Manager answers at all. It needs no NSIS + # and no packaging, so it runs on every Windows build that gets this far. + probe_check="./holder-probe-check.ps1" + cat >"$probe_check" <<'POWERSHELL' +$ErrorActionPreference = "Stop" +$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID" +Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force $dir | Out-Null +$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path + +function Invoke-Probe([string] $guardDir) { + $start = New-Object System.Diagnostics.ProcessStartInfo + $start.FileName = $probe + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir + $process = [System.Diagnostics.Process]::Start($start) + $read = $process.StandardOutput.ReadToEndAsync() + if (-not $process.WaitForExit(20000)) { + try { $process.Kill() } catch { } + throw "the holder probe did not finish within 20 s for $guardDir" + } + $output = $read.Result + $code = $process.ExitCode + $process.Dispose() + return @{ Code = $code; Output = $output } +} + +try { + $file = Join-Path $dir "locked.dll" + [IO.File]::WriteAllText($file, "x") + $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") + try { + $locked = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } + if ($locked.Output -notmatch "pid\s+$PID\b") { throw "the lookup did not name this process: $($locked.Output)" } + $empty = Join-Path $dir "empty" + New-Item -ItemType Directory -Force $empty | Out-Null + $nothing = Invoke-Probe $empty + if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" } + Write-Host "holder lookup named pid $PID for a locked file and named nothing for an empty directory" +} finally { + Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue +} +POWERSHELL + if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then + rm -f "$probe_check" + exit 1 + fi + rm -f "$probe_check" + echo "::endgroup::" +fi + if [[ $MAKE_INSTALL ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" diff --git a/CMakeLists.txt b/CMakeLists.txt index 97b87a1d8..41fe393e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -431,6 +431,13 @@ if(WITH_CLIENT) set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WIN32 AND WITH_CLIENT) + # Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed + # (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install + # rules and is not part of the client's staged tree. + add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe) +endif() + if(WITH_ORACLE) add_subdirectory(oracle) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 0ef746430..ae156313d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -28,6 +28,11 @@ Var DataBackupFindHandle Var DataBackupFindName Var IsUpgrade Var AbortReason +; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes +; $HolderOutput (one log line) and sets $HolderFound when it named anything. +Var HolderExit +Var HolderOutput +Var HolderFound Var WaitPid Var LauncherExited Var InstallLogPath @@ -1171,6 +1176,154 @@ Function CheckStaleRuntimeMask ${EndIf} FunctionEnd +; Turns a Win32 error code into the wording that goes into the log. +; usage: set $0 to the code, result in $R0. +; +; These are Windows' own names for the codes and deliberately nothing more. A +; code on its own cannot say whether an upgrade was blocked by a running instance +; or by a permission: deleting a file whose image is mapped by another process +; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does. +; LogProcessesUsingInstallDir is what separates the two, which is why it runs +; right after this and why the two are read together. +Function GetWin32ErrorText + ${If} $0 = 2 + StrCpy $R0 "already gone" + ${ElseIf} $0 = 3 + StrCpy $R0 "path not found" + ${ElseIf} $0 = 5 + StrCpy $R0 "access denied" + ${ElseIf} $0 = 19 + StrCpy $R0 "write-protected media" + ${ElseIf} $0 = 32 + StrCpy $R0 "sharing violation" + ${ElseIf} $0 = 33 + StrCpy $R0 "lock violation" + ${ElseIf} $0 = 1224 + StrCpy $R0 "file is mapped in memory" + ${Else} + StrCpy $R0 "windows error" + ${EndIf} +FunctionEnd + +; Records which runtime files the purge left behind, what Windows said about each +; one, and which processes are using the install directory. +; +; Diagnostic only, and only on the abort path: it deletes nothing, so it can never +; turn a failed upgrade into a half-removed old install, and it decides nothing - +; the abort that called it is already decided. It exists because NSIS Delete/RMDir +; only set the error flag: without this, a report of "files of a previous version +; survived the purge" said which files were present but never which one was held, +; and never whether the cause was a lock or a permission. +; +; The probe opens each survivor for DELETE access and then closes it again +; without deleting anything. The code is read by the plugin right after that call +; (?e), so a failure reports what Windows refused; clearing LastError first is +; what makes a zero code mean "this open worked", i.e. the purge-time failure was +; transient (an antivirus scan that has finished, an instance still shutting +; down) rather than a standing condition. The plugin parses the call's arguments +; between the clear and the call, so a non-zero code is reported as what Windows +; said rather than as proof of a cause - the wording below never claims one. +Function LogStaleRuntimeDiagnostics + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll" + ${If} $StaleFindHandle != "" + stale_diag_loop: + ; ClearErrors tells a plugin failure apart from an API failure: the System + ; plugin sets the error flag only when it could not make the call at all, + ; in which case it pushes its own message instead of the call's value and + ; the pop below must not run. + ClearErrors + System::Call 'kernel32::SetLastError(i 0)' + System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e' + ${If} ${Errors} + StrCpy $R0 "the delete probe could not be run" + ${Else} + Pop $R2 + ${If} $R2 = 0 + ; A valid handle: close it. The open proves the file is not held + ; open, but not that Delete would succeed: a read-only file opens + ; for DELETE and still fails DeleteFile until the attribute is + ; cleared, which the purge does not do. Report that standing + ; cause instead of calling the failure transient. + System::Call 'kernel32::CloseHandle(p R1)' + System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3' + ${If} $R3 = -1 + StrCpy $R0 "a delete-access open now succeeds, but its attributes could not be read" + ${Else} + IntOp $R3 $R3 & 1 + ${If} $R3 = 1 + StrCpy $R0 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear" + ${Else} + StrCpy $R0 "a delete-access open now succeeds" + ${EndIf} + ${EndIf} + ${Else} + StrCpy $0 $R2 + Call GetWin32ErrorText + StrCpy $R0 "win32 error $R2: $R0" + ${EndIf} + ${EndIf} + !insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0" + ClearErrors + FindNext $StaleFindHandle $StaleFindName + IfErrors 0 stale_diag_loop + FindClose $StaleFindHandle + ${EndIf} + Call LogProcessesUsingInstallDir +FunctionEnd + +; Names the applications and services that still have the install directory's files +; open or mapped. Writes one line to the log and sets $HolderFound (1 when anything was +; named), so the abort message can repeat that line to the user. +; +; The lookup is a small executable built from tools/installer-holder-probe, extracted to +; $PLUGINSDIR only when the purge has already failed: the successful path pays nothing +; for it, and $PLUGINSDIR takes it away again when the installer exits. It is compiled by +; the same Windows build that compiles this installer, which is what lets it call the +; Restart Manager API directly. The same call made from this script would have to +; marshal RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, and +; that table is precisely what cannot be checked without a Windows toolchain; in +; compiled code the layout belongs to the Windows SDK header. +; +; Restart Manager is what makes the answer complete. Walking processes can only name one +; whose image lives here or that loaded one of these libraries, while Restart Manager +; names a process that holds a plain handle on a file - an antivirus scanner - and names +; services as well as user programs. Its blind spot is a handle taken in kernel mode, +; where there is no process to name; then the codes logged above are the whole story, and +; the probe says so by naming nothing rather than by failing. +; +; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the +; running-instance guard uses, so a path with spaces or apostrophes never has to be +; embedded in a command line. +Function LogProcessesUsingInstallDir + StrCpy $HolderExit 0 + StrCpy $HolderOutput "" + StrCpy $HolderFound 0 + InitPluginsDir + ; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both + ; are built into the build root, which is three levels up from project.nsi). + File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe" + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")' + ; /TIMEOUT because this runs on the abort path, ahead of the message box: one call + ; into the Restart Manager cannot take the minute a walk over every process can, but + ; if that service stalls it must not leave the installer hanging with nothing on + ; screen. On a timeout nsExec reports it through the exit code like any other failure. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"' + Pop $HolderExit + Pop $HolderOutput + ; The exit code, not the text, decides what happened: it cannot be mangled by what + ; nsExec leaves on the end of the captured output. 1 = the line below names what is + ; holding the files, 0 = nothing holds them any more, anything else = the probe could + ; not answer, and says why in its own output. + ${If} $HolderExit == "1" + StrCpy $HolderFound 1 + ${ElseIf} $HolderExit == "0" + StrCpy $HolderOutput "nothing is holding them any more" + ${Else} + StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + ${EndIf} + !insertmacro CockLog "holders: $HolderOutput" +FunctionEnd + ; Uninstaller copies of the same routines (the uninstaller gets its own ; function set compiled in, it cannot call the installer functions). Function un.IsAppRunning @@ -1512,6 +1665,26 @@ ${EndIf} ; Remove any runtime DLLs left over from older versions, so a mismatched ; Qt/OpenSSL/protobuf set cannot survive next to the new executables. +; +; A purge that fails is reported with evidence, not only as a survivor list: the +; abort below logs which files survived, what Windows said about each of them and +; which processes still have the install directory mapped (see +; LogStaleRuntimeDiagnostics). +; +; Re-running the running-instance check here was considered and dropped: it would only +; narrow the window the uninstaller ExecWait and the backup/restore copy open between +; .onInit and this point, and on /R it would now abort after the old version has already +; been removed - leaving no install at all. The surviving files are the authority on +; whether anything still holds the directory, and they are measured anyway. +; +; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API +; that names every process and service with one of these files open or mapped. It is +; called from tools/installer-holder-probe rather than from this script, because the +; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled +; through the System plugin, and that table is the one thing here that cannot be +; checked without a Windows toolchain. Compiled by the same build that compiles this +; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe +; on the Windows runners instead of trusting that it compiles. Call IsExistingCockatriceInstall !insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" ; Remembered because the stale-runtime check below must only ever fire for a @@ -1525,6 +1698,11 @@ ${AndIf} $R0 = 1 !insertmacro CockLog "section: non-portable upgrade purge" !insertmacro CockLogListing "before-purge" "*.dll" RMDir /r "$INSTDIR\Plugins" + ; RMDir /r reports a failure only through the error flag, and only as a + ; boolean; the surviving file names come from the listing below. + ${If} ${FileExists} "$INSTDIR\Plugins\*.*" + !insertmacro CockLog "purge: '$INSTDIR\Plugins' could not be removed completely" + ${EndIf} Delete "$INSTDIR\*.dll" !insertmacro CockLogListing "after-purge" "*.dll" !insertmacro CockLogListing "after-plugins" "Plugins\*.*" @@ -1549,6 +1727,10 @@ ${EndIf} ${If} $R0 = 1 !insertmacro CockLog "section: aborting, files of a previous version survived the purge" DetailPrint "Files of a previous version could not be removed; aborting the installation." + ; Who is holding the payload is recorded before the abort, so the log that + ; reaches us answers the question the message can only guess at, and so the + ; message can name the processes to close whenever any were found. + Call LogStaleRuntimeDiagnostics ; The in-app updater runs this installer silent and detached and never looks ; at its exit code, so the user has to be told what happened. A /S install ; without /R is unattended (a package manager, for instance) and a message @@ -1556,6 +1738,9 @@ ${If} $R0 = 1 ; and the exit code. AbortInstall owns that decision. StrCpy $AbortReason \ "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + ${If} $HolderFound = 1 + StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput" + ${EndIf} Call AbortInstall ${EndIf} diff --git a/format.sh b/format.sh index 9e3a6069b..b4c78543b 100755 --- a/format.sh +++ b/format.sh @@ -19,7 +19,8 @@ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ "cmake/pch" \ -"tests") +"tests" \ +"tools/installer-holder-probe") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index aac4d4818..5420c07f5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -178,6 +178,17 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +# The installer's holder probe is Windows-only, but the wording it produces is plain C++ +# and is what a stuck user has to act on, so it is tested everywhere. +cockatrice_add_test( + NAME + holder_line_test + SOURCES + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/holder_line.cpp + holder_line_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe +) cockatrice_add_test( NAME playmat_resolver_test diff --git a/tests/holder_line_test.cpp b/tests/holder_line_test.cpp new file mode 100644 index 000000000..8588d8515 --- /dev/null +++ b/tests/holder_line_test.cpp @@ -0,0 +1,102 @@ +#include "holder_line.h" + +#include +#include + +namespace +{ +Holder runningProcess(const std::wstring &imageName, unsigned long processId) +{ + Holder holder; + holder.imageName = imageName; + holder.processId = processId; + return holder; +} + +Holder service(const std::wstring &appName, const std::wstring &shortName, unsigned long processId) +{ + Holder holder; + holder.appName = appName; + holder.serviceShortName = shortName; + holder.processId = processId; + holder.isService = true; + return holder; +} +} // namespace + +TEST(HolderLine, NamesARunningProcessTheWayTaskManagerDoes) +{ + EXPECT_EQ(formatHolder(runningProcess(L"cockatrice.exe", 24340)), + std::wstring(L"cockatrice.exe (pid 24340, running)")); +} + +TEST(HolderLine, NamesAServiceByTheNameTheUserCanSearchFor) +{ + // The image of a service is often a shared host process, so the image name would + // point at the wrong thing; both of the service's own names are kept. + Holder holder = service(L"Sophos Anti-Virus", L"SAVService", 4412); + holder.imageName = L"svchost.exe"; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Sophos Anti-Virus (pid 4412, service SAVService)")); +} + +TEST(HolderLine, FallsBackFromTheImageToTheApplicationName) +{ + // Restart Manager can name a process whose image this one is not allowed to open. + Holder holder; + holder.appName = L"Cockatrice"; + holder.processId = 7; + EXPECT_EQ(formatHolder(holder), std::wstring(L"Cockatrice (pid 7, running)")); +} + +TEST(HolderLine, NamesAnEmptyHolderRatherThanPrintingAnEmptyParenthesis) +{ + Holder holder; + holder.processId = 9; + EXPECT_EQ(formatHolder(holder), std::wstring(L"an unnamed process (pid 9, running)")); +} + +TEST(HolderLine, JoinsHoldersIntoOneLine) +{ + const std::vector holders = { + runningProcess(L"cockatrice.exe", 1), + runningProcess(L"oracle.exe", 2), + }; + EXPECT_EQ(formatHolders(holders), std::wstring(L"cockatrice.exe (pid 1, running); oracle.exe (pid 2, running)")); +} + +TEST(HolderLine, CountsTheHoldersItLeavesOut) +{ + std::vector holders; + for (unsigned long processId = 1; processId <= HOLDER_LINE_MAX + 2; ++processId) { + holders.push_back(runningProcess(L"cockatrice.exe", processId)); + } + const std::wstring line = formatHolders(holders); + // The dialog has to stay readable, but a list that silently stopped at five would + // hide that there is more to close. + EXPECT_NE(line.find(L"pid 5, running"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 6, running"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); +} + +TEST(HolderLine, StopsNamingHoldersBeforeTheLineOutgrowsTheDialog) +{ + // A Windows service name can be 255 characters, and the abort dialog has to fit the + // list next to its own text and the log path in a 1024-character NSIS string. + std::vector holders; + for (unsigned long processId = 1; processId <= 3; ++processId) { + holders.push_back(service(std::wstring(255, L'S'), L"S", processId)); + } + const std::wstring line = formatHolders(holders); + // The first holder is still named in full; the others are counted rather than written + // out until the line overflows. + EXPECT_EQ(line.compare(0, 255, std::wstring(255, L'S')), 0); + EXPECT_NE(line.find(L"(pid 1, service S)"), std::wstring::npos); + EXPECT_EQ(line.find(L"pid 2"), std::wstring::npos); + EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos); + EXPECT_LE(line.size(), HOLDER_LINE_CHAR_MAX + 20); +} + +TEST(HolderLine, FormatsNothingAsAnEmptyLine) +{ + EXPECT_EQ(formatHolders({}), std::wstring()); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt new file mode 100644 index 000000000..3098669cc --- /dev/null +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -0,0 +1,21 @@ +# Windows-only. The installer runs this on the abort path of a failed purge to name the +# applications and services holding the install directory open (Restart Manager); see +# the LogProcessesUsingInstallDir function in cmake/NSIS.template.in. It ships inside +# the installer and is extracted to $PLUGINSDIR there, never installed, so it links +# nothing from this project: no Qt, and a static C runtime because the installed runtime +# is exactly what the purge may have failed to replace. +add_executable(installer-holder-probe holder_line.cpp main.cpp restart_manager_holders.cpp) + +target_link_libraries(installer-holder-probe PRIVATE rstrtmgr) + +# The NSIS template embeds the built file by the same relative path that resolves +# NSIS.definitions.nsh, and that path points at the build root. +set_target_properties( + installer-holder-probe + PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}" + RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}" +) diff --git a/tools/installer-holder-probe/holder_line.cpp b/tools/installer-holder-probe/holder_line.cpp new file mode 100644 index 000000000..8d438ca17 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.cpp @@ -0,0 +1,64 @@ +#include "holder_line.h" + +#include + +namespace +{ +std::wstring displayNameOf(const Holder &holder) +{ + // A service's application name is its long name, which is what the user can + // search for; its image name is usually a shared host process (svchost.exe) and + // would name the wrong thing. + if (holder.isService && !holder.appName.empty()) { + return holder.appName; + } + if (!holder.imageName.empty()) { + return holder.imageName; + } + if (!holder.appName.empty()) { + return holder.appName; + } + return L"an unnamed process"; +} +} // namespace + +std::wstring formatHolder(const Holder &holder) +{ + std::wstring line = displayNameOf(holder); + line += L" (pid "; + line += std::to_wstring(holder.processId); + if (holder.isService) { + line += L", service "; + line += holder.serviceShortName.empty() ? L"without a short name" : holder.serviceShortName; + } else { + line += L", running"; + } + line += L")"; + return line; +} + +std::wstring formatHolders(const std::vector &holders) +{ + const std::size_t wanted = std::min(holders.size(), HOLDER_LINE_MAX); + std::wstring line; + std::size_t listed = 0; + for (; listed < wanted; ++listed) { + const std::wstring entry = formatHolder(holders[listed]); + const std::size_t separator = line.empty() ? 0 : 2; + // The first holder is always named, however long its name: it is the answer the + // dialog exists to show, and Windows bounds one entry below the whole budget. + if (!line.empty() && line.size() + separator + entry.size() > HOLDER_LINE_CHAR_MAX) { + break; + } + if (!line.empty()) { + line += L"; "; + } + line += entry; + } + if (holders.size() > listed) { + line += L"; and "; + line += std::to_wstring(holders.size() - listed); + line += L" more"; + } + return line; +} diff --git a/tools/installer-holder-probe/holder_line.h b/tools/installer-holder-probe/holder_line.h new file mode 100644 index 000000000..7fcc92375 --- /dev/null +++ b/tools/installer-holder-probe/holder_line.h @@ -0,0 +1,59 @@ +#ifndef HOLDER_LINE_H +#define HOLDER_LINE_H + +#include +#include +#include + +/** + * @brief One application or service that has an installer file open or mapped. + * + * Deliberately free of Windows types: the wording is what the abort dialog shows, + * and it is the only part of the probe that can be exercised off Windows. + */ +struct Holder +{ + std::wstring imageName; ///< "cockatrice.exe", empty when the image could not be read + std::wstring appName; ///< Restart Manager's name for the application or service + std::wstring serviceShortName; ///< service short name, empty when this is not a service + unsigned long processId = 0; + bool isService = false; +}; + +/// How many holders the single-line summary names before it says how many were left out. +inline constexpr std::size_t HOLDER_LINE_MAX = 5; + +/** + * @brief How many characters the summary may spend on the holders it names. + * + * The line is not the whole string: the abort dialog puts it after a fixed paragraph and + * before the path of the installer log, inside an NSIS string of 1024 characters. Windows + * caps one application or service name well below this budget, so it bounds the list + * without ever cutting a single holder short. + */ +inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 400; + +/** + * @brief Renders one holder the way the user has to recognise it, e.g. + * "cockatrice.exe (pid 24340, running)". + * + * The image name is preferred over the application name because it is what Task + * Manager shows, and the application name is what remains for a process whose + * image cannot be opened (another account, a protected process). A service is + * named by Restart Manager's long name and identified by its short name, because + * both are how the user finds it again in services.msc. + */ +std::wstring formatHolder(const Holder &holder); + +/** + * @brief Renders every holder as a single "; "-joined line. + * + * One line because it is written to the installer log as one and repeated in a + * message box, where nsExec hands the text back as one string. Only the first + * HOLDER_LINE_MAX are named, and only while they fit in HOLDER_LINE_CHAR_MAX + * characters; the rest are counted, so a machine with a dozen open handles cannot + * push the real answer off the dialog. + */ +std::wstring formatHolders(const std::vector &holders); + +#endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp new file mode 100644 index 000000000..3b5bb5d22 --- /dev/null +++ b/tools/installer-holder-probe/main.cpp @@ -0,0 +1,78 @@ +#include "holder_line.h" +#include "restart_manager_holders.h" + +#include +#include +#include + +namespace +{ +/// The installer sets this, the same variable the running-instance guard uses, instead +/// of passing the directory on the command line: a path with spaces, quotes or a +/// trailing backslash then never has to survive NSIS quoting or a command line. +constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; + +/// Exit codes the installer reads. "Something was found" is deliberately not 0, so an +/// answer of "nothing holds the files" stays distinguishable from a probe that failed. +enum ExitCode +{ + EXIT_NO_HOLDERS = 0, + EXIT_HOLDERS_FOUND = 1, + EXIT_NOT_CONFIGURED = 2, + EXIT_PROBE_FAILED = 3, +}; + +/// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes +/// come back to the installer as an ANSI string, so a non-ASCII process name would be +/// mojibake in the log; a '?' at least stays honest. Control characters are dropped +/// too, so a name can never forge a second log line. +void writeLine(const std::wstring &text) +{ + std::string narrow; + narrow.reserve(text.size() + 2); + for (const wchar_t character : text) { + narrow.push_back(character >= 0x20 && character < 0x7F ? static_cast(character) : '?'); + } + narrow += "\r\n"; + DWORD written = 0; + WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast(narrow.size()), &written, nullptr); +} + +std::wstring installDirFromEnvironment() +{ + for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) { + std::vector buffer(size); + const DWORD length = GetEnvironmentVariableW(INSTALL_DIR_VARIABLE, buffer.data(), size); + if (length == 0) { + return {}; + } + if (length < size) { + return std::wstring(buffer.data(), length); + } + } + return {}; +} +} // namespace + +int main() +{ + const std::wstring installDir = installDirFromEnvironment(); + if (installDir.empty()) { + writeLine(L"the probe was not given the install directory"); + return EXIT_NOT_CONFIGURED; + } + + std::vector holders; + const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders); + if (error != ERROR_SUCCESS) { + writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error)); + return EXIT_PROBE_FAILED; + } + if (holders.empty()) { + // No output: the installer words the "nothing is holding them" case, and it + // owns the message either way. + return EXIT_NO_HOLDERS; + } + writeLine(formatHolders(holders)); + return EXIT_HOLDERS_FOUND; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp new file mode 100644 index 000000000..39f6a0049 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -0,0 +1,160 @@ +#include "restart_manager_holders.h" + +// clang-format off +// restartmanager.h uses the types from windows.h without including it, so the order +// here is a requirement; Regroup would sort them the other way round. +#include +#include +// clang-format on + +#include +#include + +namespace +{ +/// Stop registering after this many files. The install directory carries around 150 +/// files in practice; the cap only exists so a directory someone has filled with files +/// cannot turn the abort path into a long Restart Manager call. +constexpr std::size_t MAX_PROBED_FILES = 2000; + +/// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. +constexpr int MAX_PLUGIN_DEPTH = 4; + +void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) +{ + if (files.size() >= MAX_PROBED_FILES) { + return; + } + WIN32_FIND_DATAW entry{}; + const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry); + if (find == INVALID_HANDLE_VALUE) { + return; + } + do { + if (files.size() >= MAX_PROBED_FILES) { + break; + } + const std::wstring name = entry.cFileName; + if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + continue; + } + const std::wstring path = directory + L"\\" + name; + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + if (depth < maxDepth) { + collectFilesOf(path, depth + 1, maxDepth, files); + } + } else { + files.push_back(path); + } + } while (FindNextFileW(find, &entry) != 0); + FindClose(find); +} + +std::wstring imagePathOf(unsigned long processId) +{ + // PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the + // path of a process this one does not own, which is what keeps a SYSTEM-owned holder + // from turning the whole list into an error. One that refuses even this is still + // named, by whatever Restart Manager called it. + const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId); + if (process == nullptr) { + return {}; + } + wchar_t path[MAX_PATH * 2] = {}; + DWORD length = static_cast(std::size(path)); + std::wstring imagePath; + if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) { + imagePath.assign(path, length); + } + CloseHandle(process); + return imagePath; +} + +std::wstring fileNameOf(const std::wstring &path) +{ + const std::size_t separator = path.find_last_of(L"\\/"); + return separator == std::wstring::npos ? path : path.substr(separator + 1); +} + +/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in. +template std::wstring fixedString(const wchar_t (&text)[N]) +{ + return std::wstring(text, std::find(text, text + N, L'\0')); +} + +Holder holderOf(const RM_PROCESS_INFO &info) +{ + Holder holder; + holder.processId = info.Process.dwProcessId; + holder.isService = info.ApplicationType == RmService; + holder.appName = fixedString(info.strAppName); + if (holder.isService) { + holder.serviceShortName = fixedString(info.strServiceShortName); + } + holder.imageName = fileNameOf(imagePathOf(holder.processId)); + return holder; +} +} // namespace + +std::vector collectProbeFiles(const std::wstring &installDir) +{ + std::vector files; + // depth 0, maxDepth 0: the install root's files and nothing below it. Its data + // directory holds downloaded pictures, which the purge does not touch and which + // would turn this into a walk over thousands of files. + collectFilesOf(installDir, 0, 0, files); + if (files.size() < MAX_PROBED_FILES) { + collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files); + } + return files; +} + +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders) +{ + holders.clear(); + if (files.empty()) { + return ERROR_SUCCESS; + } + + DWORD session = 0; + wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {}; + DWORD error = RmStartSession(&session, 0, sessionKey); + if (error != ERROR_SUCCESS) { + return error; + } + + // One call for the whole set, as the API asks: RmRegisterResources does registry + // work per file and is documented as too expensive to call once per file. + std::vector names; + names.reserve(files.size()); + for (const std::wstring &file : files) { + names.push_back(file.c_str()); + } + error = RmRegisterResources(session, static_cast(names.size()), names.data(), 0, nullptr, 0, nullptr); + + if (error == ERROR_SUCCESS) { + UINT needed = 0; + UINT count = 0; + DWORD rebootReasons = 0; + // The first call asks how large the answer is; Restart Manager reports + // ERROR_MORE_DATA rather than truncating a list that does not fit. + error = RmGetList(session, &needed, &count, nullptr, &rebootReasons); + // Bounded: each retry more than covers the list growing between the two calls, + // and an abort path cannot chase a process that keeps opening files. Giving up + // reports the code, which is a truthful answer instead of a truncated list. + for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) { + count = needed; + std::vector infos(count); + error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons); + if (error == ERROR_SUCCESS) { + holders.reserve(count); + for (UINT index = 0; index < count; ++index) { + holders.push_back(holderOf(infos[index])); + } + } + } + } + + RmEndSession(session); + return error; +} diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h new file mode 100644 index 000000000..d69fc0259 --- /dev/null +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -0,0 +1,35 @@ +#ifndef RESTART_MANAGER_HOLDERS_H +#define RESTART_MANAGER_HOLDERS_H + +#include "holder_line.h" + +#include +#include + +/** + * @brief Lists the files a failed purge can be holding up, as full paths. + * + * Everything directly in @p installDir plus everything below its Plugins directory: + * those are the two things the purge removes, so they are the two sets of files a + * holder can be holding. Restart Manager refuses a directory with ERROR_ACCESS_DENIED, + * which is why the probe registers files and this has to name them first. + * + * Missing or unreadable directories are skipped rather than reported, and the list is + * capped: this runs on the abort path and must not walk a tree of downloaded pictures. + */ +std::vector collectProbeFiles(const std::wstring &installDir); + +/** + * @brief Names the applications and services that have one of @p files open or mapped. + * + * Restart Manager reports both a process that loaded one of the files as a module and + * one that merely holds a handle to it, and it reports services, which is the whole + * reason it is used instead of walking every process's module list. + * + * @param holders replaced with one entry per holder, in the order Windows reported them. + * @return 0 when the lookup ran, even if nothing is holding the files; otherwise the + * Win32 code of the Restart Manager call that failed. + */ +unsigned long findProcessesUsingFiles(const std::vector &files, std::vector &holders); + +#endif