diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb3c22..ab7223f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable project changes are documented here. The project follows semantic versioning after the first generally available release. +## [0.4.0-rc2] - 2026-08-20 + +### Fixed + +- Replaced `RegLoadAppKey` for disconnected Windows system/user hives with privileged temporary `RegLoadKey` mounts and guaranteed `RegUnLoadKey` cleanup. A saved system `SOFTWARE` hive reproducibly returned `ERROR_BADDB` through the former API but opens correctly through the new loader. +- Offline public-certificate discovery now parses serialized certificate files under each profile's `AppData\Roaming\Microsoft\SystemCertificates\My\Certificates`, in addition to registry-backed Personal stores. +- Inactive-profile cleanup on the running system uses the same system-hive-capable loader. + +### Added + +- Copyable offline diagnostics in the GUI, opened automatically after an unrecognized or completely empty scan. +- Safe `--offline-scan --report ` diagnostic command. +- Stage-by-stage offline diagnostics with resolved paths, API result codes, counts, and mount/unmount results; full licenses and certificate contents are excluded. + +### Validation + +- A synthetic disconnected-Windows fixture built from saved system hives passed the full read-only pipeline with two CryptoPro products, four license candidates, one inactive profile, and seven file-backed public certificates. +- A repeat test on the user's connected Windows 7 x86 disk remains mandatory. + ## [0.4.0-rc1] - 2026-08-20 ### Added diff --git a/CryptoProCleanup.vcxproj b/CryptoProCleanup.vcxproj index ac65f88..feba097 100644 --- a/CryptoProCleanup.vcxproj +++ b/CryptoProCleanup.vcxproj @@ -34,7 +34,7 @@ - + diff --git a/CryptoProCleanupTests.vcxproj b/CryptoProCleanupTests.vcxproj index bd49e30..41487bd 100644 --- a/CryptoProCleanupTests.vcxproj +++ b/CryptoProCleanupTests.vcxproj @@ -19,6 +19,6 @@ Consoletrueadvapi32.lib;crypt32.lib;msi.lib;ole32.lib;oleaut32.lib;setupapi.lib;shell32.lib;shlwapi.lib;taskschd.lib;version.lib;wintrust.lib;%(AdditionalDependencies)/SUBSYSTEM:CONSOLE,6.01 %(AdditionalOptions) - + diff --git a/README.en.md b/README.en.md index 8c7a3a3..ac724f4 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.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. +> **Status: 0.4.0 RC2.** A user successfully completed full removal on a live Windows 10 x64 system. RC1 did not fix the real disconnected Windows 7 x86 disk; RC2 replaces the unsuitable `RegLoadAppKey` path with a system-hive loader and separately supports file-backed certificate stores. The same disk still needs a repeat test. 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. @@ -31,9 +31,10 @@ 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. +- temporary mounting of real system `SOFTWARE`, `SYSTEM`, and `NTUSER.DAT` hives through `RegLoadKey`, followed by mandatory `RegUnLoadKey` cleanup; +- fallback profile discovery from `Users`, plus registry-backed user and local-machine public-certificate stores; +- public-certificate discovery in `AppData\Roaming\Microsoft\SystemCertificates\My\Certificates` without accessing private keys; +- copyable stage-by-stage offline diagnostics and a safe `--offline-scan` command. ## System requirements @@ -62,6 +63,12 @@ Safe scan-only CLI: CryptoProCleanup.exe --scan --report C:\Temp\cryptopro-report.json --lang en ``` +Safe disconnected-Windows diagnostics without removal: + +```text +CryptoProCleanup.exe --offline-scan E:\Windows --report C:\Temp\offline-diagnostic.txt --lang en +``` + Version 0.4.0 has no unattended destructive mode. ## Build and package diff --git a/README.md b/README.md index bb864a9..9441175 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.4.0.** Безопасное сканирование проверено на Windows 11 с CryptoPro CSP 5.0; полное удаление и очистка успешно выполнены пользователем на живой Windows 10 x64. Исправление чтения подключённой Windows 7 x86 требует повторной полевой проверки перед стабильным выпуском. +> **Статус: релиз-кандидат 0.4.0 RC2.** Полное удаление успешно проверено пользователем на живой Windows 10 x64. RC1 не исправил чтение реального диска Windows 7 x86; в RC2 ошибочный `RegLoadAppKey` заменён системным загрузчиком hive, а файловое хранилище сертификатов поддерживается отдельно. Требуется повторная проверка на том же диске. Неофициальная portable-утилита для резервного копирования лицензий и открытых сертификатов, контролируемого удаления установленных продуктов CryptoPro и спасения данных из отключённой Windows 7 SP1–Windows 11. @@ -41,9 +41,10 @@ - управляемая перезагрузка и продолжение через защищённый RunOnce-сеанс; - безопасный CLI-режим сканирования без удаления. - отдельная вкладка «Неисправный диск» для чтения отключённой Windows, извлечения лицензий и открытых сертификатов и консервативной расширенной очистки. -- явное native/WOW64-чтение офлайн-реестра: x86-утилита корректно обращается к физическим веткам подключённой Windows x86/x64; -- последовательная загрузка `SOFTWARE`, пользовательских `NTUSER.DAT` и `SYSTEM`, совместимая с ограничением Windows 7; -- резервный поиск профилей в `Users` и чтение как пользовательского, так и машинного хранилища открытых сертификатов. +- временное подключение настоящих системных `SOFTWARE`, `SYSTEM` и `NTUSER.DAT` через `RegLoadKey` с обязательным `RegUnLoadKey`; +- резервный поиск профилей в `Users` и чтение пользовательского и машинного реестровых хранилищ сертификатов; +- чтение открытых сертификатов из файлового хранилища `AppData\Roaming\Microsoft\SystemCertificates\My\Certificates` без доступа к закрытым ключам; +- копируемая пошаговая диагностика офлайн-сканирования и безопасный параметр `--offline-scan`. ## Системные требования @@ -81,6 +82,12 @@ CLI только для безопасного сканирования: CryptoProCleanup.exe --scan --report C:\Temp\cryptopro-report.json --lang ru ``` +Безопасная диагностика подключённой Windows без удаления: + +```text +CryptoProCleanup.exe --offline-scan E:\Windows --report C:\Temp\offline-diagnostic.txt --lang ru +``` + Тихого автоматического удаления в версии 0.4.0 нет. ## Сборка diff --git a/docs/RELEASE_NOTES_0.4.0-rc2.md b/docs/RELEASE_NOTES_0.4.0-rc2.md new file mode 100644 index 0000000..dc2f17b --- /dev/null +++ b/docs/RELEASE_NOTES_0.4.0-rc2.md @@ -0,0 +1,20 @@ +# КриптоПро Очистка 0.4.0 RC2 + +Исправлена фундаментальная причина нулевого результата на подключённой Windows 7 x86. + +## Что изменено + +- `RegLoadAppKey`, который не смог открыть настоящий системный `SOFTWARE`, заменён на временный системный mount через `RegLoadKey`; +- после каждого чтения выполняется контролируемый `RegUnLoadKey`, его результат отражается в диагностике; +- пользовательские `NTUSER.DAT` открываются тем же способом; +- поддержаны сериализованные открытые сертификаты в `AppData\Roaming\Microsoft\SystemCertificates\My\Certificates`; +- закрытые ключи, `Microsoft\Crypto`, контейнеры CryptoPro и аппаратные токены не читаются и не экспортируются; +- добавлена кнопка «Диагностика…» с копированием пошаговых результатов; +- при полностью пустом или нераспознанном сканировании диагностика открывается автоматически; +- добавлен безопасный CLI: `--offline-scan --report `. + +## Проверка + +На искусственном отключённом образе новый конвейер нашёл 2 продукта CryptoPro, 4 значения лицензии, 1 офлайн-профиль и 7 открытых сертификатов. Оба системных hive были успешно выгружены. Модульные тесты, безопасный интеграционный тест и статический анализ MSVC пройдены. Чувствительные тестовые копии hive и сертификатов после проверки удалены. + +Нужен повторный тест на том же физическом диске Windows 7 x86. Офлайн-очистку до подтверждения корректного списка запускать не следует. diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 08b609f..f1aceb2 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -36,8 +36,11 @@ For each VM retain the initial JSON report, final JSON report, cleanup log, prod - 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.3.0 RC2 running on Windows 10 x64 returned zero products and certificates for a connected, bootable Windows 7 x86 disk. Version 0.4.0 RC1 added registry-view, MSI Installer UserData, profile-fallback, and certificate-store changes, but a repeat test still returned zero results. - 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. +- 2026-08-20: user repeat test of 0.4.0 RC1 still returned zero products and certificates on the connected Windows 7 x86 disk. Reproduction showed `RegLoadAppKey` returning `ERROR_BADDB` for a valid saved system `SOFTWARE` hive; `RegLoadKey` opened and unloaded the same hive successfully. RC2 replaces the loader and adds serialized certificate-file discovery. +- 2026-08-20: read-only synthetic disconnected-Windows fixture passed with 2 confirmed CryptoPro products, 4 license candidates, 1 inactive profile, and 7 public certificates from `AppData\Roaming\Microsoft\SystemCertificates\My\Certificates`; both `SOFTWARE` and `SYSTEM` temporary mounts unloaded successfully. +- 2026-08-20: RC2 implementation passed unit tests, the non-destructive live integration scan, and MSVC C++ static analysis with no warnings. No `CryptoProCleanup_Offline_*` registry mounts remained after testing; sensitive hive/certificate fixture copies were removed. - 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/src/certificates.cpp b/src/certificates.cpp index 625cebe..d08f32a 100644 --- a/src/certificates.cpp +++ b/src/certificates.cpp @@ -47,6 +47,32 @@ bool FileExists(const std::wstring& path) { return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY); } +bool DirectoryExistsWithoutReparsePoint(const std::wstring& path) { + const DWORD attributes = GetFileAttributesW(path.c_str()); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) && + !(attributes & FILE_ATTRIBUTE_REPARSE_POINT); +} + +bool ReadFileBytes(const std::wstring& path, std::vector* bytes) { + if (!bytes) return false; + bytes->clear(); + HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr); + if (file == INVALID_HANDLE_VALUE) return false; + LARGE_INTEGER size{}; + const bool validSize = GetFileSizeEx(file, &size) && size.QuadPart > 0 && size.QuadPart <= 16 * 1024 * 1024; + bool success = false; + if (validSize) { + bytes->resize(static_cast(size.QuadPart)); + DWORD read = 0; + success = ReadFile(file, bytes->data(), static_cast(bytes->size()), &read, nullptr) != FALSE && + read == static_cast(bytes->size()); + } + CloseHandle(file); + if (!success) bytes->clear(); + return success; +} + std::wstring CanonicalPath(std::wstring path) { path = Trim(ExpandEnvironment(path)); std::replace(path.begin(), path.end(), L'/', L'\\'); @@ -140,7 +166,7 @@ void ReadCertificateStoreAt(HKEY root, const wchar_t* storePath, const UserProfi std::unordered_set* seen, std::vector* warnings) { RegKey registryStore; - const LONG opened = RegOpenKeyExW(root, storePath, 0, KEY_READ | KEY_WOW64_64KEY, registryStore.put()); + const LONG opened = RegOpenKeyExW(root, storePath, 0, KEY_READ, 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); @@ -162,6 +188,50 @@ void ReadCertificateStore(HKEY profileRoot, const UserProfile& profile, std::vector* warnings) { ReadCertificateStoreAt(profileRoot, L"SOFTWARE\\Microsoft\\SystemCertificates\\My", profile, certificates, seen, warnings); + ReadCertificateStoreAt(profileRoot, L"SOFTWARE\\Policies\\Microsoft\\SystemCertificates\\My", + profile, certificates, seen, warnings); +} + +void ReadProfileCertificateFiles(const UserProfile& profile, + std::vector* certificates, + std::unordered_set* seen, + std::vector* warnings) { + if (!DirectoryExistsWithoutReparsePoint(profile.profilePath)) return; + std::wstring directory = profile.profilePath; + for (const wchar_t* segment : {L"AppData", L"Roaming", L"Microsoft", L"SystemCertificates", + L"My", L"Certificates"}) { + directory = JoinPath(directory, segment); + if (!DirectoryExistsWithoutReparsePoint(directory)) return; + } + + CertStore store(CertOpenStore(CERT_STORE_PROV_MEMORY, 0, 0, CERT_STORE_CREATE_NEW_FLAG, nullptr)); + if (!store) return; + WIN32_FIND_DATAW data{}; + HANDLE search = FindFirstFileW(JoinPath(directory, L"*").c_str(), &data); + if (search == INVALID_HANDLE_VALUE) return; + size_t files = 0; + size_t parsed = 0; + do { + const std::wstring name = data.cFileName; + if (name == L"." || name == L".." || (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || + (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) continue; + ++files; + std::vector bytes; + if (!ReadFileBytes(JoinPath(directory, name), &bytes)) continue; + if (CertAddSerializedElementToStore(store.get(), bytes.data(), static_cast(bytes.size()), + CERT_STORE_ADD_ALWAYS, 0, CERT_STORE_CERTIFICATE_CONTEXT_FLAG, + nullptr, nullptr)) { + ++parsed; + continue; + } + if (CertAddEncodedCertificateToStore(store.get(), X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, + bytes.data(), static_cast(bytes.size()), + CERT_STORE_ADD_ALWAYS, nullptr)) ++parsed; + } while (FindNextFileW(search, &data)); + FindClose(search); + EnumerateCertificateStore(store.get(), profile, certificates, seen); + if (files && !parsed && warnings) + warnings->push_back(L"Certificate files were present but could not be parsed for profile: " + profile.displayName); } bool WriteBinaryFile(const std::wstring& path, const BYTE* data, size_t size, std::wstring* error) { @@ -205,18 +275,30 @@ void ScanUserCertificates(const std::vector& profiles, L"MY")); if (current) EnumerateCertificateStore(current.get(), profile, certificates, &seen); else if (warnings) warnings->push_back(L"CryptoAPI could not open the current user's Personal certificate store."); + ReadProfileCertificateFiles(profile, certificates, &seen, warnings); continue; } - 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) { - if (warnings) warnings->push_back(L"Could not load certificate registry for profile: " + profile.displayName); - continue; - } + if (profile.loaded) RegOpenKeyExW(HKEY_USERS, profile.sid.c_str(), 0, KEY_READ, profileRoot.put()); + if (profileRoot.get()) { + ReadCertificateStore(profileRoot.get(), profile, certificates, &seen, warnings); + ReadProfileCertificateFiles(profile, certificates, &seen, warnings); + continue; } - ReadCertificateStore(profileRoot.get(), profile, certificates, &seen, warnings); + + const std::wstring hivePath = JoinPath(profile.profilePath, L"NTUSER.DAT"); + OfflineRegistryMount offlineHive; + const LONG loaded = FileExists(hivePath) ? offlineHive.Open(hivePath, KEY_READ) : ERROR_FILE_NOT_FOUND; + if (loaded != ERROR_SUCCESS) { + if (warnings) warnings->push_back(L"Could not mount certificate registry for profile " + + profile.displayName + L", code " + std::to_wstring(loaded) + L": " + GetLastErrorMessage(loaded)); + continue; + } + ReadCertificateStore(offlineHive.get(), profile, certificates, &seen, warnings); + const LONG unloaded = offlineHive.Close(); + if (unloaded != ERROR_SUCCESS && warnings) + warnings->push_back(L"Could not unload certificate registry for profile " + profile.displayName + + L", code " + std::to_wstring(unloaded) + L": " + GetLastErrorMessage(unloaded)); + ReadProfileCertificateFiles(profile, certificates, &seen, warnings); } std::stable_sort(certificates->begin(), certificates->end(), [](const CertificateEntry& left, const CertificateEntry& right) { if (ToLower(left.profileName) != ToLower(right.profileName)) return ToLower(left.profileName) < ToLower(right.profileName); @@ -239,6 +321,8 @@ void ScanOfflineMachineCertificates(Language language, HKEY offlineSoftware, seen.insert(ToLower(certificate.profileSid + L"|" + certificate.thumbprint)); ReadCertificateStoreAt(offlineSoftware, L"Microsoft\\SystemCertificates\\My", machine, certificates, &seen, warnings); + ReadCertificateStoreAt(offlineSoftware, L"Policies\\Microsoft\\SystemCertificates\\My", + machine, certificates, &seen, warnings); } bool ExportPublicCertificates(Language language, const std::vector& certificates, diff --git a/src/cleanup.hpp b/src/cleanup.hpp index 3772975..ba46ca8 100644 --- a/src/cleanup.hpp +++ b/src/cleanup.hpp @@ -120,10 +120,27 @@ struct OfflineScanResult { std::wstring systemHivePath; ScanResult scan; std::vector targets; + std::vector diagnostics; bool valid = false; bool cleanupCapable = false; }; +class OfflineRegistryMount { +public: + OfflineRegistryMount() = default; + ~OfflineRegistryMount(); + OfflineRegistryMount(const OfflineRegistryMount&) = delete; + OfflineRegistryMount& operator=(const OfflineRegistryMount&) = delete; + LONG Open(const std::wstring& hivePath, REGSAM access); + LONG Close(); + HKEY get() const { return key_; } + explicit operator bool() const { return key_ != nullptr; } + +private: + HKEY key_ = nullptr; + std::wstring mountName_; +}; + struct CleanupPlan { std::vector products; std::vector profiles; @@ -155,6 +172,7 @@ struct CommandLineOptions { bool languageExplicit = false; std::wstring reportPath; std::wstring resumeToken; + std::wstring offlineWindowsPath; }; using ProgressCallback = std::function; @@ -227,5 +245,6 @@ bool RequestSystemRestart(std::wstring* error = nullptr); CommandLineOptions ParseCommandLine(int argc, wchar_t** argv); int RunGui(HINSTANCE instance, Language language, const std::wstring& resumeToken); int RunScanCommand(const CommandLineOptions& options); +int RunOfflineScanCommand(const CommandLineOptions& options); } // namespace cpc diff --git a/src/core.cpp b/src/core.cpp index 6df239c..cced49f 100644 --- a/src/core.cpp +++ b/src/core.cpp @@ -1455,14 +1455,16 @@ bool CleanProfileRegistry(const UserProfile& profile, bool* protectedRetained, D } const std::wstring hiveFile = JoinPath(profile.profilePath, L"NTUSER.DAT"); if (!FileExists(hiveFile)) { if (error) *error = ERROR_FILE_NOT_FOUND; return false; } - HKEY appHive = nullptr; - const LONG status = RegLoadAppKeyW(hiveFile.c_str(), &appHive, KEY_READ | KEY_WRITE, 0, 0); + OfflineRegistryMount offlineHive; + const LONG status = offlineHive.Open(hiveFile, KEY_READ | KEY_WRITE); if (status != ERROR_SUCCESS) { if (error) *error = status; return false; } - loaded.reset(appHive); - const bool ok = DeleteRegistryBranchRecursive(loaded.get(), L"SOFTWARE\\Crypto Pro", 0, + const bool ok = DeleteRegistryBranchRecursive(offlineHive.get(), L"SOFTWARE\\Crypto Pro", 0, L"HKU\\\\SOFTWARE\\Crypto Pro", protectedRetained); - if (!ok && error) *error = GetLastError(); - return ok; + const DWORD cleanupError = ok ? ERROR_SUCCESS : GetLastError(); + const LONG unloaded = offlineHive.Close(); + if (!ok) { if (error) *error = cleanupError; return false; } + if (unloaded != ERROR_SUCCESS) { if (error) *error = unloaded; return false; } + return true; } OperationRecord DeleteServiceTarget(const CleanupTarget& target, bool* rebootRequired) { @@ -2138,6 +2140,7 @@ CommandLineOptions ParseCommandLine(int argc, wchar_t** argv) { for (int index = 1; index < argc; ++index) { const std::wstring argument = ToLower(argv[index]); if (argument == L"--scan") options.scanOnly = true; + else if (argument == L"--offline-scan" && index + 1 < argc) options.offlineWindowsPath = argv[++index]; else if (argument == L"--help" || argument == L"-h" || argument == L"/?") options.showHelp = true; else if (argument == L"--report" && index + 1 < argc) options.reportPath = argv[++index]; else if (argument == L"--resume" && index + 1 < argc) options.resumeToken = argv[++index]; @@ -2168,4 +2171,32 @@ int RunScanCommand(const CommandLineOptions& options) { return 0; } +int RunOfflineScanCommand(const CommandLineOptions& options) { + const OfflineScanResult offline = ScanOfflineWindows(options.language, options.offlineWindowsPath); + std::wostringstream text; + text << L"CryptoPro Cleanup Utility " << kVersion << L"\r\n" + << L"Offline scan diagnostics / Диагностика офлайн-сканирования\r\n\r\n"; + for (const auto& line : offline.diagnostics) text << line << L"\r\n"; + if (!offline.scan.warnings.empty()) { + text << L"\r\nWarnings / Предупреждения:\r\n"; + for (const auto& warning : offline.scan.warnings) text << L"- " << warning << L"\r\n"; + } + std::wstring report = options.reportPath; + if (report.empty()) { + std::vector current(32768, L'\0'); + GetCurrentDirectoryW(static_cast(current.size()), current.data()); + report = JoinPath(current.data(), L"CryptoProCleanup-offline-diagnostic.txt"); + } + std::wstring error; + if (!WriteUtf8File(report, Utf8(text.str()), &error)) { + MessageBoxW(nullptr, error.c_str(), L"CryptoPro Cleanup Utility", MB_OK | MB_ICONERROR); + return 2; + } + MessageBoxW(nullptr, report.c_str(), + Tr(options.language, L"Диагностика офлайн-сканирования сохранена", + L"Offline scan diagnostics saved").c_str(), + MB_OK | (offline.valid ? MB_ICONINFORMATION : MB_ICONWARNING)); + return offline.valid ? 0 : 1; +} + } // namespace cpc diff --git a/src/gui.cpp b/src/gui.cpp index 244c81d..c2f84fb 100644 --- a/src/gui.cpp +++ b/src/gui.cpp @@ -50,6 +50,9 @@ struct ConfirmState { struct LicenseDialogState { Language language = Language::English; std::wstring text; + std::wstring title; + std::wstring warning; + std::wstring copiedMessage; }; void SetText(HWND dialog, int id, const std::wstring& text) { SetWindowTextW(GetDlgItem(dialog, id), text.c_str()); } @@ -149,6 +152,7 @@ void LayoutMainDialog(AppState& state, int clientWidth, int clientHeight) { 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_DIAGNOSTICS, 0, dy, 0, 0); PlaceControl(state, &positions, IDC_OFFLINE_SAVE, dx, dy, 0, 0); PlaceControl(state, &positions, IDC_OFFLINE_CLEAN, dx, dy, 0, 0); @@ -460,10 +464,11 @@ void UpdateTabVisibility(AppState& state) { ShowWindow(GetDlgItem(state.window, IDC_CLEAN), selected == 0 ? SW_SHOW : SW_HIDE); const std::array certificateControls{IDC_CERT_INFO, IDC_CERTIFICATES, IDC_SELECT_ALL_CERTS, IDC_EXPORT_CERTS}; for (const int id : certificateControls) ShowWindow(GetDlgItem(state.window, id), selected == 1 ? SW_SHOW : SW_HIDE); - const std::array offlineControls{ + const std::array offlineControls{ IDC_OFFLINE_INFO, IDC_OFFLINE_PATH_LABEL, IDC_OFFLINE_PATH, IDC_OFFLINE_BROWSE, IDC_OFFLINE_SCAN, IDC_OFFLINE_PRODUCTS_LABEL, IDC_OFFLINE_PRODUCTS, IDC_OFFLINE_CERTS_LABEL, IDC_OFFLINE_CERTS, - IDC_OFFLINE_SELECT_ALL_CERTS, IDC_OFFLINE_SHOW_LICENSES, IDC_OFFLINE_SAVE, IDC_OFFLINE_CLEAN + IDC_OFFLINE_SELECT_ALL_CERTS, IDC_OFFLINE_SHOW_LICENSES, IDC_OFFLINE_DIAGNOSTICS, + IDC_OFFLINE_SAVE, IDC_OFFLINE_CLEAN }; for (const int id : offlineControls) ShowWindow(GetDlgItem(state.window, id), selected == 2 ? SW_SHOW : SW_HIDE); } @@ -511,6 +516,7 @@ void ApplyLanguage(AppState& state) { 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_DIAGNOSTICS, Tr(state.language, L"Диагностика...", L"Diagnostics...")); SetText(state.window, IDC_OFFLINE_SAVE, Tr(state.language, L"Сохранить найденные данные...", L"Save rescued data...")); SetText(state.window, IDC_OFFLINE_CLEAN, Tr(state.language, L"Расширенная офлайн-очистка...", L"Advanced offline cleanup...")); HWND tabs = GetDlgItem(state.window, IDC_TAB); @@ -536,13 +542,14 @@ void SetBusy(AppState& state, bool busy) { IDC_BROWSE, IDC_LANGUAGE, IDC_SCAN, IDC_CLEAN, IDC_SHOW_LICENSES, IDC_CERTIFICATES, IDC_SELECT_ALL_CERTS, IDC_EXPORT_CERTS, IDC_OFFLINE_PATH, IDC_OFFLINE_BROWSE, IDC_OFFLINE_SCAN, IDC_OFFLINE_PRODUCTS, - IDC_OFFLINE_CERTS, IDC_OFFLINE_SELECT_ALL_CERTS, IDC_OFFLINE_SHOW_LICENSES, + IDC_OFFLINE_CERTS, IDC_OFFLINE_SELECT_ALL_CERTS, IDC_OFFLINE_SHOW_LICENSES, IDC_OFFLINE_DIAGNOSTICS, IDC_OFFLINE_SAVE, IDC_OFFLINE_CLEAN}) EnableWindow(GetDlgItem(state.window, id), !busy); if (!busy) { EnableWindow(GetDlgItem(state.window, IDC_SHOW_LICENSES), !state.scan.licenses.empty()); EnableWindow(GetDlgItem(state.window, IDC_EXPORT_CERTS), !state.scan.certificates.empty()); 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_DIAGNOSTICS), !state.offline.diagnostics.empty()); EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_SAVE), state.offline.valid); EnableWindow(GetDlgItem(state.window, IDC_OFFLINE_CLEAN), state.offline.cleanupCapable && !state.offline.scan.products.empty()); } @@ -572,10 +579,11 @@ INT_PTR CALLBACK LicensesDialogProc(HWND dialog, UINT message, WPARAM wParam, LP if (message == WM_INITDIALOG) { state = reinterpret_cast(lParam); SetWindowLongPtrW(dialog, DWLP_USER, reinterpret_cast(state)); - SetWindowTextW(dialog, Tr(state->language, L"Лицензии CryptoPro", L"CryptoPro licenses").c_str()); - SetText(dialog, IDC_LICENSES_WARNING, Tr(state->language, + SetWindowTextW(dialog, (state->title.empty() ? + Tr(state->language, L"Лицензии CryptoPro", L"CryptoPro licenses") : state->title).c_str()); + SetText(dialog, IDC_LICENSES_WARNING, state->warning.empty() ? Tr(state->language, L"Полные номера являются конфиденциальными. Перед переустановкой Windows сохраните licenses.txt на внешнем диске или в защищённом облаке.", - L"Full identifiers are confidential. Before reinstalling Windows, save licenses.txt to external storage or protected cloud storage.")); + L"Full identifiers are confidential. Before reinstalling Windows, save licenses.txt to external storage or protected cloud storage.") : state->warning); SetText(dialog, IDC_LICENSES_TEXT, state->text); SetText(dialog, IDC_COPY_LICENSES, Tr(state->language, L"Копировать всё", L"Copy all")); SetText(dialog, IDOK, Tr(state->language, L"Закрыть", L"Close")); @@ -584,8 +592,9 @@ INT_PTR CALLBACK LicensesDialogProc(HWND dialog, UINT message, WPARAM wParam, LP if (message == WM_COMMAND && LOWORD(wParam) == IDC_COPY_LICENSES && state) { const bool copied = CopyUnicodeText(dialog, state->text); MessageBoxW(dialog, - Tr(state->language, copied ? L"Лицензия скопирована в буфер обмена." : L"Не удалось открыть буфер обмена.", - copied ? L"License copied to the clipboard." : L"Could not open the clipboard.").c_str(), + (copied && !state->copiedMessage.empty() ? state->copiedMessage : + Tr(state->language, copied ? L"Текст скопирован в буфер обмена." : L"Не удалось открыть буфер обмена.", + copied ? L"Text copied to the clipboard." : L"Could not open the clipboard.")).c_str(), Tr(state->language, L"Копирование", L"Copy").c_str(), MB_OK | (copied ? MB_ICONINFORMATION : MB_ICONERROR)); return TRUE; } @@ -617,6 +626,27 @@ void ShowLicensesForScan(AppState& state, const ScanResult& scan) { void ShowLicenses(AppState& state) { ShowLicensesForScan(state, state.scan); } +void ShowOfflineDiagnostics(AppState& state) { + std::wostringstream text; + text << L"CryptoPro Cleanup Utility " << kVersion << L"\r\n" + << L"Offline scan diagnostics / Диагностика офлайн-сканирования\r\n\r\n"; + for (const auto& line : state.offline.diagnostics) text << line << L"\r\n"; + if (!state.offline.scan.warnings.empty()) { + text << L"\r\nWarnings / Предупреждения:\r\n"; + for (const auto& warning : state.offline.scan.warnings) text << L"- " << warning << L"\r\n"; + } + LicenseDialogState dialogState; + dialogState.language = state.language; + dialogState.text = text.str(); + dialogState.title = Tr(state.language, L"Диагностика офлайн-сканирования", L"Offline scan diagnostics"); + dialogState.warning = Tr(state.language, + L"Здесь нет полных лицензий и содержимого сертификатов, но могут быть путь диска и имена локальных профилей. Скопируйте текст для отчёта об ошибке.", + L"This view contains no full licenses or certificate contents, but may include the disk path and local profile names. Copy it when reporting a scan problem."); + dialogState.copiedMessage = Tr(state.language, L"Диагностика скопирована.", L"Diagnostics copied."); + DialogBoxParamW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(IDD_LICENSES), state.window, + LicensesDialogProc, reinterpret_cast(&dialogState)); +} + INT_PTR CALLBACK ConfirmDialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM lParam) { auto* state = reinterpret_cast(GetWindowLongPtrW(dialog, DWLP_USER)); if (message == WM_INITDIALOG) { @@ -773,6 +803,8 @@ void DoOfflineScan(AppState& state) { AddLogLine(state, summary.str()); for (const auto& warning : state.offline.scan.warnings) AddLogLine(state, warning); SetBusy(state, false); + if (!state.offline.valid || (state.offline.scan.products.empty() && state.offline.scan.certificates.empty())) + ShowOfflineDiagnostics(state); } void SaveOfflineData(AppState& state) { @@ -1076,6 +1108,7 @@ INT_PTR CALLBACK MainDialogProc(HWND dialog, UINT message, WPARAM wParam, LPARAM case IDC_SHOW_LICENSES: if (!state->busy) ShowLicenses(*state); return TRUE; case IDC_EXPORT_CERTS: if (!state->busy) ExportCertificates(*state); return TRUE; case IDC_OFFLINE_SHOW_LICENSES: if (!state->busy) ShowLicensesForScan(*state, state->offline.scan); return TRUE; + case IDC_OFFLINE_DIAGNOSTICS: if (!state->busy) ShowOfflineDiagnostics(*state); return TRUE; case IDC_OFFLINE_SAVE: if (!state->busy) SaveOfflineData(*state); return TRUE; case IDC_OFFLINE_CLEAN: if (!state->busy) StartOfflineCleanup(*state); return TRUE; case IDC_OFFLINE_SCAN: if (!state->busy) DoOfflineScan(*state); return TRUE; diff --git a/src/main.cpp b/src/main.cpp index c9d7cde..ae33ab8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,10 +20,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE, _In_ wchar_t* MessageBoxW(nullptr, L"CryptoPro Cleanup Utility\r\n\r\n" L"--scan Safe scan only\r\n" - L"--report JSON report path\r\n" + L"--offline-scan Safe disconnected-Windows scan\r\n" + L"--report Report path\r\n" L"--lang ru|en Interface/report language\r\n" L"--resume Internal restart continuation", L"CryptoPro Cleanup Utility", MB_OK | MB_ICONINFORMATION); + } else if (!options.offlineWindowsPath.empty()) { + result = cpc::RunOfflineScanCommand(options); } else if (options.scanOnly) { result = cpc::RunScanCommand(options); } else { diff --git a/src/offline.cpp b/src/offline.cpp index 0336ec2..6234303 100644 --- a/src/offline.cpp +++ b/src/offline.cpp @@ -13,11 +13,10 @@ 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; +// RegLoadKey mounts each disconnected hive below a neutral HKEY_USERS subkey. That temporary +// root is outside redirected HKLM\Software, so child paths are already the hive's physical paths. +constexpr REGSAM kOfflineRead = KEY_READ; +constexpr REGSAM kOfflineWrite = KEY_READ | KEY_WRITE; class RegKey { public: @@ -219,7 +218,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, kOfflineRead, root.put()) != ERROR_SUCCESS) return; + const LONG opened = RegOpenKeyExW(software, rootPath.c_str(), 0, kOfflineRead, root.put()); + offline.diagnostics.push_back(L"Open uninstall root [" + rootPath + L"]: " + std::to_wstring(opened)); + if (opened != ERROR_SUCCESS) return; + const size_t before = offline.scan.products.size(); for (const auto& subkey : EnumSubkeys(root.get())) { RegKey key; if (RegOpenKeyExW(root.get(), subkey.c_str(), 0, kOfflineRead, key.put()) != ERROR_SUCCESS) continue; @@ -241,6 +243,8 @@ void ScanOfflineUninstallRoot(OfflineScanResult& offline, HKEY software, const s product.risk = IsHighRiskProduct(displayName) ? RiskLevel::High : RiskLevel::Normal; offline.scan.products.push_back(std::move(product)); } + offline.diagnostics.push_back(L"Confirmed products in [" + rootPath + L"]: " + + std::to_wstring(offline.scan.products.size() - before)); } bool IsLicenseValueName(const std::wstring& name) { @@ -308,7 +312,9 @@ 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, kOfflineRead, userData.put()) == ERROR_SUCCESS) { + const LONG installerOpened = RegOpenKeyExW(software, installerPath, 0, kOfflineRead, userData.put()); + offline.diagnostics.push_back(L"Open Installer UserData: " + std::to_wstring(installerOpened)); + if (installerOpened == ERROR_SUCCESS) { for (const auto& sid : EnumSubkeys(userData.get())) { RegKey products; if (RegOpenKeyExW(userData.get(), (sid + L"\\Products").c_str(), 0, kOfflineRead, products.put()) != ERROR_SUCCESS) continue; @@ -391,7 +397,9 @@ bool AddOfflineProfile(OfflineScanResult& offline, const std::wstring& sid, cons 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, kOfflineRead, profiles.put()) == ERROR_SUCCESS) { + const LONG profilesOpened = RegOpenKeyExW(software, profileList, 0, kOfflineRead, profiles.put()); + offline.diagnostics.push_back(L"Open ProfileList: " + std::to_wstring(profilesOpened)); + if (profilesOpened == ERROR_SUCCESS) { for (const auto& sid : EnumSubkeys(profiles.get())) { if (sid.rfind(L"S-1-5-21-", 0) != 0) continue; RegKey profile; @@ -638,7 +646,7 @@ bool DeleteRegistryTreeProtected(HKEY root, const std::wstring& subkey, const st } } key.reset(); - const LONG deleted = RegDeleteKeyExW(root, subkey.c_str(), KEY_WOW64_64KEY, 0); + const LONG deleted = RegDeleteKeyExW(root, subkey.c_str(), 0, 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; @@ -658,6 +666,11 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ offline.volumeRoot = VolumeRoot(offline.windowsDirectory); offline.softwareHivePath = JoinPath(offline.windowsDirectory, L"System32\\Config\\SOFTWARE"); offline.systemHivePath = JoinPath(offline.windowsDirectory, L"System32\\Config\\SYSTEM"); + offline.diagnostics.push_back(L"Requested path: " + requestedWindowsDirectory); + offline.diagnostics.push_back(L"Resolved Windows path: " + offline.windowsDirectory); + offline.diagnostics.push_back(L"Resolved volume root: " + offline.volumeRoot); + offline.diagnostics.push_back(L"SOFTWARE hive: " + offline.softwareHivePath); + offline.diagnostics.push_back(L"SYSTEM hive: " + offline.systemHivePath); std::vector currentWindows(32768, L'\0'); const UINT currentWindowsLength = GetWindowsDirectoryW(currentWindows.data(), static_cast(currentWindows.size())); const std::wstring currentWindowsPath = currentWindowsLength && currentWindowsLength < currentWindows.size() ? @@ -678,8 +691,10 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ 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; - const LONG softwareStatus = RegLoadAppKeyW(offline.softwareHivePath.c_str(), software.put(), KEY_READ, 0, 0); + OfflineRegistryMount software; + const LONG softwareStatus = software.Open(offline.softwareHivePath, KEY_READ); + offline.diagnostics.push_back(L"RegLoadKey SOFTWARE: " + std::to_wstring(softwareStatus) + + L" (" + GetLastErrorMessage(softwareStatus) + L")"); if (softwareStatus != ERROR_SUCCESS) { std::wostringstream details; details << Tr(language, L"Не удалось открыть офлайн-улей SOFTWARE, код ", @@ -697,10 +712,16 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ 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"); + offline.diagnostics.push_back(L"Products after native uninstall root: " + std::to_wstring(offline.scan.products.size())); ScanOfflineUninstallRoot(offline, software.get(), L"WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", L"x86"); + offline.diagnostics.push_back(L"Products after WOW uninstall root: " + std::to_wstring(offline.scan.products.size())); ScanOfflineLicenses(offline, software.get()); + offline.diagnostics.push_back(L"Products after Installer UserData: " + std::to_wstring(offline.scan.products.size())); + offline.diagnostics.push_back(L"Full license candidates: " + std::to_wstring(offline.scan.licenses.size())); ScanOfflineProfiles(language, offline, software.get()); + offline.diagnostics.push_back(L"Offline profiles with NTUSER.DAT: " + std::to_wstring(offline.scan.profiles.size())); ScanOfflineMachineCertificates(language, software.get(), &offline.scan.certificates, &offline.scan.warnings); + offline.diagnostics.push_back(L"Local-machine public certificates: " + std::to_wstring(offline.scan.certificates.size())); offline.scan.protectedItems = { L"Offline user and local-machine certificate stores (read-only)", @@ -715,7 +736,13 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ ScanOfflineProviders(offline, software.get(), approvedRoots, &identities); version.reset(); wow.reset(); - software.reset(); + const LONG softwareUnload = software.Close(); + offline.diagnostics.push_back(L"RegUnLoadKey SOFTWARE: " + std::to_wstring(softwareUnload) + + L" (" + GetLastErrorMessage(softwareUnload) + L")"); + if (softwareUnload != ERROR_SUCCESS) + offline.scan.warnings.push_back(Tr(language, L"Не удалось выгрузить временно подключённый улей SOFTWARE, код ", + L"Could not unload the temporarily mounted SOFTWARE hive, code ") + + std::to_wstring(softwareUnload)); if (progress) progress(Tr(language, L"Чтение открытых сертификатов офлайн-профилей...", L"Reading public certificates from offline profiles..."), 55); std::vector machineCertificates = std::move(offline.scan.certificates); @@ -731,13 +758,24 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ return ToLower(left.subject) < ToLower(right.subject); return left.thumbprint < right.thumbprint; }); + offline.diagnostics.push_back(L"Public certificates after user profiles: " + + std::to_wstring(offline.scan.certificates.size())); if (FileExists(offline.systemHivePath)) { - RegKey system; - const LONG systemStatus = RegLoadAppKeyW(offline.systemHivePath.c_str(), system.put(), KEY_READ, 0, 0); + OfflineRegistryMount system; + const LONG systemStatus = system.Open(offline.systemHivePath, KEY_READ); + offline.diagnostics.push_back(L"RegLoadKey SYSTEM: " + std::to_wstring(systemStatus) + + L" (" + GetLastErrorMessage(systemStatus) + L")"); if (systemStatus == ERROR_SUCCESS) { ScanOfflineServices(offline, system.get(), approvedRoots, &identities); - offline.cleanupCapable = true; + const LONG systemUnload = system.Close(); + offline.diagnostics.push_back(L"RegUnLoadKey SYSTEM: " + std::to_wstring(systemUnload) + + L" (" + GetLastErrorMessage(systemUnload) + L")"); + offline.cleanupCapable = systemUnload == ERROR_SUCCESS && softwareUnload == ERROR_SUCCESS; + if (systemUnload != ERROR_SUCCESS) + offline.scan.warnings.push_back(Tr(language, L"Не удалось выгрузить временно подключённый улей SYSTEM, код ", + L"Could not unload the temporarily mounted SYSTEM hive, code ") + + std::to_wstring(systemUnload)); } else { std::wostringstream details; details << Tr(language, L"Не удалось открыть офлайн-улей SYSTEM, код ", @@ -761,6 +799,13 @@ OfflineScanResult ScanOfflineWindows(Language language, const std::wstring& requ L"Офлайн-очистка является принудительной: штатный установщик отключённой Windows запустить невозможно. Неизвестные COM- и браузерные остатки удаляться не будут.", L"Offline cleanup is forced: the disconnected Windows installer cannot be run. Unknown COM and browser remnants will not be removed.")); offline.valid = true; + offline.diagnostics.push_back(L"Final result: valid=true, cleanupCapable=" + + std::wstring(offline.cleanupCapable ? L"true" : L"false") + + L", products=" + std::to_wstring(offline.scan.products.size()) + + L", licenses=" + std::to_wstring(offline.scan.licenses.size()) + + L", profiles=" + std::to_wstring(offline.scan.profiles.size()) + + L", certificates=" + std::to_wstring(offline.scan.certificates.size()) + + L", targets=" + std::to_wstring(offline.targets.size())); if (progress) progress(Tr(language, L"Офлайн-сканирование завершено без изменений.", L"Offline scan completed without changes."), 100); return offline; } @@ -818,6 +863,15 @@ bool SaveOfflineBackup(Language language, const OfflineScanResult& offline, << L",\n \"private_keys_exported\": false,\n \"verified_cleanup_targets\": " << offline.targets.size() << L",\n \"recovery_copies\": " << (includeRecoveryCopies ? L"true" : L"false") << L"\n}\n"; if (!WriteUtf8File(JoinPath(folder, L"offline-report.json"), Utf8(report.str()), error)) return false; + std::wostringstream diagnostics; + diagnostics << L"CryptoPro Cleanup Utility " << kVersion << L"\r\n" + << L"Offline scan diagnostics / Диагностика офлайн-сканирования\r\n\r\n"; + for (const auto& line : offline.diagnostics) diagnostics << line << L"\r\n"; + if (!offline.scan.warnings.empty()) { + diagnostics << L"\r\nWarnings / Предупреждения:\r\n"; + for (const auto& warning : offline.scan.warnings) diagnostics << L"- " << warning << L"\r\n"; + } + if (!WriteUtf8File(JoinPath(folder, L"offline-diagnostics.txt"), Utf8(diagnostics.str()), error)) return false; if (includeRecoveryCopies) { const std::wstring hives = JoinPath(folder, L"registry-hives"); @@ -862,8 +916,10 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline, return result; } const auto probeHive = [](const std::wstring& path) { - RegKey hive; - return RegLoadAppKeyW(path.c_str(), hive.put(), KEY_READ | KEY_WRITE, 0, 0); + OfflineRegistryMount hive; + const LONG opened = hive.Open(path, KEY_READ | KEY_WRITE); + if (opened != ERROR_SUCCESS) return opened; + return hive.Close(); }; const LONG softwareStatus = probeHive(offline.softwareHivePath); const LONG systemStatus = probeHive(offline.systemHivePath); @@ -907,8 +963,8 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline, }; 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); + OfflineRegistryMount hive; + const LONG opened = hive.Open(hivePath, KEY_READ | KEY_WRITE); if (opened != ERROR_SUCCESS) { result.anyFailure = true; result.operations.push_back({L"Offline cleanup", hiveType == OfflineHive::Software ? L"SOFTWARE" : L"SYSTEM", @@ -922,7 +978,13 @@ ExecutionResult ExecuteOfflineCleanup(const OfflineScanResult& offline, if (registryTarget && target.hive == hiveType) processTarget(target, hive.get()); } RegFlushKey(hive.get()); - return true; + const LONG unloaded = hive.Close(); + if (unloaded == ERROR_SUCCESS) return true; + result.anyFailure = true; + result.operations.push_back({L"Offline cleanup", hiveType == OfflineHive::Software ? L"SOFTWARE" : L"SYSTEM", + Outcome::Failed, static_cast(unloaded), + L"Cleanup operations completed, but the temporary registry mount could not be unloaded."}); + return false; }; if (!processRegistryHive(OfflineHive::Software, offline.softwareHivePath)) return result; if (!processRegistryHive(OfflineHive::System, offline.systemHivePath)) return result; diff --git a/src/offline_registry.cpp b/src/offline_registry.cpp new file mode 100644 index 0000000..a1ef307 --- /dev/null +++ b/src/offline_registry.cpp @@ -0,0 +1,81 @@ +#include "cleanup.hpp" + +#include + +namespace cpc { +namespace { + +bool EnableTokenPrivilege(const wchar_t* privilege) { + HANDLE rawToken = nullptr; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &rawToken)) return false; + TOKEN_PRIVILEGES state{}; + state.PrivilegeCount = 1; + const bool found = LookupPrivilegeValueW(nullptr, privilege, &state.Privileges[0].Luid) != FALSE; + if (found) { + state.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; + SetLastError(ERROR_SUCCESS); + } + const bool adjusted = found && AdjustTokenPrivileges(rawToken, FALSE, &state, sizeof(state), nullptr, nullptr) != FALSE && + GetLastError() == ERROR_SUCCESS; + CloseHandle(rawToken); + return adjusted; +} + +std::wstring UniqueMountName() { + static volatile LONG counter = 0; + std::wostringstream name; + name << L"CryptoProCleanup_Offline_" << GetCurrentProcessId() << L"_" + << GetTickCount64() << L"_" << InterlockedIncrement(&counter); + return name.str(); +} + +} // namespace + +OfflineRegistryMount::~OfflineRegistryMount() { Close(); } + +LONG OfflineRegistryMount::Open(const std::wstring& hivePath, REGSAM access) { + const LONG previous = Close(); + if (previous != ERROR_SUCCESS) return previous; + if (hivePath.empty()) return ERROR_INVALID_PARAMETER; + if (!EnableTokenPrivilege(SE_RESTORE_NAME) || !EnableTokenPrivilege(SE_BACKUP_NAME)) + return ERROR_PRIVILEGE_NOT_HELD; + + LONG loaded = ERROR_ALREADY_EXISTS; + for (unsigned attempt = 0; attempt < 8 && loaded == ERROR_ALREADY_EXISTS; ++attempt) { + mountName_ = UniqueMountName(); + loaded = RegLoadKeyW(HKEY_USERS, mountName_.c_str(), hivePath.c_str()); + } + if (loaded != ERROR_SUCCESS) { + mountName_.clear(); + return loaded; + } + + const LONG opened = RegOpenKeyExW(HKEY_USERS, mountName_.c_str(), 0, access, &key_); + if (opened != ERROR_SUCCESS) { + const LONG unloaded = RegUnLoadKeyW(HKEY_USERS, mountName_.c_str()); + if (unloaded == ERROR_SUCCESS || unloaded == ERROR_FILE_NOT_FOUND) mountName_.clear(); + return unloaded == ERROR_SUCCESS || unloaded == ERROR_FILE_NOT_FOUND ? opened : unloaded; + } + return ERROR_SUCCESS; +} + +LONG OfflineRegistryMount::Close() { + if (key_) { + RegCloseKey(key_); + key_ = nullptr; + } + if (mountName_.empty()) return ERROR_SUCCESS; + + LONG unloaded = ERROR_BUSY; + for (unsigned attempt = 0; attempt < 5 && unloaded == ERROR_BUSY; ++attempt) { + unloaded = RegUnLoadKeyW(HKEY_USERS, mountName_.c_str()); + if (unloaded == ERROR_BUSY) Sleep(20); + } + if (unloaded == ERROR_SUCCESS || unloaded == ERROR_FILE_NOT_FOUND) { + mountName_.clear(); + return ERROR_SUCCESS; + } + return unloaded; +} + +} // namespace cpc diff --git a/src/resource.h b/src/resource.h index da220a9..8286997 100644 --- a/src/resource.h +++ b/src/resource.h @@ -48,3 +48,4 @@ #define IDC_LINK_GITHUB 1043 #define IDC_LINK_WEBSITE 1044 #define IDC_LINK_SUPPORT 1045 +#define IDC_OFFLINE_DIAGNOSTICS 1046 diff --git a/src/resources.rc b/src/resources.rc index 6f6e63c..280acc7 100644 --- a/src/resources.rc +++ b/src/resources.rc @@ -43,6 +43,7 @@ BEGIN CONTROL "", IDC_OFFLINE_CERTS, WC_LISTVIEW, WS_TABSTOP | WS_BORDER | LVS_REPORT | LVS_SHOWSELALWAYS, 22, 265, 716, 108 CONTROL "Select all certificates", IDC_OFFLINE_SELECT_ALL_CERTS, "Button", BS_AUTOCHECKBOX | WS_TABSTOP, 22, 379, 190, 14 PUSHBUTTON "Show / copy licenses", IDC_OFFLINE_SHOW_LICENSES, 22, 414, 176, 24 + PUSHBUTTON "Diagnostics...", IDC_OFFLINE_DIAGNOSTICS, 204, 414, 154, 24 PUSHBUTTON "Save rescued data...", IDC_OFFLINE_SAVE, 364, 414, 180, 24 PUSHBUTTON "Advanced offline cleanup...", IDC_OFFLINE_CLEAN, 550, 414, 188, 24 diff --git a/tests/test_core.cpp b/tests/test_core.cpp index e7213d3..68aba30 100644 --- a/tests/test_core.cpp +++ b/tests/test_core.cpp @@ -35,6 +35,52 @@ bool DeleteGeneratedTree(const std::wstring& path) { int wmain(int argc, wchar_t** argv) { using namespace cpc; + if (argc >= 3 && std::wstring(argv[1]) == L"--probe-app-hive") { + HKEY root = nullptr; + const LONG loaded = RegLoadAppKeyW(argv[2], &root, KEY_READ, 0, 0); + std::wcout << L"RegLoadAppKey=" << loaded << L"\n"; + if (loaded == ERROR_SUCCESS) { + const wchar_t* subkey = argc >= 4 ? argv[3] : L"Microsoft"; + for (const REGSAM access : {KEY_READ, KEY_READ | KEY_WOW64_64KEY, + KEY_READ | KEY_WOW64_32KEY}) { + HKEY child = nullptr; + const LONG opened = RegOpenKeyExW(root, subkey, 0, access, &child); + std::wcout << L"RegOpenKeyEx(" << subkey << L", 0x" << std::hex << access + << std::dec << L")=" << opened << L"\n"; + if (child) RegCloseKey(child); + } + RegCloseKey(root); + } + return loaded == ERROR_SUCCESS ? 0 : static_cast(loaded); + } + if (argc >= 3 && std::wstring(argv[1]) == L"--probe-mounted-hive") { + OfflineRegistryMount hive; + const LONG loaded = hive.Open(argv[2], KEY_READ); + std::wcout << L"RegLoadKey=" << loaded << L"\n"; + LONG opened = loaded; + if (loaded == ERROR_SUCCESS) { + const wchar_t* subkey = argc >= 4 ? argv[3] : L"Microsoft"; + HKEY child = nullptr; + opened = RegOpenKeyExW(hive.get(), subkey, 0, KEY_READ, &child); + std::wcout << L"RegOpenKeyEx(" << subkey << L")=" << opened << L"\n"; + if (child) RegCloseKey(child); + } + const LONG unloaded = hive.Close(); + std::wcout << L"RegUnLoadKey=" << unloaded << L"\n"; + return loaded == ERROR_SUCCESS && opened == ERROR_SUCCESS && unloaded == ERROR_SUCCESS ? 0 : 1; + } + if (argc >= 3 && std::wstring(argv[1]) == L"--offline-scan") { + const OfflineScanResult offline = ScanOfflineWindows(Language::English, argv[2]); + std::cout << "valid=" << offline.valid << " cleanupCapable=" << offline.cleanupCapable + << " products=" << offline.scan.products.size() + << " licenses=" << offline.scan.licenses.size() + << " profiles=" << offline.scan.profiles.size() + << " certificates=" << offline.scan.certificates.size() + << " targets=" << offline.targets.size() << "\n"; + for (const auto& line : offline.diagnostics) std::cout << "DIAGNOSTIC: " << Utf8(line) << "\n"; + for (const auto& line : offline.scan.warnings) std::cout << "WARNING: " << Utf8(line) << "\n"; + return offline.valid ? 0 : 1; + } Expect(IsCryptoProPublisher(L"Компания КриптоПро"), "Russian publisher"); Expect(IsCryptoProPublisher(L"Crypto-Pro LLC"), "English publisher"); Expect(IsCryptoProPublisher(L"CRYPTO PRO"), "Normalized publisher"); @@ -89,6 +135,12 @@ int wmain(int argc, wchar_t** argv) { Expect(options.scanOnly, "Parse scan switch"); Expect(options.language == Language::Russian && options.languageExplicit, "Parse language switch"); + wchar_t offlineArg[] = L"--offline-scan"; + wchar_t offlinePath[] = L"E:\\Windows"; + wchar_t* offlineArgs[] = {arg0, offlineArg, offlinePath}; + const CommandLineOptions offlineOptions = ParseCommandLine(3, offlineArgs); + Expect(offlineOptions.offlineWindowsPath == offlinePath, "Parse safe offline scan path"); + if (argc > 1 && std::wstring(argv[1]) == L"--integration-scan") { const ScanResult scan = ScanSystem(Language::English); const CleanupPlan plan = BuildCleanupPlan(scan);