diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index f3a7c2b..9305ff3 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -6,7 +6,7 @@ on:
version:
description: Version written into archive names
required: true
- default: 0.3.0
+ default: 0.4.0
type: string
permissions:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35ae7b8..6cb3c22 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
All notable project changes are documented here. The project follows semantic versioning after the first generally available release.
+## [0.4.0-rc1] - 2026-08-20
+
+### Added
+
+- Resizable and maximizable native GUI with adaptive lists, system-DPI awareness, modern system theming, and a larger title treatment.
+- Offline profile fallback enumeration from the physical `Users` directory.
+- Offline local-machine public-certificate store scan in addition to user Personal stores.
+- MSI packed ProductCode decoding so confirmed Installer UserData can recover products missing from the uninstall list.
+
+### Fixed
+
+- Explicit native registry-view access prevents the x86 utility from losing physical offline registry paths on an x64 rescue host.
+- `SOFTWARE`, user `NTUSER.DAT`, and `SYSTEM` hives are loaded sequentially, respecting the Windows 7 one-application-hive-per-process limitation.
+- Offline data rescue remains available if `SYSTEM` cannot be read; destructive offline cleanup is disabled in that state.
+
+### Validation
+
+- User-reported full uninstall and residual cleanup succeeded on a live Windows 10 x64 installation.
+- The Windows 7 x86 disconnected-drive scenario that returned zero products/certificates in RC2 is pending a repeat test with this fix.
+
## [0.3.0-rc2] - 2026-08-19
### Changed
diff --git a/CryptoProCleanup.vcxproj b/CryptoProCleanup.vcxproj
index d1d144d..ac65f88 100644
--- a/CryptoProCleanup.vcxproj
+++ b/CryptoProCleanup.vcxproj
@@ -22,12 +22,12 @@
$(MSBuildThisFileDirectory)build\$(Configuration)\$(MSBuildThisFileDirectory)build\obj\$(ProjectName)\$(Configuration)\CryptoProCleanup
Level4trueWIN32;_WINDOWS;UNICODE;_UNICODE;WINVER=0x0601;_WIN32_WINNT=0x0601;NOMINMAX;%(PreprocessorDefinitions)truestdcpp17MultiThreadedDebug/utf-8 %(AdditionalOptions)
- WindowstrueRequireAdministratoradvapi32.lib;comctl32.lib;crypt32.lib;msi.lib;ole32.lib;oleaut32.lib;setupapi.lib;shell32.lib;shlwapi.lib;taskschd.lib;user32.lib;version.lib;wintrust.lib;%(AdditionalDependencies)/SUBSYSTEM:WINDOWS,6.01 %(AdditionalOptions)
+ WindowstrueRequireAdministratoradvapi32.lib;comctl32.lib;crypt32.lib;msi.lib;ole32.lib;oleaut32.lib;setupapi.lib;shell32.lib;shlwapi.lib;taskschd.lib;user32.lib;uxtheme.lib;version.lib;wintrust.lib;%(AdditionalDependencies)/SUBSYSTEM:WINDOWS,6.01 %(AdditionalOptions)
src\app.manifest
Level4truetruetrueWIN32;NDEBUG;_WINDOWS;UNICODE;_UNICODE;WINVER=0x0601;_WIN32_WINNT=0x0601;NOMINMAX;%(PreprocessorDefinitions)truestdcpp17MultiThreaded/utf-8 %(AdditionalOptions)
- WindowstruetruetrueRequireAdministratoradvapi32.lib;comctl32.lib;crypt32.lib;msi.lib;ole32.lib;oleaut32.lib;setupapi.lib;shell32.lib;shlwapi.lib;taskschd.lib;user32.lib;version.lib;wintrust.lib;%(AdditionalDependencies)/SUBSYSTEM:WINDOWS,6.01 %(AdditionalOptions)
+ WindowstruetruetrueRequireAdministratoradvapi32.lib;comctl32.lib;crypt32.lib;msi.lib;ole32.lib;oleaut32.lib;setupapi.lib;shell32.lib;shlwapi.lib;taskschd.lib;user32.lib;uxtheme.lib;version.lib;wintrust.lib;%(AdditionalDependencies)/SUBSYSTEM:WINDOWS,6.01 %(AdditionalOptions)
src\app.manifest
diff --git a/README.en.md b/README.en.md
index 49ea9f5..8c7a3a3 100644
--- a/README.en.md
+++ b/README.en.md
@@ -5,7 +5,7 @@
[](LICENSE)
[](#system-requirements)
-> **Status: 0.3.0 release candidate.** Safe scanning and backup have been validated on Windows 11 with CryptoPro CSP 5.0. The complete VM matrix, especially Windows 7, CSP 4.x, and destructive disconnected-Windows testing, remains mandatory before general availability.
+> **Status: 0.4.0 release candidate.** Safe scanning was validated on Windows 11 with CryptoPro CSP 5.0, and a user successfully completed full removal and residual cleanup on a live Windows 10 x64 system. The disconnected Windows 7 x86 fix still requires a repeat field test before general availability.
An unofficial portable utility for backing up license identifiers and public certificates, controlled removal of installed CryptoPro products, and rescue from a disconnected Windows 7 SP1 through Windows 11 installation.
@@ -22,6 +22,7 @@ The utility deliberately preserves Windows certificate stores, hardware tokens,
## Features
- native portable Unicode Win32 GUI with RU/EN languages and no .NET dependency;
+- resizable/maximizable native window with adaptive tables, system-DPI scaling, and Windows visual styles;
- verified 32/64-bit product discovery by publisher and MSI/EXE metadata;
- complete license display, clipboard copy, and confidential backup;
- selectable public-certificate inventory and CER/P7B export;
@@ -30,6 +31,9 @@ The utility deliberately preserves Windows certificate stores, hardware tokens,
- restart-safe continuation, masked JSON reporting, and a privacy-safe operation log;
- disconnected-Windows rescue for licenses and public certificates;
- separately confirmed, recovery-backed conservative offline cleanup.
+- explicit native-view access for cross-bitness scanning of disconnected x86/x64 Windows hives;
+- sequential `SOFTWARE`, user `NTUSER.DAT`, and `SYSTEM` loading compatible with the Windows 7 application-hive limit;
+- fallback profile discovery from `Users`, plus public certificates from user and local-machine stores.
## System requirements
@@ -58,7 +62,7 @@ Safe scan-only CLI:
CryptoProCleanup.exe --scan --report C:\Temp\cryptopro-report.json --lang en
```
-Version 0.3.0 has no unattended destructive mode.
+Version 0.4.0 has no unattended destructive mode.
## Build and package
diff --git a/README.md b/README.md
index 2293c9f..bb864a9 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
[](LICENSE)
[](#системные-требования)
-> **Статус: релиз-кандидат 0.3.0.** Безопасное сканирование и резервное копирование проверены на Windows 11 с CryptoPro CSP 5.0. Перед рекомендацией для массового применения ещё требуется полная VM-матрица, особенно Windows 7, CSP 4.x и разрушительная проверка офлайн-очистки.
+> **Статус: релиз-кандидат 0.4.0.** Безопасное сканирование проверено на Windows 11 с CryptoPro CSP 5.0; полное удаление и очистка успешно выполнены пользователем на живой Windows 10 x64. Исправление чтения подключённой Windows 7 x86 требует повторной полевой проверки перед стабильным выпуском.
Неофициальная portable-утилита для резервного копирования лицензий и открытых сертификатов, контролируемого удаления установленных продуктов CryptoPro и спасения данных из отключённой Windows 7 SP1–Windows 11.
@@ -27,6 +27,7 @@
## Возможности
- один нативный x86 EXE без .NET/PowerShell и внешнего runtime;
+- изменяемое и максимизируемое окно с адаптивными таблицами, системным DPI-масштабированием и нативным современным оформлением;
- поиск продуктов по подтверждённому издателю в 32- и 64-битных ветках реестра;
- RU/EN интерфейс, выбор продуктов и локальных профилей;
- резервная копия лицензий, JSON-отчёт и обезличенный журнал до удаления;
@@ -40,6 +41,9 @@
- управляемая перезагрузка и продолжение через защищённый RunOnce-сеанс;
- безопасный CLI-режим сканирования без удаления.
- отдельная вкладка «Неисправный диск» для чтения отключённой Windows, извлечения лицензий и открытых сертификатов и консервативной расширенной очистки.
+- явное native/WOW64-чтение офлайн-реестра: x86-утилита корректно обращается к физическим веткам подключённой Windows x86/x64;
+- последовательная загрузка `SOFTWARE`, пользовательских `NTUSER.DAT` и `SYSTEM`, совместимая с ограничением Windows 7;
+- резервный поиск профилей в `Users` и чтение как пользовательского, так и машинного хранилища открытых сертификатов.
## Системные требования
@@ -77,7 +81,7 @@ CLI только для безопасного сканирования:
CryptoProCleanup.exe --scan --report C:\Temp\cryptopro-report.json --lang ru
```
-Тихого автоматического удаления в версии 0.3.0 нет.
+Тихого автоматического удаления в версии 0.4.0 нет.
## Сборка
diff --git a/docs/RELEASE_NOTES_0.4.0-rc1.md b/docs/RELEASE_NOTES_0.4.0-rc1.md
new file mode 100644
index 0000000..7600952
--- /dev/null
+++ b/docs/RELEASE_NOTES_0.4.0-rc1.md
@@ -0,0 +1,21 @@
+# КриптоПро Очистка 0.4.0 RC1
+
+Релиз-кандидат с исправлением офлайн-сканирования Windows другой разрядности и обновлённым нативным интерфейсом.
+
+## Главное
+
+- главное окно можно растягивать и максимизировать;
+- таблицы и элементы управления адаптируются к ширине и высоте окна;
+- добавлены системное DPI-масштабирование, светлое оформление, увеличенный заголовок и Explorer-стиль списков;
+- офлайн-реестр всегда читается через явное native view, поэтому x86-утилита на Windows x64 не должна терять физические ветки Windows 7 x86;
+- `SOFTWARE`, пользовательские `NTUSER.DAT` и `SYSTEM` загружаются последовательно для совместимости с Windows 7;
+- подтверждённые MSI-продукты восстанавливаются также из Installer UserData;
+- профили дополнительно ищутся непосредственно в папке `Users`;
+- открытые сертификаты читаются из пользовательских и машинного хранилищ;
+- если `SYSTEM` недоступен, лицензии и сертификаты всё ещё можно спасти, но очистка блокируется.
+
+## Проверка
+
+Модульные тесты, безопасный интеграционный тест и статический анализ MSVC пройдены. Готовый EXE повторно проверен как нативное x86-приложение с Windows 7 subsystem/API target, UAC-манифестом и статическим C/C++ runtime. Исправление требует повторного теста на подключённом загрузочном диске Windows 7 x86, где RC2 показывал нулевой результат.
+
+EXE не подписан цифровой подписью; предупреждение SmartScreen ожидаемо. Проект не связан с ООО «КРИПТО-ПРО».
diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md
index 5ccb9de..08b609f 100644
--- a/docs/TEST_MATRIX.md
+++ b/docs/TEST_MATRIX.md
@@ -35,6 +35,9 @@ For each VM retain the initial JSON report, final JSON report, cleanup log, prod
## Recorded validation
- 2026-08-19: Windows 11 x64 build 26100, CryptoPro CSP 5.0.13000 plus CryptoPro EDS Browser plug-in 2.0.15400 — unit tests and non-destructive integration scan passed. The scan found the complete MSI `InstallProperties\ProductID` through the packed ProductCode-derived path and enumerated seven public certificates from the current user's logical Personal store without exposing values or names in test output. Temporary CER/P7B export was reopened through CryptoAPI and removed. No removal was performed.
+- 2026-08-20: user field report — full installed-product removal and residual cleanup completed successfully on a live Windows 10 x64 system.
+- 2026-08-20: RC2 running on Windows 10 x64 returned zero products and certificates for a connected, bootable Windows 7 x86 disk. Version 0.4.0 now uses explicit native registry views, sequential application-hive loading, MSI Installer UserData product recovery, filesystem profile fallback, and user/machine certificate stores. A repeat test on that disk is mandatory before this scenario can pass.
+- 2026-08-20: version 0.4.0 unit tests, non-destructive integration scan, and MSVC C++ static analysis passed. The release executable was verified as x86 with OS/subsystem version 6.01, `requireAdministrator`, system-DPI awareness, Common Controls v6, and no dynamic Visual C++ runtime dependency.
- The running Windows directory is rejected as an offline target. A destructive disconnected-Windows VM test is still pending.
- 2026-08-19: MSVC C++ static analysis passed with no warnings. Offline scanning also compares the actual `SOFTWARE` and `SYSTEM` hive file identities so a filesystem alias to the running Windows installation is rejected.
- Destructive scenarios and the remaining OS/CSP matrix, including CSP 4.x, are still mandatory before marking a build generally available.
diff --git a/scripts/package.ps1 b/scripts/package.ps1
index fb596c4..85a9cbd 100644
--- a/scripts/package.ps1
+++ b/scripts/package.ps1
@@ -1,4 +1,4 @@
-param([string]$Version = '0.3.0')
+param([string]$Version = '0.4.0')
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
diff --git a/src/app.manifest b/src/app.manifest
index 1dde7fb..5561cda 100644
--- a/src/app.manifest
+++ b/src/app.manifest
@@ -11,5 +11,11 @@
+
+
+ true
+ System
+
+
diff --git a/src/certificates.cpp b/src/certificates.cpp
index c326dd6..625cebe 100644
--- a/src/certificates.cpp
+++ b/src/certificates.cpp
@@ -135,13 +135,12 @@ void EnumerateCertificateStore(HCERTSTORE store, const UserProfile& profile,
}
}
-void ReadCertificateStore(HKEY profileRoot, const UserProfile& profile,
- std::vector* certificates,
- std::unordered_set* seen,
- std::vector* warnings) {
+void ReadCertificateStoreAt(HKEY root, const wchar_t* storePath, const UserProfile& profile,
+ std::vector* certificates,
+ std::unordered_set* seen,
+ std::vector* warnings) {
RegKey registryStore;
- constexpr wchar_t storePath[] = L"SOFTWARE\\Microsoft\\SystemCertificates\\My";
- const LONG opened = RegOpenKeyExW(profileRoot, storePath, 0, KEY_READ, registryStore.put());
+ const LONG opened = RegOpenKeyExW(root, storePath, 0, KEY_READ | KEY_WOW64_64KEY, registryStore.put());
if (opened == ERROR_FILE_NOT_FOUND || opened == ERROR_PATH_NOT_FOUND) return;
if (opened != ERROR_SUCCESS) {
if (warnings) warnings->push_back(L"Could not read the personal certificate store for profile: " + profile.displayName);
@@ -157,6 +156,14 @@ void ReadCertificateStore(HKEY profileRoot, const UserProfile& profile,
EnumerateCertificateStore(store.get(), profile, certificates, seen);
}
+void ReadCertificateStore(HKEY profileRoot, const UserProfile& profile,
+ std::vector* certificates,
+ std::unordered_set* seen,
+ std::vector* warnings) {
+ ReadCertificateStoreAt(profileRoot, L"SOFTWARE\\Microsoft\\SystemCertificates\\My",
+ profile, certificates, seen, warnings);
+}
+
bool WriteBinaryFile(const std::wstring& path, const BYTE* data, size_t size, std::wstring* error) {
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, CREATE_NEW,
FILE_ATTRIBUTE_NORMAL, nullptr);
@@ -200,7 +207,8 @@ void ScanUserCertificates(const std::vector& profiles,
else if (warnings) warnings->push_back(L"CryptoAPI could not open the current user's Personal certificate store.");
continue;
}
- if (profile.loaded) RegOpenKeyExW(HKEY_USERS, profile.sid.c_str(), 0, KEY_READ, profileRoot.put());
+ if (profile.loaded) RegOpenKeyExW(HKEY_USERS, profile.sid.c_str(), 0,
+ KEY_READ | KEY_WOW64_64KEY, profileRoot.put());
if (!profileRoot.get()) {
const std::wstring hive = JoinPath(profile.profilePath, L"NTUSER.DAT");
if (!FileExists(hive) || RegLoadAppKeyW(hive.c_str(), profileRoot.put(), KEY_READ, 0, 0) != ERROR_SUCCESS) {
@@ -217,6 +225,22 @@ void ScanUserCertificates(const std::vector& profiles,
});
}
+void ScanOfflineMachineCertificates(Language language, HKEY offlineSoftware,
+ std::vector* certificates,
+ std::vector* warnings) {
+ if (!offlineSoftware || !certificates) return;
+ UserProfile machine;
+ machine.sid = L"S-1-5-18-OFFLINE-MACHINE";
+ machine.displayName = Tr(language, L"Локальный компьютер", L"Local computer");
+ machine.loaded = false;
+ machine.selected = true;
+ std::unordered_set seen;
+ for (const auto& certificate : *certificates)
+ seen.insert(ToLower(certificate.profileSid + L"|" + certificate.thumbprint));
+ ReadCertificateStoreAt(offlineSoftware, L"Microsoft\\SystemCertificates\\My",
+ machine, certificates, &seen, warnings);
+}
+
bool ExportPublicCertificates(Language language, const std::vector& certificates,
const std::wstring& parentFolder, std::wstring* exportFolder,
std::wstring* error) {
diff --git a/src/cleanup.hpp b/src/cleanup.hpp
index df3fcf0..3772975 100644
--- a/src/cleanup.hpp
+++ b/src/cleanup.hpp
@@ -10,7 +10,7 @@
namespace cpc {
-constexpr wchar_t kVersion[] = L"0.3.0";
+constexpr wchar_t kVersion[] = L"0.4.0";
enum class Language { Russian, English };
enum class RiskLevel { Normal, High };
@@ -121,6 +121,7 @@ struct OfflineScanResult {
ScanResult scan;
std::vector targets;
bool valid = false;
+ bool cleanupCapable = false;
};
struct CleanupPlan {
@@ -172,6 +173,7 @@ bool IsCryptoProName(const std::wstring& value);
bool IsHighRiskProduct(const std::wstring& value);
bool IsGuid(const std::wstring& value);
std::wstring PackMsiProductCode(const std::wstring& productCode);
+std::wstring UnpackMsiProductCode(const std::wstring& packedProductCode);
bool IsProtectedPath(const std::wstring& path);
bool IsProtectedRegistryPath(const std::wstring& path);
bool IsSafeVendorPath(const std::wstring& path, const std::vector& approvedRoots);
@@ -183,6 +185,9 @@ void ScanUserCertificates(const std::vector& profiles,
std::vector* certificates,
std::vector* warnings,
const ProgressCallback& progress = {});
+void ScanOfflineMachineCertificates(Language language, HKEY offlineSoftware,
+ std::vector* certificates,
+ std::vector* warnings);
bool ExportPublicCertificates(Language language, const std::vector& certificates,
const std::wstring& parentFolder, std::wstring* exportFolder,
std::wstring* error = nullptr);
diff --git a/src/core.cpp b/src/core.cpp
index 3c0ed73..6df239c 100644
--- a/src/core.cpp
+++ b/src/core.cpp
@@ -448,6 +448,32 @@ std::wstring PackMsiProductCode(const std::wstring& productCode) {
return packed;
}
+std::wstring UnpackMsiProductCode(const std::wstring& packedProductCode) {
+ if (packedProductCode.size() != 32 ||
+ !std::all_of(packedProductCode.begin(), packedProductCode.end(),
+ [](wchar_t ch) { return iswxdigit(ch) != 0; })) return {};
+ auto reversePart = [&](size_t offset, size_t length) {
+ std::wstring part = packedProductCode.substr(offset, length);
+ std::reverse(part.begin(), part.end());
+ return part;
+ };
+ auto swapPairs = [&](size_t offset, size_t length) {
+ std::wstring part;
+ part.reserve(length);
+ for (size_t index = 0; index < length; index += 2) {
+ part.push_back(packedProductCode[offset + index + 1]);
+ part.push_back(packedProductCode[offset + index]);
+ }
+ return part;
+ };
+ std::wstring productCode = L"{" + reversePart(0, 8) + L"-" + reversePart(8, 4) + L"-" +
+ reversePart(12, 4) + L"-" + swapPairs(16, 4) + L"-" +
+ swapPairs(20, 12) + L"}";
+ std::transform(productCode.begin(), productCode.end(), productCode.begin(),
+ [](wchar_t ch) { return static_cast(towupper(ch)); });
+ return IsGuid(productCode) ? productCode : std::wstring();
+}
+
bool IsProtectedPath(const std::wstring& path) {
const std::wstring lower = ToLower(CanonicalPath(path));
const auto containsBoundary = [&](const std::wstring& token) {
diff --git a/src/gui.cpp b/src/gui.cpp
index bc2ae11..244c81d 100644
--- a/src/gui.cpp
+++ b/src/gui.cpp
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include
#include
@@ -11,6 +12,7 @@
#include
#include
#include
+#include
#include
namespace cpc {
@@ -30,6 +32,12 @@ struct AppState {
std::wstring logPath;
std::wstring resumeToken;
std::map> listSorts;
+ std::map> listColumnWeights;
+ std::map initialControlRects;
+ SIZE initialClient{};
+ SIZE minimumWindow{};
+ HFONT titleFont = nullptr;
+ HBRUSH backgroundBrush = nullptr;
bool busy = false;
};
@@ -46,6 +54,134 @@ struct LicenseDialogState {
void SetText(HWND dialog, int id, const std::wstring& text) { SetWindowTextW(GetDlgItem(dialog, id), text.c_str()); }
+BOOL CALLBACK CaptureControlRect(HWND control, LPARAM parameter) {
+ auto* state = reinterpret_cast(parameter);
+ const int id = GetDlgCtrlID(control);
+ if (!state || id <= 0) return TRUE;
+ RECT rectangle{};
+ GetWindowRect(control, &rectangle);
+ MapWindowPoints(HWND_DESKTOP, state->window, reinterpret_cast(&rectangle), 2);
+ state->initialControlRects[id] = rectangle;
+ return TRUE;
+}
+
+void CaptureInitialLayout(AppState& state) {
+ RECT client{};
+ RECT window{};
+ GetClientRect(state.window, &client);
+ GetWindowRect(state.window, &window);
+ state.initialClient = {client.right - client.left, client.bottom - client.top};
+ state.minimumWindow = {window.right - window.left, window.bottom - window.top};
+ state.initialControlRects.clear();
+ EnumChildWindows(state.window, CaptureControlRect, reinterpret_cast(&state));
+}
+
+void PlaceControl(AppState& state, HDWP* positions, int id,
+ int moveX, int moveY, int growX, int growY) {
+ const auto found = state.initialControlRects.find(id);
+ HWND control = GetDlgItem(state.window, id);
+ if (found == state.initialControlRects.end() || !control) return;
+ const RECT& original = found->second;
+ const int width = std::max(1, static_cast(original.right - original.left) + growX);
+ const int height = std::max(1, static_cast(original.bottom - original.top) + growY);
+ if (*positions) {
+ *positions = DeferWindowPos(*positions, control, nullptr, original.left + moveX, original.top + moveY,
+ width, height, SWP_NOACTIVATE | SWP_NOZORDER);
+ } else {
+ MoveWindow(control, original.left + moveX, original.top + moveY, width, height, TRUE);
+ }
+}
+
+void ResizeListColumns(AppState& state, int id) {
+ const auto found = state.listColumnWeights.find(id);
+ HWND list = GetDlgItem(state.window, id);
+ if (found == state.listColumnWeights.end() || !list || found->second.empty()) return;
+ RECT client{};
+ GetClientRect(list, &client);
+ const int available = std::max(100, static_cast(client.right - client.left) - GetSystemMetrics(SM_CXVSCROLL) - 4);
+ const int totalWeight = std::accumulate(found->second.begin(), found->second.end(), 0);
+ if (totalWeight <= 0) return;
+ int used = 0;
+ for (size_t index = 0; index < found->second.size(); ++index) {
+ const int width = index + 1 == found->second.size() ? std::max(40, available - used) :
+ std::max(40, (available * found->second[index]) / totalWeight);
+ ListView_SetColumnWidth(list, static_cast(index), width);
+ used += width;
+ }
+}
+
+void LayoutMainDialog(AppState& state, int clientWidth, int clientHeight) {
+ if (!state.initialClient.cx || !state.initialClient.cy) return;
+ const int dx = std::max(0, clientWidth - static_cast(state.initialClient.cx));
+ const int dy = std::max(0, clientHeight - static_cast(state.initialClient.cy));
+ const int halfY = dy / 2;
+ HDWP positions = BeginDeferWindowPos(40);
+
+ PlaceControl(state, &positions, IDC_TITLE, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_LANGUAGE, dx, 0, 0, 0);
+ PlaceControl(state, &positions, IDC_DISCLAIMER, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_TAB, 0, 0, dx, dy);
+
+ PlaceControl(state, &positions, IDC_PRODUCTS_LABEL, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_PRODUCTS, 0, 0, dx, halfY);
+ PlaceControl(state, &positions, IDC_PROFILES_LABEL, 0, halfY, dx, 0);
+ PlaceControl(state, &positions, IDC_PROFILES, 0, halfY, dx, dy - halfY);
+ for (const int id : {IDC_SELECT_ALL_PROFILES, IDC_BACKUP_LABEL, IDC_BACKUP_INFO, IDC_SHOW_LICENSES})
+ PlaceControl(state, &positions, id, 0, dy, id == IDC_BACKUP_INFO ? dx : 0, 0);
+ PlaceControl(state, &positions, IDC_BACKUP_PATH, 0, dy, dx, 0);
+ PlaceControl(state, &positions, IDC_BROWSE, dx, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_SCAN, dx, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_CLEAN, dx, dy, 0, 0);
+
+ PlaceControl(state, &positions, IDC_CERT_INFO, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_CERTIFICATES, 0, 0, dx, dy);
+ PlaceControl(state, &positions, IDC_SELECT_ALL_CERTS, 0, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_EXPORT_CERTS, dx, dy, 0, 0);
+
+ PlaceControl(state, &positions, IDC_OFFLINE_INFO, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_PATH_LABEL, 0, 0, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_PATH, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_BROWSE, dx, 0, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_SCAN, dx, 0, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_PRODUCTS_LABEL, 0, 0, dx, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_PRODUCTS, 0, 0, dx, halfY);
+ PlaceControl(state, &positions, IDC_OFFLINE_CERTS_LABEL, 0, halfY, dx, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_CERTS, 0, halfY, dx, dy - halfY);
+ PlaceControl(state, &positions, IDC_OFFLINE_SELECT_ALL_CERTS, 0, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_SHOW_LICENSES, 0, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_SAVE, dx, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_OFFLINE_CLEAN, dx, dy, 0, 0);
+
+ for (const int id : {IDC_LINK_GITHUB, IDC_LINK_WEBSITE, IDC_LINK_SUPPORT})
+ PlaceControl(state, &positions, id, 0, dy, 0, 0);
+ PlaceControl(state, &positions, IDC_PROGRESS, 0, dy, dx, 0);
+ PlaceControl(state, &positions, IDC_STATUS, 0, dy, dx, 0);
+ PlaceControl(state, &positions, IDC_LOG, 0, dy, dx, 0);
+ if (positions) EndDeferWindowPos(positions);
+ for (const int id : {IDC_PRODUCTS, IDC_PROFILES, IDC_CERTIFICATES,
+ IDC_OFFLINE_PRODUCTS, IDC_OFFLINE_CERTS}) ResizeListColumns(state, id);
+}
+
+void ApplyModernTheme(AppState& state) {
+ state.backgroundBrush = CreateSolidBrush(RGB(246, 248, 251));
+ HDC device = GetDC(state.window);
+ const int dpi = device ? GetDeviceCaps(device, LOGPIXELSY) : 96;
+ if (device) ReleaseDC(state.window, device);
+ LOGFONTW font{};
+ font.lfHeight = -MulDiv(15, dpi, 72);
+ font.lfWeight = FW_SEMIBOLD;
+ wcscpy_s(font.lfFaceName, L"Segoe UI");
+ state.titleFont = CreateFontIndirectW(&font);
+ if (state.titleFont) SendDlgItemMessageW(state.window, IDC_TITLE, WM_SETFONT,
+ reinterpret_cast(state.titleFont), TRUE);
+ for (const int id : {IDC_PRODUCTS, IDC_PROFILES, IDC_CERTIFICATES,
+ IDC_OFFLINE_PRODUCTS, IDC_OFFLINE_CERTS}) {
+ SetWindowTheme(GetDlgItem(state.window, id), L"Explorer", nullptr);
+ }
+ SetWindowTheme(GetDlgItem(state.window, IDC_TAB), L"Explorer", nullptr);
+ SendDlgItemMessageW(state.window, IDC_PROGRESS, PBM_SETSTATE, PBST_NORMAL, 0);
+}
+
void OpenProjectLink(const AppState& state, int controlId) {
const wchar_t* url = L"https://yoomoney.ru/to/4100119195083142";
if (controlId == IDC_LINK_GITHUB) url = L"https://github.com/acidtmn/CryptoProCleanup";
@@ -107,7 +243,7 @@ std::wstring DefaultBackupFolder() {
return folder;
}
-void ConfigureList(HWND list, const std::vector>& columns) {
+void ConfigureList(AppState& state, HWND list, const std::vector>& columns) {
ListView_DeleteAllItems(list);
while (ListView_DeleteColumn(list, 0)) {}
ListView_SetExtendedListViewStyle(list, LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_CHECKBOXES |
@@ -120,11 +256,16 @@ void ConfigureList(HWND list, const std::vector>& c
column.iSubItem = static_cast(index);
ListView_InsertColumn(list, static_cast(index), &column);
}
+ std::vector weights;
+ weights.reserve(columns.size());
+ for (const auto& column : columns) weights.push_back(column.second);
+ state.listColumnWeights[GetDlgCtrlID(list)] = std::move(weights);
+ ResizeListColumns(state, GetDlgCtrlID(list));
}
void PopulateCertificates(AppState& state) {
HWND list = GetDlgItem(state.window, IDC_CERTIFICATES);
- ConfigureList(list, {
+ ConfigureList(state, list, {
{Tr(state.language, L"Профиль", L"Profile"), 105},
{Tr(state.language, L"Кому выдан", L"Issued to"), 170},
{Tr(state.language, L"Кем выдан", L"Issued by"), 170},
@@ -154,7 +295,7 @@ void PopulateCertificates(AppState& state) {
void PopulateOfflineLists(AppState& state) {
HWND products = GetDlgItem(state.window, IDC_OFFLINE_PRODUCTS);
- ConfigureList(products, {
+ ConfigureList(state, products, {
{Tr(state.language, L"Продукт", L"Product"), 315},
{Tr(state.language, L"Версия", L"Version"), 115},
{Tr(state.language, L"Архитектура", L"Architecture"), 110},
@@ -176,7 +317,7 @@ void PopulateOfflineLists(AppState& state) {
}
HWND certificates = GetDlgItem(state.window, IDC_OFFLINE_CERTS);
- ConfigureList(certificates, {
+ ConfigureList(state, certificates, {
{Tr(state.language, L"Профиль", L"Profile"), 105},
{Tr(state.language, L"Кому выдан", L"Issued to"), 185},
{Tr(state.language, L"Кем выдан", L"Issued by"), 185},
@@ -203,7 +344,7 @@ void PopulateOfflineLists(AppState& state) {
void PopulateLists(AppState& state) {
HWND products = GetDlgItem(state.window, IDC_PRODUCTS);
- ConfigureList(products, {
+ ConfigureList(state, products, {
{Tr(state.language, L"Продукт", L"Product"), 255},
{Tr(state.language, L"Версия", L"Version"), 90},
{Tr(state.language, L"Архитектура", L"Architecture"), 90},
@@ -225,7 +366,7 @@ void PopulateLists(AppState& state) {
}
HWND profiles = GetDlgItem(state.window, IDC_PROFILES);
- ConfigureList(profiles, {{Tr(state.language, L"Локальный профиль", L"Local profile"), 350},
+ ConfigureList(state, profiles, {{Tr(state.language, L"Локальный профиль", L"Local profile"), 350},
{Tr(state.language, L"Состояние", L"State"), 190}});
for (size_t index = 0; index < state.scan.profiles.size(); ++index) {
const auto& profile = state.scan.profiles[index];
@@ -367,7 +508,7 @@ void ApplyLanguage(AppState& state) {
SetText(state.window, IDC_OFFLINE_BROWSE, Tr(state.language, L"Обзор...", L"Browse..."));
SetText(state.window, IDC_OFFLINE_SCAN, Tr(state.language, L"Сканировать", L"Scan"));
SetText(state.window, IDC_OFFLINE_PRODUCTS_LABEL, Tr(state.language, L"Продукты в отключённой Windows", L"Products in disconnected Windows"));
- SetText(state.window, IDC_OFFLINE_CERTS_LABEL, Tr(state.language, L"Открытые сертификаты в офлайн-профилях", L"Public certificates in offline profiles"));
+ SetText(state.window, IDC_OFFLINE_CERTS_LABEL, Tr(state.language, L"Открытые сертификаты профилей и компьютера", L"Public certificates in profiles and local machine"));
SetText(state.window, IDC_OFFLINE_SELECT_ALL_CERTS, Tr(state.language, L"Выбрать все сертификаты", L"Select all certificates"));
SetText(state.window, IDC_OFFLINE_SHOW_LICENSES, Tr(state.language, L"Показать / копировать лицензии", L"Show / copy licenses"));
SetText(state.window, IDC_OFFLINE_SAVE, Tr(state.language, L"Сохранить найденные данные...", L"Save rescued data..."));
@@ -403,7 +544,7 @@ void SetBusy(AppState& state, bool busy) {
EnableWindow(GetDlgItem(state.window, IDC_CLEAN), !state.scan.products.empty());
EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_SHOW_LICENSES), state.offline.valid && !state.offline.scan.licenses.empty());
EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_SAVE), state.offline.valid);
- EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_CLEAN), state.offline.valid && !state.offline.scan.products.empty());
+ EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_CLEAN), state.offline.cleanupCapable && !state.offline.scan.products.empty());
}
}
@@ -619,8 +760,11 @@ void DoOfflineScan(AppState& state) {
SetText(state.window, IDC_OFFLINE_PATH, state.offline.windowsDirectory);
summary << Tr(state.language, L"Офлайн: продуктов ", L"Offline: products ") << state.offline.scan.products.size()
<< Tr(state.language, L", лицензий ", L", licenses ") << state.offline.scan.licenses.size()
+ << Tr(state.language, L", профилей ", L", profiles ") << state.offline.scan.profiles.size()
<< Tr(state.language, L", открытых сертификатов ", L", public certificates ") << state.offline.scan.certificates.size()
- << Tr(state.language, L", подтверждённых целей ", L", verified targets ") << state.offline.targets.size();
+ << Tr(state.language, L", подтверждённых целей ", L", verified targets ") << state.offline.targets.size()
+ << (state.offline.cleanupCapable ? Tr(state.language, L", очистка доступна", L", cleanup available")
+ : Tr(state.language, L", только спасение данных", L", rescue only"));
UpdateProgress(state, Tr(state.language, L"Офлайн-сканирование завершено. Изменения не выполнялись.", L"Offline scan completed. No changes were made."), 100);
} else {
summary << Tr(state.language, L"Офлайн-система не распознана.", L"Offline system was not recognized.");
@@ -655,7 +799,7 @@ void SaveOfflineData(AppState& state) {
}
void StartOfflineCleanup(AppState& state) {
- if (!state.offline.valid || state.offline.scan.products.empty()) {
+ if (!state.offline.cleanupCapable || state.offline.scan.products.empty()) {
MessageBoxW(state.window, Tr(state.language, L"Не найдены подтверждённые продукты для офлайн-очистки.", L"No confirmed products were found for offline cleanup.").c_str(),
L"CryptoPro Cleanup Utility", MB_OK | MB_ICONWARNING);
return;
@@ -889,11 +1033,32 @@ INT_PTR CALLBACK MainDialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM
TabCtrl_SetCurSel(tabs, 0);
state->backupRoot = DefaultBackupFolder();
SetText(dialog, IDC_BACKUP_PATH, state->backupRoot);
+ ApplyModernTheme(*state);
ApplyLanguage(*state);
+ CaptureInitialLayout(*state);
PostMessageW(dialog, WM_CPC_START, 0, 0);
return TRUE;
}
if (!state) return FALSE;
+ if (message == WM_GETMINMAXINFO && state->minimumWindow.cx && state->minimumWindow.cy) {
+ auto* limits = reinterpret_cast(lParam);
+ limits->ptMinTrackSize.x = state->minimumWindow.cx;
+ limits->ptMinTrackSize.y = state->minimumWindow.cy;
+ return TRUE;
+ }
+ if (message == WM_SIZE && wParam != SIZE_MINIMIZED) {
+ LayoutMainDialog(*state, LOWORD(lParam), HIWORD(lParam));
+ return TRUE;
+ }
+ if (message == WM_CTLCOLORDLG && state->backgroundBrush)
+ return reinterpret_cast(state->backgroundBrush);
+ if (message == WM_CTLCOLORSTATIC && state->backgroundBrush) {
+ HDC device = reinterpret_cast(wParam);
+ SetBkMode(device, TRANSPARENT);
+ const int id = GetDlgCtrlID(reinterpret_cast(lParam));
+ SetTextColor(device, id == IDC_TITLE ? RGB(25, 42, 70) : RGB(67, 78, 96));
+ return reinterpret_cast(state->backgroundBrush);
+ }
if (message == WM_CPC_START) {
if (state->resumeToken.empty()) DoScan(*state); else ResumeCleanup(*state);
return TRUE;
@@ -965,6 +1130,11 @@ INT_PTR CALLBACK MainDialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM
return TRUE;
}
}
+ if (message == WM_DESTROY) {
+ if (state->titleFont) { DeleteObject(state->titleFont); state->titleFont = nullptr; }
+ if (state->backgroundBrush) { DeleteObject(state->backgroundBrush); state->backgroundBrush = nullptr; }
+ return TRUE;
+ }
if (message == WM_CLOSE) { if (!state->busy) EndDialog(dialog, 0); return TRUE; }
return FALSE;
}
diff --git a/src/offline.cpp b/src/offline.cpp
index ade1394..0336ec2 100644
--- a/src/offline.cpp
+++ b/src/offline.cpp
@@ -6,12 +6,19 @@
#include
#include
#include
+#include
#include
#include
namespace cpc {
namespace {
+// Registry access from this x86 utility defaults to the 32-bit view on an x64 rescue host.
+// Request the native view explicitly while walking a loaded offline hive so physical SOFTWARE
+// paths are not redirected, including when the disconnected installation itself is x86.
+constexpr REGSAM kOfflineRead = KEY_READ | KEY_WOW64_64KEY;
+constexpr REGSAM kOfflineWrite = KEY_READ | KEY_WRITE | KEY_WOW64_64KEY;
+
class RegKey {
public:
RegKey() = default;
@@ -212,10 +219,10 @@ bool ContainsVerifiedBinary(const std::wstring& directory, unsigned depth = 0) {
void ScanOfflineUninstallRoot(OfflineScanResult& offline, HKEY software, const std::wstring& rootPath,
const std::wstring& architecture) {
RegKey root;
- if (RegOpenKeyExW(software, rootPath.c_str(), 0, KEY_READ, root.put()) != ERROR_SUCCESS) return;
+ if (RegOpenKeyExW(software, rootPath.c_str(), 0, kOfflineRead, root.put()) != ERROR_SUCCESS) return;
for (const auto& subkey : EnumSubkeys(root.get())) {
RegKey key;
- if (RegOpenKeyExW(root.get(), subkey.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(root.get(), subkey.c_str(), 0, kOfflineRead, key.put()) != ERROR_SUCCESS) continue;
const std::wstring publisher = ReadRegString(key.get(), L"Publisher");
const std::wstring displayName = ReadRegString(key.get(), L"DisplayName");
if (displayName.empty() || !IsCryptoProPublisher(publisher)) continue;
@@ -266,7 +273,7 @@ void ScanOfflineLicenseTree(ScanResult& scan, HKEY key, const std::wstring& disp
}
for (const auto& child : EnumSubkeys(key)) {
RegKey subkey;
- if (RegOpenKeyExW(key, child.c_str(), 0, KEY_READ, subkey.put()) == ERROR_SUCCESS)
+ if (RegOpenKeyExW(key, child.c_str(), 0, kOfflineRead, subkey.put()) == ERROR_SUCCESS)
ScanOfflineLicenseTree(scan, subkey.get(), displayPath + L"\\" + child, depth + 1, seen);
}
}
@@ -301,46 +308,118 @@ void ScanOfflineLicenses(OfflineScanResult& offline, HKEY software) {
std::unordered_set seen;
RegKey userData;
constexpr wchar_t installerPath[] = L"Microsoft\\Windows\\CurrentVersion\\Installer\\UserData";
- if (RegOpenKeyExW(software, installerPath, 0, KEY_READ, userData.put()) == ERROR_SUCCESS) {
+ if (RegOpenKeyExW(software, installerPath, 0, kOfflineRead, userData.put()) == ERROR_SUCCESS) {
for (const auto& sid : EnumSubkeys(userData.get())) {
RegKey products;
- if (RegOpenKeyExW(userData.get(), (sid + L"\\Products").c_str(), 0, KEY_READ, products.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(userData.get(), (sid + L"\\Products").c_str(), 0, kOfflineRead, products.put()) != ERROR_SUCCESS) continue;
for (const auto& packedProduct : EnumSubkeys(products.get())) {
RegKey properties;
const std::wstring relative = sid + L"\\Products\\" + packedProduct + L"\\InstallProperties";
- if (RegOpenKeyExW(userData.get(), relative.c_str(), 0, KEY_READ, properties.put()) != ERROR_SUCCESS) continue;
- if (!IsCryptoProPublisher(ReadRegString(properties.get(), L"Publisher"))) continue;
+ if (RegOpenKeyExW(userData.get(), relative.c_str(), 0, kOfflineRead, properties.put()) != ERROR_SUCCESS) continue;
+ const std::wstring publisher = ReadRegString(properties.get(), L"Publisher");
+ if (!IsCryptoProPublisher(publisher)) continue;
+ std::wstring productName = ReadRegString(properties.get(), L"DisplayName");
+ if (productName.empty()) productName = L"CryptoPro MSI product";
+ const std::wstring productCode = UnpackMsiProductCode(packedProduct);
+ const std::wstring version = ReadRegString(properties.get(), L"DisplayVersion");
+ const bool alreadyDetected = std::any_of(offline.scan.products.begin(), offline.scan.products.end(),
+ [&](const InstalledProduct& product) {
+ return (!productCode.empty() && ToLower(product.productCode) == ToLower(productCode)) ||
+ (ToLower(product.displayName) == ToLower(productName) && product.version == version);
+ });
+ if (!alreadyDetected) {
+ InstalledProduct product;
+ product.displayName = productName;
+ product.version = version;
+ product.publisher = publisher;
+ product.productCode = productCode;
+ product.msi = true;
+ product.uninstallString = ReadRegString(properties.get(), L"UninstallString");
+ product.installLocation = RemapOfflinePath(ReadRegString(properties.get(), L"InstallLocation"),
+ offline.volumeRoot, offline.windowsDirectory);
+ const std::wstring lowerLocation = ToLower(product.installLocation);
+ product.architecture = offline.scan.osArchitecture == L"x86" ||
+ lowerLocation.find(L"program files (x86)") != std::wstring::npos ? L"x86" : L"x64";
+ product.risk = IsHighRiskProduct(product.displayName) ? RiskLevel::High : RiskLevel::Normal;
+ if (!productCode.empty()) {
+ for (const wchar_t* uninstallRoot : {
+ L"Microsoft\\Windows\\CurrentVersion\\Uninstall",
+ L"WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall"}) {
+ RegKey uninstall;
+ const std::wstring candidate = std::wstring(uninstallRoot) + L"\\" + productCode;
+ if (RegOpenKeyExW(software, candidate.c_str(), 0, kOfflineRead, uninstall.put()) == ERROR_SUCCESS) {
+ product.registryKey = candidate;
+ break;
+ }
+ }
+ }
+ offline.scan.products.push_back(std::move(product));
+ }
const std::wstring value = Trim(ReadRegString(properties.get(), L"ProductID"));
if (value.size() < 5 || value.size() > 512 || !seen.insert(ToLower(value)).second) continue;
- std::wstring product = ReadRegString(properties.get(), L"DisplayName");
- if (product.empty()) product = L"CryptoPro MSI product";
- offline.scan.licenses.push_back({product, L"OFFLINE_SOFTWARE\\" + std::wstring(installerPath) + L"\\" + relative,
+ offline.scan.licenses.push_back({productName, L"OFFLINE_SOFTWARE\\" + std::wstring(installerPath) + L"\\" + relative,
L"ProductID", value, MaskLicense(value), 100});
}
}
}
for (const wchar_t* branch : {L"Crypto Pro", L"WOW6432Node\\Crypto Pro"}) {
RegKey root;
- if (RegOpenKeyExW(software, branch, 0, KEY_READ, root.put()) == ERROR_SUCCESS)
+ if (RegOpenKeyExW(software, branch, 0, kOfflineRead, root.put()) == ERROR_SUCCESS)
ScanOfflineLicenseTree(offline.scan, root.get(), L"OFFLINE_SOFTWARE\\" + std::wstring(branch), 0, &seen);
}
PreferCompleteLicenses(offline.scan);
}
-void ScanOfflineProfiles(OfflineScanResult& offline, HKEY software) {
+bool IsSystemProfileDirectory(const std::wstring& name) {
+ const std::wstring lower = ToLower(name);
+ return lower == L"default" || lower == L"default user" || lower == L"public" ||
+ lower == L"all users" || lower == L"defaultaccount" || lower == L"wdagutilityaccount";
+}
+
+bool AddOfflineProfile(OfflineScanResult& offline, const std::wstring& sid, const std::wstring& path) {
+ const std::wstring canonical = CanonicalPath(path);
+ const std::wstring name = FileName(canonical);
+ if (name.empty() || IsSystemProfileDirectory(name) || !DirectoryExists(canonical) ||
+ !FileExists(JoinPath(canonical, L"NTUSER.DAT"))) return false;
+ const bool duplicate = std::any_of(offline.scan.profiles.begin(), offline.scan.profiles.end(),
+ [&](const UserProfile& profile) { return ToLower(CanonicalPath(profile.profilePath)) == ToLower(canonical); });
+ if (duplicate) return false;
+ offline.scan.profiles.push_back({sid, name, canonical, false, true});
+ return true;
+}
+
+void ScanOfflineProfiles(Language language, OfflineScanResult& offline, HKEY software) {
RegKey profiles;
constexpr wchar_t profileList[] = L"Microsoft\\Windows NT\\CurrentVersion\\ProfileList";
- if (RegOpenKeyExW(software, profileList, 0, KEY_READ, profiles.put()) != ERROR_SUCCESS) return;
- for (const auto& sid : EnumSubkeys(profiles.get())) {
- if (sid.rfind(L"S-1-5-21-", 0) != 0) continue;
- RegKey profile;
- if (RegOpenKeyExW(profiles.get(), sid.c_str(), 0, KEY_READ, profile.put()) != ERROR_SUCCESS) continue;
- const std::wstring path = RemapOfflinePath(ReadRegString(profile.get(), L"ProfileImagePath"), offline.volumeRoot, offline.windowsDirectory);
- if (!DirectoryExists(path) || !FileExists(JoinPath(path, L"NTUSER.DAT"))) continue;
- const std::wstring name = FileName(path);
- const std::wstring lower = ToLower(name);
- if (lower == L"default" || lower == L"default user" || lower == L"public" || lower == L"all users") continue;
- offline.scan.profiles.push_back({sid, name, path, false, true});
+ if (RegOpenKeyExW(software, profileList, 0, kOfflineRead, profiles.put()) == ERROR_SUCCESS) {
+ for (const auto& sid : EnumSubkeys(profiles.get())) {
+ if (sid.rfind(L"S-1-5-21-", 0) != 0) continue;
+ RegKey profile;
+ if (RegOpenKeyExW(profiles.get(), sid.c_str(), 0, kOfflineRead, profile.put()) != ERROR_SUCCESS) continue;
+ AddOfflineProfile(offline, sid,
+ RemapOfflinePath(ReadRegString(profile.get(), L"ProfileImagePath"),
+ offline.volumeRoot, offline.windowsDirectory));
+ }
+ }
+
+ // A damaged or redirected ProfileList must not prevent read-only certificate rescue.
+ const size_t beforeFallback = offline.scan.profiles.size();
+ const std::wstring users = JoinPath(offline.volumeRoot, L"Users");
+ WIN32_FIND_DATAW data{};
+ HANDLE search = FindFirstFileW(JoinPath(users, L"*").c_str(), &data);
+ if (search != INVALID_HANDLE_VALUE) {
+ do {
+ const std::wstring name = data.cFileName;
+ if (name == L"." || name == L".." || !(data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) ||
+ (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) continue;
+ AddOfflineProfile(offline, L"OFFLINE-FS:" + ToLower(name), JoinPath(users, name));
+ } while (FindNextFileW(search, &data));
+ FindClose(search);
+ }
+ if (offline.scan.profiles.size() > beforeFallback) {
+ offline.scan.warnings.push_back(Tr(language,
+ L"Часть офлайн-профилей найдена напрямую в папке Users (резервный способ).",
+ L"Some offline profiles were recovered directly from the Users directory."));
}
}
@@ -398,12 +477,13 @@ void AddOfflineDirectoryTargets(OfflineScanResult& offline, HKEY software,
}
for (const auto& product : offline.scan.products) {
+ if (product.registryKey.empty()) continue;
AddOfflineTarget(offline, {TargetType::RegistryTree, OfflineHive::Software, product.displayName,
{}, product.registryKey, L"Confirmed publisher uninstall registration", true, false}, identities);
}
for (const wchar_t* branch : {L"Crypto Pro", L"WOW6432Node\\Crypto Pro"}) {
RegKey key;
- if (RegOpenKeyExW(software, branch, 0, KEY_READ, key.put()) == ERROR_SUCCESS) {
+ if (RegOpenKeyExW(software, branch, 0, kOfflineRead, key.put()) == ERROR_SUCCESS) {
AddOfflineTarget(offline, {TargetType::RegistryTree, OfflineHive::Software, L"CryptoPro settings branch",
{}, branch, L"Vendor registry branch with protected key paths excluded", true, false}, identities);
}
@@ -418,11 +498,11 @@ void ScanOfflineProviders(OfflineScanResult& offline, HKEY software,
L"WOW6432Node\\Microsoft\\Cryptography\\Defaults\\Provider"
}) {
RegKey root;
- if (RegOpenKeyExW(software, providerRoot, 0, KEY_READ, root.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(software, providerRoot, 0, kOfflineRead, root.put()) != ERROR_SUCCESS) continue;
for (const auto& name : EnumSubkeys(root.get())) {
if (!IsCryptoProName(name)) continue;
RegKey key;
- if (RegOpenKeyExW(root.get(), name.c_str(), 0, KEY_READ, key.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(root.get(), name.c_str(), 0, kOfflineRead, key.put()) != ERROR_SUCCESS) continue;
const std::wstring image = RemapOfflinePath(ReadRegString(key.get(), L"Image Path"), offline.volumeRoot, offline.windowsDirectory);
const bool verified = !image.empty() && FileExists(image) &&
(VerifyCryptoProSignature(image) || std::any_of(approvedRoots.begin(), approvedRoots.end(),
@@ -441,10 +521,10 @@ void ScanOfflineServices(OfflineScanResult& offline, HKEY system,
for (const auto& controlSet : EnumSubkeys(system)) {
if (ToLower(controlSet).rfind(L"controlset", 0) != 0) continue;
RegKey services;
- if (RegOpenKeyExW(system, (controlSet + L"\\Services").c_str(), 0, KEY_READ, services.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(system, (controlSet + L"\\Services").c_str(), 0, kOfflineRead, services.put()) != ERROR_SUCCESS) continue;
for (const auto& serviceName : EnumSubkeys(services.get())) {
RegKey service;
- if (RegOpenKeyExW(services.get(), serviceName.c_str(), 0, KEY_READ, service.put()) != ERROR_SUCCESS) continue;
+ if (RegOpenKeyExW(services.get(), serviceName.c_str(), 0, kOfflineRead, service.put()) != ERROR_SUCCESS) continue;
const std::wstring image = RemapOfflinePath(ReadRegString(service.get(), L"ImagePath"), offline.volumeRoot, offline.windowsDirectory);
if (image.empty() || !FileExists(image)) continue;
const bool approved = std::any_of(approvedRoots.begin(), approvedRoots.end(),
@@ -536,7 +616,7 @@ bool DeleteRegistryTreeProtected(HKEY root, const std::wstring& subkey, const st
return true;
}
RegKey key;
- const LONG opened = RegOpenKeyExW(root, subkey.c_str(), 0, KEY_READ | KEY_WRITE, key.put());
+ const LONG opened = RegOpenKeyExW(root, subkey.c_str(), 0, kOfflineWrite, key.put());
if (opened == ERROR_FILE_NOT_FOUND || opened == ERROR_PATH_NOT_FOUND) return true;
if (opened != ERROR_SUCCESS) { if (error) *error = opened; return false; }
for (const auto& child : EnumSubkeys(key.get())) {
@@ -558,7 +638,7 @@ bool DeleteRegistryTreeProtected(HKEY root, const std::wstring& subkey, const st
}
}
key.reset();
- const LONG deleted = RegDeleteKeyW(root, subkey.c_str());
+ const LONG deleted = RegDeleteKeyExW(root, subkey.c_str(), KEY_WOW64_64KEY, 0);
if (deleted == ERROR_SUCCESS || deleted == ERROR_FILE_NOT_FOUND ||
((deleted == ERROR_ACCESS_DENIED || deleted == ERROR_KEY_HAS_CHILDREN) && retained && *retained)) return true;
if (error) *error = deleted;
@@ -590,36 +670,40 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ
offline.scan.warnings.push_back(Tr(language, L"Нельзя открыть работающую Windows как офлайн-систему.", L"The running Windows installation cannot be opened as an offline system."));
return offline;
}
- if (!FileExists(offline.softwareHivePath) || !FileExists(offline.systemHivePath)) {
- offline.scan.warnings.push_back(Tr(language, L"Не найдены офлайн-ульи SOFTWARE и SYSTEM.", L"Offline SOFTWARE and SYSTEM hives were not found."));
+ if (!FileExists(offline.softwareHivePath)) {
+ offline.scan.warnings.push_back(Tr(language, L"Не найден офлайн-улей SOFTWARE.", L"The offline SOFTWARE hive was not found."));
return offline;
}
+ if (!FileExists(offline.systemHivePath))
+ offline.scan.warnings.push_back(Tr(language, L"Не найден офлайн-улей SYSTEM: спасение данных доступно, очистка отключена.",
+ L"The offline SYSTEM hive was not found: data rescue is available, cleanup is disabled."));
if (progress) progress(Tr(language, L"Открытие офлайн-реестра только для чтения...", L"Opening offline registry read-only..."), 10);
RegKey software;
- RegKey system;
- if (RegLoadAppKeyW(offline.softwareHivePath.c_str(), software.put(), KEY_READ, 0, 0) != ERROR_SUCCESS ||
- RegLoadAppKeyW(offline.systemHivePath.c_str(), system.put(), KEY_READ, 0, 0) != ERROR_SUCCESS) {
- offline.scan.warnings.push_back(Tr(language, L"Не удалось открыть офлайн-реестр.", L"Could not open the offline registry."));
+ const LONG softwareStatus = RegLoadAppKeyW(offline.softwareHivePath.c_str(), software.put(), KEY_READ, 0, 0);
+ if (softwareStatus != ERROR_SUCCESS) {
+ std::wostringstream details;
+ details << Tr(language, L"Не удалось открыть офлайн-улей SOFTWARE, код ",
+ L"Could not open the offline SOFTWARE hive, code ") << softwareStatus;
+ offline.scan.warnings.push_back(details.str());
return offline;
}
RegKey version;
- if (RegOpenKeyExW(software.get(), L"Microsoft\\Windows NT\\CurrentVersion", 0, KEY_READ, version.put()) == ERROR_SUCCESS) {
+ if (RegOpenKeyExW(software.get(), L"Microsoft\\Windows NT\\CurrentVersion", 0, kOfflineRead, version.put()) == ERROR_SUCCESS) {
offline.scan.osName = ReadRegString(version.get(), L"ProductName");
const std::wstring build = ReadRegString(version.get(), L"CurrentBuildNumber");
if (!build.empty()) offline.scan.osName += L" (build " + build + L")";
}
RegKey wow;
- offline.scan.osArchitecture = RegOpenKeyExW(software.get(), L"WOW6432Node", 0, KEY_READ, wow.put()) == ERROR_SUCCESS ? L"x64/ARM64" : L"x86";
+ offline.scan.osArchitecture = RegOpenKeyExW(software.get(), L"WOW6432Node", 0, kOfflineRead, wow.put()) == ERROR_SUCCESS ? L"x64/ARM64" : L"x86";
if (progress) progress(Tr(language, L"Поиск офлайн-продуктов и лицензий...", L"Scanning offline products and licenses..."), 30);
ScanOfflineUninstallRoot(offline, software.get(), L"Microsoft\\Windows\\CurrentVersion\\Uninstall", offline.scan.osArchitecture == L"x86" ? L"x86" : L"x64");
ScanOfflineUninstallRoot(offline, software.get(), L"WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", L"x86");
ScanOfflineLicenses(offline, software.get());
- ScanOfflineProfiles(offline, software.get());
- if (progress) progress(Tr(language, L"Чтение открытых сертификатов офлайн-профилей...", L"Reading public certificates from offline profiles..."), 55);
- ScanUserCertificates(offline.scan.profiles, &offline.scan.certificates, &offline.scan.warnings, {});
+ ScanOfflineProfiles(language, offline, software.get());
+ ScanOfflineMachineCertificates(language, software.get(), &offline.scan.certificates, &offline.scan.warnings);
offline.scan.protectedItems = {
- L"Offline user NTUSER.DAT certificate and private-key stores (read-only)",
+ L"Offline user and local-machine certificate stores (read-only)",
L"Offline ProgramData\\Crypto Pro\\Crypto private-key containers",
L"Offline Crypto Pro\\Settings\\...\\Keys and container metadata",
L"Hardware tokens and smart cards"
@@ -629,7 +713,50 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ
std::vector approvedRoots;
for (const auto& target : offline.targets) if (target.type == TargetType::Directory) approvedRoots.push_back(target.path);
ScanOfflineProviders(offline, software.get(), approvedRoots, &identities);
- ScanOfflineServices(offline, system.get(), approvedRoots, &identities);
+ version.reset();
+ wow.reset();
+ software.reset();
+
+ if (progress) progress(Tr(language, L"Чтение открытых сертификатов офлайн-профилей...", L"Reading public certificates from offline profiles..."), 55);
+ std::vector machineCertificates = std::move(offline.scan.certificates);
+ ScanUserCertificates(offline.scan.profiles, &offline.scan.certificates, &offline.scan.warnings, {});
+ offline.scan.certificates.insert(offline.scan.certificates.end(),
+ std::make_move_iterator(machineCertificates.begin()),
+ std::make_move_iterator(machineCertificates.end()));
+ std::stable_sort(offline.scan.certificates.begin(), offline.scan.certificates.end(),
+ [](const CertificateEntry& left, const CertificateEntry& right) {
+ if (ToLower(left.profileName) != ToLower(right.profileName))
+ return ToLower(left.profileName) < ToLower(right.profileName);
+ if (ToLower(left.subject) != ToLower(right.subject))
+ return ToLower(left.subject) < ToLower(right.subject);
+ return left.thumbprint < right.thumbprint;
+ });
+
+ if (FileExists(offline.systemHivePath)) {
+ RegKey system;
+ const LONG systemStatus = RegLoadAppKeyW(offline.systemHivePath.c_str(), system.put(), KEY_READ, 0, 0);
+ if (systemStatus == ERROR_SUCCESS) {
+ ScanOfflineServices(offline, system.get(), approvedRoots, &identities);
+ offline.cleanupCapable = true;
+ } else {
+ std::wostringstream details;
+ details << Tr(language, L"Не удалось открыть офлайн-улей SYSTEM, код ",
+ L"Could not open the offline SYSTEM hive, code ") << systemStatus
+ << Tr(language, L". Спасение данных доступно, очистка отключена.",
+ L". Data rescue is available, cleanup is disabled.");
+ offline.scan.warnings.push_back(details.str());
+ }
+ }
+ if (offline.scan.products.empty())
+ offline.scan.warnings.push_back(Tr(language,
+ L"Продукты CryptoPro не найдены в native/WOW64 ветках деинсталляции и MSI Installer UserData.",
+ L"No CryptoPro products were found in native/WOW64 uninstall branches or MSI Installer UserData."));
+ if (offline.scan.profiles.empty())
+ offline.scan.warnings.push_back(Tr(language, L"Обычные профили с NTUSER.DAT не найдены.",
+ L"No regular profiles with NTUSER.DAT were found."));
+ if (offline.scan.certificates.empty())
+ offline.scan.warnings.push_back(Tr(language, L"Открытые сертификаты в пользовательских и машинном хранилищах не найдены.",
+ L"No public certificates were found in user or local-machine stores."));
offline.scan.warnings.push_back(Tr(language,
L"Офлайн-очистка является принудительной: штатный установщик отключённой Windows запустить невозможно. Неизвестные COM- и браузерные остатки удаляться не будут.",
L"Offline cleanup is forced: the disconnected Windows installer cannot be run. Unknown COM and browser remnants will not be removed."));
@@ -645,6 +772,12 @@ bool SaveOfflineBackup(Language language, const OfflineScanResult& offline,
if (error) *error = L"Offline scan or backup folder is invalid.";
return false;
}
+ if (includeRecoveryCopies && !offline.cleanupCapable) {
+ if (error) *error = Tr(language,
+ L"Улей SYSTEM не прошёл проверку: доступно только спасение данных без очистки.",
+ L"The SYSTEM hive did not pass validation: only data rescue is available.");
+ return false;
+ }
const std::wstring backupVolume = VolumeRoot(parentFolder);
if (includeRecoveryCopies && (backupVolume.empty() || ToLower(backupVolume) == ToLower(offline.volumeRoot))) {
if (error) *error = Tr(language,
@@ -721,16 +854,19 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline,
offline.scan.products.begin(), offline.scan.products.end(), [](const InstalledProduct& product) { return product.selected; });
const std::wstring softwareBackup = JoinPath(backupSession, L"registry-hives\\SOFTWARE");
const std::wstring systemBackup = JoinPath(backupSession, L"registry-hives\\SYSTEM");
- if (!offline.valid || !allSelected || !FileExists(softwareBackup) || !FileExists(systemBackup)) {
+ if (!offline.valid || !offline.cleanupCapable || !allSelected ||
+ !FileExists(softwareBackup) || !FileExists(systemBackup)) {
result.anyFailure = true;
result.operations.push_back({L"Offline cleanup", L"Safety preconditions", Outcome::Failed, ERROR_INVALID_DATA,
L"All detected products must be selected and verified SOFTWARE/SYSTEM recovery copies must exist."});
return result;
}
- RegKey software;
- RegKey system;
- const LONG softwareStatus = RegLoadAppKeyW(offline.softwareHivePath.c_str(), software.put(), KEY_READ | KEY_WRITE, 0, 0);
- const LONG systemStatus = RegLoadAppKeyW(offline.systemHivePath.c_str(), system.put(), KEY_READ | KEY_WRITE, 0, 0);
+ const auto probeHive = [](const std::wstring& path) {
+ RegKey hive;
+ return RegLoadAppKeyW(path.c_str(), hive.put(), KEY_READ | KEY_WRITE, 0, 0);
+ };
+ const LONG softwareStatus = probeHive(offline.softwareHivePath);
+ const LONG systemStatus = probeHive(offline.systemHivePath);
if (softwareStatus != ERROR_SUCCESS || systemStatus != ERROR_SUCCESS) {
result.anyFailure = true;
result.operations.push_back({L"Offline cleanup", L"Offline registry", Outcome::Failed,
@@ -738,10 +874,10 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline,
L"Could not reopen offline registry hives for controlled write access."});
return result;
}
- for (size_t index = 0; index < offline.targets.size(); ++index) {
- const auto& target = offline.targets[index];
+ size_t processed = 0;
+ const auto processTarget = [&](const OfflineCleanupTarget& target, HKEY registryHive) {
if (progress) progress(L"Offline cleanup: " + target.displayName,
- static_cast(((index + 1) * 100) / (offline.targets.empty() ? 1 : offline.targets.size())));
+ static_cast(((++processed) * 100) / (offline.targets.empty() ? 1 : offline.targets.size())));
OperationRecord operation;
operation.action = L"Offline forced cleanup";
operation.target = target.displayName;
@@ -749,10 +885,10 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline,
DWORD error = ERROR_SUCCESS;
bool success = false;
if (target.type == TargetType::RegistryTree || target.type == TargetType::Service || target.type == TargetType::DriverService) {
- HKEY hive = target.hive == OfflineHive::Software ? software.get() : system.get();
const std::wstring display = target.hive == OfflineHive::Software ?
L"HKLM\\SOFTWARE\\" + target.registrySubkey : L"HKLM\\SYSTEM\\" + target.registrySubkey;
- success = DeleteRegistryTreeProtected(hive, target.registrySubkey, display, &retained, &error);
+ success = registryHive && DeleteRegistryTreeProtected(registryHive, target.registrySubkey, display, &retained, &error);
+ if (!registryHive) error = ERROR_INVALID_HANDLE;
} else {
success = DeleteTreeSafe(target.path, offline.volumeRoot, &retained, &error);
}
@@ -768,9 +904,33 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline,
result.anyFailure = true;
}
result.operations.push_back(std::move(operation));
+ };
+
+ const auto processRegistryHive = [&](OfflineHive hiveType, const std::wstring& hivePath) {
+ RegKey hive;
+ const LONG opened = RegLoadAppKeyW(hivePath.c_str(), hive.put(), KEY_READ | KEY_WRITE, 0, 0);
+ if (opened != ERROR_SUCCESS) {
+ result.anyFailure = true;
+ result.operations.push_back({L"Offline cleanup", hiveType == OfflineHive::Software ? L"SOFTWARE" : L"SYSTEM",
+ Outcome::Failed, static_cast(opened),
+ L"The preflight succeeded but the hive could not be reopened for the write pass."});
+ return false;
+ }
+ for (const auto& target : offline.targets) {
+ const bool registryTarget = target.type == TargetType::RegistryTree || target.type == TargetType::Service ||
+ target.type == TargetType::DriverService;
+ if (registryTarget && target.hive == hiveType) processTarget(target, hive.get());
+ }
+ RegFlushKey(hive.get());
+ return true;
+ };
+ if (!processRegistryHive(OfflineHive::Software, offline.softwareHivePath)) return result;
+ if (!processRegistryHive(OfflineHive::System, offline.systemHivePath)) return result;
+ for (const auto& target : offline.targets) {
+ const bool registryTarget = target.type == TargetType::RegistryTree || target.type == TargetType::Service ||
+ target.type == TargetType::DriverService;
+ if (!registryTarget) processTarget(target, nullptr);
}
- RegFlushKey(software.get());
- RegFlushKey(system.get());
return result;
}
diff --git a/src/resources.rc b/src/resources.rc
index 8d767b5..6f6e63c 100644
--- a/src/resources.rc
+++ b/src/resources.rc
@@ -4,7 +4,8 @@
#include "resource.h"
IDD_MAIN DIALOGEX 0, 0, 760, 610
-STYLE DS_SETFONT | DS_MODALFRAME | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX
+STYLE DS_SETFONT | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_THICKFRAME | WS_CLIPCHILDREN
+EXSTYLE WS_EX_APPWINDOW
CAPTION "CryptoPro Cleanup Utility"
FONT 9, "Segoe UI"
BEGIN
diff --git a/tests/test_core.cpp b/tests/test_core.cpp
index edeac96..e7213d3 100644
--- a/tests/test_core.cpp
+++ b/tests/test_core.cpp
@@ -58,7 +58,11 @@ int wmain(int argc, wchar_t** argv) {
Expect(PackMsiProductCode(L"{50F91F80-D397-437C-B0C8-62128DE3B55E}") ==
L"08F19F05793DC7340B8C2621D83E5BE5",
"MSI packed product code");
+ Expect(UnpackMsiProductCode(L"08F19F05793DC7340B8C2621D83E5BE5") ==
+ L"{50F91F80-D397-437C-B0C8-62128DE3B55E}",
+ "MSI packed product code round-trip");
Expect(PackMsiProductCode(L"not-a-guid").empty(), "Invalid MSI product code rejected");
+ Expect(UnpackMsiProductCode(L"not-a-packed-guid").empty(), "Invalid packed MSI code rejected");
Expect(IsProtectedPath(L"C:\\ProgramData\\Crypto Pro\\Crypto\\keys"), "ProgramData key store protected");
Expect(IsProtectedPath(L"C:\\Users\\alice\\AppData\\Local\\Crypto Pro"), "User key directory protected");