From d862a22c978d9e59a2d46c3302e7e405b4ae3951 Mon Sep 17 00:00:00 2001 From: Jacksony <40433896+Jacksony100@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:21:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=2011=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B8=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20E2E=20=D0=BD=D0=B0=20=D1=85?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/static-analysis.yml | 4 +- .github/workflows/upstream-check.yml | 8 +- CHANGELOG.md | 23 ++ CMakeLists.txt | 23 +- README.md | 8 +- docs/REVIEW_FIXES.md | 67 ++++ docs/production/E2E_RELEASE_GATE.md | 108 +++++- docs/production/HOST_E2E.md | 82 ++++ docs/production/SECURITY_TOOLING.md | 7 +- .../evidence/2026-10-19-host-e2e.json | 96 +++++ scripts/e2e/Build-CheburnetHostE2E.ps1 | 28 ++ scripts/e2e/Invoke-CheburnetE2E.ps1 | 204 ++++++---- scripts/e2e/Invoke-CheburnetHostE2E.ps1 | 130 +++++++ scripts/e2e/New-CheburnetE2EBundle.ps1 | 49 +++ scripts/e2e/New-CheburnetTestVm.ps1 | 120 +++--- scripts/e2e/Run-InGuest.ps1 | 61 +++ scripts/e2e/Test-CheburnetE2EMatrix.ps1 | 48 +++ scripts/write-upstream-diff.ps1 | 17 + src/app/App.cpp | 55 ++- src/app/App.h | 3 + src/app/Diagnostics.cpp | 56 ++- src/app/OperationState.h | 10 + src/app/RuntimeRecovery.cpp | 10 + src/app/RuntimeRecovery.h | 9 + src/app/UiScreens.cpp | 39 +- src/core/Launcher.cpp | 21 +- src/core/ProcessManager.cpp | 13 +- src/core/ResourceExtractor.cpp | 232 ++++++++++-- src/core/ResourceExtractor.h | 47 ++- src/core/RuntimePaths.cpp | 17 + src/core/RuntimePaths.h | 2 + src/core/SecureFs.cpp | 20 + src/core/SecureFs.h | 4 + src/main.cpp | 45 ++- src/update/UpdateCheckService.cpp | 8 + src/update/UpdateCheckService.h | 2 + src/update/WinHttpClient.cpp | 134 ++++++- src/update/WinHttpClient.h | 2 + tests/CMakeLists.txt | 30 ++ tests/e2e_preparation.ps1 | 101 +++++ tests/host_driver_smoke.cpp | 117 ++++++ tests/host_e2e_contracts.ps1 | 70 ++++ tests/review_contracts.ps1 | 76 ++++ tests/review_regressions.cpp | 354 ++++++++++++++++++ 44 files changed, 2310 insertions(+), 250 deletions(-) create mode 100644 docs/REVIEW_FIXES.md create mode 100644 docs/production/HOST_E2E.md create mode 100644 docs/production/evidence/2026-10-19-host-e2e.json create mode 100644 scripts/e2e/Build-CheburnetHostE2E.ps1 create mode 100644 scripts/e2e/Invoke-CheburnetHostE2E.ps1 create mode 100644 scripts/e2e/New-CheburnetE2EBundle.ps1 create mode 100644 scripts/e2e/Run-InGuest.ps1 create mode 100644 scripts/e2e/Test-CheburnetE2EMatrix.ps1 create mode 100644 scripts/write-upstream-diff.ps1 create mode 100644 tests/e2e_preparation.ps1 create mode 100644 tests/host_driver_smoke.cpp create mode 100644 tests/host_e2e_contracts.ps1 create mode 100644 tests/review_contracts.ps1 create mode 100644 tests/review_regressions.cpp diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index dc62206..9528c2a 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,8 +2,8 @@ name: Статический анализ # Два независимых анализатора: встроенный в MSVC /analyze и CodeQL. # /analyze запускается с /WX, поэтому любая новая находка в коде CHEBURNET -# останавливает задание. Подавлено ровно одно предупреждение — дефект -# SAL-аннотации в заголовке Windows SDK, обоснование в CMakeLists.txt. +# останавливает задание. Исключения для дефектов заголовков Windows SDK +# обоснованы в CMakeLists.txt и у подключения ws2tcpip.h в Diagnostics.cpp. on: push: diff --git a/.github/workflows/upstream-check.yml b/.github/workflows/upstream-check.yml index dcd496b..1bb8ae5 100644 --- a/.github/workflows/upstream-check.yml +++ b/.github/workflows/upstream-check.yml @@ -83,8 +83,7 @@ jobs: } "validation=$validation" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 "detail=$detail" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - git diff --stat -- resources | Out-File -FilePath upstream\payload-diff.txt -Encoding utf8 - git status --porcelain -- resources | Out-File -FilePath upstream\payload-status.txt -Encoding utf8 + scripts\write-upstream-diff.ps1 -Root $PWD -OutDir upstream scripts\upstream-report.ps1 -ImportedRoot $PWD -Validation $validation ` -ValidationDetail $detail ` -RunUrl '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ` @@ -103,6 +102,11 @@ jobs: issues: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: upstream-detect + path: upstream + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + continue-on-error: true with: name: upstream-candidate path: upstream diff --git a/CHANGELOG.md b/CHANGELOG.md index f44440b..c993f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # История изменений +## Не выпущено — исправления ревью 18–19 октября 2026 (МСК) + +- Точный состав runtime и обязательная проверка существующих ACL; лишняя DLL + отменяет запуск. Дочерний процесс предпочитает системные DLL из System32. +- Восстановление останавливает проверенный процесс неисправной версии до фиксации + fallback. Очистка отказывается удалять версии при ошибке доверенного состояния. +- Команда --verify-install проверяет установку без UI, сети и запуска движка. + E2E использует реальные пути и схему, различает тайм-аут, отказ и успех, + возвращает ошибку при пропуске обязательного сценария. +- Исправлены upstream-отчёт, повторное подключение после завершения процесса, + показ обновлений на основных экранах и независимость пропуска движка от launcher. +- Ручная проверка обновлений выполняется в фоне; WinHTTP использует отменяемые + асинхронные операции и общий deadline без ослабления TLS. +- Экспорт проверяет активную embedded/installed среду и скрывает сокращённые IPv6. +- Добавлены регрессионные проверки файловой системы, восстановления, состояния UI, + уведомлений, диагностики, настоящего зависшего TLS-запроса и команд E2E. +- Подготовка E2E упаковывает одного кандидата для чистых Windows 10/11, проверяет + SHA-256 файлов в госте и требует четыре отчёта двух ролей токена. Исправлены + перенос файлов на подготовительный VHDX и путь лончера внутри гостя. +- Добавлен E2E без VM: отдельная сборка с фиксированным тестовым каталогом, + проверкой идентичности до изменений, снимком рабочей установки и очисткой. + Драйвер проверяется с фильтром false; чужая служба WinDivert не принимается. + ## 1.0.0-rc.4 — 2026-09-11 ### Исправлено diff --git a/CMakeLists.txt b/CMakeLists.txt index bc37ae5..60ae256 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,6 +7,16 @@ include("${CMAKE_CURRENT_LIST_DIR}/cmake/Version.cmake") project(CHEBURNET VERSION "${CHEBURNET_VERSION_PE}" LANGUAGES CXX) +# Separate build only: a fixed namespace for destructive host E2E fixtures. +# Never a runtime CLI/config/environment override of the production root. +set(CHEBURNET_E2E_INSTANCE "" CACHE STRING "Isolated host E2E instance (16 lowercase hex digits)") +if(NOT CHEBURNET_E2E_INSTANCE STREQUAL "") + string(LENGTH "${CHEBURNET_E2E_INSTANCE}" _e2e_length) + if(NOT _e2e_length EQUAL 16 OR NOT CHEBURNET_E2E_INSTANCE MATCHES "^[0-9a-f]+$") + message(FATAL_ERROR "CHEBURNET_E2E_INSTANCE must contain exactly 16 lowercase hex digits") + endif() +endif() + message(STATUS "CHEBURNET: версия ${CHEBURNET_VERSION_SEMANTIC} " "(PE ${CHEBURNET_VERSION_PE}, канал ${CHEBURNET_RELEASE_CHANNEL})") @@ -155,7 +165,10 @@ target_include_directories(cheburnet_lib PUBLIC "${GEN_DIR}") target_compile_definitions(cheburnet_lib PUBLIC UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX _CRT_SECURE_NO_WARNINGS) -target_link_libraries(cheburnet_lib PUBLIC bcrypt crypt32 winhttp advapi32 shell32 ole32 user32 wintrust) +if(NOT CHEBURNET_E2E_INSTANCE STREQUAL "") + target_compile_definitions(cheburnet_lib PUBLIC CHEBURNET_E2E_INSTANCE="${CHEBURNET_E2E_INSTANCE}") +endif() +target_link_libraries(cheburnet_lib PUBLIC bcrypt crypt32 winhttp advapi32 shell32 ole32 user32 ws2_32 wintrust) # Warnings / hardening for our own code. set(CHEBURNET_WARN /W4 /WX /permissive- /utf-8) @@ -176,8 +189,9 @@ if(CHEBURNET_ENABLE_ANALYZE) # CryptStringToBinaryA в самом wincrypt.h Windows SDK 10.0.26100.0 # ("синтаксическая ошибка _Param_(1)"). Он относится к чужому заголовку, # исправить его на нашей стороне невозможно, и он не указывает ни на какое - # свойство кода CHEBURNET. Никакие другие предупреждения анализа не - # подавляются: все находки в нашем коде разобраны и исправлены. + # свойство кода CHEBURNET. Дополнительно Diagnostics.cpp ограничивает + # C6101 только подключением ws2tcpip.h (WSAGetIPUserMtu, SDK 10.0.26100). + # Предупреждения анализа собственного кода не подавляются. list(APPEND CHEBURNET_WARN /analyze /analyze:external- /wd28285) message(STATUS "CHEBURNET: статический анализ MSVC включён") endif() @@ -193,6 +207,9 @@ add_executable(CHEBURNET resources/cheburnet.rc resources/generated/cheburnet_payload.rc) set_target_properties(CHEBURNET PROPERTIES OUTPUT_NAME "CHEBURNET") +if(NOT CHEBURNET_E2E_INSTANCE STREQUAL "") + set_target_properties(CHEBURNET PROPERTIES OUTPUT_NAME "CHEBURNET-E2E") +endif() target_link_libraries(CHEBURNET PRIVATE cheburnet_lib) target_compile_options(CHEBURNET PRIVATE ${CHEBURNET_WARN}) diff --git a/README.md b/README.md index 1e3cb0c..b9efb3c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Windows](https://img.shields.io/badge/Windows-10%20%7C%2011-00a4ef?style=for-the-badge&logo=windows11&logoColor=white)](#системные-требования) [![Лицензия](https://img.shields.io/github/license/Jacksony100/CHEBURNET?style=for-the-badge&label=%D0%BB%D0%B8%D1%86%D0%B5%D0%BD%D0%B7%D0%B8%D1%8F&color=2ea043)](LICENSE) -[Скачать RC](https://github.com/Jacksony100/CHEBURNET/releases/tag/v1.0.0-rc.3) · [Как запустить](#быстрый-старт) · [Безопасность](SECURITY.md) · [Документация обновлений](docs/UPDATE_SECURITY.md) +[Скачать RC](https://github.com/Jacksony100/CHEBURNET/releases/tag/v1.0.0-rc.4) · [Как запустить](#быстрый-старт) · [Безопасность](SECURITY.md) · [Документация обновлений](docs/UPDATE_SECURITY.md) @@ -41,7 +41,7 @@ CHEBURNET упаковывает проверенный движок из ## Быстрый старт 1. Скачайте `CHEBURNET.exe` и `CHEBURNET.exe.sha256` только со страницы - [официального релиза](https://github.com/Jacksony100/CHEBURNET/releases/tag/v1.0.0-rc.3). + [официального релиза](https://github.com/Jacksony100/CHEBURNET/releases/tag/v1.0.0-rc.4). 2. Проверьте файл в PowerShell: ```powershell @@ -123,8 +123,8 @@ scripts\run-tests.ps1 -BuildDir build scripts\package.ps1 -BuildDir build -OutDir dist -SkipBuild ``` -Публичный RC проходит чистую сборку с `/W4 /WX`, **33 CTest-теста** и -**84 проверки точности** стратегии/режима. Сценарий ручной проверки описан в +Публичный RC проходит чистую сборку с `/W4 /WX`, **55 CTest-проверок** (одна требует повышенного токена) и +**88 проверок точности** стратегии/режима. Сценарий ручной проверки описан в [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md), модель доверия — в [docs/UPDATE_SECURITY.md](docs/UPDATE_SECURITY.md). diff --git a/docs/REVIEW_FIXES.md b/docs/REVIEW_FIXES.md new file mode 100644 index 0000000..4c429e0 --- /dev/null +++ b/docs/REVIEW_FIXES.md @@ -0,0 +1,67 @@ +# Исправления 11 находок ревью + +Дата: **18–19 октября 2026, МСК**. База: 6a371e5 (1.0.0-rc.4). +Ветка: **codex/fix-review-findings**. + +| Находка | Изменение | Регрессионная проверка | +|---|---|---| +| R1 · P1 | Существующие каталоги runtime проверяются без присвоения им доверия заменой ACL. Новые каталоги создаются сразу с окончательной защитой. Полный обход состава отклоняет лишние файлы, каталоги, reparse points и hard links при извлечении, перед запуском и при принятии работающей сессии. Дочернему процессу задано предпочтение System32. | review_inventory; review_elevated | +| R2 · P1 | Исправлены пути и семь полей состояния pending. Проверяются код выхода и маркер завершения. Тайм-аут, неожиданная ошибка bootstrap и падение не считаются ожидаемым отказом. Пропущенный обязательный сценарий возвращает ошибку шлюза. | review_contracts; e2e_harness | +| R3 · P1 | Добавлена команда --verify-install. Она выполняет bootstrap, восстановление, проверку и подготовку установки без App::Run, UI, сетевой проверки или запуска движка. E2E использует эту команду и проверяет отсутствие нового winws. | review_contracts; отдельные VM-сценарии | +| R4 · P1 | Перед фиксацией fallback останавливается проверенная идентичность неисправного current; неизвестный процесс либо неуспех остановки запрещают фиксацию. Нормальное подключение затем запускает проверенный fallback. | review_recovery; review_elevated с безвредным дочерним процессом | +| R5 | Запись diff сама создаёт выходной каталог. Job задачи сначала получает отчёт detect и сохраняет его как резервный при отсутствии candidate. | review_contracts запускает настоящий write-upstream-diff.ps1 в одноразовом Git-репозитории | +| R6 | Ошибка или отсутствие доверенного состояния отменяют все удаления. Защищены current, previous, pending, embedded, версия paths и проверенного живого процесса. Возвращаются структурированные ошибки очистки. | review_cleanup; review_elevated | +| R7 | Общее обслуживание кадров сверяет покоящееся состояние с процессом. Перед подключением состояние также согласуется; переход не может затереть выполняющуюся операцию. Опрос не берёт блокировку ProcessManager, пока идёт запуск. | review_processstate | +| R8 | Ручная проверка выполняется фоновым сервисом. Асинхронный WinHTTP закрывает запрос при отмене/deadline, сохраняет контекст и буфер до последнего HANDLE_CLOSING. TLS-проверка не ослаблена. | review_httpcancel на настоящем незавершённом TLS к loopback; updatecheckservice | +| R9 | Единый read-only VerifyRuntime различает embedded и installed. Экспорт получает каталог и ожидаемые хеши фактически проверенной активной среды. | review_elevated проверяет настоящие ресурсы и summary; diagnostics | +| R10 | IPv6 валидируется InetPtonW вместо счётчика групп; поддержаны сокращение, скобки, zone ID и IPv4-mapped формы. | review_privacy; diagnostics | +| R11 | Фоновый результат обрабатывается в общем кадре, уведомление отображается в заголовке основных экранов. Пропуск payload не скрывает upgrade launcher. | review_notifications | + +## Проверки + +- Release-сборка x64 с **/W4 /WX**, **MSVC /analyze** и штатным усилением защиты. +- **55 проверок CTest** (включая защиту режима без VM); полный журнал сохраняется + вместе с результатом проверки на хосте от 19 октября 2026, МСК. + В первом проходе: 54 PASS и сбой обработки ожидаемого stderr в новом тесте; + исправленный тест прошёл отдельным повышенным запуском. Неустранённых ошибок нет. +- **88/88** независимых проверок соответствия стратегий BAT. +- Отдельная проверка нового транспорта через настоящий HTTPS: **HTTP 200**, 10 884 байта. +- Проверены отсутствие ошибок whitespace и корректность скриптовых контрактов. + +Команды воспроизведения: + +1. scripts/build-release.ps1 -BuildDir build-review-final +2. scripts/run-tests.ps1 -BuildDir build-review-final + +Статический анализ включён через CHEBURNET_ENABLE_ANALYZE=ON в CMakeCache этого +каталога. Для новой сборки установите эту опцию при конфигурации. + +Тест review_elevated возвращает SKIP (77) без повышенного токена. С повышением +он использует уникальный временный корень, настоящие встроенные ресурсы и копию +самого тестового EXE под именем winws для проверки остановки. Пакетный движок и +драйвер WinDivert этот тест не запускает. + +Полная матрица чистых Windows 10/11 **не выполнялась**. Это остаётся требованием +стабильного выпуска. На текущей Windows 11 Pro 26200 19 октября 2026 (МСК) +выполнены 6/6 сценариев отдельной host E2E сборки и реальная загрузка WinDivert +с фильтром false. Служба и тестовый каталог удалены; содержимое и ACL исходной +установки не изменились. Подробности: docs/production/HOST_E2E.md. Отчёты E2E +обычного и повышенного токена должны собираться отдельно; all выбирает сценарии +текущей роли, а required_scenarios и token_role сохраняются в отчёте. + +Переносимая [сводка доказательств](production/evidence/2026-10-19-host-e2e.json) +включает результаты обеих ролей, SHA-256 EXE, проверку драйвера и очистки, +отпечатки исходной установки, полный и повторный прогоны тестов и ограничения. + +Подготовлены New-CheburnetE2EBundle.ps1, Run-InGuest.ps1 и валидатор четырёх +отчётов Test-CheburnetE2EMatrix.ps1. Исправлены копирование на VHDX и путь +к лончеру в New-CheburnetTestVm.ps1. Проверка e2e_preparation выполняет +настоящую упаковку и перенос в макет гостевого тома и отклоняет повреждённый +пакет, неполную матрицу, пропуски, дубликаты, другую сборку и Windows Server. +Отчёты привязаны к SHA-256 EXE. Подготовка VM сохранена как дополнительная +возможность. Изолированный review_elevated отдельно прошёл с +повышенным токеном, затем повторно прошёл в составе полного набора. + +Подробности времени жизни асинхронного запроса соответствуют +[правилам конкурентности WinHTTP](https://learn.microsoft.com/en-us/windows/win32/winhttp/concurrency-in-winhttp) +и [последнему callback при закрытии](https://learn.microsoft.com/en-us/windows/win32/api/winhttp/nf-winhttp-winhttpclosehandle). diff --git a/docs/production/E2E_RELEASE_GATE.md b/docs/production/E2E_RELEASE_GATE.md index 3b59c79..553adb2 100644 --- a/docs/production/E2E_RELEASE_GATE.md +++ b/docs/production/E2E_RELEASE_GATE.md @@ -65,15 +65,42 @@ scripts\e2e\Invoke-CheburnetE2E.ps1 -SelfTest ## Воспроизводимая виртуальная машина +Проверка **без VM** описана в [HOST_E2E.md](HOST_E2E.md). На текущей Windows 11 +она выполнена в отдельном каталоге и отдельной сборке: 6/6 сценариев и загрузка +настоящего драйвера прошли, рабочая установка сохранена. Такой отчёт относится +к хосту и не заменяет доказательство чистых Windows 10/11. + ```powershell scripts\e2e\New-CheburnetTestVm.ps1 -Edition win11 ` -IsoPath D:\iso\Win11_x64.iso -ReleaseDir C:\Projects\cheburnet\dist ``` -Автоматизировано: VM Generation 2 с Secure Boot и виртуальным TPM по -зафиксированной спецификации, подготовительный VHDX с артефактами выпуска, -скриптами и маркером одноразовой машины, а также скрипт `Run-InGuest.ps1`, -который внутри гостя сверяет SHA-256 лончера с опубликованным и запускает набор. +Автоматизировано: VM Generation 2, 6 ГБ RAM, 4 CPU, динамический системный диск +80 ГБ, Secure Boot и виртуальный TPM. Ошибка включения TPM завершает подготовку +ошибкой. Подготовительный VHDX содержит каталог `cheburnet-e2e` с лончером, +скриптами, инструкцией и `bundle-manifest.json`. Проверяются хеши всех файлов. +Существующие VM и каталоги VM не перезаписываются; установка Windows и создание +чистого checkpoint выполняются отдельно. + +Подготовить один кандидат для обеих ОС можно без повышения прав: + +```powershell +.\scripts\e2e\New-CheburnetE2EBundle.ps1 ` + -Launcher .\build-review-final\CHEBURNET.exe -OutDir .\artifacts\e2e-candidate +``` + +Манифест сохраняет SHA-256 кандидата, ревизию исходников и признак незакоммиченных +изменений. Этот пакет предназначен для проверки текущей сборки; он сам по себе +не подтверждает подпись стабильного выпуска. + +В повышенном PowerShell хоста создать две VM (пути ISO указать фактические): + +```powershell +.\scripts\e2e\New-CheburnetTestVm.ps1 -Edition win10 ` + -IsoPath D:\ISO\Win10_x64.iso -BundleDir .\artifacts\e2e-candidate -VmRoot F:\CheburnetE2E +.\scripts\e2e\New-CheburnetTestVm.ps1 -Edition win11 ` + -IsoPath D:\ISO\Win11_x64.iso -BundleDir .\artifacts\e2e-candidate -VmRoot F:\CheburnetE2E +``` Остаётся ручным: выбор редакции и индекса образа внутри ISO и сама установка Windows. Универсальный `unattend.xml` с редакцией и ключом продукта нельзя @@ -84,12 +111,41 @@ Windows. Универсальный `unattend.xml` с редакцией и кл 1. `Start-VM` и установка Windows с ISO (редакция выбирается осознанно: нужен клиентский Windows 10 x64 или Windows 11 x64, не Windows Server); 2. `Checkpoint-VM -SnapshotName 'clean-after-setup'`; -3. в госте из элевированного PowerShell на томе `CHEBURNET-E2E`: - `.\scripts\e2e\Run-InGuest.ps1`; -4. вынести `C:\cheburnet-e2e-results\*\e2e-report.json` и приложить к - `docs/production/FINAL_RELEASE_CERTIFICATION.md`; +3. в госте перейти в `X:\cheburnet-e2e` на томе с меткой `CHEBURNET-E2E` + (`X:` — фактическая буква тома). Выполнить команды ниже; +4. скопировать весь `C:\cheburnet-e2e-results` с каждой VM на хост с сохранением + вложенных каталогов и приложить к сертификации; 5. перед повторным запуском — `Restore-VMSnapshot -Name 'clean-after-setup'`. +Гостевые команды для Windows 10 (для Windows 11 заменить `win10` на `win11`): + +```powershell +# 1. Повышенный PowerShell: подготовить маркер и общий каталог отчётов. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\e2e\Run-InGuest.ps1 -Edition win10 -PrepareOnly +# 2. Обычный PowerShell, без повышения: проверка отказа запуска. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\e2e\Run-InGuest.ps1 -Edition win10 -Role standard-user +# 3. Повышенный PowerShell: пять сценариев установки и восстановления. +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\e2e\Run-InGuest.ps1 -Edition win10 -Role elevated +``` + +`Run-InGuest.ps1` проверяет модель виртуальной машины, клиентскую ОС нужного +семейства, архитектуру x64, роль токена и хеши пакета. На физическом хосте он +отказывается создавать маркер. Отчёты разделены по ОС и роли токена; повторный +запуск получает уникальное имя. Для итоговой проверки собрать **ровно четыре** +выбранных отчёта одной попытки, остальные попытки хранить отдельно: + +```powershell +.\scripts\e2e\Test-CheburnetE2EMatrix.ps1 ` + -ReportsDir .\artifacts\e2e-results -BundleManifest .\artifacts\e2e-candidate\bundle-manifest.json +``` + +Валидатор требует 12 результатов `passed` (по шесть на ОС), обе роли на каждой +ОС, отсутствие пропусков/дубликатов, Windows client x64 и одинаковый SHA-256 +лончера. Неполная матрица, Windows Server или отчёт другой сборки дают ошибку. +Регрессионный тест `e2e_preparation` проверяет упаковку и перенос в макет +гостевого тома, отказ при повреждении и отклонение ошибочных матриц. Его +синтетические отчёты живут только во временном каталоге теста. + ## Ручная часть шлюза Эти сценарии требуют реальной загрузки драйвера WinDivert и взаимодействия с @@ -118,8 +174,11 @@ Windows. Универсальный `unattend.xml` с редакцией и кл "schema": 1, "generated_at": "…Z", "launcher": "…\CHEBURNET.exe", + "launcher_sha256": "…", "launcher_version": "1.0.0.0", - "machine": { "os": "…", "build": "…", "architecture": "AMD64" }, + "machine": { "os": "…", "build": "…", "architecture": "AMD64", "product_type": 1 }, + "token_role": "elevated|standard-user", + "required_scenarios": ["…"], "environment_refusals": [], "results": [ { "scenario": "…", "outcome": "passed|failed|skipped", "detail": "…", "evidence": [ "…" ], "at": "…Z" } ], @@ -137,10 +196,35 @@ Windows. Универсальный `unattend.xml` с редакцией и кл | Самопроверка оснастки | **PASS** (тест CTest `e2e_harness`) | | `refuses-unelevated` на машине разработки (Windows 11 Pro 26200) | **PASS** — запуск без прав отклонён Windows, защищённое дерево не изменено | | Привилегированные сценарии на машине разработки | **SKIPPED** — машина не помечена как одноразовая и сессия не элевирована; это намеренный отказ защиты, а не сбой | +| Изолированный регрессионный `review_elevated` | **PASS** с повышенным токеном, код 0; только уникальный временный корень и безвредный тестовый процесс | | Чистая VM Windows 11 x64 | **НЕ ВЫПОЛНЕНО** | | Чистая VM Windows 10 x64 | **НЕ ВЫПОЛНЕНО** | +| Текущая Windows 11 Pro 26200, отдельная host E2E сборка | **PASS** — 6/6, WinDivert загружен и удалён, исходная установка не изменилась; 19 октября 2026, МСК | | Ручная часть (подключение, отключение, обновление, откат) | **НЕ ВЫПОЛНЕНО** | -Это явный блокер стабильного выпуска: запускать привилегированные сценарии на -рабочей машине владельца репозитория недопустимо, а виртуальная машина с -установленной Windows в данном окружении отсутствует. +Чистые клиентские VM и ручная проверка драйвера остаются блокерами стабильного +выпуска. На хосте включён Hyper-V и есть `Default Switch`; VM отсутствуют. +ISO не найдены в проверенных корневом каталоге F:, F:\Projects и Downloads. +Для продолжения нужны пути к ISO Windows 10 x64 и Windows 11 x64, затем выбор +редакции и завершение установки Windows в каждом госте. Изолированный +регрессионный тест не заменяет эту матрицу. + +## Уточнения после ревью 18 октября 2026 (МСК) + +Оснастка вызывает CHEBURNET.exe --verify-install. Эта команда не входит в +App::Run, не проверяет обновления и не запускает winws. Проверка завершается +кодом 0 и маркером CHEBURNET_INSTALL_VERIFIED; тайм-аут, ошибка и новый процесс +winws никогда не засчитываются как успех. Исправлены пути user/config.json и +active-runtime.json в корне. + +-Scenario all выбирает сценарии роли текущего токена. В повышенной сессии это +пять сценариев установки/восстановления; в обычной — refuses-unelevated. +Для сертификации нужны **оба** отчёта на каждой ОС. Отчёт указывает token_role +и required_scenarios. Любой пропущенный явно выбранный сценарий и отсутствие +пройденных сценариев дают ненулевой код завершения. Самопроверка -SelfTest +проверяет логику оснастки и не заменяет запуск в VM. + +Регрессионные тесты запускают безвредный probe вместо движка. Проверка +review_elevated использует только отдельный временный корень, возвращает +CTest SKIP (77) без повышенного токена, а с ним проверяет настоящую распаковку, +экспорт, отказ при лишней DLL и остановку безвредного дочернего процесса. diff --git a/docs/production/HOST_E2E.md b/docs/production/HOST_E2E.md new file mode 100644 index 0000000..cec0adb --- /dev/null +++ b/docs/production/HOST_E2E.md @@ -0,0 +1,82 @@ +# Проверка на текущей Windows без VM + +Выполнено **19 октября 2026, МСК**, Windows 11 Pro x64, сборка **26200**. +Результат относится к этой ОС и отдельной тестовой сборке того же приложения. +Windows 10 и чистая установка Windows этим прогоном не проверялись. + +## Результат + +- **6/6 PASS**: отказ без повышения, первая установка, повторная установка, + восстановление pending, откат повреждённой активной версии, защита от junction. +- **PASS**: настоящий WinDivert загружен, открыт с фильтром `false` и флагом + `SNIFF`, проверен статус RUNNING. Дескриптор закрыт, созданная служба остановлена + и удалена. Это проверка загрузки драйвера, а не эффективности обхода DPI. +- **PASS**: исходное дерево `%ProgramData%\CHEBURNET` сохранило тот же отпечаток + содержимого файлов и ACL. Отдельный тестовый каталог удалён, WinDivert после + теста не зарегистрирован. +- **PASS**: сборка с `/WX` и `/analyze`, 55 регрессионных проверок подтверждены + (54 в полном проходе, исправленный новый тест — отдельным повторным запуском), + независимая сверка стратегий — 88/88. Обычный EXE возвращает код 2 на + `--e2e-identity` до bootstrap. + +Доказательства сохранены в +`F:\Projects\cheburnet\artifacts\reviews\2026-10-19\host-e2e-000001a15335366a`: +`host-report.json`, два `e2e-report.json`, `driver-smoke.log`, `cleanup.log`. +Машиночитаемые даты хранятся в UTC; пользователю время показывается в МСК. +Переносимая сводка для PR и проверки из GitHub: +[2026-10-19-host-e2e.json](evidence/2026-10-19-host-e2e.json). + +## Воспроизведение + +На хосте не нужно создавать `C:\cheburnet-e2e-vm.marker`. +Из обычного **64-разрядного Windows PowerShell**: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\e2e\Build-CheburnetHostE2E.ps1 +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\scripts\e2e\Invoke-CheburnetHostE2E.ps1 ` + -BuildManifest .\build-host-e2e\host-e2e-build.json -ResultsDir .\artifacts\host-e2e-new-run +``` + +Сначала выполняется обычная роль, затем UAC запускает повышенную фазу. +Для каждой новой попытки использовать свежий каталог результатов и новую сборку: +скрипт сборки генерирует новый идентификатор по умолчанию. Обе сборки используют +`/W4 /WX` и MSVC `/analyze`. + +## Изоляция + +`CHEBURNET_E2E_INSTANCE` задаётся **при сборке** и принимает только 16 строчных +шестнадцатеричных символов. Тестовый EXE называется `CHEBURNET-E2E.exe`; его +фиксированный корень — Known Folder ProgramData плюс `CHEBURNET-E2E-`. +Блокировка экземпляра также имеет отдельное имя. В обычной сборке корень +по-прежнему фиксирован как CHEBURNET; перенаправления через CLI, конфигурацию +или переменные окружения нет. + +Перед изменением файлов оснастка сверяет SHA-256 двух EXE и их ответы +`--e2e-identity`. Обычный EXE на эту команду возвращает код 2 до bootstrap/UI. +Существующий тестовый каталог, работающий winws или любая служба WinDivert +запрещают начало повышенной фазы. + +Очистку выполняет нативный помощник через `securefs::RemoveTreeUnder`, с +проверкой собственного фиксированного корня и без обхода reparse points. +Рабочая установка CHEBURNET не очищается и не восстанавливается из резервной +копии: тест не получает разрешения изменять её. + +## Драйвер + +Помощник проверяет распакованную среду и загружает DLL из защищённого каталога. +Он эксклюзивно создаёт собственную службу WinDivert, сохраняя её дескриптор; +существующая служба не принимается, не останавливается и не удаляется. +Создание службы до вызова DLL также исключает запись DLL в ключ EventLog. +При завершении снимается только собственная служба, затем очищается тестовое +дерево. В обычный CTest этот тест драйвера не включён: его явно запускает +координатор проверки на хосте. + +Семантика фильтра и SNIFF описана в +[документации WinDivert](https://reqrypt.org/windivert-doc.html#divert_open). +Поведение установки службы сверено с +[исходниками WinDivert 2.2.2](https://github.com/basil00/WinDivert/blob/v2.2.2/dll/windivert.c). + +Отчёты имеют `environment_kind=host-isolated-build`. Валидатор чистой матрицы +Windows 10/11 такие отчёты отклоняет. Управление соединением через UI, реальные +обновления из сети и эффективность стратегий на трафике остаются отдельными +проверками. diff --git a/docs/production/SECURITY_TOOLING.md b/docs/production/SECURITY_TOOLING.md index a674819..4773384 100644 --- a/docs/production/SECURITY_TOOLING.md +++ b/docs/production/SECURITY_TOOLING.md @@ -27,7 +27,12 @@ cmake --build build-analyze | `C6387` «`attrList` может быть равен 0» | `src/core/ProcessManager.cpp` | Реальный пробел. Размер списка атрибутов брался из вызова `InitializeProcThreadAttributeList(nullptr, …)` без проверки: при неожиданном отказе размер остался бы нулевым, выделение — нулевой длины, а последующие вызовы писали бы через указатель без хранилища. Это привилегированный путь создания дочернего процесса. | Добавлена проверка диапазона размера и указателя; при отказе путь fail-closed, элевированный дочерний процесс не создаётся. | | `C6262` «функция использует 66040 байт стека» | `src/update/WinHttpClient.cpp` | Реальный риск. Буфер передачи 64 КиБ лежал в автоматической памяти одного кадра; эта же функция теперь исполняется в фоновом потоке проверки обновлений, где запас стека не должен иметь значения. | Буфер перенесён в динамическую память. | | `C6326` «сравнение константы с константой» ×3 | `src/core/Launcher.cpp`, `src/app/UiScreens.cpp` | Проверка «встроенный resource manifest не пуст» выполнялась во время исполнения, хотя это свойство генерации. | Проверка вынесена в `static_assert`: сборка падает сразу, а не у пользователя при запуске. Экран состояния показывает фактическое число записей. | -| `C28285` «синтаксическая ошибка `_Param_(1)`» | `wincrypt.h` Windows SDK 10.0.26100.0 | Дефект SAL-аннотации `CryptStringToBinaryA` **в чужом заголовке**. На нашей стороне не исправим, о свойствах кода CHEBURNET не говорит. | Подавлено точечно (`/wd28285`) с обоснованием в `CMakeLists.txt`. Единственное подавленное предупреждение анализа. | +| `C28285` «синтаксическая ошибка `_Param_(1)`» | `wincrypt.h` Windows SDK 10.0.26100.0 | Дефект SAL-аннотации `CryptStringToBinaryA` **в чужом заголовке**. На нашей стороне не исправим, о свойствах кода CHEBURNET не говорит. | Подавлено точечно (`/wd28285`) с обоснованием в `CMakeLists.txt`. Предупреждения собственного кода не подавляются. | + +C6101 в неиспользуемом inline WSAGetIPUserMtu из ws2tcpip.h SDK 10.0.26100 +ограничено pragma warning push/pop вокруг одного подключения в Diagnostics.cpp. +Проверка SAL у getsockopt не доказывает запись в Mtu. После подключения +предупреждение снова включено; код CHEBURNET проверяется с /analyze и /WX. ### CodeQL diff --git a/docs/production/evidence/2026-10-19-host-e2e.json b/docs/production/evidence/2026-10-19-host-e2e.json new file mode 100644 index 0000000..0e0346c --- /dev/null +++ b/docs/production/evidence/2026-10-19-host-e2e.json @@ -0,0 +1,96 @@ +{ + "schema": 1, + "environment_kind": "host-isolated-build", + "source_base": "6a371e5c825c6dbce7cf2553158ada084f2d1e87", + "source_worktree_was_dirty": true, + "started_at": "2026-10-19T08:16:30.4935444Z", + "finished_at": "2026-10-19T08:17:00.6815043Z", + "os": { + "Caption": "Майкрософт Windows 11 Pro", + "BuildNumber": "26200", + "ProductType": 1 + }, + "launcher_sha256": "ecab2dd0f14adb8fa60035e197a962c6008d5bfdb40a5a6c437118a7fe21cb6a", + "host_e2e_passed": true, + "token_runs": [ + { + "token_role": "elevated", + "launcher_sha256": "ecab2dd0f14adb8fa60035e197a962c6008d5bfdb40a5a6c437118a7fe21cb6a", + "results": [ + { + "scenario": "fresh-install", + "outcome": "passed", + "detail": "first launch on an empty runtime root" + }, + { + "scenario": "existing-install", + "outcome": "passed", + "detail": "relaunch with an existing config and runtime" + }, + { + "scenario": "interrupted-pending", + "outcome": "passed", + "detail": "pending runtime left by an interrupted update" + }, + { + "scenario": "startup-rollback", + "outcome": "passed", + "detail": "invalid external current rolled back to verified previous" + }, + { + "scenario": "filesystem-guards", + "outcome": "passed", + "detail": "junction planted at the protected logs directory" + } + ], + "passed": 5, + "failed": 0, + "skipped": 0 + }, + { + "token_role": "standard-user", + "launcher_sha256": "ecab2dd0f14adb8fa60035e197a962c6008d5bfdb40a5a6c437118a7fe21cb6a", + "results": [ + { + "scenario": "refuses-unelevated", + "outcome": "passed", + "detail": "Exception calling \"Start\" with \"0\" argument(s): \"The requested operation requires elevation\"" + } + ], + "passed": 1, + "failed": 0, + "skipped": 0 + } + ], + "driver": { + "filter": "false", + "flags": "SNIFF", + "exit_code": 0, + "running_verified": true, + "handle_closed": true, + "owned_service_removed": true, + "drivers_after": [] + }, + "cleanup_exit_code": 0, + "production_before": "298275fef8d7588c8bad0956abedf280847e61323c19b85c408fca9c0cb58d79", + "production_after": "298275fef8d7588c8bad0956abedf280847e61323c19b85c408fca9c0cb58d79", + "production_unchanged": true, + "validation": { + "schema": 1, + "elevated": true, + "initial_passed": 54, + "initial_failed": 1, + "rerun_exit_code": 0, + "fidelity_exit_code": 0, + "final_passed": 55, + "final_failed": 0, + "finished_at": "2026-10-19T08:23:08.0738724Z", + "error": null + }, + "validation_note": "Initial full run: 54 passed and one test harness stderr handling failure. After fixing expected native stderr handling, the sole failed test passed in an elevated rerun. All 55 cases confirmed, zero unresolved failures; strategy fidelity 88/88.", + "limitations": [ + "Windows 11 Pro build 26200 host; isolated test build with a compile-time runtime namespace.", + "Windows 10 and clean OS matrix were not run.", + "Driver load test only: UI connection flow, live updates and effectiveness on real traffic are outside this run." + ] +} diff --git a/scripts/e2e/Build-CheburnetHostE2E.ps1 b/scripts/e2e/Build-CheburnetHostE2E.ps1 new file mode 100644 index 0000000..bb0d17a --- /dev/null +++ b/scripts/e2e/Build-CheburnetHostE2E.ps1 @@ -0,0 +1,28 @@ +# A separate fixed namespace; never accepted by the production EXE at runtime. +[CmdletBinding()] +param( + [string]$BuildDir = 'build-host-e2e', + [ValidatePattern('^[0-9a-f]{16}$')][string]$Instance = ([guid]::NewGuid().ToString('N').Substring(0, 16)) +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. (Join-Path $root 'scripts\vsenv.ps1') +Import-VcVars +$build = [IO.Path]::GetFullPath((Join-Path $root $BuildDir)) +& $CMakeExe -S $root -B $build -G Ninja "-DCMAKE_MAKE_PROGRAM=$NinjaExe" ` + -DCMAKE_BUILD_TYPE=Release -DCHEBURNET_ENABLE_ANALYZE=ON "-DCHEBURNET_E2E_INSTANCE=$Instance" +if ($LASTEXITCODE -ne 0) { throw 'host E2E configuration failed' } +& $CMakeExe --build $build --target CHEBURNET host_driver_smoke +if ($LASTEXITCODE -ne 0) { throw 'host E2E build failed' } +$launcher = Join-Path $build 'CHEBURNET-E2E.exe' +$helper = Join-Path $build 'tests\host_driver_smoke.exe' +$manifest = [ordered]@{ + schema=1; instance=$Instance; launcher=$launcher; driver_probe=$helper + launcher_sha256=(Get-FileHash -LiteralPath $launcher -Algorithm SHA256).Hash.ToLowerInvariant() + driver_probe_sha256=(Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant() + source_revision=[string](& git -C $root rev-parse HEAD) +} +[IO.File]::WriteAllText((Join-Path $build 'host-e2e-build.json'), + ($manifest | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) +Write-Output "HOST_E2E_BUILD: $build instance=$Instance" diff --git a/scripts/e2e/Invoke-CheburnetE2E.ps1 b/scripts/e2e/Invoke-CheburnetE2E.ps1 index f6b761f..05453aa 100644 --- a/scripts/e2e/Invoke-CheburnetE2E.ps1 +++ b/scripts/e2e/Invoke-CheburnetE2E.ps1 @@ -4,8 +4,8 @@ CHEBURNET release validation suite for a clean Windows environment. WHAT IT DOES The launcher performs protected-tree bootstrap, runtime extraction, integrity -verification, interrupted-pending recovery and startup rollback in App's -constructor -- before any user interface exists. The suite therefore drives real +verification, interrupted-pending recovery and startup rollback through +--verify-install, which never enters App::Run or creates a child. The suite drives real scenarios by starting the real executable and asserting on observable state (%ProgramData%\CHEBURNET contents, ACLs, active-runtime.json, config.json, exit codes) instead of scripting a text user interface. @@ -35,12 +35,15 @@ param( 'interrupted-pending', 'startup-rollback', 'filesystem-guards')] [string[]]$Scenario = @('all'), [string]$MarkerPath = 'C:\cheburnet-e2e-vm.marker', + [ValidatePattern('^[0-9a-f]{16}$')][string]$HostInstance, [switch]$SelfTest ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -$script:RuntimeRoot = Join-Path $env:ProgramData 'CHEBURNET' +$script:RuntimeRoot = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'CHEBURNET' +$script:HostMode = -not [string]::IsNullOrWhiteSpace($HostInstance) +if ($script:HostMode) { $script:RuntimeRoot += "-E2E-$HostInstance" } # --------------------------------------------------------------------------- # Scenario registry. Declared as data so the self-test can verify coverage of @@ -94,7 +97,7 @@ function Test-CheburnetE2EEnvironment { #> param([string]$Marker = $script:MarkerPathValue) $reasons = New-Object Collections.Generic.List[string] - if (-not (Test-Path -LiteralPath $Marker -PathType Leaf)) { + if (-not $script:HostMode -and -not (Test-Path -LiteralPath $Marker -PathType Leaf)) { $reasons.Add("machine is not marked as a disposable test environment ($Marker is missing)") } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() @@ -128,42 +131,78 @@ function New-CheburnetE2EResult { } function Start-CheburnetAndSettle { - <# - Starts the launcher, waits until its startup work is observable, then stops - it. Returns the observed exit behaviour. The launcher is a text UI that - waits for input, so a settled startup is detected by the state it writes. - #> param( [Parameter(Mandatory = $true)][string]$Path, - [int]$TimeoutSeconds = 60 + [int]$TimeoutSeconds = 60, + [string]$Arguments = '--verify-install' ) - $process = Start-Process -FilePath $Path -ArgumentList '--no-animation', '--ascii-only' ` - -PassThru -WindowStyle Minimized - $deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds) - $settled = $false - while ([DateTime]::UtcNow -lt $deadline) { - if ($process.HasExited) { break } - if ((Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'config.json')) -and - (Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'runtime'))) { - $settled = $true - break - } - Start-Sleep -Milliseconds 250 - } - $exitCode = $null - if ($process.HasExited) { - $exitCode = $process.ExitCode - } else { - # Stopping the UI is not part of any scenario's assertion: the startup - # work under test has already completed by this point. + $id = [guid]::NewGuid().ToString('N') + $stdout = Join-Path $results "$id-stdout.txt" + $stderr = Join-Path $results "$id-stderr.txt" + $before = @(Get-Process -Name winws -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) + $process = New-Object Diagnostics.Process + $process.StartInfo.FileName = $Path + $process.StartInfo.Arguments = $Arguments + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $process.StartInfo.StandardOutputEncoding = [Text.UTF8Encoding]::new($false) + $process.StartInfo.StandardErrorEncoding = [Text.UTF8Encoding]::new($false) + $process.Start() | Out-Null + $outputTask = $process.StandardOutput.ReadToEndAsync() + $errorTask = $process.StandardError.ReadToEndAsync() + $finished = $process.WaitForExit($TimeoutSeconds * 1000) + if (-not $finished) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue $process.WaitForExit(10000) | Out-Null + } else { + $process.WaitForExit() } - return [pscustomobject]@{ Settled = $settled; ExitCode = $exitCode } + $exitCode = if ($finished) { $process.ExitCode } else { -1 } + $output = $outputTask.GetAwaiter().GetResult() + $errors = $errorTask.GetAwaiter().GetResult() + [IO.File]::WriteAllText($stdout, $output) + [IO.File]::WriteAllText($stderr, $errors) + $process.Dispose() + $after = @(Get-Process -Name winws -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) + $newEngine = @($after | Where-Object { $before -notcontains $_ }).Count -ne 0 + return [pscustomobject]@{ + Settled = (Test-InstallProbeSuccess $finished $exitCode $output $newEngine) + ExitCode = $exitCode + TimedOut = -not $finished + NewEngine = $newEngine + ErrorText = $errors + Output = $output + } +} + +function Test-InstallProbeSuccess([bool]$Finished, $ExitCode, [string]$Output, [bool]$NewEngine) { + return $Finished -and $null -ne $ExitCode -and $ExitCode -eq 0 -and + $Output.Contains('CHEBURNET_INSTALL_VERIFIED') -and -not $NewEngine +} + +function Test-InstallProbeRefusal($Run, [int]$ExpectedError = 4393) { + # ERROR_REPARSE_TAG_INVALID is the expected junction refusal. An unrelated + # crash, missing privilege, disk failure or timeout is not a passing guard. + return -not $Run.TimedOut -and -not $Run.NewEngine -and + $null -ne $Run.ExitCode -and $Run.ExitCode -eq 3 -and + $Run.ErrorText.Contains("CHEBURNET_INSTALL_REFUSED:bootstrap:$ExpectedError") +} + +function Test-CheburnetHostIdentity($Run, [string]$Instance) { + return $Instance -cmatch '^[0-9a-f]{16}$' -and -not $Run.TimedOut -and + -not $Run.NewEngine -and $null -ne $Run.ExitCode -and $Run.ExitCode -eq 0 -and + $Run.Output.Trim() -ceq "CHEBURNET_E2E_INSTANCE:$Instance" +} + +function Get-E2EGateExitCode([int]$Passed, [int]$Failed, [int]$Skipped) { + if ($Passed -eq 0 -or $Failed -ne 0 -or $Skipped -ne 0) { return 1 } + return 0 } function Read-CheburnetRuntimeState { - $path = Join-Path $script:RuntimeRoot 'runtime\active-runtime.json' + $path = Join-Path $script:RuntimeRoot 'active-runtime.json' if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json } @@ -210,6 +249,23 @@ if ($SelfTest) { try { New-CheburnetE2EResult -Name 'x' -Outcome 'maybe' | Out-Null } catch { $threw = $true } Assert-True $threw 'an unknown outcome must be rejected' + Assert-True ((Get-E2EGateExitCode 0 0 1) -eq 1) 'all-skipped must fail' + Assert-True ((Get-E2EGateExitCode 2 0 1) -eq 1) 'mandatory skipped scenario must fail' + Assert-True ((Get-E2EGateExitCode 1 0 0) -eq 0) 'completed selected scenarios pass' + Assert-True (-not (Test-InstallProbeSuccess $false 0 'CHEBURNET_INSTALL_VERIFIED' $false)) 'timeout must fail' + Assert-True (-not (Test-InstallProbeSuccess $true 1 'CHEBURNET_INSTALL_VERIFIED' $false)) 'nonzero exit must fail' + Assert-True (-not (Test-InstallProbeSuccess $true 0 '' $false)) 'missing completion marker must fail' + Assert-True (-not (Test-InstallProbeSuccess $true 0 'CHEBURNET_INSTALL_VERIFIED' $true)) 'engine creation must fail' + Assert-True (Test-InstallProbeSuccess $true 0 'CHEBURNET_INSTALL_VERIFIED' $false) 'successful install probe passes' + $timeoutRun = [pscustomobject]@{TimedOut=$true; NewEngine=$false; ExitCode=-1; ErrorText='timeout'} + Assert-True (-not (Test-InstallProbeRefusal $timeoutRun)) 'hang must never pass as security refusal' + $crash = [pscustomobject]@{TimedOut=$false; NewEngine=$false; ExitCode=1; ErrorText='unrelated crash'} + Assert-True (-not (Test-InstallProbeRefusal $crash)) 'unrelated crash must fail' + $wrongError = [pscustomobject]@{TimedOut=$false; NewEngine=$false; ExitCode=3; ErrorText='CHEBURNET_INSTALL_REFUSED:bootstrap:5'} + Assert-True (-not (Test-InstallProbeRefusal $wrongError)) 'unexpected bootstrap failure must fail' + $expectedRefusal = [pscustomobject]@{TimedOut=$false; NewEngine=$false; ExitCode=3; ErrorText='CHEBURNET_INSTALL_REFUSED:bootstrap:4393'} + Assert-True (Test-InstallProbeRefusal $expectedRefusal) 'expected reparse refusal passes' + if ($failures.Count -ne 0) { foreach ($failure in $failures) { Write-Output "FAIL: $failure" } throw "E2E_HARNESS: FAIL ($($failures.Count))" @@ -233,21 +289,35 @@ if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { if ([string]::IsNullOrWhiteSpace($ResultsDir)) { $ResultsDir = Join-Path ([IO.Path]::GetTempPath()) 'cheburnet-e2e' } -$results = Join-Path ([IO.Path]::GetFullPath($ResultsDir)) ([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')) +$results = Join-Path ([IO.Path]::GetFullPath($ResultsDir)) ([DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $results -Force | Out-Null -$selected = if (@($Scenario) -ccontains 'all') { @($script:Scenarios.Name) } else { @($Scenario) } +$selected = @($Scenario) $records = New-Object Collections.Generic.List[object] # The unelevated refusal is the only scenario that must run WITHOUT elevation, # so it is evaluated against the environment guard rather than gated by it. $environmentReasons = @(Test-CheburnetE2EEnvironment -Marker $MarkerPath) $elevated = ($environmentReasons -join ' ') -notmatch 'elevated session' +if (@($Scenario) -ccontains 'all') { + $selected = @($script:Scenarios | Where-Object { + if ($elevated) { $_.Requires -ceq 'elevation' } else { $_.Requires -ceq 'none' } + } | Select-Object -ExpandProperty Name) +} + +if ($script:HostMode -and $elevated) { + $identityRun = Start-CheburnetAndSettle -Path $launcherPath -Arguments '--e2e-identity' -TimeoutSeconds 10 + if (-not (Test-CheburnetHostIdentity $identityRun $HostInstance)) { + throw 'host E2E refused: launcher is not the matching isolated build; no runtime changes authorized' + } +} + foreach ($name in $selected) { $definition = @($script:Scenarios | Where-Object Name -ceq $name) if ($definition.Count -ne 1) { throw "unknown scenario: $name" } - if ($definition[0].Requires -ceq 'elevation' -and $environmentReasons.Count -ne 0) { + if ((-not $script:HostMode -and -not (Test-Path -LiteralPath $MarkerPath -PathType Leaf)) -or + ($definition[0].Requires -ceq 'elevation' -and $environmentReasons.Count -ne 0)) { $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` -Detail ("environment refused: " + ($environmentReasons -join '; ')))) continue @@ -268,7 +338,7 @@ foreach ($name in $selected) { try { $process = New-Object Diagnostics.Process $process.StartInfo.FileName = $launcherPath - $process.StartInfo.Arguments = '--version' + $process.StartInfo.Arguments = '--verify-install' $process.StartInfo.UseShellExecute = $false $process.Start() | Out-Null $process.WaitForExit(15000) | Out-Null @@ -276,7 +346,8 @@ foreach ($name in $selected) { } catch { # ERROR_ELEVATION_REQUIRED (740): the embedded manifest asks for # requireAdministrator, so CreateProcess refuses outright. - $refused = $true + $refused = ($_.Exception.GetBaseException() -is [ComponentModel.Win32Exception]) -and + ($_.Exception.GetBaseException().NativeErrorCode -eq 740) $detail = [string]$_.Exception.Message } $after = if (Test-Path -LiteralPath $script:RuntimeRoot) { @@ -295,13 +366,17 @@ foreach ($name in $selected) { $run = Start-CheburnetAndSettle -Path $launcherPath $state = Read-CheburnetRuntimeState $strategyCatalogue = Join-Path $script:RuntimeRoot 'runtime' + $configExists = Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'user\config.json') -PathType Leaf + $runtimeExists = Test-Path -LiteralPath $strategyCatalogue -PathType Container + $protectedAcl = (Get-Acl -LiteralPath $script:RuntimeRoot).AreAccessRulesProtected $evidence = @( "settled: $($run.Settled)", - "config.json: $(Test-Path -LiteralPath (Join-Path $script:RuntimeRoot 'config.json'))", - "runtime dir: $(Test-Path -LiteralPath $strategyCatalogue)", + "config.json: $configExists", + "runtime dir: $runtimeExists", + "runtime root DACL protected: $protectedAcl", "active-runtime.json current: $(if ($state) { $state.current } else { '(absent)' })" ) - $ok = $run.Settled -and $null -ne $state -and + $ok = $run.Settled -and $configExists -and $runtimeExists -and $protectedAcl -and $null -ne $state -and -not [string]::IsNullOrWhiteSpace([string]$state.current) $records.Add((New-CheburnetE2EResult -Name $name ` -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` @@ -309,7 +384,7 @@ foreach ($name in $selected) { } 'existing-install' { - $configPath = Join-Path $script:RuntimeRoot 'config.json' + $configPath = Join-Path $script:RuntimeRoot 'user\config.json' if (-not (Test-Path -LiteralPath $configPath)) { $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` -Detail 'run fresh-install first')) @@ -331,7 +406,7 @@ foreach ($name in $selected) { } 'interrupted-pending' { - $statePath = Join-Path $script:RuntimeRoot 'runtime\active-runtime.json' + $statePath = Join-Path $script:RuntimeRoot 'active-runtime.json' if (-not (Test-Path -LiteralPath $statePath)) { $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` -Detail 'run fresh-install first')) @@ -345,6 +420,8 @@ foreach ($name in $selected) { current = [string]$state.current previous_known_good = '' pending = $pendingVersion + package_sha256 = $null + manifest_key_id = $null last_result = 'e2e-seeded-interruption' } [IO.File]::WriteAllText($statePath, ($seeded | ConvertTo-Json) + "`n", @@ -353,7 +430,7 @@ foreach ($name in $selected) { $after = Read-CheburnetRuntimeState $promoted = ($null -ne $after) -and ([string]$after.current -ceq $pendingVersion) $cleared = ($null -ne $after) -and [string]::IsNullOrEmpty([string]$after.pending) - $ok = (-not $promoted) -and ($cleared -or -not $run.Settled) + $ok = $run.Settled -and (-not $promoted) -and $cleared $records.Add((New-CheburnetE2EResult -Name $name ` -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` -Detail 'pending runtime left by an interrupted update' ` @@ -369,27 +446,22 @@ foreach ($name in $selected) { -Detail 'run fresh-install first')) break } - # A corrupted active runtime must not be used. Without a previous - # known-good version the launcher must refuse to start at all. - $versionDir = Join-Path $script:RuntimeRoot ('runtime\' + [string]$state.current) - $victim = @(Get-ChildItem -LiteralPath $versionDir -Recurse -File -Filter '*.txt' | - Select-Object -First 1) - if ($victim.Count -ne 1) { - $records.Add((New-CheburnetE2EResult -Name $name -Outcome 'skipped' ` - -Detail "no list file found under $versionDir to corrupt")) - break - } - Add-Content -LiteralPath $victim[0].FullName -Value 'e2e-corruption' + # An invalid EXTERNAL version tests fallback. Corrupting the + # embedded version only exercises embedded self-repair. + $originalVersion = [string]$state.current + $state.current = '99.99.99' + $state.previous_known_good = $originalVersion + $state.pending = $null + $state.last_result = 'e2e-corrupt-external' + $statePath = Join-Path $script:RuntimeRoot 'active-runtime.json' + [IO.File]::WriteAllText($statePath, ($state | ConvertTo-Json) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) $run = Start-CheburnetAndSettle -Path $launcherPath -TimeoutSeconds 30 $after = Read-CheburnetRuntimeState - $refusedOrRolledBack = (-not $run.Settled) -or - (($null -ne $after) -and ([string]$after.last_result -cmatch 'rollback')) - $records.Add((New-CheburnetE2EResult -Name $name ` - -Outcome $(if ($refusedOrRolledBack) { 'passed' } else { 'failed' }) ` - -Detail 'corrupted active runtime' ` - -Evidence @("settled: $($run.Settled)", - "last_result: $(if ($after) { $after.last_result } else { '(absent)' })", - "corrupted file: $($victim[0].FullName)"))) + $ok = $run.Settled -and $null -ne $after -and + [string]$after.current -ceq $originalVersion -and + [string]$after.last_result -ceq 'startup-integrity-rollback' + $outcome = if ($ok) { 'passed' } else { 'failed' } + $records.Add((New-CheburnetE2EResult -Name $name -Outcome $outcome -Detail 'invalid external current rolled back to verified previous' -Evidence @("settled: $($run.Settled)", "no engine created: $(-not $run.NewEngine)"))) } 'filesystem-guards' { @@ -411,7 +483,7 @@ foreach ($name in $selected) { } $run = Start-CheburnetAndSettle -Path $launcherPath -TimeoutSeconds 30 $escaped = @(Get-ChildItem -LiteralPath $decoy -Recurse -File -ErrorAction SilentlyContinue).Count - $ok = (-not $run.Settled) -and ($escaped -eq 0) + $ok = (Test-InstallProbeRefusal $run) -and ($escaped -eq 0) $records.Add((New-CheburnetE2EResult -Name $name ` -Outcome $(if ($ok) { 'passed' } else { 'failed' }) ` -Detail 'junction planted at the protected logs directory' ` @@ -427,14 +499,20 @@ foreach ($name in $selected) { $summary = [ordered]@{ schema = 1 + environment_kind = $(if ($script:HostMode) { 'host-isolated-build' } else { 'disposable-vm' }) + runtime_root = $script:RuntimeRoot generated_at = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') launcher = $launcherPath + launcher_sha256 = (Get-FileHash -LiteralPath $launcherPath -Algorithm SHA256).Hash.ToLowerInvariant() launcher_version = [string]([Diagnostics.FileVersionInfo]::GetVersionInfo($launcherPath).FileVersion) machine = [ordered]@{ os = [string](Get-CimInstance Win32_OperatingSystem).Caption build = [string](Get-CimInstance Win32_OperatingSystem).BuildNumber + product_type = [int](Get-CimInstance Win32_OperatingSystem).ProductType architecture = [string]$env:PROCESSOR_ARCHITECTURE } + token_role = $(if ($elevated) { 'elevated' } else { 'standard-user' }) + required_scenarios = [string[]]$selected environment_refusals = [string[]]$environmentReasons results = $records.ToArray() passed = @($records | Where-Object outcome -ceq 'passed').Count @@ -450,4 +528,4 @@ foreach ($record in $records) { } Write-Output ("E2E: passed=$($summary.passed) failed=$($summary.failed) " + "skipped=$($summary.skipped) report=$summaryPath") -if ($summary.failed -ne 0) { exit 1 } +exit (Get-E2EGateExitCode $summary.passed $summary.failed $summary.skipped) diff --git a/scripts/e2e/Invoke-CheburnetHostE2E.ps1 b/scripts/e2e/Invoke-CheburnetHostE2E.ps1 new file mode 100644 index 0000000..e9b619a --- /dev/null +++ b/scripts/e2e/Invoke-CheburnetHostE2E.ps1 @@ -0,0 +1,130 @@ +# Run from standard Windows PowerShell. UAC authorizes the isolated second phase. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BuildManifest, + [Parameter(Mandatory = $true)][string]$ResultsDir, + [switch]$ElevatedPhase +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$manifestPath = (Resolve-Path -LiteralPath $BuildManifest).Path +$manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json +if ($manifest.schema -ne 1 -or $manifest.instance -cnotmatch '^[0-9a-f]{16}$') { throw 'invalid host build manifest' } +foreach ($pair in @(@('launcher', 'launcher_sha256'), @('driver_probe', 'driver_probe_sha256'))) { + if ((Get-FileHash -LiteralPath $manifest.($pair[0]) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $manifest.($pair[1])) { + throw "host build hash mismatch: $($pair[0])" + } +} +$results = [IO.Path]::GetFullPath($ResultsDir) +$hostRoot = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) "CHEBURNET-E2E-$($manifest.instance)" +$windowsPS = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" +$harness = Join-Path $PSScriptRoot 'Invoke-CheburnetE2E.ps1' +$admin = ([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if ($admin -ne [bool]$ElevatedPhase) { throw 'start the coordinator without elevation; only -ElevatedPhase uses an administrator token' } +New-Item -ItemType Directory -Path $results -Force | Out-Null +if (-not $ElevatedPhase) { + if (Test-Path -LiteralPath (Join-Path $results 'host-report.json')) { throw 'use a fresh results directory' } + & $windowsPS -NoProfile -ExecutionPolicy Bypass -File $harness -Launcher $manifest.launcher ` + -ResultsDir (Join-Path $results 'standard-user') -HostInstance $manifest.instance -Scenario all ` + *> (Join-Path $results 'standard-user.log') + if ($LASTEXITCODE -ne 0) { throw 'standard-user scenario failed; see standard-user.log' } + $arguments = '-NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + + '" -BuildManifest "' + $manifestPath + '" -ResultsDir "' + $results + '" -ElevatedPhase' + $worker = Start-Process -FilePath $windowsPS -Verb RunAs -WindowStyle Hidden -ArgumentList $arguments -PassThru + $deadline = [DateTime]::UtcNow.AddMinutes(6) + $reportPath = Join-Path $results 'host-report.json' + while (-not (Test-Path -LiteralPath $reportPath)) { + if ($worker.HasExited) { throw 'elevated worker exited without a report' } + if ([DateTime]::UtcNow -gt $deadline) { throw 'host E2E did not finish within six minutes' } + Start-Sleep -Milliseconds 300 + } + $report = Get-Content -LiteralPath $reportPath -Raw -Encoding UTF8 | ConvertFrom-Json + Write-Output ("HOST_E2E: passed=$($report.passed) driver=$($report.driver_exit_code) " + + "original_unchanged=$($report.production_unchanged) cleanup=$($report.cleanup_exit_code) report=$reportPath") + exit $(if ($report.passed) { 0 } else { 1 }) +} + +function Get-ProductionFingerprint { + $production = Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'CHEBURNET' + if (-not (Test-Path -LiteralPath $production)) { return 'absent' } + $stack = [Collections.Generic.Stack[string]]::new() + $stack.Push($production) + $entries = [Collections.Generic.List[string]]::new() + while ($stack.Count -gt 0) { + $path = $stack.Pop() + $item = Get-Item -LiteralPath $path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'production snapshot refuses reparse points' } + $relative = $path.Substring($production.Length) + $entries.Add($relative + '|' + (Get-Acl -LiteralPath $path).Sddl) + if ($item.PSIsContainer) { + foreach ($child in Get-ChildItem -LiteralPath $path -Force) { $stack.Push($child.FullName) } + } else { $entries.Add($relative + '|' + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash) } + } + $sha = [Security.Cryptography.SHA256]::Create() + try { + $bytes = [Text.Encoding]::UTF8.GetBytes(($entries.ToArray() | Sort-Object) -join "`n") + return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace('-', '').ToLowerInvariant() + } finally { $sha.Dispose() } +} + +$receipt = [ordered]@{ + schema=1; environment_kind='host-isolated-build'; instance=$manifest.instance + started_at=[DateTime]::UtcNow.ToString('o'); finished_at=$null + os=(Get-CimInstance Win32_OperatingSystem | Select-Object Caption,BuildNumber,ProductType) + launcher_sha256=$manifest.launcher_sha256; passed=$false; driver_exit_code=$null + cleanup_exit_code=$null; production_before=$null; production_after=$null + production_unchanged=$false; drivers_after=@(); error=$null +} +$cleanupAuthorized = $false +try { + foreach ($executable in @($manifest.launcher, $manifest.driver_probe)) { + $identity = @(& $executable --e2e-identity) + if ($LASTEXITCODE -ne 0 -or ($identity -join '').Trim() -cne "CHEBURNET_E2E_INSTANCE:$($manifest.instance)") { + throw 'executable is not the matching isolated host build' + } + } + if (Test-Path -LiteralPath $hostRoot) { throw 'test namespace already exists; use a new build instance' } + if (@(Get-Process -Name winws -ErrorAction SilentlyContinue).Count -ne 0 -or + @(Get-Service -Name 'WinDivert*' -ErrorAction SilentlyContinue).Count -ne 0 -or + @(Get-CimInstance Win32_SystemDriver -Filter "Name LIKE 'WinDivert%'").Count -ne 0) { + throw 'existing winws/WinDivert detected; host E2E refuses to disturb it' + } + $receipt.production_before = Get-ProductionFingerprint + $cleanupAuthorized = $true + & $windowsPS -NoProfile -ExecutionPolicy Bypass -File $harness -Launcher $manifest.launcher ` + -ResultsDir (Join-Path $results 'elevated') -HostInstance $manifest.instance -Scenario all ` + *> (Join-Path $results 'elevated.log') + if ($LASTEXITCODE -ne 0) { throw 'elevated scenarios failed; see elevated.log' } + & $manifest.driver_probe --driver-smoke *> (Join-Path $results 'driver-smoke.log') + $receipt.driver_exit_code = $LASTEXITCODE + if ($LASTEXITCODE -ne 0) { throw 'driver smoke failed; see driver-smoke.log' } + $reports = @(Get-ChildItem -LiteralPath $results -Filter e2e-report.json -Recurse -File) + if ($reports.Count -ne 2) { throw 'both token reports are required' } + $total = 0 + foreach ($file in $reports) { + $report = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + if ($report.environment_kind -cne 'host-isolated-build' -or $report.launcher_sha256 -cne $manifest.launcher_sha256 -or + $report.failed -ne 0 -or $report.skipped -ne 0) { throw 'invalid host scenario report' } + $total += $report.passed + } + if ($total -ne 6) { throw 'six host scenarios must pass' } + $receipt.passed = $true +} catch { $receipt.error = $_.Exception.Message } +finally { + if ($cleanupAuthorized) { + & $manifest.driver_probe --cleanup *> (Join-Path $results 'cleanup.log') + $receipt.cleanup_exit_code = $LASTEXITCODE + $receipt.production_after = Get-ProductionFingerprint + $receipt.production_unchanged = $receipt.production_before -ceq $receipt.production_after + } + $receipt.drivers_after = @(Get-CimInstance Win32_SystemDriver -Filter "Name LIKE 'WinDivert%'" | Select-Object Name,State,PathName) + $receipt.passed = $receipt.passed -and $receipt.production_unchanged -and + $receipt.cleanup_exit_code -eq 0 -and $receipt.drivers_after.Count -eq 0 -and + -not (Test-Path -LiteralPath $hostRoot) + $receipt.finished_at = [DateTime]::UtcNow.ToString('o') + $reportPath = Join-Path $results 'host-report.json' + [IO.File]::WriteAllText("$reportPath.tmp", ($receipt | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) + Move-Item -LiteralPath "$reportPath.tmp" -Destination $reportPath +} +exit $(if ($receipt.passed) { 0 } else { 1 }) diff --git a/scripts/e2e/New-CheburnetE2EBundle.ps1 b/scripts/e2e/New-CheburnetE2EBundle.ps1 new file mode 100644 index 0000000..7af3789 --- /dev/null +++ b/scripts/e2e/New-CheburnetE2EBundle.ps1 @@ -0,0 +1,49 @@ +# Stage a fixed candidate for both clean Windows client guests. No elevation needed. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Launcher, + [Parameter(Mandatory = $true)][string]$OutDir, + [string]$ExpectedSha256 +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$launcherPath = (Resolve-Path -LiteralPath $Launcher).Path +$destination = [IO.Path]::GetFullPath($OutDir) +if (Test-Path -LiteralPath $destination) { throw "bundle destination already exists: $destination" } +$hash = (Get-FileHash -LiteralPath $launcherPath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($ExpectedSha256 -and $hash -cne $ExpectedSha256.ToLowerInvariant()) { + throw 'candidate SHA-256 does not match the requested release' +} +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$revision = (& git -C $root rev-parse HEAD 2>$null) +if ($LASTEXITCODE -ne 0) { throw 'cannot determine source revision' } +$status = @(& git -C $root status --porcelain --untracked-files=normal) +if ($LASTEXITCODE -ne 0) { throw 'cannot determine source worktree status' } +New-Item -ItemType Directory -Path (Join-Path $destination 'release') -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $destination 'scripts\e2e') -Force | Out-Null +Copy-Item -LiteralPath $launcherPath -Destination (Join-Path $destination 'release\CHEBURNET.exe') +[IO.File]::WriteAllText((Join-Path $destination 'release\CHEBURNET.exe.sha256'), + "$hash CHEBURNET.exe`r`n", [Text.Encoding]::ASCII) +foreach ($script in Get-ChildItem -LiteralPath $PSScriptRoot -Filter '*.ps1' -File) { + Copy-Item -LiteralPath $script.FullName -Destination (Join-Path $destination 'scripts\e2e') +} +Copy-Item -LiteralPath (Join-Path $root 'docs\production\E2E_RELEASE_GATE.md') ` + -Destination (Join-Path $destination 'E2E_RELEASE_GATE.md') +$files = @(Get-ChildItem -LiteralPath $destination -Recurse -File | Sort-Object FullName | ForEach-Object { + [ordered]@{ + path = $_.FullName.Substring($destination.TrimEnd('\').Length + 1).Replace('\', '/') + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + } +}) +$manifest = [ordered]@{ + schema = 1 + prepared_at = [DateTime]::UtcNow.ToString('o') + source_revision = [string]$revision + source_dirty = ($status.Count -ne 0) + launcher_sha256 = $hash + target_editions = @('win10', 'win11') + files = $files +} +[IO.File]::WriteAllText((Join-Path $destination 'bundle-manifest.json'), + ($manifest | ConvertTo-Json -Depth 5), [Text.UTF8Encoding]::new($false)) +Write-Output "E2E_BUNDLE: $destination SHA256=$hash" diff --git a/scripts/e2e/New-CheburnetTestVm.ps1 b/scripts/e2e/New-CheburnetTestVm.ps1 index 807eb6e..183cbdf 100644 --- a/scripts/e2e/New-CheburnetTestVm.ps1 +++ b/scripts/e2e/New-CheburnetTestVm.ps1 @@ -10,7 +10,7 @@ What it automates: * a Generation 2 VM with Secure Boot, TPM and a fixed, documented spec; * a provisioning VHDX carrying the release artifacts, the repository test scripts and the disposable-environment marker; - * a checkpoint taken immediately after Windows setup, so every scenario can + * commands for a checkpoint after manual Windows setup, so every run can start from a genuinely clean state. What stays manual, and why: choosing the Windows edition and image index inside @@ -22,12 +22,13 @@ Run elevated on a Hyper-V host. ASCII-only on purpose: PowerShell 5.1 reads .ps1 as ANSI without a BOM. #> -[CmdletBinding()] +[CmdletBinding(DefaultParameterSetName = 'Release')] param( [Parameter(Mandatory = $true)][ValidateSet('win10', 'win11')][string]$Edition, [Parameter(Mandatory = $true)][string]$IsoPath, - [Parameter(Mandatory = $true)][string]$ReleaseDir, - [string]$VmName, + [Parameter(Mandatory = $true, ParameterSetName = 'Release')][string]$ReleaseDir, + [Parameter(Mandatory = $true, ParameterSetName = 'Bundle')][string]$BundleDir, + [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')][string]$VmName, [string]$VmRoot = 'C:\CheburnetE2E', [string]$SwitchName = 'Default Switch', [int64]$MemoryBytes = 6GB, @@ -37,6 +38,32 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +function Copy-CheburnetE2EBundle([string]$Source, [string]$Destination) { + $sourceRoot = [IO.Path]::GetFullPath($Source).TrimEnd('\') + '\' + $manifestPath = Join-Path $sourceRoot 'bundle-manifest.json' + $manifest = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($manifest.schema -ne 1) { throw 'unsupported bundle schema' } + $copies = @() + foreach ($entry in $manifest.files) { + $inputPath = [IO.Path]::GetFullPath((Join-Path $sourceRoot $entry.path)) + $outputRoot = [IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\' + $outputPath = [IO.Path]::GetFullPath((Join-Path $outputRoot $entry.path)) + if (-not $inputPath.StartsWith($sourceRoot, [StringComparison]::OrdinalIgnoreCase) -or + -not $outputPath.StartsWith($outputRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'bundle path escaped its root' + } + if ((Get-FileHash -LiteralPath $inputPath -Algorithm SHA256).Hash.ToLowerInvariant() -cne $entry.sha256) { + throw "bundle SHA-256 mismatch: $($entry.path)" + } + $copies += [pscustomobject]@{Source=$inputPath; Target=$outputPath} + } + foreach ($copy in $copies) { + New-Item -ItemType Directory -Path (Split-Path -Parent $copy.Target) -Force | Out-Null + Copy-Item -LiteralPath $copy.Source -Destination $copy.Target + } + Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $Destination 'bundle-manifest.json') +} + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { @@ -47,12 +74,26 @@ if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { } $iso = [IO.Path]::GetFullPath($IsoPath) if (-not (Test-Path -LiteralPath $iso -PathType Leaf)) { throw "ISO not found: $iso" } -$release = [IO.Path]::GetFullPath($ReleaseDir) -foreach ($required in @('CHEBURNET.exe', 'CHEBURNET.exe.sha256')) { - if (-not (Test-Path -LiteralPath (Join-Path $release $required) -PathType Leaf)) { - throw "release directory is missing $required : $release" +$candidate = if ($PSCmdlet.ParameterSetName -ceq 'Bundle') { + [IO.Path]::GetFullPath($BundleDir) +} else { [IO.Path]::GetFullPath($ReleaseDir) } +$launcherRelative = if ($BundleDir) { 'release\CHEBURNET.exe' } else { 'CHEBURNET.exe' } +foreach ($required in @($launcherRelative, "$launcherRelative.sha256")) { + if (-not (Test-Path -LiteralPath (Join-Path $candidate $required) -PathType Leaf)) { + throw "candidate directory is missing $required : $candidate" } } +$expected = ((Get-Content -LiteralPath (Join-Path $candidate "$launcherRelative.sha256") -Raw).Trim() -split '\s+')[0] +if ($expected -notmatch '^[a-fA-F0-9]{64}$' -or + (Get-FileHash -LiteralPath (Join-Path $candidate $launcherRelative) -Algorithm SHA256).Hash -ine $expected) { + throw 'candidate SHA-256 mismatch' +} +if ($BundleDir -and -not (Test-Path -LiteralPath (Join-Path $candidate 'bundle-manifest.json'))) { + throw 'prepared bundle is missing bundle-manifest.json' +} +if (-not (Get-VMSwitch -Name $SwitchName -ErrorAction SilentlyContinue)) { + throw "Hyper-V switch not found: $SwitchName" +} if ([string]::IsNullOrWhiteSpace($VmName)) { $VmName = "CHEBURNET-E2E-$Edition" } @@ -60,42 +101,31 @@ if (Get-VM -Name $VmName -ErrorAction SilentlyContinue) { throw "virtual machine already exists: $VmName (remove it or pass -VmName)" } -$vmDir = Join-Path $VmRoot $VmName -New-Item -ItemType Directory -Path $vmDir -Force | Out-Null +$vmRootPath = [IO.Path]::GetFullPath($VmRoot).TrimEnd('\') + '\' +$vmDir = [IO.Path]::GetFullPath((Join-Path $vmRootPath $VmName)) +if (-not $vmDir.StartsWith($vmRootPath, [StringComparison]::OrdinalIgnoreCase)) { throw 'VM path escaped its root' } +if (Test-Path -LiteralPath $vmDir) { throw "VM directory already exists: $vmDir" } +New-Item -ItemType Directory -Path $vmDir | Out-Null $systemDisk = Join-Path $vmDir 'system.vhdx' $provisioningDisk = Join-Path $vmDir 'provisioning.vhdx' Write-Host "== Provisioning disk ==" -ForegroundColor Cyan New-VHD -Path $provisioningDisk -SizeBytes 2GB -Dynamic | Out-Null -$mounted = Mount-VHD -Path $provisioningDisk -PassThru | Initialize-Disk -PassThru | - New-Partition -AssignDriveLetter -UseMaximumSize -$volume = $mounted | Format-Volume -FileSystem NTFS -NewFileSystemLabel 'CHEBURNET-E2E' -Confirm:$false -$stage = "$($volume.DriveLetter):\" +$mounted = $false try { - New-Item -ItemType Directory -Path (Join-Path $stage 'release') -Force | Out-Null - Copy-Item -LiteralPath (Join-Path $release '*') -Destination (Join-Path $stage 'release') -Recurse - New-Item -ItemType Directory -Path (Join-Path $stage 'scripts') -Force | Out-Null - Copy-Item -LiteralPath $PSScriptRoot -Destination (Join-Path $stage 'scripts\e2e') -Recurse - - # Bootstrap run inside the guest. It marks the guest as disposable and runs - # the suite; it never reaches back out to the host. - $bootstrap = @' -# Run elevated inside the guest. -$ErrorActionPreference = 'Stop' -$stage = Split-Path -Parent $PSScriptRoot -New-Item -ItemType File -Path 'C:\cheburnet-e2e-vm.marker' -Force | Out-Null -$launcher = Join-Path $stage 'release\CHEBURNET.exe' -$expected = (Get-Content (Join-Path $stage 'release\CHEBURNET.exe.sha256') -Raw).Split(' ')[0].Trim() -$actual = (Get-FileHash -LiteralPath $launcher -Algorithm SHA256).Hash.ToLowerInvariant() -if ($actual -cne $expected) { throw "launcher SHA-256 mismatch: $actual" } -Write-Host "launcher SHA-256 verified: $actual" -ForegroundColor Green -& (Join-Path $PSScriptRoot 'Invoke-CheburnetE2E.ps1') -Launcher $launcher ` - -ResultsDir 'C:\cheburnet-e2e-results' -Scenario all -'@ - [IO.File]::WriteAllText((Join-Path $stage 'scripts\e2e\Run-InGuest.ps1'), $bootstrap, - [Text.UTF8Encoding]::new($false)) + $disk = Mount-VHD -Path $provisioningDisk -PassThru + $mounted = $true + $partition = $disk | Initialize-Disk -PassThru | New-Partition -AssignDriveLetter -UseMaximumSize + $volume = $partition | Format-Volume -FileSystem NTFS -NewFileSystemLabel 'CHEBURNET-E2E' -Confirm:$false + $stage = Join-Path "$($volume.DriveLetter):\" 'cheburnet-e2e' + if ($BundleDir) { + Copy-CheburnetE2EBundle -Source $candidate -Destination $stage + } else { + & (Join-Path $PSScriptRoot 'New-CheburnetE2EBundle.ps1') ` + -Launcher (Join-Path $candidate $launcherRelative) -ExpectedSha256 $expected -OutDir $stage + } } finally { - Dismount-VHD -Path $provisioningDisk + if ($mounted) { Dismount-VHD -Path $provisioningDisk } } Write-Host "== Virtual machine ==" -ForegroundColor Cyan @@ -109,12 +139,8 @@ Add-VMHardDiskDrive -VM $vm -Path $provisioningDisk # environment and are harmless for Windows 10. Set-VMFirmware -VM $vm -EnableSecureBoot On ` -FirstBootDevice (Get-VMDvdDrive -VM $vm) -try { - Set-VMKeyProtector -VM $vm -NewLocalKeyProtector - Enable-VMTPM -VM $vm -} catch { - Write-Warning "could not enable the virtual TPM: $($_.Exception.Message)" -} +Set-VMKeyProtector -VM $vm -NewLocalKeyProtector +Enable-VMTPM -VM $vm Write-Host '' Write-Host "Virtual machine created: $VmName" -ForegroundColor Green @@ -126,9 +152,11 @@ Write-Host " 1. Start-VM -Name $VmName and complete Windows setup from the ISO. Write-Host ' Choose the edition deliberately: the gate requires Windows 10 x64' Write-Host ' or Windows 11 x64 client, not Windows Server.' Write-Host " 2. Checkpoint-VM -Name $VmName -SnapshotName 'clean-after-setup'" -Write-Host ' 3. Inside the guest, open an elevated PowerShell on the CHEBURNET-E2E' -Write-Host ' volume and run: .\scripts\e2e\Run-InGuest.ps1' -Write-Host ' 4. Copy C:\cheburnet-e2e-results\*\e2e-report.json out of the guest and' +Write-Host ' 3. Inside the guest, open PowerShell on CHEBURNET-E2E:\cheburnet-e2e.' +Write-Host " Elevated: .\scripts\e2e\Run-InGuest.ps1 -Edition $Edition -PrepareOnly" +Write-Host " Standard: .\scripts\e2e\Run-InGuest.ps1 -Edition $Edition -Role standard-user" +Write-Host " Elevated: .\scripts\e2e\Run-InGuest.ps1 -Edition $Edition -Role elevated" +Write-Host ' 4. Copy C:\cheburnet-e2e-results recursively out of the guest and' Write-Host ' attach it to docs/production/FINAL_RELEASE_CERTIFICATION.md.' Write-Host " 5. Restore-VMSnapshot -Name 'clean-after-setup' -VMName $VmName before" Write-Host ' re-running the suite, so every run starts from a clean state.' diff --git a/scripts/e2e/Run-InGuest.ps1 b/scripts/e2e/Run-InGuest.ps1 new file mode 100644 index 0000000..9fb6df6 --- /dev/null +++ b/scripts/e2e/Run-InGuest.ps1 @@ -0,0 +1,61 @@ +# Execute only inside a disposable Windows client VM. Run both token roles. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][ValidateSet('win10', 'win11')][string]$Edition, + [ValidateSet('elevated', 'standard-user')][string]$Role = 'elevated', + [string]$ResultsDir = 'C:\cheburnet-e2e-results', + [switch]$PrepareOnly +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$stage = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$manifest = Get-Content -LiteralPath (Join-Path $stage 'bundle-manifest.json') -Raw -Encoding UTF8 | ConvertFrom-Json +foreach ($entry in $manifest.files) { + $path = [IO.Path]::GetFullPath((Join-Path $stage $entry.path)) + if (-not $path.StartsWith($stage.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'bundle file path escaped its root' + } + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $entry.sha256) { + throw "bundle SHA-256 mismatch: $($entry.path)" + } +} +$launcher = Join-Path $stage 'release\CHEBURNET.exe' +if ((Get-FileHash -LiteralPath $launcher -Algorithm SHA256).Hash.ToLowerInvariant() -cne $manifest.launcher_sha256) { + throw 'launcher SHA-256 mismatch' +} +$os = Get-CimInstance Win32_OperatingSystem +$machine = Get-CimInstance Win32_ComputerSystem +if ($machine.Model -notmatch 'Virtual Machine|VMware|VirtualBox|KVM|QEMU') { + throw 'Run-InGuest is restricted to a disposable VM; physical host refused' +} +$build = [int]$os.BuildNumber +if ($os.ProductType -ne 1 -or $env:PROCESSOR_ARCHITECTURE -cne 'AMD64' -or -not [Environment]::Is64BitOperatingSystem -or + -not [Environment]::Is64BitProcess -or $build -lt 10240 -or + ($Edition -ceq 'win10' -and $build -ge 22000) -or + ($Edition -ceq 'win11' -and $build -lt 22000)) { + throw "wrong target OS for $Edition : $($os.Caption) build $build; x64 Windows client required" +} +$elevated = ([Security.Principal.WindowsPrincipal]::new( + [Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if ($PrepareOnly) { + if (-not $elevated) { throw '-PrepareOnly needs an elevated guest PowerShell' } + New-Item -ItemType File -Path 'C:\cheburnet-e2e-vm.marker' -Force | Out-Null + New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null + # Evidence is shared between token roles, only inside the disposable guest. + $acl = Get-Acl -LiteralPath $ResultsDir + $users = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-545') + $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $users, 'Modify', 'ContainerInherit,ObjectInherit', 'None', 'Allow')) + Set-Acl -LiteralPath $ResultsDir -AclObject $acl + Write-Output 'Guest prepared. Run standard-user first, then elevated, from separate PowerShell sessions.' + exit 0 +} +if ($elevated -ne ($Role -ceq 'elevated')) { throw "wrong token role: expected $Role" } +if (-not (Test-Path -LiteralPath 'C:\cheburnet-e2e-vm.marker' -PathType Leaf)) { + throw 'prepare this disposable guest with -PrepareOnly first' +} +$roleResults = Join-Path $ResultsDir "$Edition\$Role" +& (Join-Path $PSScriptRoot 'Invoke-CheburnetE2E.ps1') -Launcher $launcher ` + -ResultsDir $roleResults -Scenario all +exit $LASTEXITCODE diff --git a/scripts/e2e/Test-CheburnetE2EMatrix.ps1 b/scripts/e2e/Test-CheburnetE2EMatrix.ps1 new file mode 100644 index 0000000..4c2e7e4 --- /dev/null +++ b/scripts/e2e/Test-CheburnetE2EMatrix.ps1 @@ -0,0 +1,48 @@ +# Validate the four real guest reports against one candidate. Never synthesizes evidence. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$ReportsDir, + [Parameter(Mandatory = $true)][string]$BundleManifest +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$manifest = Get-Content -LiteralPath $BundleManifest -Raw -Encoding UTF8 | ConvertFrom-Json +$reports = @(Get-ChildItem -LiteralPath $ReportsDir -Filter 'e2e-report.json' -File -Recurse) +if ($reports.Count -ne 4) { throw "exactly four reports are required (two roles per OS); found $($reports.Count)" } +$seen = @{} +foreach ($file in $reports) { + $report = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + $build = [int]$report.machine.build + if ($report.schema -ne 1 -or $report.environment_kind -cne 'disposable-vm' -or + $report.machine.product_type -ne 1 -or $build -lt 10240 -or + $report.machine.architecture -cne 'AMD64' -or + $report.launcher_sha256 -cne $manifest.launcher_sha256) { + throw "report OS, architecture or candidate mismatch: $($file.FullName)" + } + $edition = if ($build -ge 22000) { 'win11' } else { 'win10' } + $role = [string]$report.token_role + $required = @(switch -CaseSensitive ($role) { + 'elevated' { @('fresh-install', 'existing-install', 'interrupted-pending', 'startup-rollback', 'filesystem-guards') } + 'standard-user' { @('refuses-unelevated') } + default { throw "unexpected token role: $role" } + }) + $key = "$edition/$role" + if ($seen.ContainsKey($key)) { throw "duplicate OS/role report: $key" } + $seen[$key] = $true + if ($report.failed -ne 0 -or $report.skipped -ne 0 -or $report.passed -ne $required.Count -or + @($report.results).Count -ne $required.Count -or + @($report.required_scenarios).Count -ne $required.Count -or + @(Compare-Object @($report.required_scenarios | Sort-Object) @($required | Sort-Object)).Count -ne 0) { + throw "incomplete or failed gate: $key" + } + foreach ($scenario in $required) { + $record = @($report.results | Where-Object scenario -ceq $scenario) + if ($record.Count -ne 1 -or $record[0].outcome -cne 'passed') { + throw "required scenario did not pass: $key/$scenario" + } + } +} +foreach ($key in @('win10/elevated', 'win10/standard-user', 'win11/elevated', 'win11/standard-user')) { + if (-not $seen.ContainsKey($key)) { throw "missing report: $key" } +} +Write-Output 'E2E_MATRIX: PASS (Windows 10/11 x64, both token roles, 12 scenario results, same candidate)' diff --git a/scripts/write-upstream-diff.ps1 b/scripts/write-upstream-diff.ps1 new file mode 100644 index 0000000..c989cad --- /dev/null +++ b/scripts/write-upstream-diff.ps1 @@ -0,0 +1,17 @@ +# File-system preparation belongs to the writer, not the caller. +[CmdletBinding()] +param( + [string]$Root = (Split-Path -Parent $PSScriptRoot), + [Parameter(Mandatory = $true)][string]$OutDir +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +New-Item -ItemType Directory -Path $OutDir -Force | Out-Null +$diff = & git -C $Root diff --stat -- resources +if ($LASTEXITCODE -ne 0) { throw 'cannot read upstream diff' } +$status = & git -C $Root status --porcelain -- resources +if ($LASTEXITCODE -ne 0) { throw 'cannot read upstream status' } +[IO.File]::WriteAllText((Join-Path ([IO.Path]::GetFullPath($OutDir)) 'payload-diff.txt'), + ($diff -join [Environment]::NewLine) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllText((Join-Path ([IO.Path]::GetFullPath($OutDir)) 'payload-status.txt'), + ($status -join [Environment]::NewLine) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) diff --git a/src/app/App.cpp b/src/app/App.cpp index da3e0ba..630df7b 100644 --- a/src/app/App.cpp +++ b/src/app/App.cpp @@ -119,8 +119,18 @@ App::App(const CliFlags& flags) : cli_(flags) { rolledBack.previousKnownGood.clear(); rolledBack.pending.clear(); rolledBack.lastResult = "startup-integrity-rollback"; - if (!update::SaveRuntimeState(paths_.ActiveRuntimePath(), rolledBack)) { - throw std::runtime_error("не удалось зафиксировать состояние отката при запуске"); + ProcessManager recovery(paths_); + const auto running = recovery.Record(); + const bool committed = CommitIntegrityRecovery( + running.valid(), running.imagePath, paths_.WinwsExePath(), fallback.WinwsExePath(), + [&] { + const auto stopped = recovery.Stop(); + return stopped.status == StopStatus::Stopped || + stopped.status == StopStatus::NotRunning; + }, + [&] { return update::SaveRuntimeState(paths_.ActiveRuntimePath(), rolledBack); }); + if (!committed) { + throw std::runtime_error("не удалось согласовать процесс и состояние отката"); } paths_ = std::move(fallback); if (!fallbackCatalog.empty()) strategies::ActivateCatalog(std::move(fallbackCatalog)); @@ -147,6 +157,10 @@ App::App(const CliFlags& flags) : cli_(flags) { throw std::runtime_error("не удалось сохранить переход на стандартную стратегию"); } pm_ = std::make_unique(paths_); + if (pm_->IsConnectedByUs() && + !str::IEqualsAscii(pm_->Record().imagePath, paths_.WinwsExePath())) { + throw std::runtime_error("работающая версия не совпадает с доверенным состоянием среды"); + } operationState_.Complete(pm_->IsConnectedByUs() ? AppOperationState::Connected : AppOperationState::Disconnected); } @@ -179,6 +193,11 @@ ui::UiOptions App::BuildUiOptions() const { // ---- chrome helpers -------------------------------------------------------- void App::BeginFrame() { + const auto state = operationState_.Get(); + if (state == AppOperationState::Connected || state == AppOperationState::Disconnected || + state == AppOperationState::Error) + operationState_.ObserveProcess(pm_->IsConnectedByUs()); + PollUpdateCheck(); ui_->SyncSize(); ui_->FB().Clear(ui_->Attr(ui::UiColor::Default)); } @@ -191,6 +210,8 @@ void App::DrawHeader(std::wstring_view subtitle) { const std::wstring ver = L"v" CHEBURNET_VERSION_WSTR; fb.PutText(ui_->Width() - static_cast(ver.size()) - 2, 0, ver, th.Attr(ui::UiColor::Muted)); fb.HLine(2, 1, ui_->Width() - 4, th.G().h, th.Attr(ui::UiColor::PrimaryDim)); + if (!updateNotice_.empty()) + fb.PutText(2, 1, updateNotice_, ui_->Attr(ui::UiColor::Warning)); } void App::DrawFooter(std::wstring_view hints) { @@ -305,6 +326,19 @@ bool App::Confirm(std::wstring_view prompt) { // ---- run flow -------------------------------------------------------------- +int App::VerifyInstallation() { + ResourceExtractor extractor(paths_); + const auto prepared = extractor.EnsureExtracted(); + if (!prepared.ok) throw std::runtime_error(str::ToUtf8(prepared.error)); + std::vector catalog; + const auto checked = extractor.VerifyRuntime(catalog); + if (!checked.ok) throw std::runtime_error(str::ToUtf8(checked.error)); + if (!RuntimePaths::Exists(paths_.ConfigPath()) && !SaveConfig(paths_.ConfigPath(), config_)) + throw std::runtime_error("cannot initialize configuration"); + // Deliberately no UI, network check, Launcher, or child process creation. + return 0; +} + int App::Run() { ::SetConsoleTitleW(CHEBURNET_WINDOW_TITLE); ui_ = std::make_unique(BuildUiOptions()); @@ -356,19 +390,12 @@ void App::PollUpdateCheck() { if (!updateCheck_->TryTakeResult(check)) return; // Результат пришёл из фонового потока уже готовым значением: фоновый код // никогда не рисует сам и ничего не меняет в интерфейсе. - if (check.status == update::CheckStatus::Available && - check.manifest.payload.version != config_.update.skippedPayloadVersion) { - ShowMessage(L"ДОСТУПНО ОБНОВЛЕНИЕ", - L"Проверенный манифест обновления предлагает CHEBURNET " + - str::ToUtf16(check.manifest.launcher.version) + L" и движок " + - str::ToUtf16(check.manifest.payload.version) + - L".\nПрименение доступно в меню «Проверить обновления».", - ui::UiColor::Warning); - } else if (check.status == update::CheckStatus::Rejected) { - ShowMessage(L"ОБНОВЛЕНИЕ ОТКЛОНЕНО", check.message, ui::UiColor::Error); + if (update::ShouldNotify(check, config_.update.skippedPayloadVersion)) { + updateNotice_ = check.status == update::CheckStatus::Rejected + ? L"Обновление отклонено — см. меню обновлений" + : L"Доступно обновление — меню [U]"; } - // Offline/current остаются намеренно молчаливыми: запуск обязан оставаться - // рабочим с уже установленной проверенной средой. + } App::Post App::DoConnectFlow() { diff --git a/src/app/App.h b/src/app/App.h index 9bcc37d..1755c10 100644 --- a/src/app/App.h +++ b/src/app/App.h @@ -23,6 +23,7 @@ struct CliFlags { bool reducedMotion = false; bool asciiOnly = false; bool debugUi = false; + bool verifyInstall = false; }; // Top-level controller. Owns runtime paths, config, the winws ProcessManager and @@ -36,6 +37,7 @@ class App { ~App(); int Run(); + int VerifyInstallation(); private: const RuntimeStrategy& CurrentStrategy() const; @@ -71,6 +73,7 @@ class App { // интерфейса. Подключение никогда не ждёт сетевого ввода-вывода. void StartUpdateCheck(); void PollUpdateCheck(); + std::wstring updateNotice_; bool DownloadWithProgress(update::UpdateManager& manager, const update::Artifact& artifact, const std::wstring& finalName, diff --git a/src/app/Diagnostics.cpp b/src/app/Diagnostics.cpp index 041193a..15deb13 100644 --- a/src/app/Diagnostics.cpp +++ b/src/app/Diagnostics.cpp @@ -1,5 +1,13 @@ #include "Diagnostics.h" +#include +// Windows SDK 10.0.26100: /analyze C6101 in the unused inline +// WSAGetIPUserMtu wrapper (getsockopt's SAL does not prove the output write). +// Suppress only while parsing the SDK header, never CHEBURNET code. +#pragma warning(push) +#pragma warning(disable: 6101) +#include +#pragma warning(pop) #include #include #include @@ -104,20 +112,30 @@ std::wstring RedactAddresses(std::wstring_view text) { pos = probe; continue; } - // IPv6: не меньше трёх групп шестнадцатеричных цифр через двоеточие. - probe = pos; - int groups = 0; - while (probe < text.size()) { - const std::size_t groupStart = probe; - while (probe < text.size() && IsHexDigit(text[probe]) && probe - groupStart < 4) ++probe; - if (probe == groupStart) break; - ++groups; - if (probe < text.size() && text[probe] == L':') ++probe; else break; - } - if (groups >= 3 && (pos == 0 || !IsHexDigit(text[pos - 1]))) { - output.append(kRedactedAddress); - pos = probe; - continue; + // Candidate must start at a token boundary. Validate the complete + // address; never redact only a suffix of a malformed address. + const auto addressChar = [](wchar_t c) { + return (c >= L'0' && c <= L'9') || (c >= L'a' && c <= L'z') || + (c >= L'A' && c <= L'Z') || c == L':' || c == L'.' || + c == L'%' || c == L'_' || c == L'-'; + }; + if ((text[pos] == L':' || IsHexDigit(text[pos])) && + (pos == 0 || !addressChar(text[pos - 1]))) { + probe = pos; + while (probe < text.size() && addressChar(text[probe])) ++probe; + std::wstring candidate(text.substr(pos, probe - pos)); + const auto scope = candidate.find(L'%'); + const bool scopeOk = scope == std::wstring::npos || + (scope + 1 < candidate.size() && + candidate.find(L'%', scope + 1) == std::wstring::npos); + const std::wstring address = candidate.substr(0, scope); + IN6_ADDR ipv6{}; + if (scopeOk && address.find(L':') != std::wstring::npos && + ::InetPtonW(AF_INET6, address.c_str(), &ipv6) == 1) { + output.append(kRedactedAddress); + pos = probe; + continue; + } } output.push_back(text[pos++]); } @@ -363,9 +381,10 @@ Bundle BuildBundle(const RuntimePaths& paths, const Config& config, std::vector catalog; ExtractionResult integrity; + std::vector activeFiles; { ResourceExtractor verifier(paths); - integrity = verifier.VerifyInstalledRuntime(catalog); + integrity = verifier.VerifyRuntime(catalog, &activeFiles); } std::string summary; @@ -410,16 +429,15 @@ Bundle BuildBundle(const RuntimePaths& paths, const Config& config, // Хеши только тех файлов, которыми владеет CHEBURNET. Пути внутри архива // относительные, чтобы не раскрывать раскладку машины. bool firstBinary = true; - for (int i = 0; i < kEmbeddedResourceCount; ++i) { - const auto& resource = kEmbeddedResources[i]; + for (const auto& resource : activeFiles) { if (!firstBinary) summary += ",\n"; firstBinary = false; summary += " {" + Quote(std::string_view("name")) + ": " + - Quote(std::string_view(resource.logicalName)) + ", " + + Quote(std::wstring_view(resource.path)) + ", " + Quote(std::string_view("expected_sha256")) + ": " + Quote(std::string_view(resource.sha256)) + ", " + Quote(std::string_view("bytes")) + ": " + - std::to_string(resource.expectedSize) + "}"; + std::to_string(resource.size) + "}"; } summary += "\n ]\n}\n"; diff --git a/src/app/OperationState.h b/src/app/OperationState.h index ceef346..66e4c9a 100644 --- a/src/app/OperationState.h +++ b/src/app/OperationState.h @@ -30,6 +30,16 @@ class OperationState final { std::memory_order_acquire); } + void ObserveProcess(bool alive) noexcept { + auto state = Get(); + while (state == AppOperationState::Connected || state == AppOperationState::Disconnected || + state == AppOperationState::Error) { + const auto desired = alive ? AppOperationState::Connected : AppOperationState::Disconnected; + if (state_.compare_exchange_weak(state, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) return; + } + } + void Complete(AppOperationState state) noexcept { state_.store(state, std::memory_order_release); } diff --git a/src/app/RuntimeRecovery.cpp b/src/app/RuntimeRecovery.cpp index 107999b..7f3c88e 100644 --- a/src/app/RuntimeRecovery.cpp +++ b/src/app/RuntimeRecovery.cpp @@ -27,4 +27,14 @@ IntegrityAction DecideIntegrityRecovery(bool runtimeIsEmbeddedVersion, bool acti return IntegrityAction::RollbackToPrevious; } +bool CommitIntegrityRecovery(bool processValid, std::wstring_view runningImage, + std::wstring_view failedImage, std::wstring_view fallbackImage, + const std::function& stop, + const std::function& commit) { + if (processValid && !str::IEqualsAscii(runningImage, fallbackImage)) { + if (!str::IEqualsAscii(runningImage, failedImage) || !stop || !stop()) return false; + } + return commit && commit(); +} + } // namespace cheburnet diff --git a/src/app/RuntimeRecovery.h b/src/app/RuntimeRecovery.h index 12db0bb..95d038f 100644 --- a/src/app/RuntimeRecovery.h +++ b/src/app/RuntimeRecovery.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include namespace cheburnet { @@ -53,4 +54,12 @@ enum class IntegrityAction { IntegrityAction DecideIntegrityRecovery(bool runtimeIsEmbeddedVersion, bool activeVerified, bool haveTrustedState, bool havePreviousKnownGood); +// Stop a verified failed current process before committing fallback metadata. +// No process is started here: the normal connection flow starts the verified +// fallback afterwards. On failure the caller must refuse startup. +bool CommitIntegrityRecovery(bool processValid, std::wstring_view runningImage, + std::wstring_view failedImage, std::wstring_view fallbackImage, + const std::function& stop, + const std::function& commit); + } // namespace cheburnet diff --git a/src/app/UiScreens.cpp b/src/app/UiScreens.cpp index f6a1e8a..ffe97ab 100644 --- a/src/app/UiScreens.cpp +++ b/src/app/UiScreens.cpp @@ -268,6 +268,7 @@ ConnectResult App::ScreenConnect() { ConnectResult result; std::atomic done{false}; const bool alreadyConnected = pm_->IsConnectedByUs(); + operationState_.ObserveProcess(alreadyConnected); if (alreadyConnected) { operationState_.Complete(AppOperationState::Connected); } else if (!operationState_.TryTransition(AppOperationState::Disconnected, @@ -729,11 +730,12 @@ void App::ScreenMainMenu() { break; } ResourceExtractor extractor(paths_); - const int removed = extractor.CleanupOldVersions(); + const CleanupResult cleanup = extractor.CleanupOldVersions(); operationState_.Complete(prior); ShowMessage(L"ОБСЛУЖИВАНИЕ", - L"Удалено старых версий: " + std::to_wstring(removed) + L".", - UiColor::Primary); + cleanup.ok ? L"Удалено старых версий: " + + std::to_wstring(cleanup.removed) + L"." : cleanup.error, + cleanup.ok ? UiColor::Primary : UiColor::Error); } break; } @@ -1238,17 +1240,36 @@ void App::ScreenUpdates() { UiColor::Error); return; } - ShowMessage(L"ПРОВЕРКА ОБНОВЛЕНИЙ", - L"[СЕТЬ] HTTPS / системный прокси\n[ПОДПИСЬ] Отдельная подпись ECDSA P-256\n" - L"Проверка выполняется. Сетевой сбой не мешает рабочей среде.", - UiColor::Primary); // Фоновая проверка останавливается и присоединяется до явной проверки, // чтобы два запроса не конкурировали за один кэш проверенного манифеста и // чтобы пользователь видел результат именно своего действия. if (updateCheck_) updateCheck_->Cancel(); update::UpdateManager manager(paths_); - const update::CheckResult check = manager.CheckNow( - config_.update.mode != UpdateMode::Disabled); + update::UpdateCheckService manual([&](std::atomic& cancel) { + return manager.CheckNow(config_.update.mode != UpdateMode::Disabled, &cancel); + }); + if (!manual.Start()) { + operationState_.Complete(prior); + return; + } + update::CheckResult check; + while (!manual.TryTakeResult(check)) { + BeginFrame(); + if (EnsureUsableSize()) { + DrawHeader(L"// обновления"); + ui_->FB().PutText(4, 5, L"Проверка обновлений...", + ui_->Attr(UiColor::Primary)); + DrawFooter(L"Esc — отменить"); + } + ui_->Present(); + if (ui_->In().Poll(60).key == ui::Key::Esc) { + manual.Cancel(); + operationState_.Complete(pm_->IsConnectedByUs() ? AppOperationState::Connected + : AppOperationState::Disconnected); + return; + } + } + updateNotice_.clear(); std::wstring details = check.message + L"\n\n"; details += L"CHEBURNET: " CHEBURNET_VERSION_WSTR; details += L"\nДвижок: " + paths_.RuntimeVersion(); diff --git a/src/core/Launcher.cpp b/src/core/Launcher.cpp index 44c2dde..8d23f53 100644 --- a/src/core/Launcher.cpp +++ b/src/core/Launcher.cpp @@ -178,17 +178,30 @@ ConnectResult Launcher::Connect(const RuntimeStrategy& strategy, GameFilterMode L"Запустите CHEBURNET от имени администратора."); okcp(3); started(4); - if (!securefs::EnsureProtectedDirectory(paths_.RuntimeVersionDir()).ok || - !securefs::EnsureProtectedDirectory(paths_.BinDir()).ok || - !securefs::EnsureProtectedDirectory(paths_.ListsDir()).ok) + if (!securefs::EnsureTrustedDirectory(paths_.RuntimeVersionDir()).ok || + !securefs::EnsureTrustedDirectory(paths_.BinDir()).ok || + !securefs::EnsureTrustedDirectory(paths_.ListsDir()).ok) return fail(4, L"Не удалось подготовить рабочую директорию.", paths_.RuntimeVersionDir()); - if (!extractor.HardenRuntimeDirs()) + if (!extractor.VerifyRuntimeDirs()) return fail(4, L"Не удалось защитить рабочую директорию (ACL).", L"Проверьте права администратора и владельца:\n" + paths_.ProgramDataRoot()); okcp(4); // ---- Conflict / already-running checks ---- if (pm_.IsConnectedByUs()) { + // Reusing an existing PID must satisfy the same trust contract as a + // fresh launch. An old session may have been started by a vulnerable + // launcher or its on-disk runtime may have become corrupt meanwhile. + std::vector catalog; + const auto verified = extractor.VerifyRuntime(catalog); + if (!verified.ok) { + const auto stopped = pm_.Stop(); + const bool stoppedOk = stopped.status == StopStatus::Stopped || + stopped.status == StopStatus::NotRunning; + return fail(4, L"Активная среда не прошла проверку доверия.", + verified.error + (stoppedOk ? L"\nПроверенный процесс остановлен." + : L"\nОстановку процесса подтвердить не удалось.")); + } r.kind = ConnectKind::AlreadyRunningOurs; r.message = L"CHEBURNET уже подключён (winws.exe запущен ранее)."; ui::UiEvent e{ui::UiEventType::Info, 0, floor, ui::Severity::Warn, r.message, L""}; diff --git a/src/core/ProcessManager.cpp b/src/core/ProcessManager.cpp index 461bc43..7276881 100644 --- a/src/core/ProcessManager.cpp +++ b/src/core/ProcessManager.cpp @@ -151,7 +151,7 @@ StartResult ProcessManager::Start(const std::wstring& exePath, // would write through a pointer with no storage behind it. Fail closed // instead of creating an elevated child with unrestricted inheritance. SIZE_T attrSize = 0; - ::InitializeProcThreadAttributeList(nullptr, 1, 0, &attrSize); + ::InitializeProcThreadAttributeList(nullptr, 2, 0, &attrSize); if (attrSize == 0 || attrSize > 64 * 1024) { res.win32Error = ::GetLastError(); closeStd(); @@ -165,7 +165,7 @@ StartResult ProcessManager::Start(const std::wstring& exePath, closeStd(); return res; } - if (!::InitializeProcThreadAttributeList(attrList, 1, 0, &attrSize)) { + if (!::InitializeProcThreadAttributeList(attrList, 2, 0, &attrSize)) { res.win32Error = ::GetLastError(); closeStd(); return res; @@ -179,6 +179,15 @@ StartResult ProcessManager::Start(const std::wstring& exePath, return res; } + DWORD64 imagePolicy = PROCESS_CREATION_MITIGATION_POLICY_IMAGE_LOAD_PREFER_SYSTEM32_ALWAYS_ON; + if (!::UpdateProcThreadAttribute(attrList, 0, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + &imagePolicy, sizeof(imagePolicy), nullptr, nullptr)) { + res.win32Error = ::GetLastError(); + ::DeleteProcThreadAttributeList(attrList); + closeStd(); + return res; + } + STARTUPINFOEXW si{}; si.StartupInfo.cb = sizeof(si); si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; diff --git a/src/core/ResourceExtractor.cpp b/src/core/ResourceExtractor.cpp index f3e97e0..2986e80 100644 --- a/src/core/ResourceExtractor.cpp +++ b/src/core/ResourceExtractor.cpp @@ -12,6 +12,7 @@ #include "../util/Version.h" #include "../util/Win32Error.h" #include "IntegrityVerifier.h" +#include "ProcessManager.h" #include "SecureFs.h" #include "GeneratedManifest.h" #include "GeneratedProvenance.h" @@ -165,6 +166,75 @@ bool ValidateInstalledProvenance(const std::wstring& path, } // namespace +bool ValidateRuntimeInventory(const std::wstring& root, + const std::vector& allowed, + std::wstring& unexpected) { + std::set files; + std::set directories; + for (const auto& path : allowed) { + std::wstring folded = str::ToLowerAsciiW(path); + files.insert(folded); + for (auto slash = folded.find(L'\\'); slash != std::wstring::npos; + slash = folded.find(L'\\', slash + 1)) + directories.insert(folded.substr(0, slash)); + } + // Iterative and bounded, including directory count; a hostile tree cannot + // force unbounded recursion or allocation. + std::vector todo{L""}; + std::size_t visited = 0; + while (!todo.empty()) { + const std::wstring relative = std::move(todo.back()); + todo.pop_back(); + const std::wstring directory = relative.empty() ? root : root + L"\\" + relative; + if (!securefs::ValidateObject(directory, securefs::ObjectKind::Directory, false).ok) { + unexpected = directory; return false; + } + WIN32_FIND_DATAW data{}; + HANDLE find = ::FindFirstFileW((directory + L"\\*").c_str(), &data); + if (find == INVALID_HANDLE_VALUE) { + if (::GetLastError() == ERROR_FILE_NOT_FOUND) continue; + unexpected = directory; return false; + } + bool ok = true; + do { + const std::wstring name = data.cFileName; + if (name == L"." || name == L"..") continue; + const std::wstring child = relative.empty() ? name : relative + L"\\" + name; + const auto folded = str::ToLowerAsciiW(child); + const bool isDirectory = (data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; + if (++visited > 1024 || (data.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) || + (isDirectory ? !directories.contains(folded) : !files.contains(folded)) || + !securefs::ValidateObject(root + L"\\" + child, + isDirectory ? securefs::ObjectKind::Directory : securefs::ObjectKind::File, + !isDirectory).ok) { + unexpected = child; ok = false; break; + } + if (isDirectory) todo.push_back(child); + } while (::FindNextFileW(find, &data)); + const DWORD error = ok ? ::GetLastError() : ERROR_INVALID_DATA; + ::FindClose(find); + if (!ok || error != ERROR_NO_MORE_FILES) { + if (unexpected.empty()) unexpected = directory; + return false; + } + } + return true; +} + +namespace { +std::vector EmbeddedInventory() { + std::vector paths; + for (int i = 0; i < kEmbeddedResourceCount; ++i) + paths.emplace_back(kEmbeddedResources[i].relPath); + paths.emplace_back(L"strategies\\catalog.json"); + // A version installed by an older launcher may now be embedded. These + // data-only metadata files never participate in DLL lookup. + paths.emplace_back(L"runtime-manifest.json"); + paths.emplace_back(L"provenance.json"); + return paths; +} +} // namespace + ExtractionResult ResourceExtractor::EnsureExtracted() { ExtractionResult result; @@ -173,15 +243,21 @@ ExtractionResult ResourceExtractor::EnsureExtracted() { return VerifyInstalledRuntime(unused); } - if (!securefs::EnsureProtectedDirectory(paths_.RuntimeVersionDir()).ok || - !securefs::EnsureProtectedDirectory(paths_.BinDir()).ok || - !securefs::EnsureProtectedDirectory(paths_.ListsDir()).ok || + if (!securefs::EnsureTrustedDirectory(paths_.RuntimeVersionDir()).ok || + !securefs::EnsureTrustedDirectory(paths_.BinDir()).ok || + !securefs::EnsureTrustedDirectory(paths_.ListsDir()).ok || !securefs::EnsureProtectedDirectory(paths_.UserDir() + L"\\lists").ok) { result.error = L"Не удалось создать рабочую директорию: " + paths_.RuntimeVersionDir() + L"\nПричина: " + win32::FormatLastError(); return result; } + if (!ValidateRuntimeInventory(paths_.RuntimeVersionDir(), EmbeddedInventory(), + result.failedResource)) { + result.error = L"В рабочей среде найден посторонний объект: " + result.failedResource; + return result; + } + // User overlays are version-independent and are never replaced by an // upstream import. Empty files preserve upstream's expected filenames. for (const wchar_t* name : {L"list-general-user.txt", L"list-exclude-user.txt", @@ -265,7 +341,7 @@ ExtractionResult ResourceExtractor::EnsureExtracted() { } const std::wstring strategyDir = paths_.RuntimeVersionDir() + L"\\strategies"; - if (!securefs::EnsureProtectedDirectory(strategyDir).ok) { + if (!securefs::EnsureTrustedDirectory(strategyDir).ok) { result.error = L"Не удалось защитить каталог стратегий."; return result; } @@ -293,13 +369,14 @@ ExtractionResult ResourceExtractor::EnsureExtracted() { } ExtractionResult ResourceExtractor::VerifyInstalledRuntime( - std::vector& catalog) const { + std::vector& catalog, std::vector* filesOut) const { + catalog.clear(); + if (filesOut) filesOut->clear(); ExtractionResult result; for (const std::wstring& directory : { paths_.RuntimeVersionDir(), paths_.BinDir(), paths_.ListsDir(), paths_.RuntimeVersionDir() + L"\\strategies"}) { - if (!securefs::HardenObject(directory, securefs::ObjectKind::Directory).ok || - !securefs::ValidateProtectedObject( + if (!securefs::ValidateProtectedObject( directory, securefs::ObjectKind::Directory, false).ok) { result.error = L"ACL каталога установленной рабочей среды не прошёл проверку."; return result; @@ -327,6 +404,7 @@ ExtractionResult ResourceExtractor::VerifyInstalledRuntime( result.error = L"Манифест установленной рабочей среды имеет неверную схему или версию."; return result; } + std::vector verifiedFiles; bool winws = false; bool strategyCatalog = false; bool provenance = false; @@ -350,16 +428,23 @@ ExtractionResult ResourceExtractor::VerifyInstalledRuntime( } const std::wstring full = paths_.RuntimeVersionDir() + L"\\" + normalized; if (!FileIsValid(full, *size, sha->c_str()) || - !securefs::HardenObject(full, securefs::ObjectKind::File).ok) { + !securefs::ValidateProtectedObject(full, securefs::ObjectKind::File, true).ok) { result.failedResource = normalized; result.error = L"Проверка целостности установленной рабочей среды не пройдена: " + normalized; return result; } + verifiedFiles.push_back({normalized, *size, *sha}); winws = winws || folded == L"bin\\winws.exe"; strategyCatalog = strategyCatalog || folded == L"strategies\\catalog.json"; provenance = provenance || folded == L"provenance.json"; ++result.present; } + std::vector inventory{L"runtime-manifest.json"}; + for (const auto& file : verifiedFiles) inventory.push_back(file.path); + if (!ValidateRuntimeInventory(paths_.RuntimeVersionDir(), inventory, result.failedResource)) { + result.error = L"В установленной среде найден посторонний объект: " + result.failedResource; + return result; + } if (!winws || !strategyCatalog || !provenance || !ValidateInstalledProvenance(paths_.RuntimeVersionDir() + L"\\provenance.json", paths_.RuntimeVersion()) || @@ -367,6 +452,55 @@ ExtractionResult ResourceExtractor::VerifyInstalledRuntime( if (result.error.empty()) result.error = L"Рабочая среда не содержит обязательных компонентов."; return result; } + if (filesOut) *filesOut = std::move(verifiedFiles); + result.ok = true; + return result; +} + +ExtractionResult ResourceExtractor::VerifyRuntime(std::vector& catalog, + std::vector* files) const { + if (paths_.RuntimeVersion() != upstream::kVersion) + return VerifyInstalledRuntime(catalog, files); + ExtractionResult result; + catalog.clear(); + if (files) files->clear(); + if (!ValidateRuntimeInventory(paths_.RuntimeVersionDir(), EmbeddedInventory(), + result.failedResource)) { + result.error = L"Во встроенной среде найден посторонний объект: " + result.failedResource; + return result; + } + for (const auto& directory : {paths_.RuntimeVersionDir(), paths_.BinDir(), paths_.ListsDir(), + paths_.RuntimeVersionDir() + L"\\strategies"}) { + if (!securefs::ValidateProtectedObject(directory, securefs::ObjectKind::Directory, false).ok) { + result.error = L"Нарушена защита каталога встроенной среды: " + directory; + return result; + } + } + std::vector verified; + for (int i = 0; i < kEmbeddedResourceCount; ++i) { + const auto& resource = kEmbeddedResources[i]; + const auto full = paths_.RuntimeVersionDir() + L"\\" + resource.relPath; + if (!FileIsValid(full, resource.expectedSize, resource.sha256) || + !securefs::ValidateProtectedObject(full, securefs::ObjectKind::File, true).ok) { + result.failedResource = resource.relPath; + result.error = L"Нарушена целостность встроенной среды: " + result.failedResource; + return result; + } + verified.push_back({resource.relPath, resource.expectedSize, resource.sha256}); + ++result.present; + } + const auto catalogHash = IntegrityVerifier::Sha256Hex( + kGeneratedStrategyCatalogJson, std::char_traits::length(kGeneratedStrategyCatalogJson)); + if (!catalogHash || + !securefs::ValidateProtectedObject(paths_.StrategyCatalogPath(), + securefs::ObjectKind::File, true).ok || + !FileIsValid(paths_.StrategyCatalogPath(), + std::char_traits::length(kGeneratedStrategyCatalogJson), catalogHash->c_str()) || + !strategies::LoadCatalogFile(paths_.StrategyCatalogPath(), catalog, result.error)) { + if (result.error.empty()) result.error = L"Нарушена целостность встроенного каталога стратегий"; + return result; + } + if (files) *files = std::move(verified); result.ok = true; return result; } @@ -378,6 +512,8 @@ bool ResourceExtractor::VerifyBinaries(std::wstring& firstBad) const { firstBad = verified.failedResource; return verified.ok; } + if (!ValidateRuntimeInventory(paths_.RuntimeVersionDir(), EmbeddedInventory(), firstBad)) + return false; for (int i = 0; i < kEmbeddedResourceCount; ++i) { const EmbeddedResource& r = kEmbeddedResources[i]; if (r.category != ResourceCategory::Binary) continue; @@ -392,10 +528,9 @@ bool ResourceExtractor::VerifyBinaries(std::wstring& firstBad) const { return true; } -bool ResourceExtractor::HardenRuntimeDirs() { - // Every directory an attacker could pre-create must have its owner reset and - // a protected DACL applied - setting owner on a parent does NOT change a - // child's owner, so each level is hardened explicitly. +bool ResourceExtractor::VerifyRuntimeDirs() { + // Existing runtime directories must already be trusted. Never turn + // an attacker-owned tree into a trusted tree by only replacing its ACL. struct Dir { std::wstring path; bool critical; }; const Dir dirs[] = { {paths_.ProgramDataRoot(), true}, @@ -410,7 +545,7 @@ bool ResourceExtractor::HardenRuntimeDirs() { bool allCriticalOk = true; for (const Dir& d : dirs) { if (!RuntimePaths::Exists(d.path)) continue; - if (!securefs::HardenObject(d.path, securefs::ObjectKind::Directory).ok) { + if (!securefs::ValidateProtectedObject(d.path, securefs::ObjectKind::Directory, false).ok) { if (d.critical) allCriticalOk = false; } } @@ -422,30 +557,61 @@ bool ResourceExtractor::HardenRuntimeDirs() { return allCriticalOk; } -int ResourceExtractor::CleanupOldVersions() { - int removed = 0; - const std::wstring pattern = paths_.RuntimeRoot() + L"\\*"; - WIN32_FIND_DATAW fd; - HANDLE h = ::FindFirstFileW(pattern.c_str(), &fd); - if (h == INVALID_HANDLE_VALUE) return 0; +bool BuildRuntimeKeepSet(const update::StateResult& state, std::wstring_view active, + std::wstring_view embedded, std::wstring_view runningVersion, + std::vector& keep) { + keep.clear(); + if (!state.ok || state.state.current.empty() || active.empty() || embedded.empty()) return false; + const auto current = str::ToUtf16(state.state.current); + for (const auto version : {active, embedded, runningVersion, + std::wstring_view(current)}) { + if (!version.empty()) keep.emplace_back(version); + } + for (const auto& version : {state.state.previousKnownGood, state.state.pending}) + if (!version.empty()) keep.push_back(str::ToUtf16(version)); + return true; +} + +CleanupResult ResourceExtractor::CleanupOldVersions() { + CleanupResult result; + const update::StateResult state = update::LoadRuntimeState(paths_.ActiveRuntimePath()); + const auto record = ProcessManager::LoadRecord(paths_.ProgramDataRoot() + L"\\winws-run.txt"); + std::wstring runningVersion; + if (record.valid() && ProcessManager::IdentityMatches(record)) { + if (!ProcessManager::IsExpectedWinwsImage(record.imagePath, paths_.RuntimeRoot())) { + result.error = L"Неизвестная работающая среда; очистка отклонена"; return result; + } + const auto relative = record.imagePath.substr(paths_.RuntimeRoot().size() + 1); + runningVersion = relative.substr(0, relative.find(L'\\')); + } + std::vector keep; + if (!BuildRuntimeKeepSet(state, paths_.RuntimeVersion(), upstream::kVersion, runningVersion, keep)) { + result.error = L"Не удалось прочитать доверенное состояние; очистка отклонена"; return result; + } + if (!securefs::ValidateProtectedObject(paths_.RuntimeRoot(), + securefs::ObjectKind::Directory, false).ok) { + result.error = L"Нарушена защита корня среды; очистка отклонена"; return result; + } + WIN32_FIND_DATAW fd{}; + HANDLE find = ::FindFirstFileW((paths_.RuntimeRoot() + L"\\*").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) { result.error = L"Не удалось перечислить версии рабочей среды"; return result; } + bool ok = true; do { if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; const std::wstring name = fd.cFileName; if (name == L"." || name == L"..") continue; - bool preserve = name == upstream::kVersion; - const update::StateResult state = update::LoadRuntimeState(paths_.ActiveRuntimePath()); - if (state.ok) { - preserve = preserve || name == str::ToUtf16(state.state.current) || - name == str::ToUtf16(state.state.previousKnownGood) || - name == str::ToUtf16(state.state.pending); - } - if (preserve) continue; - const std::wstring full = paths_.RuntimeRoot() + L"\\" + name; - Logger::Info(L"удаляется старая версия рабочей среды: " + name); - if (securefs::RemoveTreeUnder(paths_.RuntimeRoot(), full).ok) ++removed; - } while (::FindNextFileW(h, &fd)); - ::FindClose(h); - return removed; + if (std::any_of(keep.begin(), keep.end(), [&](const auto& version) { + return str::IEqualsAscii(name, version); })) continue; + const auto removed = securefs::RemoveTreeUnder(paths_.RuntimeRoot(), + paths_.RuntimeRoot() + L"\\" + name); + if (!removed.ok) { result.error = removed.detail; ok = false; break; } + ++result.removed; + } while (::FindNextFileW(find, &fd)); + const DWORD error = ok ? ::GetLastError() : ERROR_INVALID_DATA; + ::FindClose(find); + result.ok = ok && error == ERROR_NO_MORE_FILES; + if (!result.ok && result.error.empty()) result.error = L"Ошибка перечисления версий рабочей среды"; + return result; } } // namespace cheburnet diff --git a/src/core/ResourceExtractor.h b/src/core/ResourceExtractor.h index 37af9c1..33852a7 100644 --- a/src/core/ResourceExtractor.h +++ b/src/core/ResourceExtractor.h @@ -1,9 +1,12 @@ #pragma once #include +#include +#include #include #include "RuntimePaths.h" #include "../config/Strategies.h" +#include "../update/RuntimeStateStore.h" namespace cheburnet { @@ -15,6 +18,27 @@ struct ExtractionResult { std::wstring failedResource; // logical name of the first failure }; +struct RuntimeFile { + std::wstring path; + std::uint64_t size = 0; + std::string sha256; +}; +struct CleanupResult { + bool ok = false; + int removed = 0; + std::wstring error; +}; +// Pure deletion policy. A missing/unreadable state authorizes no deletions. +bool BuildRuntimeKeepSet(const update::StateResult& state, std::wstring_view active, + std::wstring_view embedded, std::wstring_view runningVersion, + std::vector& keep); + +// Inventory validation does not change files/ACLs and never follows links. +// Missing allowlisted files are permitted during extraction, extras are not. +bool ValidateRuntimeInventory(const std::wstring& root, + const std::vector& allowed, + std::wstring& unexpected); + // Extracts the embedded runtime payload (winws.exe, WinDivert, cygwin, .bin // patterns, immutable vendor list files) into the embedded versioned runtime // directory. Every resource is verified by size + SHA-256 every launch and is @@ -30,7 +54,11 @@ class ResourceExtractor { // Validate all files of an externally installed runtime against its // protected runtime-manifest.json and load its strict strategy catalog. - ExtractionResult VerifyInstalledRuntime(std::vector& catalog) const; + ExtractionResult VerifyInstalledRuntime(std::vector& catalog, + std::vector* files = nullptr) const; + // Read-only inspection of either embedded or externally installed runtime. + ExtractionResult VerifyRuntime(std::vector& catalog, + std::vector* files = nullptr) const; // Re-hash every executable/binary resource on disk against the applicable // embedded or installed manifest. @@ -39,19 +67,16 @@ class ResourceExtractor { // (logical name) on the first size/SHA-256 mismatch or missing file. bool VerifyBinaries(std::wstring& firstBad) const; - // Harden every CHEBURNET directory: reset the OWNER to Administrators and - // apply a PROTECTED, inheritable DACL (SYSTEM/Administrators full, Users - // read+execute). Resetting the owner is essential: an object owner keeps - // implicit WRITE_DAC, so a standard user who pre-creates the tree could - // otherwise rewrite the DACL. Call BEFORE extracting so files inherit the - // locked-down ACL. Returns false if a critical directory cannot be secured; - // the launch pipeline treats that as fatal. - bool HardenRuntimeDirs(); + // Verify existing directories without adopting untrusted owners or ACLs. + // All critical runtime directories must already carry the exact policy. + bool VerifyRuntimeDirs(); // Remove runtime\ directories other than the current one. Never // recurses through reparse points (junction/symlink) - it unlinks them. - // Returns the number of old version directories removed. - int CleanupOldVersions(); + // Missing/corrupt state refuses all deletions. Preserve active paths and + // a verified running version in addition to current/previous/pending/embedded. + // Returns an explicit error, including partial deletion/enumeration errors. + CleanupResult CleanupOldVersions(); private: const RuntimePaths& paths_; diff --git a/src/core/RuntimePaths.cpp b/src/core/RuntimePaths.cpp index 07d4c03..9d5227c 100644 --- a/src/core/RuntimePaths.cpp +++ b/src/core/RuntimePaths.cpp @@ -37,6 +37,9 @@ RuntimePaths::RuntimePaths(std::wstring runtimeVersion) { const std::wstring programData = ProgramDataDir(); if (programData.empty()) return; root_ = programData + L"\\" CHEBURNET_PRODUCT_WNAME; +#ifdef CHEBURNET_E2E_INSTANCE + root_ += L"-E2E-" + str::ToUtf16(CHEBURNET_E2E_INSTANCE); +#endif logsDir_ = root_ + L"\\logs"; configPath_ = root_ + L"\\user\\config.json"; runtimeRoot_ = root_ + L"\\runtime"; @@ -57,6 +60,20 @@ RuntimePaths::RuntimePaths(std::wstring runtimeVersion) { listsDir_ = versionDir_ + L"\\lists"; } +RuntimePaths RuntimePaths::ForOwnedRoot(std::wstring root, std::wstring runtimeVersion) { + RuntimePaths result(std::move(runtimeVersion)); + result.root_ = std::move(root); + result.logsDir_ = result.root_ + L"\\logs"; + result.configPath_ = result.root_ + L"\\user\\config.json"; + result.runtimeRoot_ = result.root_ + L"\\runtime"; + result.updatesDir_ = result.root_ + L"\\updates"; + result.userDir_ = result.root_ + L"\\user"; + result.versionDir_ = result.runtimeRoot_ + L"\\" + result.runtimeVersion_; + result.binDir_ = result.versionDir_ + L"\\bin"; + result.listsDir_ = result.versionDir_ + L"\\lists"; + return result; +} + std::wstring RuntimePaths::WinwsExePath() const { return binDir_ + L"\\winws.exe"; } diff --git a/src/core/RuntimePaths.h b/src/core/RuntimePaths.h index d1e24b9..6a9864d 100644 --- a/src/core/RuntimePaths.h +++ b/src/core/RuntimePaths.h @@ -9,6 +9,8 @@ class RuntimePaths { public: RuntimePaths(); explicit RuntimePaths(std::wstring runtimeVersion); + // Isolated integration fixtures only; never populated from CLI/config/environment. + static RuntimePaths ForOwnedRoot(std::wstring root, std::wstring runtimeVersion); const std::wstring& ProgramDataRoot() const { return root_; } // %ProgramData%\CHEBURNET const std::wstring& LogsDir() const { return logsDir_; } // ...\logs diff --git a/src/core/SecureFs.cpp b/src/core/SecureFs.cpp index 9b8d1f6..6e09f2a 100644 --- a/src/core/SecureFs.cpp +++ b/src/core/SecureFs.cpp @@ -341,6 +341,26 @@ Result EnsureProtectedDirectory(const std::wstring& path) { return HardenObject(full, ObjectKind::Directory); } +Result EnsureTrustedDirectory(const std::wstring& path) { + const std::wstring parent = ParentOf(path); + Result parents = ValidatePathComponents(parent); + if (!parents.ok) return parents; + Result parentAcl = ValidateProtectedObject(parent, ObjectKind::Directory, false); + if (!parentAcl.ok) return parentAcl; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!::ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"O:BAD:PAI(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)", + SDDL_REVISION_1, &descriptor, nullptr)) + return WinError(L"Не удалось создать защиту доверенного каталога"); + SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), descriptor, FALSE}; + const BOOL created = ::CreateDirectoryW(path.c_str(), &attributes); + const DWORD error = created ? ERROR_SUCCESS : ::GetLastError(); + ::LocalFree(descriptor); + if (!created && error != ERROR_ALREADY_EXISTS) + return Error(error, L"Не удалось создать доверенный каталог: " + path); + return ValidateProtectedObject(path, ObjectKind::Directory, false); +} + Result AtomicWrite(const std::wstring& target, const void* data, std::size_t size, const std::string& expectedSha256) { std::optional memorySha = IntegrityVerifier::Sha256Hex(data, size); diff --git a/src/core/SecureFs.h b/src/core/SecureFs.h index eac73eb..2471d11 100644 --- a/src/core/SecureFs.h +++ b/src/core/SecureFs.h @@ -44,6 +44,10 @@ Result HardenObject(const std::wstring& path, ObjectKind kind); // reparse point, then harden and verify every component below the volume root. Result EnsureProtectedDirectory(const std::wstring& path); +// Create with the final owner/DACL, or verify an existing trusted directory. +// Never adopts a pre-created directory by changing its security. +Result EnsureTrustedDirectory(const std::wstring& path); + // Privileged atomic write: 128-bit random temp, CREATE_NEW + OPEN_REPARSE_POINT, // exact write/flush, mandatory ACL, SHA-256, atomic replace, reopen verification. Result AtomicWrite(const std::wstring& target, const void* data, std::size_t size, diff --git a/src/main.cpp b/src/main.cpp index 4144939..e3b6f1c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -68,6 +68,7 @@ void PrintVersion() { void PrintHelp() { WriteStdout(std::wstring(L"CHEBURNET v") + CHEBURNET_VERSION_WSTR + L"\r\nИспользование: CHEBURNET.exe [флаги]\r\n\r\n" + L" --verify-install проверить установку без запуска движка\r\n" L" --version показать версию и выйти\r\n" L" --no-animation мгновенный вывод без анимаций\r\n" L" --reduced-motion спокойный режим (без glitch/typewriter/idle)\r\n" @@ -82,6 +83,16 @@ void PrintHelp() { int wmain(int argc, wchar_t** argv) { cheburnet::CliFlags flags; for (int i = 1; i < argc; ++i) { + if (ArgEq(argv[i], L"--e2e-identity")) { +#ifdef CHEBURNET_E2E_INSTANCE + WriteStdout(L"CHEBURNET_E2E_INSTANCE:" + + cheburnet::str::ToUtf16(CHEBURNET_E2E_INSTANCE) + L"\r\n"); + return 0; +#else + WriteStderr(L"An isolated E2E build is required."); + return 2; // no bootstrap or UI on a production build +#endif + } if (ArgEq(argv[i], L"--version") || ArgEq(argv[i], L"-v")) { PrintVersion(); return 0; @@ -90,7 +101,8 @@ int wmain(int argc, wchar_t** argv) { PrintHelp(); return 0; } - if (ArgEq(argv[i], L"--no-animation")) flags.noAnimation = true; + if (ArgEq(argv[i], L"--verify-install")) flags.verifyInstall = true; + else if (ArgEq(argv[i], L"--no-animation")) flags.noAnimation = true; else if (ArgEq(argv[i], L"--reduced-motion")) flags.reducedMotion = true; else if (ArgEq(argv[i], L"--ascii-only")) flags.asciiOnly = true; else if (ArgEq(argv[i], L"--debug-ui")) flags.debugUi = true; @@ -102,7 +114,7 @@ int wmain(int argc, wchar_t** argv) { if (!::SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)) { const wchar_t* message = L"Не удалось включить безопасный поиск DLL. Запуск отклонён."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); return 6; } @@ -113,7 +125,7 @@ int wmain(int argc, wchar_t** argv) { &mutexDescriptor, nullptr)) { const wchar_t* message = L"Не удалось создать security descriptor блокировки запуска."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); return 7; } @@ -121,22 +133,25 @@ int wmain(int argc, wchar_t** argv) { mutexSecurity.nLength = sizeof(mutexSecurity); mutexSecurity.lpSecurityDescriptor = mutexDescriptor; mutexSecurity.bInheritHandle = FALSE; - HANDLE instanceMutex = ::CreateMutexW(&mutexSecurity, FALSE, - L"Global\\CHEBURNET.PublicRelease.Instance.v1"); + std::wstring mutexName = L"Global\\CHEBURNET.PublicRelease.Instance.v1"; +#ifdef CHEBURNET_E2E_INSTANCE + mutexName += L".E2E." + cheburnet::str::ToUtf16(CHEBURNET_E2E_INSTANCE); +#endif + HANDLE instanceMutex = ::CreateMutexW(&mutexSecurity, FALSE, mutexName.c_str()); const DWORD mutexError = ::GetLastError(); ::LocalFree(mutexDescriptor); if (!instanceMutex || mutexError == ERROR_ALREADY_EXISTS) { if (instanceMutex) ::CloseHandle(instanceMutex); const wchar_t* message = L"Другой экземпляр CHEBURNET уже выполняется."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET", MB_OK | MB_ICONINFORMATION); + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET", MB_OK | MB_ICONINFORMATION); return 5; } if (!cheburnet::PrivilegeManager::IsElevated()) { const wchar_t* message = L"CHEBURNET требует подтверждённые права администратора."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); ::CloseHandle(instanceMutex); return 2; } @@ -148,7 +163,7 @@ int wmain(int argc, wchar_t** argv) { if (paths.ProgramDataRoot().empty()) { const wchar_t* message = L"Не удалось получить доверенный путь ProgramData."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); ::CloseHandle(instanceMutex); return 3; @@ -157,9 +172,11 @@ int wmain(int argc, wchar_t** argv) { paths.ProgramDataRoot(), paths.LogsDir(), paths.RuntimeRoot(), paths.UpdatesDir(), paths.UserDir()); if (!bootstrap.ok) { + if (flags.verifyInstall) + WriteStderr(L"CHEBURNET_INSTALL_REFUSED:bootstrap:" + std::to_wstring(bootstrap.win32Error)); const std::wstring message = L"Защищённый bootstrap отклонён:\n" + bootstrap.detail; WriteStderr(message); - ::MessageBoxW(nullptr, message.c_str(), L"CHEBURNET — ОБНОВЛЕНИЕ ОТКЛОНЕНО", + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message.c_str(), L"CHEBURNET — ОБНОВЛЕНИЕ ОТКЛОНЕНО", MB_OK | MB_ICONERROR); ::CloseHandle(instanceMutex); return 3; @@ -167,7 +184,7 @@ int wmain(int argc, wchar_t** argv) { if (!cheburnet::Logger::Init(paths.LogsDir())) { const wchar_t* message = L"Не удалось создать защищённый журнал. Запуск отклонён."; WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — безопасный запуск", MB_OK | MB_ICONERROR); ::CloseHandle(instanceMutex); return 4; } @@ -175,19 +192,21 @@ int wmain(int argc, wchar_t** argv) { int code = 0; try { cheburnet::App app(flags); - code = app.Run(); + code = flags.verifyInstall ? app.VerifyInstallation() : app.Run(); + if (flags.verifyInstall && code == 0) + WriteStdout(L"CHEBURNET_INSTALL_VERIFIED\r\n"); } catch (const std::exception& error) { const std::wstring message = L"Критическая ошибка CHEBURNET: " + cheburnet::str::ToUtf16(error.what()); cheburnet::Logger::Error(message); WriteStderr(message); - ::MessageBoxW(nullptr, message.c_str(), L"CHEBURNET — КРИТИЧЕСКАЯ ОШИБКА", MB_OK | MB_ICONERROR); + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message.c_str(), L"CHEBURNET — КРИТИЧЕСКАЯ ОШИБКА", MB_OK | MB_ICONERROR); code = 1; } catch (...) { const wchar_t* message = L"Критическая неизвестная ошибка CHEBURNET."; cheburnet::Logger::Error(message); WriteStderr(message); - ::MessageBoxW(nullptr, message, L"CHEBURNET — КРИТИЧЕСКАЯ ОШИБКА", MB_OK | MB_ICONERROR); + if (!flags.verifyInstall) ::MessageBoxW(nullptr, message, L"CHEBURNET — КРИТИЧЕСКАЯ ОШИБКА", MB_OK | MB_ICONERROR); code = 1; } diff --git a/src/update/UpdateCheckService.cpp b/src/update/UpdateCheckService.cpp index 12b5fc1..bc643dc 100644 --- a/src/update/UpdateCheckService.cpp +++ b/src/update/UpdateCheckService.cpp @@ -6,6 +6,14 @@ namespace cheburnet::update { +bool ShouldNotify(const CheckResult& result, std::string_view skippedPayloadVersion) { + return result.status == CheckStatus::Rejected || + (result.status == CheckStatus::Available && + (result.launcher == Eligibility::Upgrade || + (result.payload == Eligibility::Upgrade && + result.manifest.payload.version != skippedPayloadVersion))); +} + UpdateCheckService::UpdateCheckService(CheckFunction check) : check_(std::move(check)) {} UpdateCheckService::UpdateCheckService(const RuntimePaths& paths) diff --git a/src/update/UpdateCheckService.h b/src/update/UpdateCheckService.h index ffcd0e8..3b8ef68 100644 --- a/src/update/UpdateCheckService.h +++ b/src/update/UpdateCheckService.h @@ -14,6 +14,8 @@ class RuntimePaths; namespace cheburnet::update { +bool ShouldNotify(const CheckResult& result, std::string_view skippedPayloadVersion); + enum class CheckPhase { Idle, Running, Ready, Taken, Cancelled }; // Выполняет одну проверку обновлений вне потока интерфейса, чтобы недоступный diff --git a/src/update/WinHttpClient.cpp b/src/update/WinHttpClient.cpp index 2856cb9..7c41e57 100644 --- a/src/update/WinHttpClient.cpp +++ b/src/update/WinHttpClient.cpp @@ -4,6 +4,10 @@ #include #include +#include +#include +#include +#include #include "../util/Version.h" #include "../core/SecureFs.h" @@ -34,9 +38,103 @@ bool Cancelled(const HttpOptions& options) { return options.cancel && options.cancel->load(std::memory_order_relaxed); } +// Only asynchronous WinHTTP requests are closed to cancel in-flight I/O. +// The callback context and read buffer remain alive through HANDLE_CLOSING, +// WinHTTP's final callback. No thread closes a synchronous pending handle. +class AsyncRequest final { +public: + AsyncRequest(HINTERNET handle, const HttpOptions& options, + std::chrono::steady_clock::time_point deadline) + : handle_(handle), options_(options), deadline_(deadline) { + if (!handle_) return; + DWORD_PTR context = reinterpret_cast(this); + if (!::WinHttpSetOption(handle_, WINHTTP_OPTION_CONTEXT_VALUE, + &context, sizeof(context))) return; + ready_ = ::WinHttpSetStatusCallback(handle_, Callback, + WINHTTP_CALLBACK_FLAG_ALL_COMPLETIONS | WINHTTP_CALLBACK_FLAG_HANDLES, + 0) != WINHTTP_INVALID_STATUS_CALLBACK; + } + ~AsyncRequest() { Close(); } + AsyncRequest(const AsyncRequest&) = delete; + AsyncRequest& operator=(const AsyncRequest&) = delete; + HINTERNET get() const { return handle_; } + bool ready() const { return ready_; } + + template + bool Run(DWORD expected, Begin begin, DWORD* read = nullptr) { + { + std::scoped_lock lock(mutex_); + completed_ = 0; + error_ = 0; + bytes_ = 0; + } + if (Cancelled(options_)) { ::SetLastError(ERROR_OPERATION_ABORTED); return false; } + if (std::chrono::steady_clock::now() >= deadline_) { + ::SetLastError(ERROR_WINHTTP_TIMEOUT); return false; + } + if (!begin() && ::GetLastError() != ERROR_IO_PENDING) return false; + std::unique_lock lock(mutex_); + while (completed_ != expected && error_ == 0) { + const bool cancel = Cancelled(options_); + const bool timeout = std::chrono::steady_clock::now() >= deadline_; + if (cancel || timeout) { + lock.unlock(); + Close(); + ::SetLastError(cancel ? ERROR_OPERATION_ABORTED : ERROR_WINHTTP_TIMEOUT); + return false; + } + condition_.wait_for(lock, std::chrono::milliseconds(25)); + } + if (error_ != 0) { ::SetLastError(error_); return false; } + if (read) *read = bytes_; + return true; + } + +private: + static void CALLBACK Callback(HINTERNET, DWORD_PTR context, DWORD status, + void* information, DWORD length) noexcept { + if (!context) return; + auto& self = *reinterpret_cast(context); + // Notifications are serialized by the mutex; Close waits until the + // final notification has released it before destroying this object. + std::scoped_lock lock(self.mutex_); + if (status == WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING) self.closed_ = true; + else if (status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR && information) + self.error_ = static_cast(information)->dwError; + else if (status == WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE || + status == WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE || + status == WINHTTP_CALLBACK_STATUS_READ_COMPLETE) { + self.completed_ = status; + if (status == WINHTTP_CALLBACK_STATUS_READ_COMPLETE) self.bytes_ = length; + } + self.condition_.notify_all(); + } + void Close() { + const HINTERNET handle = std::exchange(handle_, nullptr); + if (!handle) return; + ::WinHttpCloseHandle(handle); + if (ready_) { + std::unique_lock lock(mutex_); + condition_.wait(lock, [&] { return closed_; }); + } + } + HINTERNET handle_ = nullptr; + const HttpOptions& options_; + std::chrono::steady_clock::time_point deadline_; + std::mutex mutex_; + std::condition_variable condition_; + DWORD completed_ = 0; + DWORD error_ = 0; + DWORD bytes_ = 0; + bool closed_ = false; + bool ready_ = false; +}; + } // namespace HttpStatus ClassifyWinHttpError(unsigned long error) { + if (error == ERROR_OPERATION_ABORTED || error == ERROR_WINHTTP_OPERATION_CANCELLED) + return HttpStatus::Cancelled; return error == ERROR_WINHTTP_TIMEOUT ? HttpStatus::Timeout : HttpStatus::WinHttpError; } @@ -46,9 +144,15 @@ HttpResult WinHttpClient::Get(std::wstring_view initialUrl, const HttpOptions& o result.status = HttpStatus::InvalidUrl; return result; } + if (Cancelled(options)) { result.status = HttpStatus::Cancelled; return result; } + const auto deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds((std::max)(1, options.totalTimeoutMs)); const std::wstring agent = L"CHEBURNET/" CHEBURNET_VERSION_WSTR; - InternetHandle session(::WinHttpOpen(agent.c_str(), WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, - WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + InternetHandle session(::WinHttpOpen(agent.c_str(), options.useSystemProxy + ? WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY + : WINHTTP_ACCESS_TYPE_NO_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, + WINHTTP_FLAG_ASYNC)); if (!session.get()) { result.win32Error = ::GetLastError(); result.status = ClassifyWinHttpError(result.win32Error); @@ -79,10 +183,11 @@ HttpResult WinHttpClient::Get(std::wstring_view initialUrl, const HttpOptions& o if (!connection.get()) { result.win32Error = ::GetLastError(); result.status = ClassifyWinHttpError(result.win32Error); return result; } - InternetHandle request(::WinHttpOpenRequest(connection.get(), L"GET", path.c_str(), nullptr, + std::vector buffer(64 * 1024, 0); + AsyncRequest request(::WinHttpOpenRequest(connection.get(), L"GET", path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, - WINHTTP_FLAG_SECURE)); - if (!request.get()) { + WINHTTP_FLAG_SECURE), options, deadline); + if (!request.get() || !request.ready()) { result.win32Error = ::GetLastError(); result.status = ClassifyWinHttpError(result.win32Error); return result; } DWORD redirectPolicy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; @@ -92,11 +197,14 @@ HttpResult WinHttpClient::Get(std::wstring_view initialUrl, const HttpOptions& o } std::wstring headers; if (!options.etag.empty()) headers = L"If-None-Match: " + options.etag + L"\r\n"; - if (!::WinHttpSendRequest(request.get(), headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS - : headers.c_str(), - headers.empty() ? 0 : static_cast(-1), - WINHTTP_NO_REQUEST_DATA, 0, 0, 0) || - !::WinHttpReceiveResponse(request.get(), nullptr)) { + if (!request.Run(WINHTTP_CALLBACK_STATUS_SENDREQUEST_COMPLETE, [&] { + return ::WinHttpSendRequest(request.get(), + headers.empty() ? WINHTTP_NO_ADDITIONAL_HEADERS : headers.c_str(), + headers.empty() ? 0 : static_cast(-1), + WINHTTP_NO_REQUEST_DATA, 0, 0, reinterpret_cast(&request)); + }) || + !request.Run(WINHTTP_CALLBACK_STATUS_HEADERS_AVAILABLE, + [&] { return ::WinHttpReceiveResponse(request.get(), nullptr); })) { result.win32Error = ::GetLastError(); result.status = ClassifyWinHttpError(result.win32Error); return result; } DWORD status = 0, statusSize = sizeof(status); @@ -164,12 +272,14 @@ HttpResult WinHttpClient::Get(std::wstring_view initialUrl, const HttpOptions& o // frame (MSVC /analyze C6262). This function also runs on the // background update-check thread, so the transfer buffer must not // depend on how much stack that thread happens to have. - std::vector buffer(64 * 1024, 0); std::uint64_t total = 0; for (;;) { if (Cancelled(options)) { result.status = HttpStatus::Cancelled; return result; } DWORD read = 0; - if (!::WinHttpReadData(request.get(), buffer.data(), static_cast(buffer.size()), &read)) { + if (!request.Run(WINHTTP_CALLBACK_STATUS_READ_COMPLETE, [&] { + return ::WinHttpReadData(request.get(), buffer.data(), + static_cast(buffer.size()), nullptr); + }, &read)) { result.win32Error = ::GetLastError(); result.status = ClassifyWinHttpError(result.win32Error); return result; } if (read == 0) break; diff --git a/src/update/WinHttpClient.h b/src/update/WinHttpClient.h index a4f1cec..0d49e12 100644 --- a/src/update/WinHttpClient.h +++ b/src/update/WinHttpClient.h @@ -22,6 +22,8 @@ enum class HttpStatus { struct HttpOptions { std::uint64_t maxBytes = 64 * 1024; int maxRedirects = 4; + int totalTimeoutMs = 60000; // one deadline across redirects and body reads + bool useSystemProxy = true; int resolveTimeoutMs = 5000; int connectTimeoutMs = 8000; int sendTimeoutMs = 10000; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c99616c..d188895 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,34 @@ add_executable(cheburnet_tests test_main.cpp) +if(NOT CHEBURNET_E2E_INSTANCE STREQUAL "") + add_executable(host_driver_smoke host_driver_smoke.cpp + "${CMAKE_SOURCE_DIR}/resources/generated/cheburnet_payload.rc") + target_link_libraries(host_driver_smoke PRIVATE cheburnet_lib) + target_compile_options(host_driver_smoke PRIVATE ${CHEBURNET_WARN}) +endif() +add_executable(review_regressions review_regressions.cpp + "${CMAKE_SOURCE_DIR}/resources/generated/cheburnet_payload.rc") +target_link_libraries(review_regressions PRIVATE cheburnet_lib) +target_compile_options(review_regressions PRIVATE /W4 /WX /permissive- /utf-8) +foreach(case inventory cleanup recovery processstate notifications privacy httpcancel elevated) + add_test(NAME review_${case} COMMAND review_regressions ${case}) + set_tests_properties(review_${case} PROPERTIES TIMEOUT 20) +endforeach() +set_tests_properties(review_elevated PROPERTIES SKIP_RETURN_CODE 77) +add_test(NAME review_contracts + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/tests/review_contracts.ps1" + -RepositoryRoot "${CMAKE_SOURCE_DIR}" -Probe "$") +set_tests_properties(review_contracts PROPERTIES TIMEOUT 30) +add_test(NAME e2e_preparation + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/tests/e2e_preparation.ps1" + -RepositoryRoot "${CMAKE_SOURCE_DIR}" -Probe "$") +set_tests_properties(e2e_preparation PROPERTIES TIMEOUT 30) +add_test(NAME host_e2e_contracts + COMMAND powershell.exe -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_SOURCE_DIR}/tests/host_e2e_contracts.ps1" + -RepositoryRoot "${CMAKE_SOURCE_DIR}" -Probe "$") +set_tests_properties(host_e2e_contracts PROPERTIES TIMEOUT 30) target_link_libraries(cheburnet_tests PRIVATE cheburnet_lib) target_compile_options(cheburnet_tests PRIVATE /W4 /WX /permissive- /utf-8) diff --git a/tests/e2e_preparation.ps1 b/tests/e2e_preparation.ps1 new file mode 100644 index 0000000..d751c34 --- /dev/null +++ b/tests/e2e_preparation.ps1 @@ -0,0 +1,101 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string]$Probe +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('cheburnet-e2e-prep-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixture | Out-Null +try { + $bundle = Join-Path $fixture 'bundle' + & (Join-Path $RepositoryRoot 'scripts\e2e\New-CheburnetE2EBundle.ps1') -Launcher $Probe -OutDir $bundle | Out-Null + $manifestPath = Join-Path $bundle 'bundle-manifest.json' + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + if ($manifest.launcher_sha256 -cne (Get-FileHash -LiteralPath $Probe -Algorithm SHA256).Hash.ToLowerInvariant()) { + throw 'bundle must carry the actual candidate hash' + } + foreach ($entry in $manifest.files) { + if ((Get-FileHash -LiteralPath (Join-Path $bundle $entry.path) -Algorithm SHA256).Hash.ToLowerInvariant() -cne $entry.sha256) { + throw "staged file hash mismatch: $($entry.path)" + } + } + foreach ($name in @('New-CheburnetTestVm.ps1', 'Run-InGuest.ps1', 'Test-CheburnetE2EMatrix.ps1')) { + if (-not (Test-Path -LiteralPath (Join-Path $bundle "scripts\e2e\$name"))) { throw "missing $name" } + } + # Exercise the very same copy routine used for the mounted provisioning disk. + $tokens = $null; $parseErrors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile( + (Join-Path $RepositoryRoot 'scripts\e2e\New-CheburnetTestVm.ps1'), [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -ne 0) { throw 'VM script must parse' } + $copy = $ast.Find({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Copy-CheburnetE2EBundle' + }, $true) + if ($null -eq $copy) { throw 'missing VM payload copy routine' } + Invoke-Expression $copy.Extent.Text + $guestStage = Join-Path $fixture 'guest-volume\cheburnet-e2e' + Copy-CheburnetE2EBundle -Source $bundle -Destination $guestStage + $bootstrap = Join-Path $guestStage 'scripts\e2e\Run-InGuest.ps1' + $computedRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $bootstrap)) + if (-not (Test-Path -LiteralPath (Join-Path $computedRoot 'release\CHEBURNET.exe'))) { + throw 'guest bootstrap must resolve the actual launcher path' + } + [IO.File]::AppendAllText((Join-Path $bundle 'release\CHEBURNET.exe'), 'corruption') + $rejected = $false + try { Copy-CheburnetE2EBundle -Source $bundle -Destination (Join-Path $fixture 'invalid-stage') } catch { + $rejected = $_.Exception.Message -match 'SHA-256 mismatch' + } + if (-not $rejected -or (Test-Path -LiteralPath (Join-Path $fixture 'invalid-stage'))) { + throw 'tampered bundle must be refused before writing the guest volume' + } + # Synthetic reports exist only in this temporary regression fixture. + $reportRoot = Join-Path $fixture 'synthetic-reports' + $reportPaths = @() + foreach ($edition in @('win10', 'win11')) { + foreach ($role in @('elevated', 'standard-user')) { + $scenarios = if ($role -ceq 'elevated') { + @('fresh-install', 'existing-install', 'interrupted-pending', 'startup-rollback', 'filesystem-guards') + } else { @('refuses-unelevated') } + $folder = Join-Path $reportRoot "$edition\$role" + New-Item -ItemType Directory -Path $folder -Force | Out-Null + $path = Join-Path $folder 'e2e-report.json' + $report = [ordered]@{ + schema=1; environment_kind='disposable-vm'; launcher_sha256=$manifest.launcher_sha256; token_role=$role + machine=@{product_type=1; architecture='AMD64'; build=$(if ($edition -ceq 'win10') {19045} else {26100})} + required_scenarios=@($scenarios) + results=@($scenarios | ForEach-Object { @{scenario=$_; outcome='passed'} }) + passed=@($scenarios).Count; failed=0; skipped=0 + } + [IO.File]::WriteAllText($path, ($report | ConvertTo-Json -Depth 6)) + $reportPaths += $path + } + } + $validator = Join-Path $RepositoryRoot 'scripts\e2e\Test-CheburnetE2EMatrix.ps1' + & $validator -ReportsDir $reportRoot -BundleManifest $manifestPath | Out-Null + $original = Get-Content -LiteralPath $reportPaths[0] -Raw + foreach ($mutation in @('skip', 'hash', 'server', 'host', 'duplicate-scenario', 'duplicate-role', 'missing-report')) { + $bad = $original | ConvertFrom-Json + switch ($mutation) { + 'skip' { $bad.skipped=1; $bad.passed=4; $bad.results[0].outcome='skipped' } + 'hash' { $bad.launcher_sha256=('0' * 64) } + 'server' { $bad.machine.product_type=3 } + 'host' { $bad.environment_kind='host-isolated-build' } + 'duplicate-scenario' { $bad.results[0].scenario=$bad.results[1].scenario } + 'duplicate-role' { $bad.machine.build=26100 } + 'missing-report' { Remove-Item -LiteralPath $reportPaths[0] } + } + if ($mutation -cne 'missing-report') { + [IO.File]::WriteAllText($reportPaths[0], ($bad | ConvertTo-Json -Depth 6)) + } + $rejected = $false + try { & $validator -ReportsDir $reportRoot -BundleManifest $manifestPath | Out-Null } catch { $rejected=$true } + if (-not $rejected) { throw "matrix validator accepted $mutation" } + [IO.File]::WriteAllText($reportPaths[0], $original) + } + Write-Output 'E2E_PREPARATION: PASS (bundle hashes, guest copy/path, tampering refusal, matrix validation)' +} finally { + $full = [IO.Path]::GetFullPath($fixture) + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $full.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'cleanup escaped temporary directory' } + Remove-Item -LiteralPath $full -Recurse -Force +} diff --git a/tests/host_driver_smoke.cpp b/tests/host_driver_smoke.cpp new file mode 100644 index 0000000..599a855 --- /dev/null +++ b/tests/host_driver_smoke.cpp @@ -0,0 +1,117 @@ +// Opt-in host test only; never run by the ordinary CTest suite. +#include +#include +#include +#include +#include +#include "core/PrivilegeManager.h" +#include "core/ResourceExtractor.h" +#include "core/RuntimePaths.h" +#include "core/SecureFs.h" +#include "util/StringUtil.h" + +#ifndef CHEBURNET_E2E_INSTANCE +#error The driver smoke helper requires an isolated host E2E build +#endif + +namespace { +struct ServiceFixture { + SC_HANDLE manager = nullptr; + SC_HANDLE service = nullptr; + bool Remove() { + if (!service) return true; + SERVICE_STATUS status{}; + if (!::QueryServiceStatus(service, &status)) return false; + if (status.dwCurrentState != SERVICE_STOPPED) { + if (!::ControlService(service, SERVICE_CONTROL_STOP, &status) && + ::GetLastError() != ERROR_SERVICE_NOT_ACTIVE) return false; + const auto deadline = ::GetTickCount64() + 5000; + do { + if (!::QueryServiceStatus(service, &status)) return false; + if (status.dwCurrentState == SERVICE_STOPPED) break; + ::Sleep(25); + } while (::GetTickCount64() < deadline); + if (status.dwCurrentState != SERVICE_STOPPED) return false; + } + if (!::DeleteService(service) && ::GetLastError() != ERROR_SERVICE_MARKED_FOR_DELETE) + return false; + ::CloseServiceHandle(service); + service = nullptr; + return true; + } + ~ServiceFixture() { + if (!Remove()) std::printf("DRIVER_CLEANUP_FAILED: %lu\n", ::GetLastError()); + if (service) ::CloseServiceHandle(service); + if (manager) ::CloseServiceHandle(manager); + } +}; +struct Module { + HMODULE value = nullptr; + ~Module() { if (value) ::FreeLibrary(value); } +}; +template Function Procedure(HMODULE module, const char* name) { + const auto address = ::GetProcAddress(module, name); + Function result = nullptr; + static_assert(sizeof(result) == sizeof(address)); + std::memcpy(&result, &address, sizeof(result)); + return result; +} +} + +int wmain(int argc, wchar_t** argv) { + if (argc != 2 || !cheburnet::PrivilegeManager::IsElevated()) return 2; + const std::wstring mode = argv[1]; + if (mode == L"--e2e-identity") { + std::printf("CHEBURNET_E2E_INSTANCE:%s\n", CHEBURNET_E2E_INSTANCE); + return 0; + } + const cheburnet::RuntimePaths paths; + const auto& root = paths.ProgramDataRoot(); + if (root.empty() || root.substr(root.find_last_of(L'\\') + 1) != + L"CHEBURNET-E2E-" + cheburnet::str::ToUtf16(CHEBURNET_E2E_INSTANCE)) return 3; + if (mode == L"--cleanup") { + const auto parent = root.substr(0, root.find_last_of(L'\\')); + const auto removed = cheburnet::securefs::RemoveTreeUnder(parent, root); + std::printf("HOST_FIXTURE_CLEANUP: %s error=%lu\n", removed.ok ? "PASS" : "FAIL", removed.win32Error); + return removed.ok ? 0 : 1; + } + if (mode != L"--driver-smoke") return 2; + std::vector catalog; + if (!cheburnet::ResourceExtractor(paths).VerifyRuntime(catalog).ok) return 4; + if (!::SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32)) return 5; + ServiceFixture fixture; + fixture.manager = ::OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE); + if (!fixture.manager) return 6; + const auto sys = paths.BinDir() + L"\\WinDivert64.sys"; + // Retain a handle to OUR exclusively created service. Never adopt an + // existing service. Precreation also avoids the DLL's EventLog key write. + fixture.service = ::CreateServiceW(fixture.manager, L"WinDivert", L"WinDivert", + SERVICE_ALL_ACCESS, SERVICE_KERNEL_DRIVER, SERVICE_DEMAND_START, + SERVICE_ERROR_NORMAL, sys.c_str(), nullptr, nullptr, nullptr, nullptr, nullptr); + if (!fixture.service) { + std::printf("DRIVER_SMOKE: exclusive service creation refused error=%lu\n", ::GetLastError()); + return 7; + } + Module dll{::LoadLibraryExW((paths.BinDir() + L"\\WinDivert.dll").c_str(), nullptr, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32)}; + if (!dll.value) return 8; + using Open = HANDLE(WINAPI*)(const char*, int, INT16, UINT64); + using Close = BOOL(WINAPI*)(HANDLE); + const auto open = Procedure(dll.value, "WinDivertOpen"); + const auto close = Procedure(dll.value, "WinDivertClose"); + if (!open || !close) return 9; + // false matches no packets; SNIFF never drops original packets. + const HANDLE handle = open("false", 0, 0, 1); + if (handle == INVALID_HANDLE_VALUE) { + std::printf("DRIVER_SMOKE: WinDivertOpen failed error=%lu\n", ::GetLastError()); + return 10; + } + SERVICE_STATUS status{}; + const bool running = ::QueryServiceStatus(fixture.service, &status) && + status.dwCurrentState == SERVICE_RUNNING; + const bool closed = close(handle) != FALSE; + const bool removed = fixture.Remove(); + std::printf("DRIVER_SMOKE: filter=false flags=SNIFF running=%d closed=%d service_removed=%d\n", + running, closed, removed); + return running && closed && removed ? 0 : 1; +} diff --git a/tests/host_e2e_contracts.ps1 b/tests/host_e2e_contracts.ps1 new file mode 100644 index 0000000..ebe9ebe --- /dev/null +++ b/tests/host_e2e_contracts.ps1 @@ -0,0 +1,70 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string]$Probe +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$harness = Join-Path $RepositoryRoot 'scripts\e2e\Invoke-CheburnetE2E.ps1' +$tokens=$null; $errors=$null +$ast = [Management.Automation.Language.Parser]::ParseFile($harness, [ref]$tokens, [ref]$errors) +if ($errors.Count -ne 0) { throw 'host-capable harness must parse' } +$function = $ast.Find({param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq 'Test-CheburnetHostIdentity' +}, $true) +Invoke-Expression $function.Extent.Text +$id = [guid]::NewGuid().ToString('N').Substring(0,16) +$valid = [pscustomobject]@{TimedOut=$false;NewEngine=$false;ExitCode=0;Output="CHEBURNET_E2E_INSTANCE:$id`r`n"} +if (-not (Test-CheburnetHostIdentity $valid $id)) { throw 'matching isolated identity must pass' } +foreach ($case in @('timeout','engine','nonzero','production','other-instance','trailing-output')) { + $run = [pscustomobject]@{TimedOut=$false;NewEngine=$false;ExitCode=0;Output=$valid.Output} + switch ($case) { + 'timeout' { $run.TimedOut=$true } + 'engine' { $run.NewEngine=$true } + 'nonzero' { $run.ExitCode=2 } + 'production' { $run.Output='CHEBURNET_INSTALL_VERIFIED' } + 'other-instance' { $run.Output='CHEBURNET_E2E_INSTANCE:ffffffffffffffff' } + 'trailing-output' { $run.Output += 'extra' } + } + if (Test-CheburnetHostIdentity $run $id) { throw "unsafe identity accepted: $case" } +} +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('cheburnet-host-contract-' + [guid]::NewGuid().ToString('N')) +$data = [Environment]::GetFolderPath('CommonApplicationData') +$namespace = Join-Path $data "CHEBURNET-E2E-$id" +$elevated = ([Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +$owned = $false +try { + if (Test-Path -LiteralPath $namespace) { throw 'test namespace collision' } + if ($elevated) { + New-Item -ItemType Directory -Path $namespace | Out-Null + $owned = $true + [IO.File]::WriteAllText((Join-Path $namespace 'sentinel'), 'must survive refused probe') + } + # The real destructive harness must refuse a probe without a matching + # compile-time identity BEFORE it can execute fresh-install's cleanup. + $savedPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' # expected native stderr from a refused build + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $harness -Launcher $Probe ` + -ResultsDir $fixture -HostInstance $id -Scenario fresh-install *> ($fixture + '.log') + $refusalCode = $LASTEXITCODE + } finally { $ErrorActionPreference = $savedPreference } + if ($refusalCode -eq 0) { throw 'unmatched host build must fail' } + if ($owned -and (Get-Content -LiteralPath (Join-Path $namespace 'sentinel') -Raw) -cne 'must survive refused probe') { + throw 'refused host probe modified existing namespace' + } + Write-Output 'HOST_E2E_CONTRACTS: PASS (identity guards, actual harness refusal before deletion)' +} finally { + if ($owned) { + $full = [IO.Path]::GetFullPath($namespace) + if ($full -cne (Join-Path $data "CHEBURNET-E2E-$id")) { throw 'namespace cleanup escaped fixture' } + Remove-Item -LiteralPath $full -Recurse -Force + } + $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not [IO.Path]::GetFullPath($fixture).StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { + throw 'cleanup escaped temporary fixture' + } + if (Test-Path -LiteralPath $fixture) { Remove-Item -LiteralPath $fixture -Recurse -Force } + if (Test-Path -LiteralPath ($fixture + '.log')) { Remove-Item -LiteralPath ($fixture + '.log') } +} diff --git a/tests/review_contracts.ps1 b/tests/review_contracts.ps1 new file mode 100644 index 0000000..86fedb0 --- /dev/null +++ b/tests/review_contracts.ps1 @@ -0,0 +1,76 @@ +# Run real script entry points against an isolated fixture and a harmless probe. +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$RepositoryRoot, + [Parameter(Mandatory = $true)][string]$Probe +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('cheburnet-contracts-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $fixture | Out-Null +try { + # A disposable Git index also works inside the importer's non-Git validation tree. + $gitFixture = Join-Path $fixture 'repository' + New-Item -ItemType Directory -Path (Join-Path $gitFixture 'resources') -Force | Out-Null + & git -C $gitFixture init -q + if ($LASTEXITCODE -ne 0) { throw 'cannot create fixture repository' } + $payload = Join-Path $gitFixture 'resources\fixture.txt' + [IO.File]::WriteAllText($payload, 'before') + & git -C $gitFixture add -f -- resources/fixture.txt + if ($LASTEXITCODE -ne 0) { throw 'cannot stage fixture' } + [IO.File]::WriteAllText($payload, 'after') + # The writer creates its own previously absent parent before its first write. + $diffDir = Join-Path $fixture 'absent\upstream' + & (Join-Path $RepositoryRoot 'scripts\write-upstream-diff.ps1') -Root $gitFixture -OutDir $diffDir + foreach ($name in @('payload-diff.txt', 'payload-status.txt')) { + if (-not (Test-Path -LiteralPath (Join-Path $diffDir $name))) { throw "missing $name" } + } + if ((Get-Content -LiteralPath (Join-Path $diffDir 'payload-diff.txt') -Raw) -notmatch 'fixture.txt') { + throw 'diff writer must report the actual changed resource' + } + $e2e = Join-Path $RepositoryRoot 'scripts\e2e\Invoke-CheburnetE2E.ps1' + $tokens = $null + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile($e2e, [ref]$tokens, [ref]$errors) + if ($errors.Count -ne 0) { throw 'E2E script must parse' } + $functions = @('Start-CheburnetAndSettle', 'Test-InstallProbeSuccess', 'Test-InstallProbeRefusal', + 'Read-CheburnetRuntimeState', 'Get-E2EGateExitCode') + foreach ($function in $ast.FindAll({ + param($node) + $node -is [Management.Automation.Language.FunctionDefinitionAst] + }, $true)) { + if ($functions -contains $function.Name) { Invoke-Expression $function.Extent.Text } + } + $script:RuntimeRoot = Join-Path $fixture 'owned-root' + $results = Join-Path $fixture 'evidence' + New-Item -ItemType Directory -Path $script:RuntimeRoot,$results | Out-Null + $state = [ordered]@{schema=1; current='1.10.2'; previous_known_good=$null; pending=$null; + package_sha256=$null; manifest_key_id=$null; last_result='fixture'} + [IO.File]::WriteAllText((Join-Path $script:RuntimeRoot 'active-runtime.json'), + ($state | ConvertTo-Json), [Text.UTF8Encoding]::new($false)) + if ((Read-CheburnetRuntimeState).current -cne '1.10.2') { throw 'wrong runtime state path' } + $run = Start-CheburnetAndSettle -Path $Probe -TimeoutSeconds 5 + if (-not $run.Settled -or $run.NewEngine -or $run.TimedOut) { + throw "install probe failed or started engine: $($run | ConvertTo-Json)" + } + + # The actual CLI, not a regex: selected but skipped is a failed gate. + $reportDir = Join-Path $fixture 'skipped-report' + $marker = Join-Path $fixture 'absent-marker' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $e2e -Launcher $Probe -ResultsDir $reportDir -MarkerPath $marker -Scenario fresh-install + if ($LASTEXITCODE -eq 0) { throw 'all-skipped CLI must return nonzero' } + $reportFile = @(Get-ChildItem -LiteralPath $reportDir -Filter e2e-report.json -Recurse) + if ($reportFile.Count -ne 1) { throw 'gate must emit its evidence even on skipped failure' } + $report = Get-Content -LiteralPath $reportFile[0].FullName -Raw | ConvertFrom-Json + if ($report.passed -ne 0 -or $report.failed -ne 0 -or $report.skipped -ne 1) { + throw 'skipped scenario must be reported accurately' + } + Write-Output 'REVIEW_CONTRACTS: PASS (diff writer, real install probe, state path, skipped CLI)' +} finally { + $full = [IO.Path]::GetFullPath($fixture) + $parent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if (-not $full.StartsWith($parent, [StringComparison]::OrdinalIgnoreCase)) { + throw 'fixture cleanup escaped temporary directory' + } + Remove-Item -LiteralPath $full -Recurse -Force +} diff --git a/tests/review_regressions.cpp b/tests/review_regressions.cpp new file mode 100644 index 0000000..96b1fb8 --- /dev/null +++ b/tests/review_regressions.cpp @@ -0,0 +1,354 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app/Diagnostics.h" +#include "app/OperationState.h" +#include "app/RuntimeRecovery.h" +#include "core/PrivilegeManager.h" +#include "core/Launcher.h" +#include "core/ProcessManager.h" +#include "core/StatusProbe.h" +#include "core/ResourceExtractor.h" +#include "core/SecureFs.h" +#include "update/UpdateCheckService.h" +#include "update/WinHttpClient.h" +#include "util/Json.h" +#include "util/StringUtil.h" +#include "GeneratedProvenance.h" +#include "GeneratedStrategies.h" + +using namespace cheburnet; +namespace fs = std::filesystem; +static int failures = 0; +#define REQUIRE(condition) do { if (!(condition)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #condition); ++failures; } } while (false) + +struct Fixture { + std::wstring parent; + std::wstring root; + Fixture() { + wchar_t buffer[MAX_PATH]{}; + REQUIRE(::GetTempPathW(MAX_PATH, buffer) != 0); + parent = fs::absolute(buffer).wstring(); + root = (fs::path(parent) / (L"cheburnet-review-" + std::to_wstring(::GetCurrentProcessId()) + + L"-" + std::to_wstring(::GetTickCount64()))).wstring(); + REQUIRE(securefs::IsStrictDescendant(parent, root)); + REQUIRE(::CreateDirectoryW(root.c_str(), nullptr) != FALSE); + } + ~Fixture() { + if (securefs::IsStrictDescendant(parent, root)) + REQUIRE(securefs::RemoveTreeUnder(parent, root).ok); + } + void Put(const std::wstring& relative, const std::string& text = "fixture") { + const fs::path target = fs::path(root) / relative; + fs::create_directories(target.parent_path()); + std::ofstream file(target, std::ios::binary); + file << text; + REQUIRE(file.good()); + } +}; + +static int Inventory() { + Fixture fixture; + fixture.Put(L"bin\\winws.exe"); + const std::vector allow{L"bin\\winws.exe", L"lists\\vendor.txt"}; + std::wstring unexpected; + REQUIRE(ValidateRuntimeInventory(fixture.root, allow, unexpected)); + fixture.Put(L"bin\\wlanapi.dll", "inert marker"); + REQUIRE(!ValidateRuntimeInventory(fixture.root, allow, unexpected)); + REQUIRE(unexpected == L"bin\\wlanapi.dll"); + REQUIRE(fs::remove(fs::path(fixture.root) / L"bin\\wlanapi.dll")); + fixture.Put(L"bin\\nested\\other.dll"); + REQUIRE(!ValidateRuntimeInventory(fixture.root, allow, unexpected)); + REQUIRE(fs::remove(fs::path(fixture.root) / L"bin\\nested\\other.dll")); + REQUIRE(fs::remove(fs::path(fixture.root) / L"bin\\nested")); + const auto link = (fs::path(fixture.root) / L"lists\\vendor.txt").wstring(); + fs::create_directories(fs::path(link).parent_path()); + REQUIRE(::CreateHardLinkW(link.c_str(), + (fs::path(fixture.root) / L"bin\\winws.exe").c_str(), nullptr) != FALSE); + REQUIRE(!ValidateRuntimeInventory(fixture.root, allow, unexpected)); + REQUIRE(fs::remove(link)); + // Existing weak security must not be silently adopted. + REQUIRE(!securefs::EnsureTrustedDirectory(fixture.root + L"\\untrusted").ok); + REQUIRE(!fs::exists(fs::path(fixture.root) / L"untrusted")); + return failures; +} + +static int Cleanup() { + update::StateResult state; + std::vector keep{L"must be cleared"}; + REQUIRE(!BuildRuntimeKeepSet(state, L"2.0.0", L"1.0.0", L"", keep)); + REQUIRE(keep.empty()); + state.missing = true; + REQUIRE(!BuildRuntimeKeepSet(state, L"2.0.0", L"1.0.0", L"", keep)); + state.ok = true; + state.missing = false; + state.state.current = "2.0.0"; + state.state.previousKnownGood = "1.9.0"; + state.state.pending = "3.0.0"; + REQUIRE(BuildRuntimeKeepSet(state, L"2.1.0", L"1.0.0", L"1.8.0", keep)); + for (const auto version : {L"2.0.0", L"1.9.0", L"3.0.0", L"2.1.0", L"1.0.0", L"1.8.0"}) + REQUIRE(std::find(keep.begin(), keep.end(), version) != keep.end()); + + Fixture fixture; + fixture.Put(L"runtime\\2.0.0\\keep.txt", "current"); + fixture.Put(L"runtime\\1.9.0\\keep.txt", "previous"); + const auto paths = RuntimePaths::ForOwnedRoot(fixture.root, L"2.0.0"); + ResourceExtractor extractor(paths); + auto result = extractor.CleanupOldVersions(); + REQUIRE(!result.ok && result.removed == 0); + fixture.Put(L"active-runtime.json", "{broken"); + result = extractor.CleanupOldVersions(); + REQUIRE(!result.ok && result.removed == 0); + REQUIRE(fs::exists(fs::path(fixture.root) / L"runtime\\2.0.0\\keep.txt")); + REQUIRE(fs::exists(fs::path(fixture.root) / L"runtime\\1.9.0\\keep.txt")); + return failures; +} + +static int Recovery() { + std::vector calls; + const auto stop = [&] { calls.push_back(1); return true; }; + const auto commit = [&] { calls.push_back(2); return true; }; + REQUIRE(CommitIntegrityRecovery(true, L"current", L"current", L"previous", stop, commit)); + REQUIRE((calls == std::vector{1, 2})); + calls.clear(); + REQUIRE(!CommitIntegrityRecovery(true, L"unknown", L"current", L"previous", stop, commit)); + REQUIRE(calls.empty()); + REQUIRE(!CommitIntegrityRecovery(true, L"current", L"current", L"previous", + [&] { calls.push_back(1); return false; }, commit)); + REQUIRE((calls == std::vector{1})); + calls.clear(); + REQUIRE(CommitIntegrityRecovery(true, L"previous", L"current", L"previous", stop, commit)); + REQUIRE((calls == std::vector{2})); + calls.clear(); + REQUIRE(CommitIntegrityRecovery(false, L"", L"current", L"previous", stop, commit)); + REQUIRE((calls == std::vector{2})); + REQUIRE(!CommitIntegrityRecovery(false, L"", L"current", L"previous", stop, [] { return false; })); + return failures; +} + +static int ProcessState() { + OperationState state; + state.Complete(AppOperationState::Connected); + state.ObserveProcess(false); // engine died while the main menu was open + REQUIRE(state.TryTransition(AppOperationState::Disconnected, AppOperationState::Connecting)); + state.ObserveProcess(false); + REQUIRE(state.Get() == AppOperationState::Connecting); + state.Complete(AppOperationState::Updating); + state.ObserveProcess(true); + REQUIRE(state.Get() == AppOperationState::Updating); + state.Complete(AppOperationState::Error); + state.ObserveProcess(false); + REQUIRE(state.Get() == AppOperationState::Disconnected); + return failures; +} + +static int Notifications() { + update::CheckResult result; + result.status = update::CheckStatus::Available; + result.payload = update::Eligibility::Upgrade; + result.manifest.payload.version = "2.0.0"; + REQUIRE(update::ShouldNotify(result, "")); + REQUIRE(!update::ShouldNotify(result, "2.0.0")); + result.launcher = update::Eligibility::Upgrade; + REQUIRE(update::ShouldNotify(result, "2.0.0")); + result.status = update::CheckStatus::Current; + REQUIRE(!update::ShouldNotify(result, "")); + result.status = update::CheckStatus::Rejected; + REQUIRE(update::ShouldNotify(result, "")); + return failures; +} + +static int Privacy() { + for (const auto address : {L"2001:db8::1", L"fe80::abcd", L"::1", L"::", + L"fe80::1234%12", L"::ffff:192.0.2.1", L"2001:db8:0:1:2:3:4:5"}) { + const std::wstring input = L"peer=[" + std::wstring(address) + L"]:443"; + const auto output = diag::Redact(input, {}); + REQUIRE(output.find(address) == std::wstring::npos); + REQUIRE(output.find(L"]:443") != std::wstring::npos); + } + REQUIRE(diag::Redact(L"duration=12:34 hash=abcdef normal", {}) == + L"duration=12:34 hash=abcdef normal"); + return failures; +} + +// Actual WinHTTP pending I/O against a loopback TCP peer that never completes +// TLS. No certificate bypass, external network, driver or machine trust changes. +static int HttpCancellation() { + WSADATA data{}; + REQUIRE(::WSAStartup(MAKEWORD(2, 2), &data) == 0); + for (const bool cancelRequest : {true, false}) { + SOCKET listener = ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + REQUIRE(listener != INVALID_SOCKET); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + REQUIRE(::bind(listener, reinterpret_cast(&address), sizeof(address)) == 0); + REQUIRE(::listen(listener, 1) == 0); + int size = sizeof(address); + REQUIRE(::getsockname(listener, reinterpret_cast(&address), &size) == 0); + std::promise accepted; + auto acceptedFuture = accepted.get_future(); + std::jthread server([&](std::stop_token token) { + while (!token.stop_requested()) { + fd_set set; + FD_ZERO(&set); + FD_SET(listener, &set); + timeval timeout{0, 50000}; + if (::select(0, &set, nullptr, nullptr, &timeout) <= 0) continue; + const SOCKET peer = ::accept(listener, nullptr, nullptr); + if (peer == INVALID_SOCKET) continue; + accepted.set_value(); + while (!token.stop_requested()) std::this_thread::sleep_for(std::chrono::milliseconds(5)); + ::closesocket(peer); + return; + } + }); + std::atomic cancel{false}; + update::HttpOptions options; + options.useSystemProxy = false; + options.cancel = &cancel; + options.totalTimeoutMs = cancelRequest ? 8000 : 600; + const auto url = L"https://127.0.0.1:" + std::to_wstring(ntohs(address.sin_port)) + L"/"; + auto response = std::async(std::launch::async, [&] { + return update::WinHttpClient{}.Get(url, options); + }); + REQUIRE(acceptedFuture.wait_for(std::chrono::seconds(4)) == std::future_status::ready); + const auto start = std::chrono::steady_clock::now(); + if (cancelRequest) cancel.store(true); + REQUIRE(response.wait_for(std::chrono::seconds(2)) == std::future_status::ready); + const auto result = response.get(); + REQUIRE(result.status == (cancelRequest ? update::HttpStatus::Cancelled : update::HttpStatus::Timeout)); + REQUIRE(std::chrono::steady_clock::now() - start < std::chrono::seconds(2)); + server.request_stop(); + server.join(); + ::closesocket(listener); + } + ::WSACleanup(); + return failures; +} + +static int ElevatedRuntime() { + if (!PrivilegeManager::IsElevated()) { + std::puts("SKIP: requires an elevated token; fixture is isolated from ProgramData"); + return 77; + } + Fixture fixture; + auto paths = RuntimePaths::ForOwnedRoot(fixture.root, upstream::kVersion); + REQUIRE(securefs::BootstrapProtectedTree(paths.ProgramDataRoot(), paths.LogsDir(), + paths.RuntimeRoot(), paths.UpdatesDir(), paths.UserDir()).ok); + ResourceExtractor extractor(paths); + REQUIRE(extractor.EnsureExtracted().ok); + std::vector catalog; + std::vector files; + REQUIRE(extractor.VerifyRuntime(catalog, &files).ok); + REQUIRE(catalog.size() == static_cast(kGeneratedStrategyCount)); + REQUIRE(!files.empty()); + const auto bundle = diag::BuildBundle(paths, Config{}); + for (const auto& entry : bundle.entries) if (entry.name == "summary.json") { + const auto summary = json::Parse(entry.content); + REQUIRE(summary.ok); + const auto* runtime = summary.value.Find("runtime"); + REQUIRE(runtime != nullptr); + if (runtime) { + REQUIRE(*runtime->Find("integrity")->AsString() == str::ToUtf8(L"проверена")); + REQUIRE(*runtime->Find("strategies_available")->AsString() == std::to_string(catalog.size())); + } + } + // No code is ever loaded from this file. + REQUIRE(securefs::AtomicWrite(paths.BinDir() + L"\\wlanapi.dll", std::string("inert")).ok); + std::wstring bad; + REQUIRE(!extractor.EnsureExtracted().ok); + REQUIRE(!extractor.VerifyBinaries(bad)); + REQUIRE(!extractor.VerifyRuntime(catalog).ok); + REQUIRE(fs::remove(fs::path(paths.BinDir()) / L"wlanapi.dll")); + // A previously untrusted directory cannot acquire trust through extraction. + const auto other = paths.RuntimeRoot() + L"\\preplanted"; + REQUIRE(::CreateDirectoryW(other.c_str(), nullptr) != FALSE); + REQUIRE(!securefs::EnsureTrustedDirectory(other).ok); // inherited DACL is not protected + // Start only this harmless test executable under a winws filename, never + // the bundled packet engine. The fixture owns every file and process. + auto processPaths = RuntimePaths::ForOwnedRoot(fixture.root, L"2.0.0"); + REQUIRE(securefs::EnsureTrustedDirectory(processPaths.RuntimeVersionDir()).ok); + REQUIRE(securefs::EnsureTrustedDirectory(processPaths.BinDir()).ok); + wchar_t self[32768]{}; + REQUIRE(::GetModuleFileNameW(nullptr, self, static_cast(std::size(self))) != 0); + REQUIRE(::CopyFileW(self, processPaths.WinwsExePath().c_str(), TRUE)); + REQUIRE(securefs::HardenObject(processPaths.WinwsExePath(), securefs::ObjectKind::File).ok); + ProcessManager managed(processPaths); + struct StopOnExit { + ProcessManager& process; + ~StopOnExit() { process.Stop(); } + } stopGuard{managed}; + const auto started = managed.Start(processPaths.WinwsExePath(), {L"--wait"}, + processPaths.BinDir(), processPaths.LogsDir() + L"\\stdout.log", + processPaths.LogsDir() + L"\\stderr.log", 50); + REQUIRE(started.status == StartStatus::Ok); + REQUIRE(CommitIntegrityRecovery(started.status == StartStatus::Ok, + started.record.imagePath, processPaths.WinwsExePath(), paths.WinwsExePath(), + [&] { return managed.Stop().status == StopStatus::Stopped; }, + [&] { return !probe::ProcessAlive(started.record.pid); })); + REQUIRE(!managed.IsConnectedByUs()); + // Resuming an old session must not bypass runtime verification. Replace + // only the inert, not-running embedded EXE with this test helper, start + // --wait, then exercise the real Launcher adoption path. + REQUIRE(::CopyFileW(self, paths.WinwsExePath().c_str(), FALSE)); + REQUIRE(securefs::HardenObject(paths.WinwsExePath(), securefs::ObjectKind::File).ok); + ProcessManager resumed(paths); + StopOnExit resumedGuard{resumed}; + const auto oldSession = resumed.Start(paths.WinwsExePath(), {L"--wait"}, paths.BinDir(), + paths.LogsDir() + L"\\resume-out.log", paths.LogsDir() + L"\\resume-err.log", 50); + REQUIRE(oldSession.status == StartStatus::Ok); + ui::UiEventQueue events; + Launcher launcher(paths, resumed); + const auto adopted = launcher.Connect(strategies::Default(), GameFilterMode::Off, events); + REQUIRE(!adopted.success()); + REQUIRE(!resumed.IsConnectedByUs()); + REQUIRE(!probe::ProcessAlive(oldSession.record.pid)); + REQUIRE(extractor.EnsureExtracted().ok); + // A corrupt state authorizes no deletions even with an elevated token. + REQUIRE(securefs::AtomicWrite(paths.ActiveRuntimePath(), std::string("{broken")).ok); + const auto cleanup = extractor.CleanupOldVersions(); + REQUIRE(!cleanup.ok && cleanup.removed == 0); + REQUIRE(fs::exists(paths.WinwsExePath())); + return failures; +} + +int wmain(int argc, wchar_t** argv) { + if (argc != 2) return 2; + const std::wstring mode = argv[1]; + // Harmless child used by the E2E contract test. No engine is started. + if (mode == L"--verify-install") { std::puts("CHEBURNET_INSTALL_VERIFIED"); return 0; } + if (mode == L"--wait") { ::Sleep(INFINITE); return 0; } + if (mode == L"inventory") return Inventory(); + if (mode == L"cleanup") return Cleanup(); + if (mode == L"recovery") return Recovery(); + if (mode == L"processstate") return ProcessState(); + if (mode == L"notifications") return Notifications(); + if (mode == L"privacy") return Privacy(); + if (mode == L"httpsmoke") { + update::HttpOptions options; + options.totalTimeoutMs = 15000; + const auto response = update::WinHttpClient{}.Get( + L"https://raw.githubusercontent.com/Jacksony100/CHEBURNET/6a371e5c825c6dbce7cf2553158ada084f2d1e87/README.md", + options); + REQUIRE(response.status == update::HttpStatus::Ok); + REQUIRE(response.httpStatus == 200 && response.body.size() > 1000); + std::printf("HTTPS smoke: HTTP %lu, bytes=%zu, status=%d\n", + response.httpStatus, response.body.size(), static_cast(response.status)); + return failures; + } + if (mode == L"httpcancel") return HttpCancellation(); + if (mode == L"elevated") return ElevatedRuntime(); + return 2; +}