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 @@ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Windows 7–11](https://img.shields.io/badge/Windows-7%20SP1%E2%80%9311-0078D6.svg)](#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 @@ [![MIT License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) [![Windows 7–11](https://img.shields.io/badge/Windows-7%20SP1%E2%80%9311-0078D6.svg)](#системные-требования) -> **Статус: релиз-кандидат 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");