Cockatrice/tools/installer-holder-probe/restart_manager_holders.cpp
sentry[bot] d42b9607c5
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
[Installer] Check for running instances with the Restart Manager probe (#7671)
* [Installer] Check for running instances with the Restart Manager probe

The running-instance guard was built on powershell.exe started from the
32-bit NSIS stub, which is the 32-bit Windows PowerShell: its Get-Process
cannot read the path of a 64-bit process, so a running 64-bit Cockatrice
was reported as not running, the update went ahead, and the purge then
failed on the DLLs that instance still had mapped.

Use tools/installer-holder-probe instead - the same Restart Manager lookup
the abort path already had - before anything is replaced and in the
uninstaller as well. It is bitness-agnostic, so the Sysnative and WMI
fallbacks are gone.

The guard looks only at the payload, asks Cockatrice to close once by
itself (WM_CLOSE, the same request as the window's close button) and then
offers close again / retry after closing by hand / cancel for whatever the
close cannot reach - Oracle writes the card database in place, Servatrice
has no window - without force-stopping anything. A failed purge offers the
same close-and-retry, tries one last purge when nobody is named, and every
dialog shows in every mode: a silent reinstall stays silent until
something goes wrong.

* [Installer] Name processes running from the install directory

The running-instance guard answers from the Restart Manager, which reports
who has a file open. A running instance does not have to have one open: its
own image and the modules it loaded are mapped into it rather than opened by
it, and a mapping is not reliably reported. That is the state the probe was
added for - every DLL still on disk, an instance running from the directory,
and "nothing is holding them any more" as the answer.

A guard built on file handles alone is blind to exactly that instance, so it
passes, the purge fails, the holder lookup after it names nobody either, and
the install ends in a warning that no running program was found: no close is
attempted and no retry is offered. A manual install with another instance
open never asks it to close at all.

Name every process whose image lives in the directory being replaced as
well, with the same bitness-safe QueryFullProcessImageNameW the probe
already uses to label its holders. The image lookup is listed first - it is
the instance the user is asked to close - and the Restart Manager entries
follow with the pids already named dropped, so the close still reaches
everything that holds a file. A Restart Manager call that fails only makes
the probe unable to answer when the image lookup named nobody either.

Ask instead of dead-ending when the probe cannot answer at all: Retry checks
again, Ignore carries on, Abort cancels, in the installer and the uninstaller
alike, and the abort dialog says the check failed instead of claiming no
running program was found.

The Windows CI probe check covers the new lookup with a process started from
the directory that holds no file open.

* [Installer] Offer to wait again when the launcher has not exited

A client that is still closing after 60 s is usually not hung: it is showing
its own "quit anyway?" confirmation for an unsaved deck, an open game or a
card database update, and the user is the one who has to answer it. The
installer answered that with a plain warning and stopped, so the only way
forward was to close Cockatrice by hand and run the update again - which on
an in-app update means finding the downloaded installer.

Ask instead: Retry brings the client to the front, asks it to close again
(WM_CLOSE, nothing forced) and keeps waiting on its handle, Cancel stops as
before. There is no "install anyway" answer because a live process has its
image and Qt DLLs mapped, so writing the payload over them is the broken
install this wait exists to prevent - and the guard that runs right after
names the same process by its image path and stops there, so ignoring would
only move the same question a moment later.

* [Installer] Ask about a launcher that has not exited after ten seconds

A minute of silence is a long time to sit in front of an installer that has no
window yet, and the case it is waiting for is not a slow flush: a client that
started an update exits in well under a second, so a minute means it is almost
always showing its own "quit anyway?" confirmation with nobody looking at it.

Wait ten seconds for the first question instead, and the full grace for the
ones after it. The normal path is untouched - WaitForSingleObject returns as
soon as the handle signals, so a client that exits in 200 ms still costs 200
ms - and answering the first question once does not turn into a stream of
prompts while the user is in Cockatrice's window.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-26 19:25:10 +01:00

256 lines
9.6 KiB
C++

#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 <windows.h>
#include <restartmanager.h>
#include <tlhelp32.h>
// clang-format on
#include <algorithm>
#include <iterator>
#include <utility>
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\<category>\<plugin>.dll.
constexpr int MAX_PLUGIN_DEPTH = 4;
/// The payload files the installer replaces by name; the DLLs are matched by mask below.
constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"};
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &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<DWORD>(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);
}
/// @p directory with exactly one trailing separator, so that a sibling whose name merely
/// begins with the same letters ("Cockatrice-old" next to "Cockatrice") cannot pass for a
/// subdirectory of it.
std::wstring directoryPrefixOf(const std::wstring &directory)
{
std::wstring prefix = directory;
while (!prefix.empty() && (prefix.back() == L'\\' || prefix.back() == L'/')) {
prefix.pop_back();
}
prefix += L'\\';
return prefix;
}
/// Whether @p path lies inside @p prefix, which is a directory with a trailing separator.
/// Ordinal and case-insensitive, as Windows compares paths.
bool isUnderDirectory(const std::wstring &path, const std::wstring &prefix)
{
if (path.size() < prefix.size()) {
return false;
}
const int length = static_cast<int>(prefix.size());
return CompareStringOrdinal(path.c_str(), length, prefix.c_str(), length, TRUE) == CSTR_EQUAL;
}
/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in.
template <std::size_t N> 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<std::wstring> collectPayloadFiles(const std::wstring &installDir)
{
std::vector<std::wstring> files;
for (const wchar_t *name : PAYLOAD_FILES) {
const std::wstring path = installDir + L"\\" + name;
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
files.push_back(path);
}
}
// Every top-level DLL: the set the purge deletes and therefore the set a running
// instance can have mapped. Matched by mask rather than by a list of expected names,
// so a transitive dependency nobody thought of is covered as well.
WIN32_FIND_DATAW entry{};
const HANDLE dlls = FindFirstFileW((installDir + L"\\*.dll").c_str(), &entry);
if (dlls != INVALID_HANDLE_VALUE) {
do {
if (files.size() >= MAX_PROBED_FILES) {
break;
}
if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 ||
(entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
continue;
}
files.push_back(installDir + L"\\" + entry.cFileName);
} while (FindNextFileW(dlls, &entry) != 0);
FindClose(dlls);
}
// Plugins is the other tree the purge removes. Its data directory is not: it holds
// downloaded pictures, which the purge does not touch and which would turn this into
// a walk over thousands of files.
if (files.size() < MAX_PROBED_FILES) {
collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files);
}
return files;
}
unsigned long findProcessesUsingFiles(const std::vector<std::wstring> &files, std::vector<Holder> &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<LPCWSTR> names;
names.reserve(files.size());
for (const std::wstring &file : files) {
names.push_back(file.c_str());
}
error = RmRegisterResources(session, static_cast<UINT>(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<RM_PROCESS_INFO> 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;
}
std::vector<Holder> findProcessesRunningFrom(const std::wstring &installDir)
{
std::vector<Holder> holders;
if (installDir.empty()) {
return holders;
}
const std::wstring prefix = directoryPrefixOf(installDir);
// Never the probe itself: it runs from the directory being replaced when the
// uninstaller asks in place or the installer was copied next to the payload, and
// naming it would ask the user to close the process that is doing the asking.
const DWORD self = GetCurrentProcessId();
const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) {
return holders;
}
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
if (Process32FirstW(snapshot, &entry) != FALSE) {
do {
if (entry.th32ProcessID == self) {
continue;
}
// One process at a time, because the path is per process; a process that
// refuses to be read is not named here and is still named by Restart
// Manager when it holds a handle. The idle process has no image at all.
const std::wstring imagePath = imagePathOf(entry.th32ProcessID);
if (!isUnderDirectory(imagePath, prefix)) {
continue;
}
Holder holder;
holder.processId = entry.th32ProcessID;
holder.imageName = fileNameOf(imagePath);
holders.push_back(std::move(holder));
} while (Process32NextW(snapshot, &entry) != FALSE);
}
CloseHandle(snapshot);
return holders;
}