diff --git a/.agent/skills/repository-orientation/SKILL.md b/.agent/skills/repository-orientation/SKILL.md index 3b48062..4917108 100644 --- a/.agent/skills/repository-orientation/SKILL.md +++ b/.agent/skills/repository-orientation/SKILL.md @@ -22,18 +22,30 @@ src/styles/app.css global/component CSS src-tauri/src/models.rs Rust domain models src-tauri/src/validation.rs input normalization/validation src-tauri/src/storage.rs JSON config/state storage +src-tauri/src/migration.rs versioned storage adoption/migration src-tauri/src/activity.rs activity log src-tauri/src/subscription.rs subscription fetch/parse -src-tauri/src/component_detection.rs component status detection +src-tauri/src/component_catalog.rs pinned offline package catalog +src-tauri/src/component_inventory.rs exact native SCM/process/registry inventory +src-tauri/src/component_detection.rs component status mapping +src-tauri/src/component_packages.rs bundled/cache package plans +src-tauri/src/component_cutover.rs durable legacy cutover/rollback/cleanup +src-tauri/src/privileged_jobs.rs sealed one-shot elevation records +src-tauri/src/privileged_runtime.rs fixed native privileged actions +src-tauri/src/proxifyre_runtime.rs native ProxiFyre lifecycle +src-tauri/src/singbox_runtime.rs native sing-box lifecycle src-tauri/src/adapters/* ProxiFyre/sing-box/proxy router adapters src-tauri/src/commands.rs Tauri command layer, currently too large src-tauri/tests/* Rust tests -scripts/*.ps1 Windows install/control scripts +scripts/*.ps1 build/release/QA tooling only ``` ## Source of truth - Persistent app config/state: `C:\ProgramData\ProxyWarden\config` and `state`. +- `config\components.json` is legacy migration input only; live component truth comes from native inventory and verified receipts. +- Current managed roots: `C:\Program Files\ProxyWarden\components\ProxiFyre` and `...\sing-box`. +- Offline baseline: packaged component catalog; verified downloaded cache: `C:\ProgramData\ProxyWarden\packages`. - Generated artifacts: `C:\ProgramData\ProxyWarden\generated`. - Frontend state is not source of truth. It should represent backend state and pending UI edits. - Component detection/runtime status should come from backend, not guessed in UI. @@ -45,13 +57,17 @@ scripts/*.ps1 Windows install/control scripts 3. Inspect the relevant source files listed above. 4. Determine whether the change crosses the Tauri boundary. If yes, update both Rust DTO/command and TypeScript wrapper/types. 5. Determine whether the change touches secrets, service control, generated configs, process execution, filesystem deletion, or network fetch. If yes, apply security checklist. -6. Prefer small, isolated changes over broad rewrites. +6. If the change touches install/service/elevation, trace `privileged_jobs.rs`/`privileged_runtime.rs` and the native component runtime. Do not introduce PowerShell runtime fallback. +7. If the change touches packaging or release tooling, run `scripts/check-runtime-powershell-boundary.ps1 -CheckOnly`. +8. Prefer small, isolated changes over broad rewrites. ## Do not - Do not treat `App.tsx` or `commands.rs` as the correct permanent architecture just because they currently contain lots of logic. - Do not introduce a second storage system. - Do not move service/install logic into frontend. +- Do not treat legacy `components.json`, fuzzy paths, or service name alone as ownership proof. +- Do not add `.ps1`, `powershell.exe`, `pwsh`, or generated script text to production Rust/Tauri/NSIS paths. - Do not claim Windows service/elevation behavior is verified unless it was actually tested on Windows. ## Output expectations diff --git a/.agent/skills/testing-ci-release/SKILL.md b/.agent/skills/testing-ci-release/SKILL.md index 059d5e1..91fff9b 100644 --- a/.agent/skills/testing-ci-release/SKILL.md +++ b/.agent/skills/testing-ci-release/SKILL.md @@ -2,7 +2,7 @@ ## Когда использовать -Используй этот skill при добавлении CI, release scripts, build fixes, test changes, dependency updates, packaging changes или перед финальным отчетом по крупной задаче. +Используй этот skill при изменениях CI, release scripts, tests, dependencies, Tauri/NSIS packaging, offline component catalog или перед финальным отчётом по крупной задаче. ## Minimal local checks @@ -10,109 +10,84 @@ Frontend: ```powershell npm ci +npm run format:check +npm run lint +npm run typecheck +npm test -- --run npm run build ``` Rust: ```powershell -cd src-tauri +Push-Location src-tauri cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets +Pop-Location ``` -Tauri: +Tauri/build/release boundaries: ```powershell npm run tauri -- info -npm run tauri -- build +& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly +& .\scripts\update-component-bundle.ps1 -PlanOnly +& .\scripts\update-component-bundle.ps1 -CheckOnly +& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly +& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild ``` -PowerShell plan-only: - -```powershell -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly -``` +`PlanOnly`/`CheckOnly` должны возвращать structured JSON с `changed: false` и не менять repo, ProgramData, services или network state. ## Interaction smoke for UI motion -Build, lint, and unit tests do not validate motion or pointer behavior. For any hover, disclosure, stagger, or hit-target change, verify: +Build, lint и unit tests не проверяют motion/geometry. Для hover, disclosure, stagger или hit-target изменений проверь first/repeated/rapid toggle, keyboard focus, loading/long labels, `prefers-reduced-motion`, desktop и narrow window. Если visual smoke не выполнен, так и напиши. -- first open and first close; -- repeated and rapid toggle; -- hover and click before, during, and after transition; -- keyboard focus and hidden-control tab order; -- loading and longest localized labels; -- `prefers-reduced-motion`; -- desktop and narrow window geometry. +## CI contract -Use a controlled mock or preview state when backend status is difficult to reproduce. If no visual interaction smoke is possible, report that evidence as missing and do not claim the motion task is complete. +Windows baseline должен включать: -## CI recommendation +- frontend format/lint/typecheck/tests/build; +- Rust fmt/clippy/all-target tests; +- Tauri environment check; +- runtime PowerShell boundary check; +- offline bundle PlanOnly + CheckOnly; +- Windows audit PlanOnly; +- release preparation PlanOnly with build skipped. -Add GitHub Actions with at least: - -- frontend build on Windows and Ubuntu if practical; -- Rust fmt/clippy/test; -- PowerShell syntax/plan-only smoke on Windows; -- Tauri build on Windows for release branches/tags; -- artifact upload only for trusted release workflow. +CI не изображает реальную SCM/UAC/driver проверку. Artifact upload и tag/publish допустимы только в отдельном trusted release workflow после принятой VM evidence. ## Dependency updates -When changing dependencies: - -- Update lockfiles. -- Check Tauri v2 compatibility. -- Avoid adding large UI/runtime dependencies for tiny tasks. -- Avoid adding shell/process libraries that bypass existing backend boundaries. -- Note why dependency is needed. +- Обновить lockfiles. +- Проверить Tauri v2 и Windows x64 compatibility. +- Не добавлять dependency, если stdlib/native API или уже установленный crate решает задачу. +- Не добавлять shell/process library, возвращающую production PowerShell path. +- Объяснить, зачем dependency нужна и какой owner её вызывает. ## Release hygiene -Before release: +Перед release candidate: -- Verify app version in `package.json` and Tauri config if applicable. -- Verify icons/assets size. -- Verify CSP and capabilities. -- Verify no raw secrets/test URLs in repo. -- Verify installer scripts with `-PlanOnly`. -- Verify clean install on Windows VM. -- Verify external SOCKS5 flow. -- Verify local sing-box subscription flow. -- Verify uninstall/safe cleanup behavior. +- версии совпадают в `package.json`, `package-lock.json`, `src-tauri/tauri.conf.json` и `src-tauri/Cargo.toml`; +- packaged component catalog, asset hashes, licenses и `THIRD_PARTY_NOTICES.md` согласованы; +- installer содержит consolidated offline component bundle и WebView2 Offline Installer; +- runtime PowerShell checker проходит, bundled cleanup script отсутствует; +- NSIS hook разделяет verify-only upgrade и full managed uninstall; +- fresh offline VM, legacy upgrade/rollback, foreign service refusal, UAC cancel, uninstall/reboot и реальные routing flows записаны в evidence; +- tag/publish выполняются только для того же проверенного commit. -## Final report format +Установка Control App не должна скрыто install/start/update routing-компоненты. Payloads могут быть в installer, но component mutation остаётся отдельным user action. -```text -Changed: -- ... +## Финальный отчёт -Verified: -- npm run build -- cargo test +Разделить: -Not verified: -- Windows elevated install/uninstall, because ... +- `Проверено`: точные команды и результаты; +- `Не проверено`: Windows VM/UAC/SCM/driver/installer gaps; +- `Риски`: только конкретные release blockers. -Risks: -- ... -``` +Не писать «все тесты проходят», если весь релевантный набор действительно не запускался. -Do not write “all tests pass” unless all listed relevant tests actually ran. Humanity has enough fictional dashboards. - -## Как отчитываться - -Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`. - -Минимум для нетривиальной задачи: - -- короткая сводка; -- таблица файлов `Файл / Что изменилось / Зачем`; -- важные места без пересказа каждой строки; -- что проверено; -- что не проверено; -- конкретные риски. +Перед ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`. diff --git a/.agent/skills/windows-services-powershell/SKILL.md b/.agent/skills/windows-services-powershell/SKILL.md index 42bbe77..4c58a76 100644 --- a/.agent/skills/windows-services-powershell/SKILL.md +++ b/.agent/skills/windows-services-powershell/SKILL.md @@ -2,93 +2,73 @@ ## Когда использовать -Используй этот skill при изменениях в `scripts/*.ps1`, ProxiFyre install/start/stop/uninstall, sing-box service control, UAC/admin checks, helper/elevation boundary, component detection. +Используй этот skill при изменениях ProxiFyre/sing-box install/start/stop/update/uninstall, UAC/admin boundary, native inventory, NSIS upgrade/uninstall или build/release/QA PowerShell scripts. ## Цель -Сохранять service/install operations явными, безопасными и проверяемыми. Пользователь должен понимать, что приложение собирается менять в системе. Компьютер пользователя — не песочница для творческих экспериментов агента, как ни печально. +Сохранять системные операции явными, native и проверяемыми. Production runtime не зависит от PowerShell; Rust владеет Windows SCM, registry, process, filesystem, package verification и UAC flow. -## Инварианты +## Runtime-инварианты -- Install/start/stop/uninstall are explicit user actions. -- `apply` must not silently install/uninstall/start/stop components unless that behavior is clearly designed and surfaced. -- `-PlanOnly` scripts must be side-effect-free. -- PowerShell output intended for UI/backend must be structured JSON. -- Service detection must distinguish managed service from fuzzy candidate. -- Never relax safe-path checks to make uninstall easier. +- Install/start/stop/update/uninstall/migrate — только явные действия пользователя. +- `apply` не устанавливает, не обновляет, не переносит и не удаляет компоненты. +- Current managed roots — только `C:\Program Files\ProxyWarden\components\ProxiFyre` и `...\sing-box`. +- Service control требует exact `PathName`, marker/receipt, canonical path и non-reparse checks. Имя службы или fuzzy candidate недостаточны. +- Elevated UI action передаёт только UUID sealed job record; fixed early mode сам повторно проверяет ACL, TTL, action, paths, hashes и ownership. +- NSIS использует только exact `--nsis-verify-upgrade` и `--nsis-uninstall-managed`; никаких user/path/script arguments. +- Active/recovery/pending cutover journal блокирует upgrade/uninstall и не удаляется общим cleanup. +- Runtime-generated scripts и запуск `powershell.exe`/`pwsh` запрещены. -## Script rules +## PowerShell allowlist -PowerShell scripts should: +PowerShell остаётся только для build/release/QA: -- use `Set-StrictMode -Version Latest` where practical; -- set `$ErrorActionPreference = 'Stop'`; -- return structured JSON for plan/status paths; -- avoid localized text parsing for control flow; -- avoid writing secrets to host output; -- have clear exit codes; -- support `-PlanOnly` for dry-run/status checks; -- avoid downloading/executing arbitrary remote scripts. +- `scripts/check-runtime-powershell-boundary.ps1`; +- `scripts/update-component-bundle.ps1`; +- `scripts/prepare-release.ps1`; +- `scripts/audit-windows-smoke.ps1`. -## Elevation rules +`PlanOnly`/`CheckOnly` должны быть side-effect-free и возвращать structured JSON с `changed: false`. Любой новый `.ps1`, `.psm1`, `.psd1`, production caller или bundled cleanup resource должен ломать boundary checker. -When launching elevated PowerShell: +## Native service flow -- keep command fixed and parameters escaped; -- avoid user-controlled script text; -- avoid predictable temp script names; -- do not pass secrets via command line; -- verify script path before launch; -- clean up temp artifacts best-effort; -- return clear error if user cancels UAC. +1. Получить inventory через Windows API и canonicalize все пути. +2. Классифицировать `Missing / Managed / Foreign / Incomplete` до первой mutation. +3. Проверить marker/receipt, service `PathName`, file identity, ACL и reparse boundary. +4. Захватить общий lifecycle lock. +5. Выполнить только allowlisted fixed action. +6. Query-back подтвердить service/path/start policy/state. +7. При ошибке оставить durable recovery state; не угадывать cleanup. -## Service detection +Для uninstall сначала preflight всех компонентов. `Missing` — no-op; `Foreign`/`Incomplete` — zero mutation. Running service сначала останавливается и проверяется, затем удаляется. MSI code `3010` означает success with reboot required, а не обычную ошибку. -Preferred approach: +## Удаление файлов -1. Search known managed service names first. -2. Read service `PathName` through WMI/CIM. -3. Verify binary path and managed install metadata. -4. Only then mark as managed/controllable. -5. Fuzzy matches should be shown as candidates, not automatically controlled. +- Не использовать generic recursive delete по app root. +- Удалять только exact receipt/journal-owned entries после safe-path, ACL, reparse и file-shape checks. +- `.proxywarden-cutover` и `.proxywarden-quarantine` удаляет только owner terminal retirement после проверки journal state. +- Unexpected files, active jobs, process/service references или partial tombstone блокируют cleanup. -## Testing +## Проверка -Pure logic can be tested cross-platform with mocks. - -Real verification requires Windows: +Cross-platform/pure logic: ```powershell -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly -npm run tauri -- dev +Push-Location src-tauri +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +Pop-Location + +& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly +& .\scripts\update-component-bundle.ps1 -CheckOnly +& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly ``` -For real service tests: +Реальная проверка требует Windows 10/11 x64 VM: UAC cancel/success, SCM create/start/stop/delete, driver/VC installer exit codes, fresh offline install, foreign same-name service refusal, legacy rollback/recovery и NSIS upgrade/uninstall/reboot. -- Windows 10/11. -- Admin/UAC path. -- Fresh machine or VM snapshot. -- Existing ProxiFyre/sing-box absent. -- Existing fuzzy ProxiFyre-like service present, if testing safety. - -## Do not - -- Do not claim actual service operations were tested unless they were run on Windows. -- Do not parse human-localized `sc.exe` output if structured WMI/CIM data is available. -- Do not delete paths from fuzzy discovery alone. -- Do not make scripts silently modify firewall/proxy/system settings outside their stated purpose. +Не называть service/elevation behavior проверенным без этой VM evidence. ## Как отчитываться -Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`. - -Минимум для нетривиальной задачи: - -- короткая сводка; -- таблица файлов `Файл / Что изменилось / Зачем`; -- важные места без пересказа каждой строки; -- что проверено; -- что не проверено; -- конкретные риски. +Перед финальным ответом применить `.agent/skills/communication-reporting/SKILL.md` и `.agent/checklists/communication.md`. Отдельно перечислить automated evidence, Windows/manual evidence и незакрытые UAC/SCM/driver риски. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6615479..fb64b84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,10 @@ jobs: working-directory: src-tauri run: cargo clippy --all-targets --all-features -- -D warnings + - name: Test component catalog contract + working-directory: src-tauri + run: cargo test --test component_catalog_tests + - name: Run Rust tests working-directory: src-tauri run: cargo test --all-targets @@ -56,18 +60,25 @@ jobs: - name: Check Tauri environment run: npm run tauri -- info - - name: Plan control app installer + - name: Check runtime PowerShell boundary shell: pwsh - run: .\scripts\install-control-app.ps1 -PlanOnly + run: .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly - - name: Plan ProxiFyre installer + - name: Plan component bundle update shell: pwsh - run: .\scripts\install-proxyfier.ps1 -PlanOnly + run: .\scripts\update-component-bundle.ps1 -PlanOnly - - name: Plan sing-box installer + - name: Check component bundle shell: pwsh - run: .\scripts\install-singbox.ps1 -PlanOnly + run: .\scripts\update-component-bundle.ps1 -CheckOnly - name: Plan Windows smoke evidence capture shell: pwsh run: .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly + + - name: Plan release preparation + shell: pwsh + run: .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild + + - name: Test release workflow with local Git remotes + run: node --test scripts/prepare-release.check.mjs diff --git a/.gitignore b/.gitignore index 26e00b6..eba3b37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +.pnpm-store/ +*.tsbuildinfo dist/ releases/ src-tauri/target/ diff --git a/AGENTS.md b/AGENTS.md index cf509aa..3c5831c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Назначение -ProxyWarden — standalone Windows desktop-приложение для удобного per-app proxy routing. Стек: Tauri 2, Rust backend, React/TypeScript frontend, Vite, PowerShell installer/control scripts. Приложение управляет выбранными Windows-приложениями через ProxiFyre и, опционально, через локальный sing-box runtime. +ProxyWarden — standalone Windows desktop-приложение для удобного per-app proxy routing. Стек: Tauri 2, Rust backend, React/TypeScript frontend и Vite. Production install/service/UAC runtime реализован в Rust; PowerShell остаётся только build/release/QA tooling. Приложение управляет выбранными Windows-приложениями через ProxiFyre и, опционально, через локальный sing-box runtime. Этот файл — главный контракт для кодового агента. Любой агент, который меняет репозиторий, обязан соблюдать эти правила. Да, даже если ему очень хочется «быстренько поправить одну кнопочку» и случайно переписать половину сетевого стека. Особенно тогда. @@ -21,6 +21,9 @@ ProxyWarden — standalone Windows desktop-приложение для удоб - `src/api/tauriCommands.ts` — единственная TypeScript-обертка над `invoke(...)`. - Rust backend отвечает за storage, validation, config generation, component detection, service/install orchestration и structured errors. - `C:\ProgramData\ProxyWarden\config` и `C:\ProgramData\ProxyWarden\state` — source of truth. +- `C:\Program Files\ProxyWarden\components\ProxiFyre` и `C:\Program Files\ProxyWarden\components\sing-box` — единственные current managed component roots. +- `config\components.json` — только legacy migration input. Реальный component status принадлежит native Windows inventory и проверенным receipts. +- Packaged component catalog — immutable offline baseline; проверенный download cache лежит отдельно в `C:\ProgramData\ProxyWarden\packages`. - `C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json` и `sing-box-config.json` — derived artifacts. Их можно пересоздавать. - Install/start/stop/uninstall — только явные действия пользователя. `apply` не должен скрыто устанавливать, удалять или «чинить» компоненты. - Subscription URL, credentials, proxy passwords и userinfo нельзя выводить полностью в UI, logs, diagnostics, crash text или activity. @@ -47,18 +50,25 @@ src-tauri/ src/storage.rs # JSON storage, tmp/bak writes src/activity.rs # activity log src/subscription.rs # subscription fetch/parse - src/component_detection.rs # ProxiFyre/sing-box detection - src/singbox_service.rs # sing-box Windows service logic - src/process.rs # process/system helpers - src/helper.rs # helper/elevation boundary + src/component_catalog.rs # pinned offline component catalog + src/component_inventory.rs # exact native SCM/process/registry inventory + src/component_packages.rs # verified bundled/cache package plans + src/component_cutover.rs # durable legacy cutover/rollback/cleanup + src/migration.rs # versioned storage migration/adoption + src/privileged_jobs.rs # sealed one-shot elevated job records + src/privileged_runtime.rs # fixed native elevated action dispatcher + src/proxifyre_runtime.rs # native ProxiFyre lifecycle + src/singbox_runtime.rs # native sing-box lifecycle + src/singbox_service.rs # WinSW service spec/status logic + src/safe_fs.rs # safe path/ACL/reparse helpers src/adapters/* # ProxiFyre/sing-box/proxy-router adapters src/commands.rs # Tauri command handlers; currently too large tests/* # Rust integration/domain tests scripts/ - install-control-app.ps1 - install-proxyfier.ps1 - install-singbox.ps1 + check-runtime-powershell-boundary.ps1 + update-component-bundle.ps1 + audit-windows-smoke.ps1 prepare-release.ps1 ``` @@ -150,13 +160,16 @@ scripts/ - Не отключать CSP. Если CSP мешает, исправлять source policy, а не ставить `csp: null`. - Не добавлять Tauri shell permissions без жесткого scope и отдельного обоснования. - Не запускать произвольные команды из UI input. -- Runtime-generated elevated scripts должны использовать непредсказуемые имена, safe directory/ACL и cleanup best-effort. +- Runtime-generated elevated scripts запрещены. Elevation использует current exe, fixed early modes и sealed typed job records без arbitrary command/path arguments. - Удаление директорий допускается только после safe-path/marker/service-path checks. - Subscription fetch должен иметь timeout и защиту от очевидно опасных/local metadata адресов либо explicit allow-mode. ### Windows/service boundary -- `-PlanOnly` у PowerShell scripts должен оставаться side-effect-free и возвращать structured JSON. +- PowerShell разрешён только в build/release/QA allowlist: `check-runtime-powershell-boundary.ps1`, `update-component-bundle.ps1`, `audit-windows-smoke.ps1`, `prepare-release.ps1`. +- `PlanOnly`/`CheckOnly` у этих scripts должны быть side-effect-free, возвращать structured JSON и иметь `changed: false`. +- Production Rust, Tauri resources и NSIS hooks не должны запускать `powershell.exe`, `pwsh`, `.ps1` или generated script text. +- После изменения этой границы запускать `scripts/check-runtime-powershell-boundary.ps1 -CheckOnly`. - Install/start/stop/uninstall должны быть явными user actions. - Fuzzy-detected service не считать managed service без проверки `PathName`/metadata. - В Linux/macOS CI не пытаться «проверить» Windows service operations как реальные. Тестировать pure logic/mocks. @@ -165,12 +178,6 @@ scripts/ - `src-tauri/src/commands.rs` слишком большой. Главная цель рефакторинга: разрезать на модули по use-case. - `src/app/App.tsx` слишком большой. Главная цель frontend-рефакторинга: hooks/components/view-model helpers. -- `tauri.conf.json` сейчас требует security review, особенно CSP и window resize settings. -- JSON storage молча возвращает default при invalid JSON. Нужен corruption recovery через `.bak` и user-visible warning. -- ProxiFyre config apply должен стать atomic. -- Subscription URL redaction должен исключать userinfo/password. -- Link subscription parser сейчас ориентирован на VLESS; не обещать больше, чем реально поддерживается. -- Ping/select по server tag может ломаться при duplicate tags. Нужен stable server id. ## Минимальная проверка перед ответом @@ -200,12 +207,14 @@ npm run tauri -- dev npm run tauri -- build ``` -Для installer boundaries: +Для offline bundle/release boundaries: ```powershell -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly +& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly +& .\scripts\update-component-bundle.ps1 -PlanOnly +& .\scripts\update-component-bundle.ps1 -CheckOnly +& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly +& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild ``` Не оставлять dev/preview/Tauri dev servers запущенными после проверки. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f20688..97d9857 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,11 @@ cargo test --all-targets Pop-Location npm run tauri -- info -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly +& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly +& .\scripts\update-component-bundle.ps1 -PlanOnly +& .\scripts\update-component-bundle.ps1 -CheckOnly & .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly +& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild ``` Windows service, UAC, installer и реальный routing нельзя считать проверенными только по unit-тестам. Для таких изменений укажите выполненный ручной сценарий или явно оставьте этот пробел в отчете. @@ -32,6 +33,8 @@ Windows service, UAC, installer и реальный routing нельзя счи - Держите `src/api/tauriCommands.ts` единственным TypeScript facade над Tauri `invoke`. - Не показывайте subscription URL, credentials, proxy password или `X-HWID` в логах и UI. - Не добавляйте скрытые install/start/stop/uninstall действия в apply. +- Не добавляйте PowerShell, `.ps1` resources или generated scripts в production runtime. PowerShell разрешён только в точном build/release/QA allowlist, который проверяет `check-runtime-powershell-boundary.ps1`. +- Храните managed components только в `C:\Program Files\ProxyWarden\components`; `config\components.json` допустим лишь как legacy migration input, а не source of truth. - Добавляйте минимальный тест для новой ветвящейся логики. - Не коммитьте runtime-файлы из `C:\ProgramData\ProxyWarden` и generated output. diff --git a/README.md b/README.md index aa26364..c371daf 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,36 @@ # ProxyWarden -ProxyWarden - это standalone Windows desktop-приложение для маршрутизации выбранных программ через SOCKS5-прокси. По сути это удобная оболочка управления над внешними компонентами: обязательным маршрутизатором приложений ProxiFyre и, опционально, локальным runtime `sing-box`. +ProxyWarden — standalone Windows desktop-приложение для маршрутизации выбранных программ через SOCKS5-прокси. Оно управляет обязательным ProxiFyre и, при необходимости, локальным `sing-box`, но само не является VPN-драйвером, proxy server или облачным control plane. -ProxyWarden сам не является VPN-драйвером, прокси-сервером или отдельным gateway/server. Он хранит настройки, показывает состояние компонентов, генерирует конфиги и запускает только явные действия пользователя: установить, запустить, остановить, удалить или применить конфиг. +Все системные действия остаются явными: `apply` только проверяет и применяет конфигурацию; установка, обновление, запуск, остановка, перенос и удаление компонентов выполняются отдельными командами пользователя. ## Главное -- Работает как Windows-клиент: Tauri 2 + React/TypeScript UI + Rust backend. -- Маршрутизирует не всю систему, а выбранные приложения: процесс, папку или конкретный `.exe`. -- Не меняет глобальный proxy в Windows. -- Для per-app routing нужен ProxiFyre. -- Local sing-box нужен только для сценария с подпиской и локальным SOCKS5 endpoint. -- Внешний SOCKS5-прокси работает без Local sing-box. -- Применение профиля не устанавливает и не чинит компоненты скрыто. +- Tauri 2 + React/TypeScript UI + Rust backend. +- Маршрутизируются выбранные процессы, папки или `.exe`, а не вся система. +- Глобальный Windows proxy не меняется. +- Внешний SOCKS5 работает без Local sing-box. +- Production runtime не запускает PowerShell: service/install/UAC orchestration принадлежит native Rust. +- x64 installer содержит проверенные offline payloads компонентов и WebView2 Offline Installer; сеть для baseline-установки не нужна. -## Из чего состоит +## Компоненты -| Компонент | Что это | Нужен когда | Откуда берется | -| --- | --- | --- | --- | -| ProxyWarden Control App | Окно управления, настройки, status/readiness, генерация конфигов | Всегда | Этот репозиторий | -| [ProxiFyre](https://github.com/wiresock/proxifyre) | Windows-приложение/служба для перехвата трафика выбранных процессов и отправки его в SOCKS5 | Всегда для маршрутизации приложений | GitHub releases `wiresock/proxifyre` | -| [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер, который нужен ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует | GitHub releases `wiresock/ndisapi` | -| [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость для `ProxiFyre.exe` | Устанавливается вместе с ProxiFyre, если отсутствует | Официальный `vc_redist` Microsoft | -| [sing-box](https://github.com/SagerNet/sing-box) | Локальный proxy/VPN runtime, который слушает `127.0.0.1:1080` | Только для маршрута через subscription/выбранный сервер | GitHub releases `SagerNet/sing-box` | -| [WinSW](https://github.com/winsw/winsw) | Wrapper, который запускает Local sing-box как Windows-службу | Только для Local sing-box | GitHub releases `winsw/winsw` | +| Компонент | Роль | Когда нужен | +| --- | --- | --- | +| ProxyWarden Control App | UI, storage, validation, config generation и orchestration | Всегда | +| [ProxiFyre](https://github.com/wiresock/proxifyre) | Перехватывает трафик выбранных приложений и направляет его в SOCKS5 | Для любого per-app routing | +| [Windows Packet Filter / NDISAPI](https://github.com/wiresock/ndisapi) | Сетевой драйвер ProxiFyre | Устанавливается вместе с ProxiFyre, если отсутствует | +| [Microsoft Visual C++ Redistributable](https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist) | Runtime-зависимость ProxiFyre | Устанавливается при необходимости | +| [sing-box](https://github.com/SagerNet/sing-box) | Создаёт локальный SOCKS5 endpoint для выбранного subscription-сервера | Только для Local sing-box flow | +| [WinSW](https://github.com/winsw/winsw) | Запускает sing-box как Windows-службу | Только для Local sing-box flow | -В UI и коде компонент ProxiFyre иногда проходит через внутренний id `proxyfier`. Это не отдельный продукт Proxifier; текущий backend adapter работает именно с ProxiFyre. +Версии, SHA-256 и лицензии offline payloads зафиксированы в packaged component catalog. Установка Control App не запускает routing-компоненты: нужный компонент устанавливается отдельным действием в UI. -## Как идут маршруты +В UI и части внутренних DTO ProxiFyre может иметь исторический id `proxyfier`. Это не продукт Proxifier. -Внешний SOCKS5-прокси: +## Маршруты + +Внешний SOCKS5: ```text выбранные приложения -> ProxiFyre -> внешний SOCKS5 proxy @@ -38,192 +39,140 @@ ProxyWarden сам не является VPN-драйвером, прокси-с Local sing-box: ```text -выбранные приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный сервер из подписки +выбранные приложения -> ProxiFyre -> Local sing-box 127.0.0.1:1080 -> выбранный subscription-сервер ``` -Во втором сценарии ProxiFyre все равно обязателен: именно он делает маршрутизацию конкретных Windows-приложений. Local sing-box только дает локальный SOCKS5 endpoint и ходит дальше к выбранному серверу. +Во втором маршруте ProxiFyre по-прежнему отвечает за выбор приложений. Local sing-box только предоставляет локальный SOCKS5 endpoint и соединяется с выбранным сервером. -## Что устанавливается +## Установка и системные пути -### Control App - -Обычная сборка Tauri создает desktop-приложение ProxyWarden. Отдельный скрипт `scripts/install-control-app.ps1` сейчас подготавливает стандартные директории: +Tauri NSIS installer устанавливает Control App per-machine. Managed runtime-компоненты лежат только под текущим app root: ```text -C:\Program Files\ProxyWarden\ControlApp -C:\ProgramData\ProxyWarden\config -C:\ProgramData\ProxyWarden\state -C:\ProgramData\ProxyWarden\generated +C:\Program Files\ProxyWarden +C:\Program Files\ProxyWarden\components\ProxiFyre +C:\Program Files\ProxyWarden\components\sing-box ``` -### ProxiFyre - -Явная установка ProxiFyre из приложения выполняется через elevated PowerShell и ставит/обновляет: +Службы: ```text -C:\Tools\ProxiFyre -C:\Tools\ProxiFyre\ProxiFyre.exe -C:\Tools\ProxiFyre\app-config.json -Windows service: ProxiFyreService +ProxiFyreService +ProxyWardenSingBox ``` -Если на машине не найдены зависимости, установщик также скачивает и ставит Microsoft Visual C++ Redistributable и Windows Packet Filter / NDISAPI. +ProxyWarden управляет службой только после точной проверки `PathName`, marker/receipt и canonical component root. Похожее имя службы или найденная папка сами по себе не дают права на start/stop/delete. -### Local sing-box +## Релиз одной командой -Явная установка Local sing-box ставит: +В PowerShell из корня проекта: + +```powershell +.\release.cmd +``` + +То же действие доступно как `npm run release`. Сценарий показывает изменения Git и предлагает patch/minor/major, произвольную версию или текущую ещё не выпущенную версию. Можно сразу ввести номер вроде `1.2.1`. + +После выбора он синхронизирует версии в package.json, package-lock.json, tauri.conf.json, Cargo.toml и Cargo.lock, проверяет frontend/Rust/offline bundle, собирает NSIS и готовит папку `releases/proxywarden-vX.Y.Z`. Затем создаёт commit со всеми текущими отслеживаемыми и неигнорируемыми новыми файлами, annotated tag `vX.Y.Z` и одним atomic push отправляет текущую ветку и этот тег в `origin`. При отсутствии изменений новый commit не нужен. Артефакты не попадают в Git. + +В папке релиза: `artifacts/nsis/ProxyWarden_X.Y.Z_x64-setup.exe`, `SHA256SUMS.txt`, `release-manifest.json` с точным commit/hash и `release-notes.md`. EXE загружается на сайт вручную; GitHub/Gitea release page автоматически не создаётся. + +Нужны Git с настроенной identity и доступом к origin, Node, установленные frontend-зависимости (`npm ci` один раз), Rust/MSVC/Windows SDK. Сам сценарий сборки использует Node напрямую и не требует npm в PATH. Запуск от администратора не нужен. + +```powershell +.\release.cmd -PlanOnly # только JSON-план: без записи, сборки и сети +.\release.cmd -Version 1.2.1 # версия без вопроса +.\release.cmd -Version 1.2.1 -Resume # повторить только неудачный push +``` + +Не меняйте исходники во время сборки. Существующие теги не перезаписываются; при расхождении с удалённой веткой сценарий останавливается до изменения версий. При ошибке сборки изменения версии остаются локально для исправления, commit/tag/push не выполняются. При неудачном push готовая папка и локальный commit/tag сохраняются; `-Resume` проверяет исходники и SHA-256 перед повторной отправкой. + +Для локальной подготовки без commit/tag/push остаётся `scripts/prepare-release.ps1 -Version X.Y.Z`. Автоматические проверки не заменяют Windows VM/UAC/driver/routing acceptance: в manifest это отмечается отдельно. + +## Данные и source of truth + +Настройки и состояние лежат под `C:\ProgramData\ProxyWarden`: ```text -C:\Program Files\ProxyWarden\sing-box\sing-box.exe -C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe -C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.xml -C:\Program Files\ProxyWarden\sing-box\config.json -Windows service: ProxyWardenSingBox +config\profiles.json +config\targets.json +config\local-singbox.json +config\storage-meta.json +state\activity.json +state\component-layout.json +state\component-updates.json +state\migrations\... +packages\... ``` -`ProxyWardenSingBox.exe` - это WinSW wrapper. Он нужен только чтобы запускать `sing-box.exe` как Windows-службу. +`config\components.json` не является текущим источником статуса компонентов. Это только legacy input: migration может проверить, сохранить snapshot/archive и затем перестать использовать его. Фактический install/service/version status читается из native inventory Windows и проверенных receipts. + +Generated artifacts можно пересоздать: + +```text +generated\proxifyre-app-config.json +generated\sing-box-config.json +``` + +Не редактируйте generated-файлы как основной источник правды. Subscription URL, userinfo, credentials, proxy password и внутренние migration/job records нельзя выводить целиком в UI, logs или diagnostics. + +## Миграция старой установки + +- Startup выполняет только безопасную storage adoption/migration: backup, validation, atomic commit и повторный no-op. +- Старые component roots и службы сначала обнаруживаются read-only. +- Перенос компонента — отдельное UAC-действие с exact identity checks, rollback journal и quarantine. +- Foreign или incomplete installation не управляется автоматически. +- Пока cutover journal активен, требует recovery или quarantine ещё не подтверждён к удалению, upgrade/uninstall блокируется до безопасного завершения. ## Права администратора -Без прав администратора можно открыть приложение, редактировать настройки, добавлять приложения, вводить внешний proxy, загружать/выбирать подписку и смотреть состояние. +Без UAC можно редактировать настройки, выбирать приложения и proxy, загружать subscription, смотреть статус и генерировать конфигурацию. -Права администратора или UAC confirmation нужны для операций, которые меняют систему: +UAC требуется для явных действий, которые меняют Windows: -- установка или удаление ProxiFyre; -- установка Windows Packet Filter / NDISAPI; -- установка Microsoft Visual C++ Redistributable, если его нет; -- установка или удаление Local sing-box; -- создание, запуск и остановка Windows-служб; -- удаление install folder для managed-компонентов. +- install/update/uninstall ProxiFyre или Local sing-box; +- установка Windows Packet Filter и VC++ Runtime при необходимости; +- start/stop/create/delete Windows-служб; +- подтверждённый legacy component cutover и его cleanup. -Применение профиля не запускает установку. Оно генерирует derived config и пытается записать его в найденную установку ProxiFyre. Если прав на запись в папку установки не хватает, операция должна завершиться ошибкой, а не устанавливать что-то скрыто. - -## Поддержанная среда - -Подтверждено вручную сейчас: - -```text -Windows 11 -PowerShell 7 как пользовательская shell для запуска команд разработки -``` - -Важно: Rust backend и elevated-операции сейчас запускают именно `powershell.exe` с `-NoProfile` и `-ExecutionPolicy Bypass`. На Windows это обычно Windows PowerShell 5.1. Скрипты используют стандартные команды вроде `Get-CimInstance`, `Invoke-WebRequest`, `Expand-Archive`, `Get-FileHash`, `Start-Service`, `Stop-Service`, `ConvertTo-Json`, поэтому должны быть близки к Windows PowerShell 5.1, но полный ручной тест пока был только на Windows 11 с PowerShell 7 в окружении разработки. - -Ожидаемая, но не полностью подтвержденная область: - -- Windows 10/11 desktop; -- x64 как основной сценарий; -- x86 и ARM64 частично учтены в installer-логике через выбор release assets, но не считаются проверенными; -- обычный desktop/laptop без специальных требований к GPU; -- доступ в интернет к GitHub releases и Microsoft download endpoints для установки компонентов. - -Linux/macOS не являются целевой платформой для этого клиента. - -## Где лежат настройки - -Source of truth лежит в JSON под `C:\ProgramData\ProxyWarden`: - -```text -C:\ProgramData\ProxyWarden\config\profiles.json -C:\ProgramData\ProxyWarden\config\targets.json -C:\ProgramData\ProxyWarden\config\components.json -C:\ProgramData\ProxyWarden\config\local-singbox.json -C:\ProgramData\ProxyWarden\state\activity.json -C:\ProgramData\ProxyWarden\state\singbox-subscription-cache.json -``` - -Сгенерированные файлы лежат отдельно и могут быть пересозданы: - -```text -C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json -C:\ProgramData\ProxyWarden\generated\sing-box-config.json -``` - -Не редактируйте generated-файлы как основной источник правды. При следующей генерации они могут быть перезаписаны. - -Subscription URL считается секретом. UI и diagnostics должны показывать только редактированную/сокращенную версию ссылки. - -При загрузке подписки ProxyWarden отправляет провайдеру стандартные идентификационные заголовки приложения и `X-HWID` - случайный постоянный UUID этой установки. Это не серийный номер оборудования, но провайдер может использовать его для связывания запросов одной установки. Проверка маршрута делает HTTPS-запросы через выбранный proxy к Cloudflare и ipify, чтобы подтвердить выход и определить внешний IP. +Elevated mode принимает только заранее записанный typed job ID либо один из фиксированных NSIS modes. UI не передаёт произвольную команду, script text или install path. ## Типовые сценарии ### Внешний SOCKS5 -1. Запустите ProxyWarden. -2. Установите или проверьте ProxiFyre. -3. На вкладке `VPN / Прокси` выберите внешний proxy. -4. Введите `host:port` или `socks5://host:port`. -5. На вкладке `ProxiFyre` добавьте приложения. -6. Нажмите `Применить в ProxiFyre`. +1. Установите ProxiFyre явной кнопкой, если он отсутствует. +2. На вкладке `VPN / Прокси` выберите внешний proxy и укажите `host:port` или `socks5://host:port`. +3. Добавьте приложения в ProxiFyre route. +4. Нажмите `Применить`. Local sing-box для этого сценария не нужен. ### Local sing-box с подпиской -1. Запустите ProxyWarden. -2. Установите ProxiFyre. -3. Установите Local sing-box. -4. Вставьте subscription URL. -5. Загрузите список серверов и выберите сервер. -6. Добавьте приложения. -7. Сгенерируйте/примените маршрут. +1. Явно установите ProxiFyre и Local sing-box. +2. Добавьте subscription URL, загрузите список и выберите сервер. +3. Добавьте приложения и примените маршрут. -## Установка и запуск из исходников +## Разработка -Нужны: - -- Windows 11 для подтвержденного пути разработки; -- Node.js и npm; -- Rust через rustup; -- Visual Studio Build Tools с MSVC и Windows SDK; -- Microsoft Edge WebView2 Runtime; -- PowerShell 7 удобно использовать как shell разработки, но elevated runtime-команды приложения запускаются через `powershell.exe`. - -Установка зависимостей и запуск: +Целевая платформа — Windows 10/11 x64. Для сборки нужны Node.js/npm, Rust через rustup, Visual Studio Build Tools с MSVC и Windows SDK. PowerShell 7 используется только для build/release/QA tooling; установленному приложению PowerShell не нужен. ```powershell -cd D:\repos\ProxyWarden -npm install +Set-Location D:\repos\ProxyWarden +npm ci npm run tauri -- dev ``` -Собрать frontend: - -```powershell -npm run build -``` - -Собрать установочный пакет Tauri: - -```powershell -npm run tauri -- build -``` - -Запустить только browser-preview без нативных Tauri-команд: +Browser preview не доказывает работу Tauri commands, UAC или Windows-служб: ```powershell npm run dev -- --host 127.0.0.1 ``` -Browser-preview годится для проверки интерфейса, но не доказывает работу Windows-служб, elevated-операций и Tauri command handlers. +## Проверка -## Installer-скрипты - -В репозитории есть явные entrypoint-скрипты: - -```powershell -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly -``` - -`-PlanOnly` возвращает structured JSON и не должен иметь side effects. - -Реальная установка через эти скрипты требует прав администратора. `scripts/install-proxyfier.ps1` как standalone boundary сейчас ожидает локальный `-PackagePath`; путь установки из UI/backend использует отдельный elevated-скрипт, который скачивает ProxiFyre, Windows Packet Filter и runtime-зависимости сам. - -## Проверка для разработчика - -Frontend/UI: +Frontend и Rust: ```powershell npm run format:check @@ -231,37 +180,34 @@ npm run lint npm run typecheck npm test -- --run npm run build + +Push-Location src-tauri +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --all-targets +Pop-Location ``` -Rust/backend: - -```powershell -cd D:\repos\ProxyWarden\src-tauri -cargo test -``` - -Tauri/toolchain: +Build/release/QA boundaries: ```powershell +& .\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly +& .\scripts\update-component-bundle.ps1 -PlanOnly +& .\scripts\update-component-bundle.ps1 -CheckOnly +& .\scripts\audit-windows-smoke.ps1 -Mode PlanOnly +& .\scripts\prepare-release.ps1 -PlanOnly -SkipBuild npm run tauri -- info -npm run tauri -- dev npm run tauri -- build ``` -Installer boundaries: +`PlanOnly` и `CheckOnly` возвращают structured JSON с `changed: false`. Обновление packaged component catalog — отдельная release-команда и не является runtime action. -```powershell -& .\scripts\install-control-app.ps1 -PlanOnly -& .\scripts\install-proxyfier.ps1 -PlanOnly -& .\scripts\install-singbox.ps1 -PlanOnly -``` +Unit tests и build не подтверждают реальный UAC/SCM/driver/routing flow. Для release candidate нужны Windows VM smoke-сценарии: fresh offline install, legacy upgrade/rollback, foreign same-name service refusal и uninstall/reboot behavior. -## Ограничения текущей версии +## Ограничения -- Основной поддержанный маршрут - SOCKS5. -- Link-подписки разбирают VLESS, VMess, Trojan и Shadowsocks; sing-box JSON также принимает поддержанные proxy outbounds. Неизвестные форматы отклоняются явно. -- Для VLESS outbound без собственного `packet_encoding` генератор добавляет `xudp`; значение, заданное провайдером подписки, не перезаписывается. -- ProxiFyre является текущим backend-слоем для per-app routing. -- Local sing-box остается опциональным и не требуется для внешнего SOCKS5. -- Elevated install/start/stop/uninstall операции считаются реализованными, но требуют дополнительной проверки на реальной Windows-машине с UAC/admin confirmation. -- Windows 10, Windows PowerShell 5.1, ARM64 и x86 нужно отдельно подтвердить перед тем, как называть их официально поддержанными. +- Основной routing protocol — SOCKS5. +- Link subscriptions поддерживают только форматы, которые явно принимает текущий parser; неизвестные поля/форматы отклоняются, а не теряются молча. +- Local sing-box остаётся optional. +- x86 и ARM64 не входят в текущий release contract. +- Реальные Windows service, UAC, driver и offline installer сценарии нельзя считать подтверждёнными без VM evidence. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..e035fe1 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,41 @@ +# Third-party notices for the ProxyWarden offline bundle + +This file records the third-party runtime payload planned for the ProxyWarden +`1.2.0` Windows x64 installer. It is an engineering inventory, not legal advice +or a completed distribution approval. Exact bundled hashes and sizes are owned +by `src-tauri/bundled/components/catalog.json`. + +## Managed runtime assets + +| Component | Pinned asset and official source | License copy | Update trust and distribution note | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ProxiFyre | `2.4.0`, [`ProxiFyre-v2.4.0-x64-signed.zip`](https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip); [commit-pinned source archive](https://github.com/wiresock/proxifyre/archive/dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed.tar.gz) | `AGPL-3.0-only`; `src-tauri/bundled/components/proxifyre/LICENSE` | Origin is accepted only with the official GitHub release digest and the Authenticode publisher `The Anti-Cloud Corporation` on the inner executable. Before release, the project/release owner must record the corresponding-source or written-source-offer decision and approve redistribution. | +| Windows Packet Filter | release `3.6.2`, product `3.6.2.1`, [`Windows.Packet.Filter.3.6.2.1.x64.msi`](https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi); [commit-pinned source archive](https://github.com/wiresock/ndisapi/archive/417b8734e844083a10236387fba705d94a2d6bc9.tar.gz) | `MIT`; `src-tauri/bundled/components/windows-packet-filter/LICENSE` | Origin is accepted only with the official GitHub release digest and MSI Authenticode publisher `The Anti-Cloud Corporation`. The MSI is a shared system dependency; its presence alone does not prove ProxyWarden ownership and does not authorize uninstall. | +| Microsoft Visual C++ x64 Redistributable | file/product version `14.51.36247.0`, [`VC_redist.x64.exe`](https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe) | `LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026`; [`src-tauri/bundled/components/vc-runtime/LICENSE.docx`](https://visualstudio.microsoft.com/wp-content/uploads/2025/10/Visual-C-V14-License-Redistributable_and_Runtime_ENU.docx) | Build-time refresh only. The pinned file must retain a valid Microsoft Corporation Authenticode signature; no in-app remote update is offered. This is proprietary Microsoft software, so the project/release owner must approve its redistribution under the bundled official terms before release. | +| sing-box | `1.13.19`, [`sing-box-1.13.19-windows-amd64.zip`](https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip); [commit-pinned source archive](https://github.com/SagerNet/sing-box/archive/b5ebaa1fc0f2b94256180b95468e73ef53caa27d.tar.gz) | `LicenseRef-Sing-Box-Project` (GPL-3.0-or-later plus the upstream name restriction); `src-tauri/bundled/components/sing-box/LICENSE` | Origin is accepted only with the official GitHub release digest. Redistribution must preserve the GPL terms and the upstream name restriction. Before release, the project/release owner must record the corresponding-source/source-offer decision and approve the notice text. | +| WinSW | `2.12.0`, [`WinSW.NET461.exe`](https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe); [commit-pinned source archive](https://github.com/winsw/winsw/archive/eef5bade59fca0254e387ac73ed7625ba6aa7147.tar.gz) | `MIT`; `src-tauri/bundled/components/winsw/LICENSE.txt` | The selected binary is IL-only AnyCPU and is used on the x64 target with supported .NET Framework 4.8/4.8.1. Upstream supplies neither an independent digest nor an Authenticode signature for this asset, so it is `bundled-only/no-independent-proof`: remote update is disabled and a newer bundle is required to replace it. | + +## WebView2 prerequisite + +Microsoft Edge WebView2 Runtime is not part of the managed component catalog and +does not receive an in-app update action. Tauri packages the Microsoft WebView2 +Evergreen Standalone Offline Installer into the NSIS installer through +`bundle.windows.webviewInstallMode.type = "offlineInstaller"`. Microsoft/Windows +owns later runtime servicing. The release evidence must prove that the offline +payload is present and that a clean Windows 10/11 x64 machine can install and +start ProxyWarden without network access. See the official +[WebView2 distribution page](https://developer.microsoft.com/en-us/microsoft-edge/webview2/). + +## Release compliance gate + +No license or distribution sign-off is claimed by this file. Before tagging or +publishing `1.2.0`, the project/release owner must record in +`docs/goals/production-ready-offline-migration/EVIDENCE.md`: + +- the exact installer composition and catalog hashes; +- the reviewed license copies and source links; +- the corresponding-source/source-offer decisions for ProxiFyre and sing-box; +- the Microsoft Visual C++ and WebView2 redistribution decision; +- reviewer name/date and explicit approval. + +Until that record exists, license/distribution remains a release blocker. diff --git a/package-lock.json b/package-lock.json index bf42fec..c7417c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "proxywarden", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "proxywarden", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@fontsource-variable/jetbrains-mono": "^5.2.8", "@tauri-apps/api": "^2.0.0", diff --git a/package.json b/package.json index b02b8fb..4ce55ef 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "proxywarden", - "version": "1.1.0", + "version": "2.0.0", "private": true, "type": "module", "description": "Standalone Windows desktop proxy management app for ProxyWarden.", "scripts": { + "release": ".\\release.cmd", + "test:release": "node --test scripts/prepare-release.check.mjs", "dev": "vite", "build": "npm run typecheck && vite build", "typecheck": "tsc --noEmit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 7646ea4..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,2356 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@fontsource-variable/jetbrains-mono': - specifier: ^5.2.8 - version: 5.3.0 - '@tauri-apps/api': - specifier: ^2.0.0 - version: 2.11.1 - '@tauri-apps/plugin-dialog': - specifier: ^2.7.1 - version: 2.7.2 - lucide-react: - specifier: ^1.23.0 - version: 1.25.0(react@19.2.7) - react: - specifier: ^19.0.0 - version: 19.2.7 - react-dom: - specifier: ^19.0.0 - version: 19.2.7(react@19.2.7) - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.7.0) - '@tauri-apps/cli': - specifier: ^2.0.0 - version: 2.11.4 - '@types/react': - specifier: ^19.0.0 - version: 19.2.17 - '@types/react-dom': - specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.17) - '@vitejs/plugin-react': - specifier: ^5.0.0 - version: 5.2.0(vite@7.3.6) - eslint: - specifier: ^10.7.0 - version: 10.7.0 - prettier: - specifier: ^3.9.5 - version: 3.9.5 - typescript: - specifier: ^5.8.0 - version: 5.9.3 - typescript-eslint: - specifier: ^8.63.0 - version: 8.65.0(eslint@10.7.0)(typescript@5.9.3) - vite: - specifier: ^7.0.0 - version: 7.3.6 - vitest: - specifier: ^3.2.4 - version: 3.2.7 - -packages: - - '@babel/code-frame@7.29.7': - resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.7': - resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.7': - resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.29.7': - resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-globals@7.29.7': - resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.29.7': - resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.29.7': - resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.29.7': - resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.7': - resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-transform-react-jsx-self@7.29.7': - resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-source@7.29.7': - resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/template@7.29.7': - resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - '@fontsource-variable/jetbrains-mono@5.3.0': - resolution: {integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.62.2': - resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.62.2': - resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.62.2': - resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.62.2': - resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.62.2': - resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.62.2': - resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.62.2': - resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.62.2': - resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.62.2': - resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.62.2': - resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.62.2': - resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.62.2': - resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.62.2': - resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.62.2': - resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} - cpu: [x64] - os: [win32] - - '@tauri-apps/api@2.11.1': - resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - - '@tauri-apps/cli-darwin-arm64@2.11.4': - resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tauri-apps/cli-darwin-x64@2.11.4': - resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} - engines: {node: '>= 10'} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tauri-apps/cli-linux-x64-musl@2.11.4': - resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tauri-apps/cli@2.11.4': - resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} - engines: {node: '>= 10'} - hasBin: true - - '@tauri-apps/plugin-dialog@2.7.2': - resolution: {integrity: sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==} - - '@types/babel__core@7.20.5': - resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - - '@types/babel__generator@7.27.0': - resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} - - '@types/babel__template@7.4.4': - resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - - '@types/babel__traverse@7.28.0': - resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} - - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.65.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@vitejs/plugin-react@5.2.0': - resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} - engines: {node: ^20.19.0 || >=22.12.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - - '@vitest/expect@3.2.7': - resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} - - '@vitest/mocker@3.2.7': - resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} - peerDependencies: - msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@3.2.7': - resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - - '@vitest/runner@3.2.7': - resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} - - '@vitest/snapshot@3.2.7': - resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} - - '@vitest/spy@3.2.7': - resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} - - '@vitest/utils@3.2.7': - resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.44: - resolution: {integrity: sha512-T3ghW+sl/ZJ8w1v/yQx3qvJ9040DWoLBz8JT/CILbAKcFyG9b2MRe75v6W5uXjv6uH1lumK2Kv46y2zSkcej0Q==} - engines: {node: '>=6.0.0'} - hasBin: true - - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} - - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} - - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} - engines: {node: '>=18'} - - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - electron-to-chromium@1.5.393: - resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.7.0: - resolution: {integrity: sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@1.25.0: - resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} - engines: {node: '>=18'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - - postcss@8.5.20: - resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier@3.9.5: - resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} - engines: {node: '>=14'} - hasBin: true - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - react-dom@19.2.7: - resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} - peerDependencies: - react: ^19.2.7 - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react@19.2.7: - resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} - engines: {node: '>=0.10.0'} - - rollup@4.62.2: - resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.8.5: - resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - siginfo@2.0.0: - resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - stackback@0.0.2: - resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - - tinybench@2.9.0: - resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - - tinyexec@0.3.2: - resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} - engines: {node: '>=14.0.0'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitest@3.2.7: - resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.7 - '@vitest/ui': 3.2.7 - happy-dom: '*' - jsdom: '*' - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@types/debug': - optional: true - '@types/node': - optional: true - '@vitest/browser': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - -snapshots: - - '@babel/code-frame@7.29.7': - dependencies: - '@babel/helper-validator-identifier': 7.29.7 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.7': {} - - '@babel/core@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) - '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.7': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-compilation-targets@7.29.7': - dependencies: - '@babel/compat-data': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.6 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-globals@7.29.7': {} - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-string-parser@7.29.7': {} - - '@babel/helper-validator-identifier@7.29.7': {} - - '@babel/helper-validator-option@7.29.7': {} - - '@babel/helpers@7.29.7': - dependencies: - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/parser@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/template@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - - '@esbuild/aix-ppc64@0.28.1': - optional: true - - '@esbuild/android-arm64@0.28.1': - optional: true - - '@esbuild/android-arm@0.28.1': - optional: true - - '@esbuild/android-x64@0.28.1': - optional: true - - '@esbuild/darwin-arm64@0.28.1': - optional: true - - '@esbuild/darwin-x64@0.28.1': - optional: true - - '@esbuild/freebsd-arm64@0.28.1': - optional: true - - '@esbuild/freebsd-x64@0.28.1': - optional: true - - '@esbuild/linux-arm64@0.28.1': - optional: true - - '@esbuild/linux-arm@0.28.1': - optional: true - - '@esbuild/linux-ia32@0.28.1': - optional: true - - '@esbuild/linux-loong64@0.28.1': - optional: true - - '@esbuild/linux-mips64el@0.28.1': - optional: true - - '@esbuild/linux-ppc64@0.28.1': - optional: true - - '@esbuild/linux-riscv64@0.28.1': - optional: true - - '@esbuild/linux-s390x@0.28.1': - optional: true - - '@esbuild/linux-x64@0.28.1': - optional: true - - '@esbuild/netbsd-arm64@0.28.1': - optional: true - - '@esbuild/netbsd-x64@0.28.1': - optional: true - - '@esbuild/openbsd-arm64@0.28.1': - optional: true - - '@esbuild/openbsd-x64@0.28.1': - optional: true - - '@esbuild/openharmony-arm64@0.28.1': - optional: true - - '@esbuild/sunos-x64@0.28.1': - optional: true - - '@esbuild/win32-arm64@0.28.1': - optional: true - - '@esbuild/win32-ia32@0.28.1': - optional: true - - '@esbuild/win32-x64@0.28.1': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@10.7.0)': - dependencies: - eslint: 10.7.0 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.6.0': - dependencies: - '@eslint/core': 1.2.1 - - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/js@10.0.1(eslint@10.7.0)': - optionalDependencies: - eslint: 10.7.0 - - '@eslint/object-schema@3.0.5': {} - - '@eslint/plugin-kit@0.7.2': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 - - '@fontsource-variable/jetbrains-mono@5.3.0': {} - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@rolldown/pluginutils@1.0.0-rc.3': {} - - '@rollup/rollup-android-arm-eabi@4.62.2': - optional: true - - '@rollup/rollup-android-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-arm64@4.62.2': - optional: true - - '@rollup/rollup-darwin-x64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-arm64@4.62.2': - optional: true - - '@rollup/rollup-freebsd-x64@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.62.2': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-linux-x64-musl@4.62.2': - optional: true - - '@rollup/rollup-openbsd-x64@4.62.2': - optional: true - - '@rollup/rollup-openharmony-arm64@4.62.2': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.62.2': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.62.2': - optional: true - - '@tauri-apps/api@2.11.1': {} - - '@tauri-apps/cli-darwin-arm64@2.11.4': - optional: true - - '@tauri-apps/cli-darwin-x64@2.11.4': - optional: true - - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - optional: true - - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - optional: true - - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - optional: true - - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - optional: true - - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - optional: true - - '@tauri-apps/cli-linux-x64-musl@2.11.4': - optional: true - - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - optional: true - - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - optional: true - - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - optional: true - - '@tauri-apps/cli@2.11.4': - optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.4 - '@tauri-apps/cli-darwin-x64': 2.11.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 - '@tauri-apps/cli-linux-arm64-musl': 2.11.4 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-musl': 2.11.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 - '@tauri-apps/cli-win32-x64-msvc': 2.11.4 - - '@tauri-apps/plugin-dialog@2.7.2': - dependencies: - '@tauri-apps/api': 2.11.1 - - '@types/babel__core@7.20.5': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@types/babel__generator': 7.27.0 - '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.28.0 - - '@types/babel__generator@7.27.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/babel__template@7.4.4': - dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - - '@types/babel__traverse@7.28.0': - dependencies: - '@babel/types': 7.29.7 - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - - '@types/deep-eql@4.0.2': {} - - '@types/esrecurse@4.3.1': {} - - '@types/estree@1.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/react-dom@19.2.3(@types/react@19.2.17)': - dependencies: - '@types/react': 19.2.17 - - '@types/react@19.2.17': - dependencies: - csstype: 3.2.3 - - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 - eslint: 10.7.0 - ignore: 7.0.6 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - eslint: 10.7.0 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.65.0': - dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 - - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.65.0(eslint@10.7.0)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.7.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.65.0': {} - - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.65.0(eslint@10.7.0)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - eslint: 10.7.0 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.65.0': - dependencies: - '@typescript-eslint/types': 8.65.0 - eslint-visitor-keys: 5.0.1 - - '@vitejs/plugin-react@5.2.0(vite@7.3.6)': - dependencies: - '@babel/core': 7.29.7 - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) - '@rolldown/pluginutils': 1.0.0-rc.3 - '@types/babel__core': 7.20.5 - react-refresh: 0.18.0 - vite: 7.3.6 - transitivePeerDependencies: - - supports-color - - '@vitest/expect@3.2.7': - dependencies: - '@types/chai': 5.2.3 - '@vitest/spy': 3.2.7 - '@vitest/utils': 3.2.7 - chai: 5.3.3 - tinyrainbow: 2.0.0 - - '@vitest/mocker@3.2.7(vite@7.3.6)': - dependencies: - '@vitest/spy': 3.2.7 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.3.6 - - '@vitest/pretty-format@3.2.7': - dependencies: - tinyrainbow: 2.0.0 - - '@vitest/runner@3.2.7': - dependencies: - '@vitest/utils': 3.2.7 - pathe: 2.0.3 - strip-literal: 3.1.0 - - '@vitest/snapshot@3.2.7': - dependencies: - '@vitest/pretty-format': 3.2.7 - magic-string: 0.30.21 - pathe: 2.0.3 - - '@vitest/spy@3.2.7': - dependencies: - tinyspy: 4.0.4 - - '@vitest/utils@3.2.7': - dependencies: - '@vitest/pretty-format': 3.2.7 - loupe: 3.2.1 - tinyrainbow: 2.0.0 - - acorn-jsx@5.3.2(acorn@8.17.0): - dependencies: - acorn: 8.17.0 - - acorn@8.17.0: {} - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - assertion-error@2.0.1: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.44: {} - - brace-expansion@5.0.7: - dependencies: - balanced-match: 4.0.4 - - browserslist@4.28.6: - dependencies: - baseline-browser-mapping: 2.10.44 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.393 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) - - cac@6.7.14: {} - - caniuse-lite@1.0.30001806: {} - - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.3: {} - - convert-source-map@2.0.0: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-eql@5.0.2: {} - - deep-is@0.1.4: {} - - electron-to-chromium@1.5.393: {} - - es-module-lexer@1.7.0: {} - - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.7.0: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.7.0) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - transitivePeerDependencies: - - supports-color - - espree@11.2.0: - dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.9 - - esutils@2.0.3: {} - - expect-type@1.4.0: {} - - fast-deep-equal@3.1.3: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fdir@6.5.0(picomatch@4.0.5): - optionalDependencies: - picomatch: 4.0.5 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fsevents@2.3.3: - optional: true - - gensync@1.0.0-beta.2: {} - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - ignore@5.3.2: {} - - ignore@7.0.6: {} - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - isexe@2.0.0: {} - - js-tokens@4.0.0: {} - - js-tokens@9.0.1: {} - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - loupe@3.2.1: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@1.25.0(react@19.2.7): - dependencies: - react: 19.2.7 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.7 - - ms@2.1.3: {} - - nanoid@3.3.16: {} - - natural-compare@1.4.0: {} - - node-releases@2.0.51: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - pathe@2.0.3: {} - - pathval@2.0.1: {} - - picocolors@1.1.1: {} - - picomatch@4.0.5: {} - - postcss@8.5.20: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier@3.9.5: {} - - punycode@2.3.1: {} - - react-dom@19.2.7(react@19.2.7): - dependencies: - react: 19.2.7 - scheduler: 0.27.0 - - react-refresh@0.18.0: {} - - react@19.2.7: {} - - rollup@4.62.2: - dependencies: - '@types/estree': 1.0.9 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.2 - '@rollup/rollup-android-arm64': 4.62.2 - '@rollup/rollup-darwin-arm64': 4.62.2 - '@rollup/rollup-darwin-x64': 4.62.2 - '@rollup/rollup-freebsd-arm64': 4.62.2 - '@rollup/rollup-freebsd-x64': 4.62.2 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 - '@rollup/rollup-linux-arm-musleabihf': 4.62.2 - '@rollup/rollup-linux-arm64-gnu': 4.62.2 - '@rollup/rollup-linux-arm64-musl': 4.62.2 - '@rollup/rollup-linux-loong64-gnu': 4.62.2 - '@rollup/rollup-linux-loong64-musl': 4.62.2 - '@rollup/rollup-linux-ppc64-gnu': 4.62.2 - '@rollup/rollup-linux-ppc64-musl': 4.62.2 - '@rollup/rollup-linux-riscv64-gnu': 4.62.2 - '@rollup/rollup-linux-riscv64-musl': 4.62.2 - '@rollup/rollup-linux-s390x-gnu': 4.62.2 - '@rollup/rollup-linux-x64-gnu': 4.62.2 - '@rollup/rollup-linux-x64-musl': 4.62.2 - '@rollup/rollup-openbsd-x64': 4.62.2 - '@rollup/rollup-openharmony-arm64': 4.62.2 - '@rollup/rollup-win32-arm64-msvc': 4.62.2 - '@rollup/rollup-win32-ia32-msvc': 4.62.2 - '@rollup/rollup-win32-x64-gnu': 4.62.2 - '@rollup/rollup-win32-x64-msvc': 4.62.2 - fsevents: 2.3.3 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.8.5: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - siginfo@2.0.0: {} - - source-map-js@1.2.1: {} - - stackback@0.0.2: {} - - std-env@3.10.0: {} - - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - - tinybench@2.9.0: {} - - tinyexec@0.3.2: {} - - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.65.0(eslint@10.7.0)(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.7.0)(typescript@5.9.3))(eslint@10.7.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.7.0)(typescript@5.9.3) - eslint: 10.7.0 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - update-browserslist-db@1.2.3(browserslist@4.28.6): - dependencies: - browserslist: 4.28.6 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - vite-node@3.2.4: - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.6 - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - vite@7.3.6: - dependencies: - esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 - postcss: 8.5.20 - rollup: 4.62.2 - tinyglobby: 0.2.17 - optionalDependencies: - fsevents: 2.3.3 - - vitest@3.2.7: - dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.7 - '@vitest/mocker': 3.2.7(vite@7.3.6) - '@vitest/pretty-format': 3.2.7 - '@vitest/runner': 3.2.7 - '@vitest/snapshot': 3.2.7 - '@vitest/spy': 3.2.7 - '@vitest/utils': 3.2.7 - chai: 5.3.3 - debug: 4.4.3 - expect-type: 1.4.0 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.5 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.17 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 - vite: 7.3.6 - vite-node: 3.2.4 - why-is-node-running: 2.3.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - why-is-node-running@2.3.0: - dependencies: - siginfo: 2.0.0 - stackback: 0.0.2 - - word-wrap@1.2.5: {} - - yallist@3.1.1: {} - - yocto-queue@0.1.0: {} diff --git a/release.cmd b/release.cmd new file mode 100644 index 0000000..fdbc198 --- /dev/null +++ b/release.cmd @@ -0,0 +1,8 @@ +@echo off +where pwsh >nul 2>nul +if errorlevel 1 ( + powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\prepare-release.ps1" -Publish %* +) else ( + pwsh -NoProfile -File "%~dp0scripts\prepare-release.ps1" -Publish %* +) +exit /b %errorlevel% diff --git a/scripts/audit-windows-smoke.ps1 b/scripts/audit-windows-smoke.ps1 index 885b191..f0cd71c 100644 --- a/scripts/audit-windows-smoke.ps1 +++ b/scripts/audit-windows-smoke.ps1 @@ -2,8 +2,9 @@ param( [ValidateSet("PlanOnly", "Capture")] [string]$Mode = "PlanOnly", [string]$DataRoot = "C:\ProgramData\ProxyWarden", - [string]$ProxiFyreRoot = "C:\Tools\ProxiFyre", - [string]$SingBoxRoot = "C:\Program Files\ProxyWarden\sing-box", + [string]$AppRoot = "C:\Program Files\ProxyWarden", + [string]$ProxiFyreRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre", + [string]$SingBoxRoot = "C:\Program Files\ProxyWarden\components\sing-box", [string]$ForeignServiceName = "", [string]$OutputPath = "" ) @@ -110,10 +111,35 @@ function Get-SecretFindingCategories { return $findings } +function Get-InternalStateEvidence { + param([string]$Root) + + $categories = [ordered]@{ + cutoverJournal = ".proxywarden-cutover" + cutoverQuarantine = ".proxywarden-quarantine" + packageStaging = ".proxywarden-package-staging" + privilegedJobs = ".proxywarden-privileged-jobs" + serviceLogs = ".proxywarden-service-logs" + singBoxCleanupTombstone = ".proxywarden-sing-box-cleanup" + } + + $result = @() + foreach ($entry in $categories.GetEnumerator()) { + $path = Join-Path $Root $entry.Value + $item = Get-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + $result += [ordered]@{ + category = $entry.Key + present = $null -ne $item + itemType = if ($null -eq $item) { $null } elseif ($item.PSIsContainer) { "directory" } else { "file" } + } + } + return $result +} + try { - $quotedServiceFixture = '"C:\Program Files\ProxyWarden\sing-box\ProxyWardenSingBox.exe" -service' + $quotedServiceFixture = '"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" -service' $quotedExecutable = Get-ServiceExecutablePath -PathName $quotedServiceFixture - if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\sing-box")) { + if (-not (Test-PathUnderRoot -Path $quotedExecutable -Root "C:\Program Files\ProxyWarden\components\sing-box")) { throw "Quoted service PathName ownership self-test failed." } @@ -122,11 +148,12 @@ try { serviceNames = @("ProxiFyreService", "ProxyWardenSingBox") foreignServiceName = $ForeignServiceName roots = [ordered]@{ + app = [IO.Path]::GetFullPath($AppRoot) data = [IO.Path]::GetFullPath($DataRoot) proxifyre = [IO.Path]::GetFullPath($ProxiFyreRoot) singbox = [IO.Path]::GetFullPath($SingBoxRoot) } - checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan") + checks = @("service-state-and-path", "managed-root-membership", "file-metadata", "secret-category-scan", "internal-state-presence-only") } if ($Mode -eq "PlanOnly") { @@ -167,6 +194,7 @@ try { ownership = $ownership files = @(Get-FileEvidence -Root $DataRoot) secretFindingCategories = @(Get-SecretFindingCategories -Root $DataRoot) + internalState = @(Get-InternalStateEvidence -Root $AppRoot) } $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $outputFullPath -Encoding UTF8 @@ -175,6 +203,7 @@ try { serviceCount = @($services | Where-Object found).Count fileCount = @($report.files).Count secretFindingCount = @($report.secretFindingCategories).Count + internalStateCategoryCount = @($report.internalState).Count } } catch { New-Result -Success $false -Action "audit-windows-smoke.$($Mode.ToLowerInvariant())" -Changed $false -Message $_.Exception.Message -Details @{} diff --git a/scripts/check-runtime-powershell-boundary.ps1 b/scripts/check-runtime-powershell-boundary.ps1 new file mode 100644 index 0000000..c3f93f3 --- /dev/null +++ b/scripts/check-runtime-powershell-boundary.ps1 @@ -0,0 +1,253 @@ +[CmdletBinding()] +param( + [switch]$CheckOnly +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) +$AllowedPowerShellFiles = @( + "scripts/audit-windows-smoke.ps1", + "scripts/check-runtime-powershell-boundary.ps1", + "scripts/prepare-release.ps1", + "scripts/update-component-bundle.ps1" +) +$ExpectedNsisFlags = @( + "--nsis-uninstall-managed", + "--nsis-verify-upgrade" +) +$IgnoredPathPattern = '^(?:\.git|node_modules|dist|releases|src-tauri/target)(?:/|$)' + +function Get-RelativeRepoPath { + param([string]$Path) + + $rootUri = [Uri]($RepoRoot.TrimEnd("\", "/") + [IO.Path]::DirectorySeparatorChar) + $pathUri = [Uri][IO.Path]::GetFullPath($Path) + [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($pathUri).ToString()).Replace("\", "/") +} + +function New-Violation { + param( + [string]$Rule, + [string]$Path, + [string]$Message, + [int]$Line = 0 + ) + + [ordered]@{ + rule = $Rule + path = $Path + line = $Line + message = $Message + } +} + +function Get-ProductionLines { + param([string]$Path) + + $lines = @(Get-Content -LiteralPath $Path) + for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -match '^\s*#\s*\[\s*cfg\s*\(\s*test\s*\)\s*\]') { + if ($index -eq 0) { return @() } + return @($lines[0..($index - 1)]) + } + } + return $lines +} + +function Write-Result { + param( + [bool]$Success, + [string]$Message, + [object[]]$Violations, + [int]$PowerShellFileCount, + [int]$ProductionFileCount, + [string[]]$ObservedNsisFlags + ) + + [ordered]@{ + success = $Success + action = "runtime-powershell-boundary.check" + changed = $false + message = $Message + details = [ordered]@{ + allowlistedPowerShellFiles = $AllowedPowerShellFiles + scannedPowerShellFileCount = $PowerShellFileCount + scannedProductionFileCount = $ProductionFileCount + expectedNsisFlags = $ExpectedNsisFlags + observedNsisFlags = $ObservedNsisFlags + violations = $Violations + } + } | ConvertTo-Json -Depth 8 +} + +$violations = New-Object System.Collections.Generic.List[object] +$powerShellFileCount = 0 +$productionFileCount = 0 +$observedNsisFlags = @() + +try { + if (-not $CheckOnly) { + [void]$violations.Add((New-Violation ` + -Rule "check-only-required" ` + -Path "scripts/check-runtime-powershell-boundary.ps1" ` + -Message "Invoke this read-only boundary as -CheckOnly.")) + } + + $powerShellFiles = @( + Get-ChildItem -LiteralPath $RepoRoot -Recurse -File | + Where-Object { $_.Extension -in @(".ps1", ".psm1", ".psd1") } | + ForEach-Object { + [ordered]@{ + fullPath = $_.FullName + relativePath = Get-RelativeRepoPath -Path $_.FullName + } + } | + Where-Object { $_.relativePath -notmatch $IgnoredPathPattern } | + Sort-Object relativePath + ) + $powerShellFileCount = $powerShellFiles.Count + + foreach ($file in $powerShellFiles) { + if ($file.relativePath -notin $AllowedPowerShellFiles) { + [void]$violations.Add((New-Violation ` + -Rule "unexpected-powershell-file" ` + -Path $file.relativePath ` + -Message "PowerShell is allowed only for the exact build/release/QA allowlist.")) + } + } + foreach ($allowedPath in $AllowedPowerShellFiles) { + if ($allowedPath -notin $powerShellFiles.relativePath) { + [void]$violations.Add((New-Violation ` + -Rule "missing-allowlisted-tool" ` + -Path $allowedPath ` + -Message "Required build/release/QA tool is missing.")) + } + } + + $forbiddenRuntimeFiles = @( + "src-tauri/src/elevated_scripts.rs", + "src-tauri/src/helper.rs", + "src-tauri/src/powershell.rs", + "src-tauri/src/proxifyre_scripts.rs", + "src-tauri/bundled/cleanup/uninstall-managed-components.ps1" + ) + foreach ($relativePath in $forbiddenRuntimeFiles) { + if (Test-Path -LiteralPath (Join-Path $RepoRoot $relativePath.Replace("/", "\"))) { + [void]$violations.Add((New-Violation ` + -Rule "legacy-runtime-file" ` + -Path $relativePath ` + -Message "Legacy runtime PowerShell owner must be deleted after the native cutover.")) + } + } + + $tauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json" + if ((Get-Content -LiteralPath $tauriConfigPath -Raw) -match '(?i)bundled[\\/]cleanup') { + [void]$violations.Add((New-Violation ` + -Rule "bundled-cleanup-resource" ` + -Path "src-tauri/tauri.conf.json" ` + -Message "The installer must not package the displaced PowerShell cleanup resource.")) + } + + $productionFiles = @( + Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\src") -Recurse -File -Filter "*.rs" + Get-ChildItem -LiteralPath (Join-Path $RepoRoot "src-tauri\bundled\installer-hooks") -Recurse -File | Where-Object { $_.Extension -in @(".nsh", ".nsi") } + ) + $productionFileCount = $productionFiles.Count + $rules = @( + [ordered]@{ name = "powershell-process"; pattern = '(?i)(?:command_no_window|Command::new).*\b(?:powershell|pwsh)(?:\.exe)?\b' }, + [ordered]@{ name = "powershell-command-line"; pattern = '(?i)\b(?:powershell|pwsh)(?:\.exe)?\b\s+-[A-Za-z]' }, + [ordered]@{ name = "powershell-policy-bypass"; pattern = '(?i)-ExecutionPolicy\b' }, + [ordered]@{ name = "powershell-script-path"; pattern = '(?i)\.ps1\b' }, + [ordered]@{ name = "powershell-runtime-helper"; pattern = '(?i)\b(?:run|write)_powershell_(?:command|file|script)\b' }, + [ordered]@{ name = "legacy-module-declaration"; pattern = '(?i)\b(?:pub\s+)?mod\s+(?:elevated_scripts|helper|powershell|proxifyre_scripts)\s*;' }, + [ordered]@{ name = "legacy-module-reexport"; pattern = '(?i)\bpub\s+use\s+crate::(?:elevated_scripts|helper|powershell|proxifyre_scripts)\b' } + ) + + $productionTextParts = New-Object System.Collections.Generic.List[string] + $rustTextParts = New-Object System.Collections.Generic.List[string] + foreach ($file in $productionFiles) { + $relativePath = Get-RelativeRepoPath -Path $file.FullName + $lines = @(Get-ProductionLines -Path $file.FullName) + for ($index = 0; $index -lt $lines.Count; $index++) { + $line = [string]$lines[$index] + [void]$productionTextParts.Add($line) + if ($file.Extension -ieq ".rs") { + [void]$rustTextParts.Add($line) + } + foreach ($rule in $rules) { + if ($line -match $rule.pattern) { + [void]$violations.Add((New-Violation ` + -Rule $rule.name ` + -Path $relativePath ` + -Line ($index + 1) ` + -Message "Production code still contains a PowerShell runtime boundary.")) + } + } + } + } + + $productionText = $productionTextParts -join "`n" + $rustText = $rustTextParts -join "`n" + $observedNsisFlags = @( + [regex]::Matches($productionText, '--nsis-[a-z0-9-]+', [Text.RegularExpressions.RegexOptions]::IgnoreCase) | + ForEach-Object { $_.Value.ToLowerInvariant() } | + Sort-Object -Unique + ) + foreach ($flag in $ExpectedNsisFlags) { + if (-not $rustText.Contains($flag)) { + [void]$violations.Add((New-Violation ` + -Rule "missing-nsis-runtime-mode" ` + -Path "src-tauri/src" ` + -Message "Rust early-mode parser is missing fixed NSIS mode: $flag")) + } + } + foreach ($flag in $observedNsisFlags) { + if ($flag -notin $ExpectedNsisFlags) { + [void]$violations.Add((New-Violation ` + -Rule "unexpected-nsis-mode" ` + -Path "src-tauri" ` + -Message "Unexpected reserved NSIS early mode: $flag")) + } + } + + $hookPath = Join-Path $RepoRoot "src-tauri\bundled\installer-hooks\proxywarden-hooks.nsh" + $hookText = Get-Content -LiteralPath $hookPath -Raw + foreach ($flag in $ExpectedNsisFlags) { + if (-not $hookText.Contains($flag)) { + [void]$violations.Add((New-Violation ` + -Rule "missing-nsis-hook-mode" ` + -Path "src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh" ` + -Message "Installer hook does not call fixed early mode: $flag")) + } + } + + $success = $violations.Count -eq 0 + $message = if ($success) { + "Runtime PowerShell boundary is clean." + } else { + "Runtime PowerShell boundary has $($violations.Count) violation(s)." + } + Write-Result ` + -Success $success ` + -Message $message ` + -Violations $violations.ToArray() ` + -PowerShellFileCount $powerShellFileCount ` + -ProductionFileCount $productionFileCount ` + -ObservedNsisFlags $observedNsisFlags + if (-not $success) { exit 1 } +} catch { + $failure = New-Violation ` + -Rule "checker-error" ` + -Path "scripts/check-runtime-powershell-boundary.ps1" ` + -Message $_.Exception.Message + Write-Result ` + -Success $false ` + -Message "Runtime PowerShell boundary check could not complete." ` + -Violations @($failure) ` + -PowerShellFileCount $powerShellFileCount ` + -ProductionFileCount $productionFileCount ` + -ObservedNsisFlags $observedNsisFlags + exit 1 +} diff --git a/scripts/install-control-app.ps1 b/scripts/install-control-app.ps1 deleted file mode 100644 index a49e460..0000000 --- a/scripts/install-control-app.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -param( - [string]$InstallRoot = "C:\Program Files\ProxyWarden\ControlApp", - [string]$DataRoot = "C:\ProgramData\ProxyWarden", - [switch]$PlanOnly, - [switch]$Force -) - -$ErrorActionPreference = "Stop" - -function New-Result { - param( - [bool]$Success, - [string]$Action, - [bool]$Changed, - [string]$Message, - [hashtable]$Details = @{} - ) - - [ordered]@{ - success = $Success - action = $Action - changed = $Changed - message = $Message - details = $Details - } | ConvertTo-Json -Depth 6 -} - -function Test-IsAdministrator { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = [Security.Principal.WindowsPrincipal]::new($identity) - $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Ensure-Directory { - param([string]$Path) - if (-not (Test-Path -LiteralPath $Path)) { - New-Item -ItemType Directory -Path $Path -Force | Out-Null - return $true - } - return $false -} - -try { - $details = @{ - installRoot = $InstallRoot - dataRoot = $DataRoot - planOnly = [bool]$PlanOnly - } - - if ($PlanOnly) { - New-Result -Success $true -Action "install-control-app" -Changed $false -Message "Control App install plan is ready." -Details $details - exit 0 - } - - if (-not (Test-IsAdministrator)) { - New-Result -Success $false -Action "install-control-app" -Changed $false -Message "Administrator rights are required." -Details $details - exit 1 - } - - $changed = $false - $changed = (Ensure-Directory -Path $InstallRoot) -or $changed - $changed = (Ensure-Directory -Path (Join-Path $DataRoot "config")) -or $changed - $changed = (Ensure-Directory -Path (Join-Path $DataRoot "state")) -or $changed - $changed = (Ensure-Directory -Path (Join-Path $DataRoot "generated")) -or $changed - - $markerPath = Join-Path $InstallRoot "install-control-app.marker.json" - if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) { - @{ component = "control-app"; installedAt = (Get-Date).ToString("o") } | - ConvertTo-Json -Depth 4 | - Set-Content -LiteralPath $markerPath -Encoding UTF8 - $changed = $true - } - - $details.markerPath = $markerPath - New-Result -Success $true -Action "install-control-app" -Changed $changed -Message "Control App directories are installed." -Details $details -} catch { - New-Result -Success $false -Action "install-control-app" -Changed $false -Message $_.Exception.Message - exit 1 -} diff --git a/scripts/install-proxyfier.ps1 b/scripts/install-proxyfier.ps1 deleted file mode 100644 index 74cdc63..0000000 --- a/scripts/install-proxyfier.ps1 +++ /dev/null @@ -1,96 +0,0 @@ -param( - [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\ProxiFyre", - [string]$PackagePath = "", - [string]$ServiceName = "ProxiFyreService", - [switch]$PlanOnly, - [switch]$Force -) - -$ErrorActionPreference = "Stop" - -function New-Result { - param( - [bool]$Success, - [string]$Action, - [bool]$Changed, - [string]$Message, - [hashtable]$Details = @{} - ) - - [ordered]@{ - success = $Success - action = $Action - changed = $Changed - message = $Message - details = $Details - } | ConvertTo-Json -Depth 6 -} - -function Test-IsAdministrator { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = [Security.Principal.WindowsPrincipal]::new($identity) - $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Backup-File { - param([string]$Path) - if (Test-Path -LiteralPath $Path) { - $backup = "$Path.bak" - Copy-Item -LiteralPath $Path -Destination $backup -Force - return $backup - } - return $null -} - -try { - $details = @{ - installRoot = $InstallRoot - packagePath = $PackagePath - serviceName = $ServiceName - planOnly = [bool]$PlanOnly - } - - if ($PlanOnly) { - New-Result -Success $true -Action "install-proxyfier" -Changed $false -Message "Proxyfier install plan is ready." -Details $details - exit 0 - } - - if (-not (Test-IsAdministrator)) { - New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "Administrator rights are required." -Details $details - exit 1 - } - - if ([string]::IsNullOrWhiteSpace($PackagePath) -or -not (Test-Path -LiteralPath $PackagePath)) { - New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message "PackagePath is required and must point to a local ProxiFyre package." -Details $details - exit 2 - } - - $changed = $false - if (-not (Test-Path -LiteralPath $InstallRoot)) { - New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null - $changed = $true - } - - $configPath = Join-Path $InstallRoot "app-config.json" - $backupPath = Backup-File -Path $configPath - if ($backupPath) { - $details.backupPath = $backupPath - } - - $markerPath = Join-Path $InstallRoot "install-proxyfier.marker.json" - if ((-not (Test-Path -LiteralPath $markerPath)) -or $Force) { - @{ - component = "proxyfier" - packagePath = $PackagePath - serviceName = $ServiceName - installedAt = (Get-Date).ToString("o") - } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8 - $changed = $true - } - - $details.markerPath = $markerPath - New-Result -Success $true -Action "install-proxyfier" -Changed $changed -Message "Proxyfier install boundary completed." -Details $details -} catch { - New-Result -Success $false -Action "install-proxyfier" -Changed $false -Message $_.Exception.Message - exit 1 -} diff --git a/scripts/install-singbox.ps1 b/scripts/install-singbox.ps1 deleted file mode 100644 index f2e6cb3..0000000 --- a/scripts/install-singbox.ps1 +++ /dev/null @@ -1,270 +0,0 @@ -param( - [string]$InstallRoot = "C:\Program Files\ProxyWarden\components\sing-box", - [string]$ServiceName = "ProxyWardenSingBox", - [string]$ConfigSource = "C:\ProgramData\ProxyWarden\generated\sing-box-config.json", - [switch]$PlanOnly, - [switch]$Force, - [switch]$Uninstall -) - -$ErrorActionPreference = "Stop" - -$SingBoxReleaseApi = "https://api.github.com/repos/SagerNet/sing-box/releases/latest" -$WinSwReleaseApi = "https://api.github.com/repos/winsw/winsw/releases/latest" -$WrapperFile = "$ServiceName.exe" -$ConfigFile = "config.json" - -function New-Result { - param( - [bool]$Success, - [string]$Action, - [bool]$Changed, - [string]$Message, - [hashtable]$Details = @{} - ) - - [ordered]@{ - success = $Success - action = $Action - changed = $Changed - message = $Message - details = $Details - } | ConvertTo-Json -Depth 8 -} - -function Test-IsAdministrator { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = [Security.Principal.WindowsPrincipal]::new($identity) - $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -} - -function Get-NativeArchitecture { - $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 - if ($null -ne $processor -and $processor.Architecture -eq 12) { return "arm64" } - if ([Environment]::Is64BitOperatingSystem) { return "amd64" } - return "386" -} - -function Get-WinSwArchitecture { - param([string]$Arch) - if ($Arch -eq "arm64") { return "arm64" } - if ($Arch -eq "386") { return "x86" } - return "x64" -} - -function Invoke-Download { - param([string]$Uri, [string]$Path) - Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $Path -Headers @{ "User-Agent" = "proxywarden" } -} - -function Select-Asset { - param( - [object[]]$Assets, - [string]$Pattern, - [string]$Label - ) - - $asset = $Assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 - if ($null -eq $asset) { - throw "Не найден release asset для $Label по шаблону $Pattern." - } - return $asset -} - -function Test-SafeInstallRoot { - param([string]$Path) - $full = [System.IO.Path]::GetFullPath($Path).TrimEnd("\") - $leaf = Split-Path -Leaf $full - $parent = Split-Path -Parent $full - if ($leaf -ne "sing-box") { return $false } - return $parent -match "\\ProxyWarden\\components$|\\proxywarden\\components$|\\ProxyWarden$|\\proxywarden$" -} - -function Backup-File { - param([string]$Path) - if (Test-Path -LiteralPath $Path) { - $backup = "$Path.bak" - Copy-Item -LiteralPath $Path -Destination $backup -Force - return $backup - } - return $null -} - -function Write-Utf8NoBomFile { - param( - [string]$Path, - [string]$Value - ) - - $encoding = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::WriteAllText($Path, $Value, $encoding) -} - -function Write-WinSwConfig { - param( - [string]$Root, - [string]$Name - ) - - $xmlPath = Join-Path $Root "$Name.xml" - $logDir = Join-Path $Root "logs" - New-Item -ItemType Directory -Path $logDir -Force | Out-Null - $xml = @" - - $Name - ProxyWarden Local sing-box - Local sing-box runtime managed by ProxyWarden. - %BASE%\sing-box.exe - run -c "%BASE%\config.json" - %BASE%\logs - - 10485760 - 4 - - - -"@ - Write-Utf8NoBomFile -Path $xmlPath -Value $xml - return $xmlPath -} - -function Stop-And-Uninstall-Service { - param( - [string]$Root, - [string]$Name - ) - - $wrapper = Join-Path $Root "$Name.exe" - $service = Get-Service -Name $Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne "Stopped") { - Stop-Service -Name $Name -Force -ErrorAction SilentlyContinue - $service = Get-Service -Name $Name -ErrorAction SilentlyContinue - if ($null -ne $service) { - try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) } catch {} - } - } - - if (Test-Path -LiteralPath $wrapper) { - Push-Location $Root - try { & $wrapper uninstall | Out-Null } finally { Pop-Location } - } - - $service = Get-Service -Name $Name -ErrorAction SilentlyContinue - if ($null -ne $service) { - sc.exe delete $Name | Out-Null - } -} - -try { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - $installRootFull = [System.IO.Path]::GetFullPath($InstallRoot) - $details = @{ - installRoot = $installRootFull - serviceName = $ServiceName - configSource = $ConfigSource - singboxReleaseApi = $SingBoxReleaseApi - winswReleaseApi = $WinSwReleaseApi - planOnly = [bool]$PlanOnly - uninstall = [bool]$Uninstall - } - - if ($PlanOnly) { - $details.items = @( - @{ id = "sing-box-binary"; name = "sing-box.exe"; source = $SingBoxReleaseApi; target = (Join-Path $installRootFull "sing-box.exe") }, - @{ id = "winsw-wrapper"; name = $WrapperFile; source = $WinSwReleaseApi; target = (Join-Path $installRootFull $WrapperFile) }, - @{ id = "windows-service"; name = $ServiceName; target = "Windows Service" }, - @{ id = "config"; name = $ConfigFile; source = $ConfigSource; target = (Join-Path $installRootFull $ConfigFile) } - ) - New-Result -Success $true -Action "install-singbox.plan" -Changed $false -Message "Local sing-box install plan is ready." -Details $details - exit 0 - } - - if (-not (Test-IsAdministrator)) { - New-Result -Success $false -Action "install-singbox" -Changed $false -Message "Administrator rights are required." -Details $details - exit 1 - } - - if ($Uninstall) { - if (-not (Test-SafeInstallRoot -Path $installRootFull)) { - New-Result -Success $false -Action "uninstall-singbox" -Changed $false -Message "Unsafe InstallRoot for recursive uninstall." -Details $details - exit 2 - } - - Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName - if (Test-Path -LiteralPath $installRootFull) { - Remove-Item -LiteralPath $installRootFull -Recurse -Force - } - New-Result -Success $true -Action "uninstall-singbox" -Changed $true -Message "Local sing-box service and install folder were removed." -Details $details - exit 0 - } - - $changed = $false - New-Item -ItemType Directory -Path $installRootFull -Force | Out-Null - $workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("proxywarden-singbox-" + [guid]::NewGuid().ToString("N")) - $extractDir = Join-Path $workDir "extract" - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - - try { - $arch = Get-NativeArchitecture - $winswArch = Get-WinSwArchitecture -Arch $arch - $details.architecture = $arch - $details.winswArchitecture = $winswArch - - $singboxRelease = Invoke-RestMethod -Uri $SingBoxReleaseApi -Headers @{ "User-Agent" = "proxywarden" } - $singboxAsset = Select-Asset $singboxRelease.assets "windows-$arch\.zip$" "sing-box" - $singboxZip = Join-Path $workDir $singboxAsset.name - Invoke-Download $singboxAsset.browser_download_url $singboxZip - Expand-Archive -LiteralPath $singboxZip -DestinationPath $extractDir -Force - $singboxExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter "sing-box.exe" | Select-Object -First 1 - if ($null -eq $singboxExe) { throw "В архиве sing-box не найден sing-box.exe." } - Copy-Item -LiteralPath $singboxExe.FullName -Destination (Join-Path $installRootFull "sing-box.exe") -Force - $changed = $true - - $winswRelease = Invoke-RestMethod -Uri $WinSwReleaseApi -Headers @{ "User-Agent" = "proxywarden" } - $winswAsset = Select-Asset $winswRelease.assets "WinSW-$winswArch\.exe$" "WinSW" - Invoke-Download $winswAsset.browser_download_url (Join-Path $installRootFull $WrapperFile) - $changed = $true - - $configTarget = Join-Path $installRootFull $ConfigFile - $backupPath = Backup-File -Path $configTarget - if ($backupPath) { $details.backupPath = $backupPath } - if (Test-Path -LiteralPath $ConfigSource) { - Copy-Item -LiteralPath $ConfigSource -Destination $configTarget -Force - } elseif (-not (Test-Path -LiteralPath $configTarget)) { - Write-Utf8NoBomFile -Path $configTarget -Value '{"log":{"level":"info","timestamp":true},"inbounds":[],"outbounds":[{"type":"direct","tag":"direct"}],"route":{"final":"direct"}}' - } - - $xmlPath = Write-WinSwConfig -Root $installRootFull -Name $ServiceName - $details.configPath = $configTarget - $details.wrapperConfigPath = $xmlPath - - if ($Force) { - Stop-And-Uninstall-Service -Root $installRootFull -Name $ServiceName - } - - Push-Location $installRootFull - try { - $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue - if ($null -eq $service) { - & ".\$WrapperFile" install - if ($LASTEXITCODE -ne 0) { throw "WinSW install завершился с кодом $LASTEXITCODE." } - $changed = $true - } - & ".\$WrapperFile" start - if ($LASTEXITCODE -ne 0) { - Start-Service -Name $ServiceName -ErrorAction Stop - } - } finally { - Pop-Location - } - } finally { - if (Test-Path -LiteralPath $workDir) { - Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue - } - } - - New-Result -Success $true -Action "install-singbox" -Changed $changed -Message "Local sing-box service is installed and started." -Details $details -} catch { - New-Result -Success $false -Action "install-singbox" -Changed $false -Message $_.Exception.Message - exit 1 -} diff --git a/scripts/prepare-release.check.mjs b/scripts/prepare-release.check.mjs new file mode 100644 index 0000000..9b75c8a --- /dev/null +++ b/scripts/prepare-release.check.mjs @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + writeFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const source = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "prepare-release.ps1"), + "utf8", +).replace(/^\ufeff/, ""); +const entry = source.lastIndexOf("try {\n Push-Location $RepoRoot"); +const crlfEntry = source.lastIndexOf("try {\r\n Push-Location $RepoRoot"); +const entryOffset = Math.max(entry, crlfEntry); +assert.ok(entryOffset > 0); + +function run(cwd, command, args, ok = true) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + timeout: 60000, + windowsHide: true, + }); + if (ok) + assert.equal( + result.status, + 0, + `${command}: ${result.stdout}\n${result.stderr}`, + ); + return result; +} + +function fixture(t, build = "") { + const root = mkdtempSync(join(tmpdir(), "proxywarden-release-test-")); + t.after(() => { + assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep)); + assert.ok(root.includes("proxywarden-release-test-")); + rmSync(root, { recursive: true, force: true }); + }); + const repo = join(root, "repo"); + mkdirSync(repo); + const write = (path, text) => { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), text); + }; + const git = (...args) => run(repo, "git", args).stdout.trim(); + write("package.json", '{"name":"proxywarden","version":"1.2.0"}\n'); + write( + "package-lock.json", + '{"name":"proxywarden","version":"1.2.0","packages":{"":{"name":"proxywarden","version":"1.2.0"}}}\n', + ); + write("src-tauri/tauri.conf.json", '{"version":"1.2.0"}\n'); + write( + "src-tauri/Cargo.toml", + '[package]\nname = "proxywarden"\nversion = "1.2.0"\n', + ); + write( + "src-tauri/Cargo.lock", + '[[package]]\nname = "proxywarden"\nversion = "1.2.0"\n', + ); + write(".gitignore", "node_modules/\nsrc-tauri/target/\nreleases/\n"); + for (const cli of [ + "typescript/bin/tsc", + "vite/bin/vite.js", + "@tauri-apps/cli/tauri.js", + "prettier/bin/prettier.cjs", + "eslint/bin/eslint.js", + "vitest/vitest.mjs", + ]) + write(`node_modules/${cli}`, "fixture"); + // Replace only expensive checks/build in this isolated copy. Git/version/artifact/push code is real. + const stub = ` +function Invoke-ReleaseChecks {} +function Invoke-ReleaseBuild { + ${build} + $output = Join-Path $BundleRoot 'nsis' + New-Item -ItemType Directory -Path $output -Force | Out-Null + [IO.File]::WriteAllText((Join-Path $output "ProxyWarden_$($targetVersion)_x64-setup.exe"), 'test artifact') +} +`; + write( + "scripts/prepare-release.ps1", + "\ufeff" + source.slice(0, entryOffset) + stub + source.slice(entryOffset), + ); + git("init", "-b", "master"); + git("config", "user.name", "Release Test"); + git("config", "user.email", "release-test@example.invalid"); + git("config", "core.autocrlf", "false"); + git("add", "."); + git("commit", "-m", "initial"); + const remote = join(root, "origin.git"); + run(root, "git", ["init", "--bare", remote]); + git("remote", "add", "origin", remote); + git("push", "-u", "origin", "master"); + const release = (...args) => + run( + repo, + "pwsh", + [ + "-NoProfile", + "-File", + "scripts/prepare-release.ps1", + "-Publish", + ...args, + ], + false, + ); + const manifest = () => + JSON.parse( + readFileSync( + join(repo, "releases/proxywarden-v1.2.1/release-manifest.json"), + "utf8", + ).replace(/^\ufeff/, ""), + ); + return { root, repo, remote, git, write, release, manifest }; +} + +test("PlanOnly is offline and leaves versions/index/refs unchanged", (t) => { + const f = fixture(t); + f.git("remote", "set-url", "origin", join(f.root, "absent.git")); + const before = f.git("status", "--porcelain"); + const head = f.git("rev-parse", "HEAD"); + const result = f.release("-PlanOnly"); + assert.equal(result.status, 0, result.stderr); + const plan = JSON.parse(result.stdout); + assert.equal(plan.changed, false); + assert.equal(plan.details.targetVersion, "1.2.1"); + assert.equal(f.git("status", "--porcelain"), before); + assert.equal(f.git("rev-parse", "HEAD"), head); +}); + +test("release commits exact dirty source, versions both locks, tags and atomically pushes", (t) => { + const f = fixture(t); + f.write("feature.txt", "new feature"); + const result = f.release("-Version", "1.2.1"); + assert.equal(result.status, 0, result.stdout + result.stderr); + const head = f.git("rev-parse", "HEAD"); + assert.equal(f.git("rev-parse", "v1.2.1^{commit}"), head); + assert.equal( + f.git("ls-remote", "origin", "refs/heads/master").split(/\s/)[0], + head, + ); + assert.equal(f.git("status", "--porcelain"), ""); + assert.equal(f.manifest().gitCommit, head); + assert.equal(f.manifest().gitRelease.status, "pushed"); + assert.equal(f.manifest().artifacts.length, 1); + assert.match( + readFileSync(join(f.repo, "src-tauri/Cargo.lock"), "utf8"), + /version = "1.2.1"/, + ); + const repeat = f.release("-Version", "1.2.1"); + assert.notEqual(repeat.status, 0); + assert.equal(f.git("rev-parse", "HEAD"), head); +}); + +test("failed build creates no commit/tag/push and preserves existing staging", (t) => { + const f = fixture(t, "throw 'Synthetic build failure'"); + f.write("staged.txt", "staged"); + f.git("add", "staged.txt"); + const index = f.git("write-tree"), + head = f.git("rev-parse", "HEAD"); + assert.notEqual(f.release("-Version", "1.2.1").status, 0); + assert.equal(f.git("write-tree"), index); + assert.equal(f.git("rev-parse", "HEAD"), head); + assert.equal(f.git("tag", "--list"), ""); +}); + +test("source edit during build refuses to tag an artifact from another tree", (t) => { + const f = fixture( + t, + "[IO.File]::WriteAllText((Join-Path $RepoRoot 'concurrent.txt'), 'changed during build')", + ); + const head = f.git("rev-parse", "HEAD"); + const result = f.release("-Version", "1.2.1"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /changed during the build/); + assert.match(result.stderr, /concurrent\.txt/); + assert.equal(f.git("rev-parse", "HEAD"), head); + assert.equal(f.git("tag", "--list"), ""); +}); + +test("failed atomic push keeps artifacts and resumes without rebuilding", (t) => { + const f = fixture(t); + const hook = join(f.remote, "hooks/pre-receive"); + writeFileSync(hook, "#!/bin/sh\nexit 1\n"); + const before = f.git("ls-remote", "origin", "refs/heads/master"); + const failed = f.release("-Version", "1.2.1"); + assert.notEqual(failed.status, 0); + assert.equal(f.manifest().gitRelease.status, "pending-push"); + assert.equal(f.git("ls-remote", "origin", "refs/heads/master"), before); + assert.equal(f.git("ls-remote", "origin", "refs/tags/v1.2.1"), ""); + const commit = f.git("rev-parse", "HEAD"); + rmSync(hook); + const resumed = f.release("-Version", "1.2.1", "-Resume"); + assert.equal(resumed.status, 0, resumed.stdout + resumed.stderr); + assert.equal(f.git("rev-parse", "HEAD"), commit); + assert.equal(f.manifest().gitRelease.status, "pushed"); + f.write( + "releases/proxywarden-v1.2.1/artifacts/nsis/ProxyWarden_1.2.1_x64-setup.exe", + "tampered", + ); + assert.notEqual(f.release("-Version", "1.2.1", "-Resume").status, 0); +}); + +test("remote-only version tag and diverged branch are refused before version edits", (t) => { + const f = fixture(t); + f.git("tag", "v1.2.1"); + f.git("push", "origin", "refs/tags/v1.2.1"); + f.git("tag", "-d", "v1.2.1"); + const version = readFileSync(join(f.repo, "package.json"), "utf8"); + assert.notEqual(f.release("-Version", "1.2.1").status, 0); + assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version); + const clone = join(f.root, "other"); + run(f.root, "git", ["clone", "--branch", "master", f.remote, clone]); + run(clone, "git", ["config", "user.name", "Other"]); + run(clone, "git", ["config", "user.email", "other@example.invalid"]); + writeFileSync(join(clone, "remote-change.txt"), "remote"); + run(clone, "git", ["add", "."]); + run(clone, "git", ["commit", "-m", "remote change"]); + run(clone, "git", ["push"]); + const result = f.release("-Version", "1.2.2"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Integrate them before releasing/); + assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version); +}); + +test("invalid Windows versions and mismatched Cargo.lock fail without mutations", (t) => { + const f = fixture(t); + for (const version of ["01.2.3", "1.2.65536", "1.2.3-rc.1"]) + assert.notEqual(f.release("-Version", version, "-PlanOnly").status, 0); + f.write( + "src-tauri/Cargo.lock", + '[[package]]\nname = "proxywarden"\nversion = "0.0.0"\n', + ); + const result = f.release("-PlanOnly"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Version mismatch/); +}); + +test("unreachable origin reports the Git cause and diagnostic command without a PowerShell stack", (t) => { + const f = fixture(t); + f.git("remote", "set-url", "origin", join(f.root, "absent.git")); + const head = f.git("rev-parse", "HEAD"); + const version = readFileSync(join(f.repo, "package.json"), "utf8"); + const result = f.release("-Version", "1.2.1"); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /git ls-remote origin/); + assert.match(result.stderr, /does not appear to be a git repository/); + assert.doesNotMatch( + result.stderr, + /prepare-release\.ps1:\d|ScriptStackTrace|Line \|/, + ); + assert.equal(f.git("rev-parse", "HEAD"), head); + assert.equal(readFileSync(join(f.repo, "package.json"), "utf8"), version); +}); diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 index 0758c3b..7971f13 100644 --- a/scripts/prepare-release.ps1 +++ b/scripts/prepare-release.ps1 @@ -1,4 +1,4 @@ -param( +param( [string]$Version = "", [ValidateSet("", "patch", "minor", "major")] [string]$Bump = "", @@ -6,9 +6,12 @@ param( [switch]$SkipTests, [switch]$SkipBuild, [switch]$PlanOnly, + [switch]$Publish, + [switch]$Resume, [switch]$Force ) +Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..")) @@ -16,7 +19,11 @@ $PackageJsonPath = Join-Path $RepoRoot "package.json" $PackageLockPath = Join-Path $RepoRoot "package-lock.json" $TauriConfigPath = Join-Path $RepoRoot "src-tauri\tauri.conf.json" $CargoTomlPath = Join-Path $RepoRoot "src-tauri\Cargo.toml" +$CargoLockPath = Join-Path $RepoRoot "src-tauri\Cargo.lock" $BundleRoot = Join-Path $RepoRoot "src-tauri\target\release\bundle" +$RuntimeBoundaryCheckPath = Join-Path $RepoRoot "scripts\check-runtime-powershell-boundary.ps1" +$ComponentBundleScriptPath = Join-Path $RepoRoot "scripts\update-component-bundle.ps1" +$WindowsAuditScriptPath = Join-Path $RepoRoot "scripts\audit-windows-smoke.ps1" function Write-Utf8NoBomFile { param( @@ -143,9 +150,12 @@ function Set-PackageLockVersions { function Assert-Semver { param([string]$Value) - if ($Value -notmatch "^\d+\.\d+\.\d+$") { + if ($Value -notmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$') { throw "Version '$Value' is not supported. Use numeric SemVer like 0.1.0." } + foreach ($part in $Value.Split('.')) { + if ([long]$part -gt 65535) { throw "Version components must be between 0 and 65535 for Windows." } + } } function ConvertTo-VersionParts { @@ -206,6 +216,9 @@ function Get-CargoPackageVersion { function Get-VersionState { $packageLock = Get-PackageLockVersions + $cargoLock = Get-Content -Raw -LiteralPath $CargoLockPath + $cargoMatch = [regex]::Match($cargoLock, '(?m)^name = "proxywarden"\r?\nversion = "([^"]+)"') + if (-not $cargoMatch.Success) { throw 'Cannot find ProxyWarden in Cargo.lock.' } [ordered]@{ packageJson = [string](Get-FirstJsonVersion -Path $PackageJsonPath -Label "package.json") @@ -213,6 +226,7 @@ function Get-VersionState { packageLockRoot = [string]$packageLock.packageLockRoot tauriConfig = [string](Get-FirstJsonVersion -Path $TauriConfigPath -Label "tauri.conf.json") cargoToml = [string](Get-CargoPackageVersion) + cargoLock = $cargoMatch.Groups[1].Value } } @@ -223,7 +237,8 @@ function Get-CurrentVersion { $state.packageLock, $state.packageLockRoot, $state.tauriConfig, - $state.cargoToml + $state.cargoToml, + $state.cargoLock ) | Select-Object -Unique) if ($versions.Count -ne 1) { @@ -247,23 +262,24 @@ function Resolve-TargetVersion { return Get-NextVersion -Current $Current -Kind $Bump } - if ($PlanOnly -or -not [Environment]::UserInteractive) { + if ($PlanOnly) { return Get-NextVersion -Current $Current -Kind "patch" } + if (-not [Environment]::UserInteractive) { throw "Specify -Version or -Bump in non-interactive mode." } $patch = Get-NextVersion -Current $Current -Kind "patch" $minor = Get-NextVersion -Current $Current -Kind "minor" $major = Get-NextVersion -Current $Current -Kind "major" Write-Host "" - Write-Host "Current version: $Current" - Write-Host "Choose release version:" + Write-Host "Текущая версия: $Current" + Write-Host "Выбери номер или введи версию, например $patch :" Write-Host " 1) patch $patch" Write-Host " 2) minor $minor" Write-Host " 3) major $major" - Write-Host " 4) custom" - Write-Host " 5) keep current $Current" - $choice = Read-Host "Selection [1]" + Write-Host " 4) другая версия" + Write-Host " 5) текущая $Current (если ещё не выпущена)" + $choice = Read-Host "Версия [1]" if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" } @@ -277,7 +293,7 @@ function Resolve-TargetVersion { return $custom } "5" { return $Current } - default { throw "Unknown selection '$choice'." } + default { Assert-Semver -Value $choice.Trim(); return $choice.Trim() } } } @@ -313,6 +329,9 @@ function Set-ManifestVersions { Set-PackageLockVersions -TargetVersion $TargetVersion Set-FirstJsonVersion -Path $TauriConfigPath -TargetVersion $TargetVersion -Label "tauri.conf.json" Set-CargoPackageVersion -TargetVersion $TargetVersion + $lock = Get-Content -Raw -LiteralPath $CargoLockPath + $lock = Replace-RegexGroup -Content $lock -Pattern '(?m)^name = "proxywarden"\r?\nversion = "(?[^"]+)"' -GroupName "value" -Value $TargetVersion -Label "ProxyWarden version in Cargo.lock" + Write-Utf8NoBomFile -Path $CargoLockPath -Value $lock } function Get-FullPath { @@ -354,6 +373,7 @@ function New-ReleaseDirectory { $releaseDir = Join-Path $root "proxywarden-v$TargetVersion" if (Test-Path -LiteralPath $releaseDir) { + if ($Publish -or -not $Force) { throw "Release directory already exists: $releaseDir. Use -Resume for a failed push, or choose another version." } if (-not (Test-IsSubPath -Parent $root -Child $releaseDir)) { throw "Refusing to remove release directory outside OutputRoot: $releaseDir" } @@ -412,17 +432,51 @@ function Invoke-ReleaseBuild { return } - Invoke-NativeCommand -Name "Frontend build" -FilePath "npm" -Arguments @("run", "build") + Invoke-NativeCommand -Name "Frontend types" -FilePath "node" -Arguments @("node_modules/typescript/bin/tsc", "--noEmit") if (-not $SkipTests) { - Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") + Invoke-NativeCommand -Name "Frontend formatting" -FilePath "node" -Arguments @("node_modules/prettier/bin/prettier.cjs", "--check", "src/**/*.{ts,tsx,css}") + Invoke-NativeCommand -Name "Frontend lint" -FilePath "node" -Arguments @("node_modules/eslint/bin/eslint.js", "src") + Invoke-NativeCommand -Name "Frontend tests" -FilePath "node" -Arguments @("node_modules/vitest/vitest.mjs", "run") + Invoke-NativeCommand -Name "Rust formatting" -FilePath "cargo" -Arguments @("fmt", "--all", "--", "--check") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") + Invoke-NativeCommand -Name "Rust lint" -FilePath "cargo" -Arguments @("clippy", "--locked", "--all-targets", "--all-features", "--", "-D", "warnings") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") + Invoke-NativeCommand -Name "Rust tests" -FilePath "cargo" -Arguments @("test", "--locked", "--all-targets") -WorkingDirectory (Join-Path $RepoRoot "src-tauri") } else { Write-Host "" Write-Host "Skipping Rust tests because -SkipTests was provided." } + Invoke-NativeCommand -Name "Frontend build" -FilePath "node" -Arguments @("node_modules/vite/bin/vite.js", "build") Clear-ReleaseBundleOutput - Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build") + # Use a temporary config file: JSON command-line quoting differs between Windows PowerShell and pwsh. + $config = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-build-" + [guid]::NewGuid().ToString('N') + '.json') + try { + Write-Utf8NoBomFile -Path $config -Value '{"build":{"beforeBuildCommand":""}}' + Invoke-NativeCommand -Name "Tauri release build" -FilePath "node" -Arguments @("node_modules/@tauri-apps/cli/tauri.js", "build", "--config", $config, "--bundles", "nsis") + } finally { if (Test-Path -LiteralPath $config) { Remove-Item -LiteralPath $config } } +} + +function Invoke-ScriptCheck { + param( + [string]$Name, + [string]$ScriptPath, + [hashtable]$Parameters + ) + + Write-Host "" + Write-Host "==> $Name" + $output = & $ScriptPath @Parameters + $succeeded = $? + $output | Write-Output + if (-not $succeeded) { + throw "$Name failed." + } +} + +function Invoke-ReleaseChecks { + Invoke-ScriptCheck -Name "Runtime PowerShell boundary" -ScriptPath $RuntimeBoundaryCheckPath -Parameters @{ CheckOnly = $true } + Invoke-ScriptCheck -Name "Offline component bundle" -ScriptPath $ComponentBundleScriptPath -Parameters @{ CheckOnly = $true } + Invoke-ScriptCheck -Name "Windows smoke evidence plan" -ScriptPath $WindowsAuditScriptPath -Parameters @{ Mode = "PlanOnly" } } function Get-ArtifactVersionPattern { @@ -503,7 +557,7 @@ function Get-GitValue { param([string[]]$Arguments) try { - $value = & git @Arguments 2>$null + $value = & git --no-optional-locks @Arguments 2>$null if ($LASTEXITCODE -eq 0) { return ($value -join [Environment]::NewLine).Trim() } @@ -511,11 +565,176 @@ function Get-GitValue { return "" } +function Get-GitFailureMessage { + param([string]$Operation, [int]$ExitCode, [string]$Diagnostic) + $reason = if ($Diagnostic -match 'Too many authentication failures') { + 'SSH-сервер отклонил слишком много попыток входа. Укажи правильный ключ и IdentitiesOnly yes для этого Git-сервера.' + } elseif ($Diagnostic -match 'Permission denied \(publickey|Authentication failed|could not read Username|terminal prompts disabled') { + 'Сервер Git отклонил вход. Проверь SSH-ключ или HTTPS-аутентификацию и доступ к репозиторию.' + } elseif ($Diagnostic -match 'Host key verification failed|REMOTE HOST IDENTIFICATION HAS CHANGED') { + 'Не подтверждён SSH-ключ сервера. Проверь его отпечаток перед повторным подключением.' + } elseif ($Diagnostic -match 'Could not resolve|Connection timed out|Connection refused|Network is unreachable|connect to host.*Permission denied|Failed to connect') { + 'Не удалось подключиться к Git-серверу. Проверь сеть/VPN, адрес и порт origin.' + } elseif ($Diagnostic -match 'not found|does not appear to be a git repository') { + 'Репозиторий недоступен по адресу origin. Проверь URL и права доступа.' + } else { + "Git не выполнил операцию $Operation (код $ExitCode)." + } + $details = "$Diagnostic".Trim() -replace '(https?://)[^/\s@]+@', '$1[redacted]@' -replace '(https?://[^\s?#]+)[?#][^\s]*', '$1' + if ($details.Length -gt 2500) { $details = $details.Substring(0, 2500) + '...' } + $next = if ($Operation -in @('ls-remote', 'fetch')) { + 'Проверка origin завершилась до изменения версии, сборки, commit, tag и push. Для диагностики запусти: git ls-remote origin' + } else { 'Подробности ответа Git приведены ниже.' } + return "$reason`n$next`n`nОтвет Git:`n$details" +} + +function Invoke-Git { + param([string[]]$Arguments) + $stderrPath = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-git-" + [guid]::NewGuid().ToString('N') + '.log') + $previousPreference = $ErrorActionPreference + try { + # Windows PowerShell wraps redirected stderr as NativeCommandError; preserve it, + # then classify by the actual exit code instead of losing the original cause. + $ErrorActionPreference = 'Continue' + $output = & git @Arguments 2>$stderrPath + $exitCode = $LASTEXITCODE + $ErrorActionPreference = $previousPreference + [string]$diagnostic = '' + if (Test-Path -LiteralPath $stderrPath) { $diagnostic = [string](Get-Content -Raw -LiteralPath $stderrPath) } + if ($exitCode -ne 0) { throw (Get-GitFailureMessage -Operation $Arguments[0] -ExitCode $exitCode -Diagnostic $diagnostic) } + if (-not [string]::IsNullOrWhiteSpace($diagnostic)) { Write-Host $diagnostic.Trim() } + return ([string]($output -join "`n")).Trim() + } finally { + $ErrorActionPreference = $previousPreference + if (Test-Path -LiteralPath $stderrPath) { Remove-Item -LiteralPath $stderrPath } + } +} + +function Get-SourceTree { + # Snapshot tracked + non-ignored new files without touching the user's staging area. + $previousIndex = $env:GIT_INDEX_FILE + $index = Join-Path ([IO.Path]::GetTempPath()) ("proxywarden-index-" + [guid]::NewGuid().ToString('N')) + try { + $env:GIT_INDEX_FILE = $index + Invoke-Git @('read-tree', 'HEAD') | Out-Null + Invoke-Git @('add', '-A', '--', '.') | Out-Null + return Invoke-Git @('write-tree') + } finally { + $env:GIT_INDEX_FILE = $previousIndex + foreach ($path in @($index, "$index.lock")) { + if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path } + } + } +} + +function Get-ReleasePath { + param([string]$TargetVersion) + $root = if ([IO.Path]::IsPathRooted($OutputRoot)) { $OutputRoot } else { Join-Path $RepoRoot $OutputRoot } + return [IO.Path]::GetFullPath((Join-Path $root "proxywarden-v$TargetVersion")) +} + +function Test-GitTag { + param([string]$Tag) + & git show-ref --verify --quiet "refs/tags/$Tag" + if ($LASTEXITCODE -eq 0) { return $true } + if ($LASTEXITCODE -ne 1) { throw "Cannot inspect local tag $Tag." } + return $false +} + +function Get-ReleaseGitContext { + param([string]$TargetVersion) + $branch = Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD') + $headCommit = Invoke-Git @('rev-parse', 'HEAD') + foreach ($marker in @('MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'rebase-merge', 'rebase-apply')) { + $path = Invoke-Git @('rev-parse', '--git-path', $marker) + if (Test-Path -LiteralPath $path) { throw "Finish the active Git operation before releasing ($marker)." } + } + if (Invoke-Git @('diff', '--name-only', '--diff-filter=U')) { throw 'Resolve Git conflicts before releasing.' } + Invoke-Git @('var', 'GIT_AUTHOR_IDENT') | Out-Null + Invoke-Git @('var', 'GIT_COMMITTER_IDENT') | Out-Null + $remote = Invoke-Git @('remote', 'get-url', '--push', 'origin') + $tag = "v$TargetVersion" + if (-not $Resume -and (Test-GitTag $tag)) { throw "Tag $tag already exists. Use -Version $TargetVersion -Resume for a failed push, or choose another version." } + $remoteTag = Invoke-Git @('ls-remote', '--tags', 'origin', "refs/tags/$tag", "refs/tags/$tag^{}") + if (-not $Resume -and $remoteTag) { throw "Remote tag $tag already exists. Choose another version." } + $remoteBranch = Invoke-Git @('ls-remote', '--heads', 'origin', "refs/heads/$branch") + if ($remoteBranch) { + Invoke-Git @('fetch', '--no-tags', 'origin', "refs/heads/$branch") | Out-Null + & git merge-base --is-ancestor FETCH_HEAD HEAD + if ($LASTEXITCODE -ne 0) { throw "The origin/$branch branch has changes not in HEAD. Integrate them before releasing; automatic merge is not performed." } + } + return @{ branch = $branch; head = $headCommit; remote = $remote; tag = $tag } +} + +function Complete-ReleaseGit { + param([hashtable]$Context, [string]$SourceTree, [string]$TargetVersion) + $currentTree = Get-SourceTree + if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Context.head -or + (Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or + $currentTree -ne $SourceTree) { + $changed = Invoke-Git @('-c', 'core.quotepath=false', 'diff', '--name-only', $SourceTree, $currentTree) + throw "Source files or HEAD changed during the build. No release commit/tag was created.`nИсходники изменились во время сборки. Повтори сборку после завершения правок.`nИзменённые файлы:`n$changed" + } + if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree) { + Invoke-Git @('add', '-A', '--', '.') | Out-Null + if ((Invoke-Git @('write-tree')) -ne $SourceTree) { throw 'Staged source changed. Rebuild before releasing.' } + Invoke-Git @('commit', '-m', "Release v$TargetVersion") | Write-Host + } + if ((Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $SourceTree -or + (Get-SourceTree) -ne $SourceTree) { throw 'A Git hook changed source files. Rebuild before tagging.' } + return Invoke-Git @('rev-parse', 'HEAD') +} + +function Push-Release { + param([hashtable]$Context, [string]$Commit) + if ((Invoke-Git @('rev-parse', 'HEAD')) -ne $Commit -or + (Invoke-Git @('symbolic-ref', '--quiet', '--short', 'HEAD')) -ne $Context.branch -or + (Invoke-Git @('remote', 'get-url', '--push', 'origin')) -ne $Context.remote) { + throw 'HEAD, branch or origin changed before push.' + } + if (Test-GitTag $Context.tag) { + if ((Invoke-Git @('rev-parse', "$($Context.tag)^{commit}")) -ne $Commit) { throw 'Existing tag points to another commit.' } + } else { + Invoke-Git @('tag', '-a', $Context.tag, $Commit, '-m', "ProxyWarden $($Context.tag)") | Out-Null + } + # One atomic push; never force or push unrelated tags. A failure leaves a resumable local release. + Invoke-Git @('push', '--atomic', 'origin', "${Commit}:refs/heads/$($Context.branch)", "refs/tags/$($Context.tag):refs/tags/$($Context.tag)") | Write-Host +} + +function Resume-Release { + param([string]$TargetVersion, [hashtable]$Context) + $releaseDir = Get-ReleasePath $TargetVersion + $manifestPath = Join-Path $releaseDir 'release-manifest.json' + $manifest = Read-JsonFile $manifestPath + if (-not $manifest.PSObject.Properties['gitRelease'] -or -not $manifest.gitRelease) { + throw 'This folder has no completed release commit. Resume only retries a failed push; choose a new version and rebuild.' + } + if ($manifest.version -ne $TargetVersion -or $manifest.gitRelease.branch -ne $Context.branch -or + $manifest.gitRelease.remote -ne $Context.remote -or $manifest.gitRelease.tag -ne $Context.tag -or + $manifest.gitRelease.status -notin @('pending-push', 'pushed') -or + $manifest.gitCommit -ne $Context.head -or + (Invoke-Git @('rev-parse', 'HEAD^{tree}')) -ne $manifest.gitRelease.sourceTree -or + (Get-SourceTree) -ne $manifest.gitRelease.sourceTree) { + throw 'This release no longer matches HEAD/source/origin. Resume refused; use a new version.' + } + if (@($manifest.artifacts).Count -eq 0) { throw 'No artifacts to resume.' } + foreach ($artifact in $manifest.artifacts) { + $path = [IO.Path]::GetFullPath((Join-Path $releaseDir $artifact.path)) + if (-not (Test-IsSubPath $releaseDir $path) -or + (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ne $artifact.sha256) { throw 'Release artifact checksum mismatch.' } + } + Push-Release -Context $Context -Commit $manifest.gitCommit + $manifest.gitRelease.status = 'pushed' + Write-JsonFile -Path $manifestPath -Value $manifest + Write-Host "Релиз отправлен. Файлы для сайта: $releaseDir" +} + function Write-ReleaseMetadata { param( [string]$ReleaseDir, [string]$TargetVersion, - [object[]]$Artifacts + [object[]]$Artifacts, + [object]$GitRelease = $null ) $artifactDir = Join-Path $ReleaseDir "artifacts" @@ -534,6 +753,8 @@ function Write-ReleaseMetadata { source = "local" gitCommit = Get-GitValue -Arguments @("rev-parse", "HEAD") gitStatus = Get-GitValue -Arguments @("status", "--short") + gitRelease = $GitRelease + windowsAcceptance = "not-verified-by-this-command" artifacts = @($artifactItems) } @@ -560,7 +781,7 @@ See `SHA256SUMS.txt`. ## Release boundary -This release contains the ProxyWarden Control App only. ProxiFyre and Local sing-box remain explicit user-managed components. +The ProxyWarden installer contains pinned offline payloads for ProxiFyre, Windows Packet Filter, VC++ Runtime, sing-box, WinSW, and WebView2. Installing, updating, starting, stopping, or removing routing components remains an explicit user action. "@ @@ -590,16 +811,31 @@ function New-PlanResult { releaseDirectory = (Join-Path $outputRootFull "proxywarden-v$Target") skipTests = [bool]$SkipTests skipBuild = [bool]$SkipBuild + publish = [bool]$Publish + resume = [bool]$Resume + git = [ordered]@{ + branch = Get-GitValue @('symbolic-ref', '--quiet', '--short', 'HEAD') + remote = 'origin' + tag = "v$Target" + includedChanges = Get-GitValue @('status', '--short') + commitAfterSuccessfulBuild = [bool]$Publish + atomicPush = [bool]$Publish + } manifests = @( $PackageJsonPath, $PackageLockPath, $TauriConfigPath, - $CargoTomlPath + $CargoTomlPath, + $CargoLockPath ) commands = @( - "npm run build", - "cd src-tauri; cargo test", - "npm run tauri -- build" + ".\scripts\check-runtime-powershell-boundary.ps1 -CheckOnly", + ".\scripts\update-component-bundle.ps1 -CheckOnly", + ".\scripts\audit-windows-smoke.ps1 -Mode PlanOnly", + "node: TypeScript, Prettier, ESLint, Vitest, Vite", + "cargo fmt / clippy --locked / test --locked --all-targets", + "node: Tauri build --bundles nsis", + "if -Publish: commit source, annotated version tag, atomic branch+tag push to origin" ) } } | ConvertTo-Json -Depth 8 @@ -608,6 +844,15 @@ function New-PlanResult { try { Push-Location $RepoRoot + if ($Resume -and (-not $Publish -or -not $Version -or $Bump)) { throw '-Resume requires -Publish -Version X.Y.Z.' } + if ($Version -and $Bump) { throw 'Use either -Version or -Bump.' } + if ($Publish -and -not $PlanOnly -and ($SkipTests -or $SkipBuild -or $Force)) { throw 'A published release requires checks and a fresh build; SkipTests, SkipBuild and Force are not allowed.' } + if ($Publish -and -not $PlanOnly -and -not $Resume) { + Write-Host 'В релиз войдут все изменения Git ниже (кроме игнорируемых файлов).' + Write-Host 'После успешной сборки: commit, тег версии и push текущей ветки в origin.' + Write-Host 'Файлы установщика останутся локально для загрузки на сайт.' + Write-Host (Invoke-Git @('status', '--short')) + } $currentVersion = Get-CurrentVersion $targetVersion = Resolve-TargetVersion -Current $currentVersion Assert-Semver -Value $targetVersion @@ -625,12 +870,34 @@ try { Write-Host "Preparing ProxyWarden release $targetVersion..." Write-Host "Repository: $RepoRoot" + $gitContext = $null + if ($Publish) { + $gitContext = Get-ReleaseGitContext $targetVersion + if ($Resume) { Resume-Release -TargetVersion $targetVersion -Context $gitContext; return } + } + $releasePath = Get-ReleasePath $targetVersion + if ((Test-Path -LiteralPath $releasePath) -and ($Publish -or -not $Force)) { + throw "Release directory already exists: $releasePath. Use -Resume for a failed push, or choose another version." + } + if ($Publish -and (Test-IsSubPath $RepoRoot $releasePath)) { + & git check-ignore --quiet -- (Join-Path $releasePath 'release-manifest.json') + if ($LASTEXITCODE -ne 0) { throw 'OutputRoot must be ignored by Git, or outside the repository.' } + } + if (-not $SkipBuild) { + Get-Command node, cargo -ErrorAction Stop | Out-Null + foreach ($cli in @('typescript/bin/tsc', 'vite/bin/vite.js', '@tauri-apps/cli/tauri.js', 'prettier/bin/prettier.cjs', 'eslint/bin/eslint.js', 'vitest/vitest.mjs')) { + if (-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules/$cli"))) { throw 'Frontend dependencies are missing. Run npm ci once, then retry release.' } + } + } + Invoke-ReleaseChecks + Set-ManifestVersions -TargetVersion $targetVersion $afterUpdateVersion = Get-CurrentVersion if ($afterUpdateVersion -ne $targetVersion) { throw "Version update failed. Current version is $afterUpdateVersion." } + $sourceTree = if ($Publish) { Get-SourceTree } else { $null } Invoke-ReleaseBuild $releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion @@ -638,11 +905,24 @@ try { Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts + if ($Publish) { + $commit = Complete-ReleaseGit -Context $gitContext -SourceTree $sourceTree -TargetVersion $targetVersion + $gitRelease = [ordered]@{ branch = $gitContext.branch; remote = $gitContext.remote; tag = $gitContext.tag; sourceTree = $sourceTree; status = 'pending-push' } + Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease + try { Push-Release -Context $gitContext -Commit $commit } + catch { throw "Push failed; local release is preserved. Retry: .\release.cmd -Version $targetVersion -Resume. $($_.Exception.Message)" } + $gitRelease.status = 'pushed' + Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts -GitRelease $gitRelease + } + Write-Host "" Write-Host "Release folder is ready:" Write-Host $releaseDir Write-Host "" - Write-Host "Upload the files from the release folder to GitHub release v$targetVersion." + Write-Host 'Загрузи EXE из artifacts\nsis на сайт. SHA256SUMS.txt содержит контрольную сумму.' +} catch { + [Console]::Error.WriteLine("`nРелиз не завершён.`n" + $_.Exception.Message) + exit 1 } finally { Pop-Location } diff --git a/scripts/update-component-bundle.ps1 b/scripts/update-component-bundle.ps1 new file mode 100644 index 0000000..c6913d7 --- /dev/null +++ b/scripts/update-component-bundle.ps1 @@ -0,0 +1,1917 @@ +[CmdletBinding()] +param( + [string]$OutputDir = '', + [switch]$PlanOnly, + [switch]$CheckOnly, + [switch]$UseFrozenReleaseEvidence, + [ValidateSet('None', 'Download', 'Validation', 'Promotion')] + [string]$SimulateFailure = 'None' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$ProgressPreference = 'SilentlyContinue' + +if ([string]::IsNullOrEmpty($OutputDir)) { + $OutputDir = Join-Path $PSScriptRoot '..\src-tauri\bundled\components' +} elseif ([string]::IsNullOrWhiteSpace($OutputDir)) { + throw 'OutputDir must not be whitespace.' +} + +$ExpectedComponents = @( + [PSCustomObject]@{ + id = 'proxifyre'; version = '2.4.0'; installRole = 'proxifyre-runtime'; assetArch = 'x64' + assetName = 'ProxiFyre-v2.4.0-x64-signed.zip'; licenseId = 'AGPL-3.0-only'; licensePath = 'proxifyre/LICENSE' + policyType = 'githubReleaseDigest'; sourceUrl = 'https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip' + sha256 = 'eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac'; size = 1519694L + fileVersion = '2.4.0'; productVersion = '2.4.0' + licenseSha256 = '8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef'; licenseSize = 34523L + } + [PSCustomObject]@{ + id = 'windows-packet-filter'; version = '3.6.2'; installRole = 'packet-filter-driver'; assetArch = 'x64' + assetName = 'Windows.Packet.Filter.3.6.2.1.x64.msi'; licenseId = 'MIT'; licensePath = 'windows-packet-filter/LICENSE' + policyType = 'githubReleaseDigest'; sourceUrl = 'https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi' + sha256 = '9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c'; size = 819200L + fileVersion = '3.6.2.1'; productVersion = '3.6.2.1' + licenseSha256 = 'b12f4cfcce43cef59100cf8c4eaf67ae5246c9a047e00f6059655f6694030efe'; licenseSize = 1070L + } + [PSCustomObject]@{ + id = 'vc-runtime'; version = '14.51.36247.0'; installRole = 'vc-runtime-prerequisite'; assetArch = 'x64' + assetName = 'VC_redist.x64.exe'; licenseId = 'LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026' + licensePath = 'vc-runtime/LICENSE.docx'; policyType = 'buildTimeOnlyAuthenticode' + sourceUrl = 'https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe' + sha256 = '843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c'; size = 18731856L + fileVersion = '14.51.36247.0'; productVersion = '14.51.36247.0' + licenseSha256 = '08651651a7602fc7c0e2763de0fde1ff9f868df2780597cd1775ee9d6441c783'; licenseSize = 39553L + } + [PSCustomObject]@{ + id = 'sing-box'; version = '1.13.19'; installRole = 'sing-box-runtime'; assetArch = 'x64' + assetName = 'sing-box-1.13.19-windows-amd64.zip'; licenseId = 'LicenseRef-Sing-Box-Project' + licensePath = 'sing-box/LICENSE'; policyType = 'githubReleaseDigest' + sourceUrl = 'https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip' + sha256 = 'e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d'; size = 21046252L + fileVersion = $null; productVersion = $null + licenseSha256 = '650d5e3b99a446fb38e820fa87a49562e0c79eab868fff58618ac487a58e554c'; licenseSize = 791L + } + [PSCustomObject]@{ + id = 'winsw'; version = '2.12.0'; installRole = 'sing-box-service-wrapper'; assetArch = 'anycpu' + assetName = 'WinSW.NET461.exe'; licenseId = 'MIT'; licensePath = 'winsw/LICENSE.txt' + policyType = 'bundledOnlyNoIndependentProof' + sourceUrl = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe' + sha256 = 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f'; size = 655872L + fileVersion = '2.12.0.0'; productVersion = '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147' + licenseSha256 = '1cdf703c10a70e5973bf3acf2a5eeabe7746237155b92db2034aeae26fdf7802'; licenseSize = 1158L + } +) + +function ConvertTo-ResultJson([object]$Value) { + $Value | ConvertTo-Json -Depth 20 +} + +function Assert-ExactProperties( + [object]$Value, + [string[]]$Required, + [string[]]$Optional, + [string]$Label +) { + if ($null -eq $Value) { + throw "$Label is missing." + } + + $names = @($Value.PSObject.Properties.Name) + foreach ($name in $Required) { + if ($names -cnotcontains $name) { + throw "$Label is missing required property '$name'." + } + } + foreach ($name in $names) { + if (($Required -cnotcontains $name) -and ($Optional -cnotcontains $name)) { + throw "$Label contains unknown property '$name'." + } + } +} + +function Test-StableNumericVersion([object]$Value) { + if ($Value -isnot [string] -or $Value -notmatch '^[0-9]{1,10}(\.[0-9]{1,10}){1,3}$') { + return $false + } + return $true +} + +function Test-StableProductVersion([object]$Value) { + if ($Value -isnot [string]) { + return $false + } + $parts = $Value.Split('+') + if ($parts.Count -eq 1) { + return (Test-StableNumericVersion $Value) + } + if ($parts.Count -ne 2 -or -not (Test-StableNumericVersion $parts[0])) { + return $false + } + return $parts[1] -match '^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*$' -and $parts[1].Length -le 128 +} + +function Test-WindowsReservedName([string]$Segment) { + $stem = $Segment.Split('.')[0].ToUpperInvariant() + if (@('CON', 'PRN', 'AUX', 'NUL') -contains $stem) { + return $true + } + return $stem -match '^(COM|LPT)[1-9]$' +} + +function Assert-SafeRelativePath([object]$Value, [string]$Label) { + if ($Value -isnot [string] -or $Value.Length -eq 0 -or $Value.Length -gt 512) { + throw "$Label is not a safe relative path." + } + if ($Value.Contains('\') -or $Value.StartsWith('/') -or $Value.EndsWith('/')) { + throw "$Label is not a safe relative path." + } + foreach ($segment in $Value.Split('/')) { + if ( + $segment.Length -eq 0 -or + $segment.Length -gt 128 -or + $segment -in @('.', '..') -or + $segment.EndsWith('.') -or + (Test-WindowsReservedName $segment) -or + $segment -notmatch '^[A-Za-z0-9._-]+$' + ) { + throw "$Label is not a safe relative path." + } + } +} + +function Assert-PlainHttpsUrl([object]$Value, [string]$Label) { + if ($Value -isnot [string]) { + throw "$Label must be an HTTPS URL." + } + $uri = $null + if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { + throw "$Label must be an HTTPS URL." + } + if ( + $uri.Scheme -ne 'https' -or + [string]::IsNullOrWhiteSpace($uri.Host) -or + -not [string]::IsNullOrEmpty($uri.UserInfo) -or + -not [string]::IsNullOrEmpty($uri.Query) -or + -not [string]::IsNullOrEmpty($uri.Fragment) -or + $Value -notmatch '^https://[^/:@]+(?:/|$)' + ) { + throw "$Label must be a plain HTTPS URL." + } + return $uri +} + +function Test-CatalogPattern([string]$Pattern, [string]$Value) { + $parts = $Pattern.Split('*') + if ($parts.Count -eq 1) { + return [string]::Equals($Pattern, $Value, [StringComparison]::Ordinal) + } + if ($parts.Count -ne 2) { + return $false + } + return $Value.StartsWith($parts[0], [StringComparison]::Ordinal) -and + $Value.EndsWith($parts[1], [StringComparison]::Ordinal) -and + $Value.Length -ge ($parts[0].Length + $parts[1].Length) +} + +function Assert-CatalogPattern([object]$Value, [string]$Label) { + if ( + $Value -isnot [string] -or + $Value.Length -eq 0 -or + $Value.Length -gt 160 -or + @($Value.ToCharArray() | Where-Object { $_ -eq '*' }).Count -gt 1 -or + $Value -notmatch '^[A-Za-z0-9._+*-]+$' + ) { + throw "$Label is invalid." + } +} + +function Assert-StringArray([object]$Value, [string]$Label) { + if ($Value -isnot [Array]) { + throw "$Label must be an array." + } + $items = @($Value) + if ($items.Count -eq 0) { + throw "$Label must not be empty." + } + $seen = @{} + foreach ($item in $items) { + if ($item -isnot [string] -or [string]::IsNullOrWhiteSpace($item) -or $item.Trim() -ne $item) { + throw "$Label contains an invalid value." + } + if ($seen.ContainsKey($item)) { + throw "$Label contains a duplicate value." + } + $seen[$item] = $true + } +} + +function Assert-TrustPolicy([object]$Component, [Uri]$SourceUri, [string]$AssetName) { + $policy = $Component.updateTrustPolicy + if ($null -eq $policy -or $policy.PSObject.Properties.Name -cnotcontains 'type') { + throw "updateTrustPolicy is missing for $($Component.id)." + } + + switch -CaseSensitive ($policy.type) { + 'githubReleaseDigest' { + Assert-ExactProperties $policy @('type', 'repository', 'tagPattern', 'assetPattern', 'requireStable') @('authenticodePublishers') "updateTrustPolicy for $($Component.id)" + if ($policy.repository -isnot [string] -or $policy.repository -notmatch '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$') { + throw "GitHub repository is invalid for $($Component.id)." + } + Assert-CatalogPattern $policy.tagPattern 'tagPattern' + Assert-CatalogPattern $policy.assetPattern 'assetPattern' + if ($policy.requireStable -isnot [bool] -or -not $policy.requireStable) { + throw "GitHub release policy must require a stable release." + } + if ($policy.PSObject.Properties.Name -ccontains 'authenticodePublishers') { + Assert-StringArray $policy.authenticodePublishers 'authenticodePublishers' + } + if ($SourceUri.Host -ne 'github.com') { + throw "GitHub release source must use github.com." + } + $segments = @($SourceUri.AbsolutePath.Trim('/').Split('/')) + if ( + $segments.Count -ne 6 -or + $segments[2] -cne 'releases' -or + $segments[3] -cne 'download' -or + -not [string]::Equals("$($segments[0])/$($segments[1])", $policy.repository, [StringComparison]::OrdinalIgnoreCase) -or + $segments[5] -cne $AssetName -or + -not (Test-CatalogPattern $policy.tagPattern $segments[4]) -or + -not (Test-CatalogPattern $policy.assetPattern $AssetName) + ) { + throw "GitHub source does not match trust policy for $($Component.id)." + } + } + 'buildTimeOnlyAuthenticode' { + Assert-ExactProperties $policy @('type', 'allowedSourceHosts', 'assetPattern', 'publishers') @() "updateTrustPolicy for $($Component.id)" + Assert-StringArray $policy.allowedSourceHosts 'allowedSourceHosts' + Assert-StringArray $policy.publishers 'publishers' + Assert-CatalogPattern $policy.assetPattern 'assetPattern' + $hosts = @($policy.allowedSourceHosts | ForEach-Object { $_.ToLowerInvariant() }) + if ($hosts -notcontains $SourceUri.Host.ToLowerInvariant() -or -not (Test-CatalogPattern $policy.assetPattern $AssetName)) { + throw "Authenticode source does not match trust policy for $($Component.id)." + } + } + 'bundledOnlyNoIndependentProof' { + Assert-ExactProperties $policy @('type', 'reason') @() "updateTrustPolicy for $($Component.id)" + if ( + $policy.reason -isnot [string] -or + [string]::IsNullOrWhiteSpace($policy.reason) -or + $policy.reason.Trim() -ne $policy.reason -or + $policy.reason.Length -gt 240 -or + $policy.reason.IndexOfAny([char[]]@(0..31)) -ge 0 + ) { + throw "Bundled-only trust reason is invalid for $($Component.id)." + } + } + default { + throw "Unknown update trust policy for $($Component.id)." + } + } +} + +function Assert-PinnedTrustPolicy([object]$Component) { + $policy = $Component.updateTrustPolicy + switch -CaseSensitive ($Component.id) { + 'proxifyre' { + if ( + $policy.type -cne 'githubReleaseDigest' -or + $policy.repository -cne 'wiresock/proxifyre' -or + $policy.tagPattern -cne 'v*' -or + $policy.assetPattern -cne 'ProxiFyre-v*-x64-signed.zip' -or + @($policy.authenticodePublishers).Count -ne 1 -or + @($policy.authenticodePublishers)[0] -cne 'The Anti-Cloud Corporation' + ) { throw 'Pinned ProxiFyre trust policy mismatch.' } + } + 'windows-packet-filter' { + if ( + $policy.type -cne 'githubReleaseDigest' -or + $policy.repository -cne 'wiresock/ndisapi' -or + $policy.tagPattern -cne 'v*' -or + $policy.assetPattern -cne 'Windows.Packet.Filter.*.x64.msi' -or + @($policy.authenticodePublishers).Count -ne 1 -or + @($policy.authenticodePublishers)[0] -cne 'The Anti-Cloud Corporation' + ) { throw 'Pinned Windows Packet Filter trust policy mismatch.' } + } + 'sing-box' { + if ( + $policy.type -cne 'githubReleaseDigest' -or + $policy.repository -cne 'SagerNet/sing-box' -or + $policy.tagPattern -cne 'v*' -or + $policy.assetPattern -cne 'sing-box-*-windows-amd64.zip' -or + $policy.PSObject.Properties.Name -ccontains 'authenticodePublishers' + ) { throw 'Pinned sing-box trust policy mismatch.' } + } + 'vc-runtime' { + if ( + $policy.type -cne 'buildTimeOnlyAuthenticode' -or + @($policy.allowedSourceHosts).Count -ne 1 -or + @($policy.allowedSourceHosts)[0] -cne 'aka.ms' -or + $policy.assetPattern -cne 'VC_redist.x64.exe' -or + @($policy.publishers).Count -ne 1 -or + @($policy.publishers)[0] -cne 'Microsoft Corporation' + ) { throw 'Pinned VC runtime trust policy mismatch.' } + } + 'winsw' { + $expectedReason = 'The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled.' + if ($policy.type -cne 'bundledOnlyNoIndependentProof' -or $policy.reason -cne $expectedReason) { + throw 'Pinned WinSW trust policy mismatch.' + } + } + default { throw "Unknown component id '$($Component.id)'." } + } +} + +function Assert-OfficialSource([object]$Component, [Uri]$SourceUri, [string]$AssetName) { + $repositories = @{ + 'proxifyre' = 'wiresock/proxifyre' + 'windows-packet-filter' = 'wiresock/ndisapi' + 'sing-box' = 'SagerNet/sing-box' + 'winsw' = 'winsw/winsw' + } + if ($Component.id -ceq 'vc-runtime') { + if (@('aka.ms', 'download.visualstudio.microsoft.com') -notcontains $SourceUri.Host.ToLowerInvariant()) { + throw 'VC runtime source is not an approved Microsoft host.' + } + return + } + if (-not $repositories.ContainsKey($Component.id) -or $SourceUri.Host -ne 'github.com') { + throw "Component source is not an official GitHub source for $($Component.id)." + } + $segments = @($SourceUri.AbsolutePath.Trim('/').Split('/')) + if ( + $segments.Count -ne 6 -or + -not [string]::Equals("$($segments[0])/$($segments[1])", $repositories[$Component.id], [StringComparison]::OrdinalIgnoreCase) -or + $segments[2] -cne 'releases' -or + $segments[3] -cne 'download' -or + $segments[4].TrimStart('v') -cne $Component.version -or + $segments[5] -cne $AssetName + ) { + throw "Component source is not its pinned official release for $($Component.id)." + } +} + +function Assert-NoReparseTree([string]$Root) { + $items = @((Get-Item -LiteralPath $Root -Force)) + @(Get-ChildItem -LiteralPath $Root -Recurse -Force) + foreach ($item in $items) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Bundle contains a reparse point: $($item.Name)" + } + } +} + +function Remove-SafeGeneratedDirectory( + [string]$Path, + [string]$ExpectedParent, + [string]$LeafPattern +) { + if (-not (Test-Path -LiteralPath $Path)) { + return + } + $fullPath = [IO.Path]::GetFullPath($Path) + $fullParent = [IO.Path]::GetFullPath((Split-Path -Parent $fullPath)).TrimEnd('\', '/') + $expectedFullParent = [IO.Path]::GetFullPath($ExpectedParent).TrimEnd('\', '/') + $leaf = Split-Path -Leaf $fullPath + if ( + -not [string]::Equals($fullParent, $expectedFullParent, [StringComparison]::OrdinalIgnoreCase) -or + $leaf -notmatch $LeafPattern -or + -not (Test-Path -LiteralPath $fullPath -PathType Container) + ) { + throw "Refusing to remove an unexpected generated directory: $leaf" + } + Assert-NoReparseTree $fullPath + Remove-Item -LiteralPath $fullPath -Recurse -Force +} + +function Test-SafeEmptyDirectory([string]$Path) { + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return $false + } + Assert-NoReparseTree $Path + return $null -eq (Get-ChildItem -LiteralPath $Path -Force | Select-Object -First 1) +} + +function Remove-SafeEmptyDirectory([string]$Path, [string]$ExpectedPath) { + $fullPath = [IO.Path]::GetFullPath($Path) + $expectedFullPath = [IO.Path]::GetFullPath($ExpectedPath) + if (-not [string]::Equals($fullPath, $expectedFullPath, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Refusing to remove an unexpected empty directory.' + } + if (-not (Test-SafeEmptyDirectory $fullPath)) { + throw 'Refusing to remove a directory that is not a safe empty placeholder.' + } + Remove-Item -LiteralPath $fullPath -Force +} + +function Get-RelativeBundlePath([string]$Root, [string]$Path) { + $prefix = $Root.TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar + if (-not $Path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Bundle entry escaped its root.' + } + return $Path.Substring($prefix.Length).Replace('\', '/') +} + +function Test-ComponentBundle([string]$Root) { + $resolvedRoot = [IO.Path]::GetFullPath($Root) + if (-not (Test-Path -LiteralPath $resolvedRoot -PathType Container)) { + throw "Component bundle does not exist: $resolvedRoot" + } + Assert-ValidatedParent (Split-Path -Parent $resolvedRoot) + Assert-NoReparseTree $resolvedRoot + + $catalogPath = Join-Path $resolvedRoot 'catalog.json' + if (-not (Test-Path -LiteralPath $catalogPath -PathType Leaf)) { + throw 'catalog.json is missing.' + } + try { + $catalog = Get-Content -Raw -LiteralPath $catalogPath | ConvertFrom-Json + } catch { + throw "catalog.json is invalid: $($_.Exception.Message)" + } + Assert-ExactProperties $catalog @('schemaVersion', 'targetArch', 'components') @() 'catalog' + $schemaIsInteger = $catalog.schemaVersion -is [Int32] -or $catalog.schemaVersion -is [Int64] + if ( + -not $schemaIsInteger -or + [Int64]$catalog.schemaVersion -ne 1 -or + $catalog.targetArch -isnot [string] -or + $catalog.targetArch -cne 'x64' + ) { + throw 'Unsupported component catalog schema or target architecture.' + } + + $components = @($catalog.components) + if ($components.Count -ne $ExpectedComponents.Count) { + throw 'Catalog must contain exactly five components.' + } + $seenIds = @{} + $seenRoles = @{} + $seenAssets = @{} + $seenLicenses = @{} + $expectedFiles = @{ 'catalog.json' = $true } + + foreach ($component in $components) { + Assert-ExactProperties $component @( + 'id', 'version', 'assetPath', 'assetArch', 'effectiveTarget', 'sha256', 'size', + 'sourceUrl', 'license', 'installRole', 'updateTrustPolicy' + ) @('fileVersion', 'productVersion') "component" + $expected = @($ExpectedComponents | Where-Object { $_.id -ceq $component.id }) + if ($expected.Count -ne 1) { + throw "Unknown or duplicate component id '$($component.id)'." + } + if ($seenIds.ContainsKey($component.id)) { + throw "Duplicate component id '$($component.id)'." + } + $seenIds[$component.id] = $true + + if ( + $component.version -cne $expected[0].version -or + $component.installRole -cne $expected[0].installRole -or + $component.assetArch -cne $expected[0].assetArch -or + $component.effectiveTarget -cne 'x64' + ) { + throw "Catalog identity does not match the pinned x64 baseline for $($component.id)." + } + if ($seenRoles.ContainsKey($component.installRole)) { + throw "Duplicate installRole '$($component.installRole)'." + } + $seenRoles[$component.installRole] = $true + if (-not (Test-StableNumericVersion $component.version)) { + throw "Invalid version for $($component.id)." + } + if ($component.PSObject.Properties.Name -ccontains 'fileVersion') { + if (-not (Test-StableNumericVersion $component.fileVersion)) { + throw "Invalid fileVersion for $($component.id)." + } + } + if ($component.PSObject.Properties.Name -ccontains 'productVersion') { + if (-not (Test-StableProductVersion $component.productVersion)) { + throw "Invalid productVersion for $($component.id)." + } + } + $hasFileVersion = $component.PSObject.Properties.Name -ccontains 'fileVersion' + $hasProductVersion = $component.PSObject.Properties.Name -ccontains 'productVersion' + if ( + ($null -eq $expected[0].fileVersion -and $hasFileVersion) -or + ($null -ne $expected[0].fileVersion -and (-not $hasFileVersion -or $component.fileVersion -cne $expected[0].fileVersion)) -or + ($null -eq $expected[0].productVersion -and $hasProductVersion) -or + ($null -ne $expected[0].productVersion -and (-not $hasProductVersion -or $component.productVersion -cne $expected[0].productVersion)) + ) { + throw "Version metadata does not match the pinned baseline for $($component.id)." + } + + Assert-SafeRelativePath $component.assetPath 'assetPath' + if ( + $component.assetPath.Split('/')[0] -cne $component.id -or + $component.assetPath.Split('/')[-1] -cne $expected[0].assetName + ) { + throw "assetPath must be inside the $($component.id) directory." + } + if ($seenAssets.ContainsKey($component.assetPath)) { + throw "Duplicate asset path '$($component.assetPath)'." + } + $seenAssets[$component.assetPath] = $true + + Assert-ExactProperties $component.license @('id', 'path') @() "license for $($component.id)" + if ( + $component.license.id -isnot [string] -or + $component.license.id -cnotmatch '^[A-Za-z0-9.+_-]{1,96}$' -or + $component.license.id -cne $expected[0].licenseId + ) { + throw "Invalid license id for $($component.id)." + } + Assert-SafeRelativePath $component.license.path 'license.path' + if ( + $component.license.path.Split('/')[0] -cne $component.id -or + $component.license.path -cne $expected[0].licensePath -or + $component.license.path -ceq $component.assetPath + ) { + throw "license.path must be inside the $($component.id) directory." + } + if ($seenLicenses.ContainsKey($component.license.path)) { + throw "Duplicate license path '$($component.license.path)'." + } + $seenLicenses[$component.license.path] = $true + + if ( + $component.sha256 -isnot [string] -or + $component.sha256 -cnotmatch '^[0-9a-f]{64}$' -or + $component.sha256 -cne $expected[0].sha256 + ) { + throw "Invalid SHA-256 for $($component.id)." + } + $sizeIsInteger = $component.size -is [Int32] -or $component.size -is [Int64] + if ( + -not $sizeIsInteger -or + [Int64]$component.size -le 0 -or + [Int64]$component.size -ne $expected[0].size + ) { + throw "Invalid size for $($component.id)." + } + $size = [Int64]$component.size + $sourceUri = Assert-PlainHttpsUrl $component.sourceUrl 'sourceUrl' + if ($component.sourceUrl -cne $expected[0].sourceUrl) { + throw "sourceUrl does not match the pinned baseline for $($component.id)." + } + $assetName = $component.assetPath.Split('/')[-1] + if ([Uri]::UnescapeDataString($sourceUri.Segments[-1].Trim('/')) -cne $assetName) { + throw "sourceUrl filename does not match assetPath for $($component.id)." + } + Assert-OfficialSource $component $sourceUri $assetName + Assert-TrustPolicy $component $sourceUri $assetName + if ($component.updateTrustPolicy.type -cne $expected[0].policyType) { + throw "Trust policy type does not match the pinned baseline for $($component.id)." + } + Assert-PinnedTrustPolicy $component + + $assetFullPath = [IO.Path]::GetFullPath((Join-Path $resolvedRoot $component.assetPath.Replace('/', '\'))) + $licenseFullPath = [IO.Path]::GetFullPath((Join-Path $resolvedRoot $component.license.path.Replace('/', '\'))) + if (-not (Test-Path -LiteralPath $assetFullPath -PathType Leaf)) { + throw "Asset is missing for $($component.id)." + } + if (-not (Test-Path -LiteralPath $licenseFullPath -PathType Leaf)) { + throw "License is missing or empty for $($component.id)." + } + $licenseItem = Get-Item -LiteralPath $licenseFullPath + $licenseHash = (Get-FileHash -LiteralPath $licenseFullPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($licenseItem.Length -ne $expected[0].licenseSize -or $licenseHash -cne $expected[0].licenseSha256) { + throw "License hash or size mismatch for $($component.id)." + } + Assert-LocalLicenseIdentity $component.id $licenseFullPath + $asset = Get-Item -LiteralPath $assetFullPath + if ($asset.Length -ne $size) { + throw "Asset size mismatch for $($component.id)." + } + $actualHash = (Get-FileHash -LiteralPath $assetFullPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -cne $component.sha256) { + throw "Asset SHA-256 mismatch for $($component.id)." + } + Assert-LocalPackageIdentity $component.id $assetFullPath + $expectedFiles[$component.assetPath] = $true + $expectedFiles[$component.license.path] = $true + } + + $actualFiles = @{} + foreach ($file in Get-ChildItem -LiteralPath $resolvedRoot -Recurse -File -Force) { + $relative = Get-RelativeBundlePath $resolvedRoot $file.FullName + Assert-SafeRelativePath $relative 'bundle entry' + $actualFiles[$relative] = $true + } + $missing = @($expectedFiles.Keys | Where-Object { -not $actualFiles.ContainsKey($_) }) + $extra = @($actualFiles.Keys | Where-Object { -not $expectedFiles.ContainsKey($_) }) + if ($missing.Count -gt 0 -or $extra.Count -gt 0) { + throw "Bundle file set mismatch (missing: $($missing.Count), extra: $($extra.Count))." + } + return $catalog +} + +function Assert-ValidatedParent([string]$ParentPath) { + if (-not (Test-Path -LiteralPath $ParentPath -PathType Container)) { + throw "Output parent directory does not exist: $ParentPath" + } + $current = Get-Item -LiteralPath $ParentPath -Force + while ($null -ne $current) { + if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Output parent contains a reparse point: $($current.FullName)" + } + $current = $current.Parent + } +} + +function Get-RetryDelaySeconds([object]$Response, [int]$TransientFailures) { + $delaySeconds = [Math]::Pow(2, $TransientFailures - 1) + $retryAfter = $Response.Headers.RetryAfter + if ($null -ne $retryAfter) { + if ($null -ne $retryAfter.Delta) { + $delaySeconds = $retryAfter.Delta.TotalSeconds + } elseif ($null -ne $retryAfter.Date) { + $delaySeconds = ($retryAfter.Date.UtcDateTime - [DateTime]::UtcNow).TotalSeconds + } + } + return [Math]::Min(30, [Math]::Max(0, [Math]::Ceiling($delaySeconds))) +} + +function Invoke-JsonApi([string]$Uri) { + $parsed = Assert-PlainHttpsUrl $Uri 'API URL' + if ($parsed.Host -ne 'api.github.com') { + throw 'Only the official GitHub API is allowed.' + } + + Add-Type -AssemblyName System.Net.Http + $handler = [Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $false + $handler.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate + $client = [Net.Http.HttpClient]::new($handler) + $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan + $deadline = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(60)) + [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('proxywarden-component-bundle-updater') + [void]$client.DefaultRequestHeaders.Accept.ParseAdd('application/vnd.github+json') + [void]$client.DefaultRequestHeaders.Add('X-GitHub-Api-Version', '2022-11-28') + $response = $null + try { + try { + $transientFailures = 0 + while ($true) { + $response = $client.GetAsync( + $parsed, + [Net.Http.HttpCompletionOption]::ResponseHeadersRead, + $deadline.Token + ).GetAwaiter().GetResult() + $statusCode = [int]$response.StatusCode + if ($statusCode -in @(301, 302, 303, 307, 308)) { + throw 'GitHub API redirect was rejected for api.github.com.' + } + if ($statusCode -eq 408 -or $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -le 599)) { + $transientFailures++ + if ($transientFailures -ge 3) { + throw "GitHub API failed with transient HTTP status $statusCode after three attempts at api.github.com." + } + $delaySeconds = Get-RetryDelaySeconds $response $transientFailures + $response.Dispose() + $response = $null + [void]([Threading.Tasks.Task]::Delay([TimeSpan]::FromSeconds($delaySeconds), $deadline.Token).GetAwaiter().GetResult()) + continue + } + if ($statusCode -lt 200 -or $statusCode -gt 299) { + throw "GitHub API failed with HTTP status $statusCode at api.github.com." + } + $contentLength = $response.Content.Headers.ContentLength + if ($null -ne $contentLength -and [Int64]$contentLength -gt 1048576) { + throw 'GitHub API response exceeded 1 MiB at api.github.com.' + } + $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $output = [IO.MemoryStream]::new() + try { + $buffer = [byte[]]::new(32768) + $total = 0L + while (($count = $input.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token).GetAwaiter().GetResult()) -gt 0) { + $total += $count + if ($total -gt 1048576) { + throw 'GitHub API response exceeded 1 MiB at api.github.com.' + } + $output.Write($buffer, 0, $count) + } + $body = [Text.UTF8Encoding]::new($false, $true).GetString($output.ToArray()).TrimStart([char]0xfeff) + } finally { + $output.Dispose() + $input.Dispose() + } + try { + return $body | ConvertFrom-Json + } catch { + throw 'GitHub API returned invalid JSON from api.github.com.' + } + } + } catch { + if ($_.Exception.Message.StartsWith('GitHub API ', [StringComparison]::Ordinal)) { + throw + } + throw 'GitHub API request failed for api.github.com.' + } + } finally { + if ($null -ne $response) { $response.Dispose() } + $deadline.Dispose() + $client.Dispose() + $handler.Dispose() + } +} + +function Get-PinnedRelease([string]$Repository, [string]$Tag) { + $release = Invoke-JsonApi "https://api.github.com/repos/$Repository/releases/tags/$Tag" + if ($release.tag_name -cne $Tag -or [bool]$release.draft -or [bool]$release.prerelease) { + throw "GitHub release $Repository/$Tag is not the expected stable release." + } + return $release +} + +function Get-ReleaseEvidence([string]$Repository, [string]$Tag, [bool]$UseFrozen) { + if (-not $UseFrozen) { + return Get-PinnedRelease $Repository $Tag + } + + $asset = switch ("$Repository@$Tag") { + 'wiresock/proxifyre@v2.4.0' { + $releaseId = 356296939L + $publishedAt = '2026-07-19T08:51:37Z' + $releaseHtmlUrl = 'https://github.com/wiresock/proxifyre/releases/tag/v2.4.0' + [PSCustomObject]@{ + id = 482601136L + url = 'https://api.github.com/repos/wiresock/proxifyre/releases/assets/482601136' + name = 'ProxiFyre-v2.4.0-x64-signed.zip' + browser_download_url = 'https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip' + size = 1519694L + digest = 'sha256:eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac' + } + } + 'wiresock/ndisapi@v3.6.2' { + $releaseId = 256618257L + $publishedAt = '2025-10-23T09:12:20Z' + $releaseHtmlUrl = 'https://github.com/wiresock/ndisapi/releases/tag/v3.6.2' + [PSCustomObject]@{ + id = 307688568L + url = 'https://api.github.com/repos/wiresock/ndisapi/releases/assets/307688568' + name = 'Windows.Packet.Filter.3.6.2.1.x64.msi' + browser_download_url = 'https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi' + size = 819200L + digest = 'sha256:9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c' + } + } + 'SagerNet/sing-box@v1.13.19' { + $releaseId = 371636056L + $publishedAt = '2026-08-17T09:47:06Z' + $releaseHtmlUrl = 'https://github.com/SagerNet/sing-box/releases/tag/v1.13.19' + [PSCustomObject]@{ + id = 517910532L + url = 'https://api.github.com/repos/SagerNet/sing-box/releases/assets/517910532' + name = 'sing-box-1.13.19-windows-amd64.zip' + browser_download_url = 'https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip' + size = 21046252L + digest = 'sha256:e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d' + } + } + 'winsw/winsw@v2.12.0' { + $releaseId = 90528888L + $publishedAt = '2023-01-28T16:22:38Z' + $releaseHtmlUrl = 'https://github.com/winsw/winsw/releases/tag/v2.12.0' + [PSCustomObject]@{ + id = 93386826L + url = 'https://api.github.com/repos/winsw/winsw/releases/assets/93386826' + name = 'WinSW.NET461.exe' + browser_download_url = 'https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe' + size = 655872L + digest = $null + } + } + default { throw 'Frozen release evidence does not cover the requested repository and tag.' } + } + return [PSCustomObject]@{ + id = $releaseId + url = "https://api.github.com/repos/$Repository/releases/$releaseId" + html_url = $releaseHtmlUrl + tag_name = $Tag + draft = $false + prerelease = $false + published_at = $publishedAt + assets = @($asset) + } +} + +function Get-ReleaseAsset([object]$Release, [string]$Name) { + $matches = @($Release.assets | Where-Object { $_.name -ceq $Name }) + if ($matches.Count -ne 1) { + throw "Expected exactly one release asset named $Name." + } + return $matches[0] +} + +function Test-AllowedRedirect([Uri]$InitialUri, [Uri]$NextUri) { + if ( + $NextUri.Scheme -ne 'https' -or + -not [string]::IsNullOrEmpty($NextUri.UserInfo) -or + -not $NextUri.IsDefaultPort -or + -not [string]::IsNullOrEmpty($NextUri.Fragment) + ) { + return $false + } + $initialHost = $InitialUri.Host.ToLowerInvariant() + $nextHost = $NextUri.Host.ToLowerInvariant() + switch ($initialHost) { + 'github.com' { return @('github.com', 'release-assets.githubusercontent.com') -contains $nextHost } + 'release-assets.githubusercontent.com' { return $nextHost -eq 'release-assets.githubusercontent.com' } + 'raw.githubusercontent.com' { return $nextHost -eq 'raw.githubusercontent.com' } + 'aka.ms' { return @('aka.ms', 'download.visualstudio.microsoft.com') -contains $nextHost } + 'download.visualstudio.microsoft.com' { return $nextHost -eq 'download.visualstudio.microsoft.com' } + 'visualstudio.microsoft.com' { return $nextHost -eq 'visualstudio.microsoft.com' } + default { return $false } + } +} + +function Save-Download([string]$Uri, [string]$Path, [Int64]$MaxBytes) { + $initialUri = Assert-PlainHttpsUrl $Uri 'Download URL' + if ($MaxBytes -le 0) { + throw 'Download size limit must be positive.' + } + $parent = Split-Path -Parent $Path + [void](New-Item -ItemType Directory -Path $parent -Force) + $partial = "$Path.part" + Add-Type -AssemblyName System.Net.Http + $handler = [Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $false + $handler.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate + $client = [Net.Http.HttpClient]::new($handler) + $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan + $deadline = [Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(240)) + [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('proxywarden-component-bundle-updater') + [void]$client.DefaultRequestHeaders.Accept.ParseAdd('application/octet-stream,*/*') + $currentUri = $initialUri + $response = $null + try { + $redirectCount = 0 + $transientFailures = 0 + while ($true) { + $response = $client.GetAsync( + $currentUri, + [Net.Http.HttpCompletionOption]::ResponseHeadersRead, + $deadline.Token + ).GetAwaiter().GetResult() + $statusCode = [int]$response.StatusCode + if ($statusCode -in @(301, 302, 303, 307, 308)) { + if ($redirectCount -ge 5 -or $null -eq $response.Headers.Location) { + throw "Download exceeded the redirect limit: $Uri" + } + $nextUri = if ($response.Headers.Location.IsAbsoluteUri) { + $response.Headers.Location + } else { + [Uri]::new($currentUri, $response.Headers.Location) + } + if (-not (Test-AllowedRedirect $currentUri $nextUri)) { + throw "Download redirect target is not allowed: $($nextUri.Host)" + } + $response.Dispose() + $response = $null + $currentUri = $nextUri + $redirectCount++ + continue + } + if ($statusCode -eq 408 -or $statusCode -eq 429 -or ($statusCode -ge 500 -and $statusCode -le 599)) { + $transientFailures++ + if ($transientFailures -ge 3) { + throw "Download failed with transient HTTP status $statusCode after three attempts." + } + $delaySeconds = Get-RetryDelaySeconds $response $transientFailures + $response.Dispose() + $response = $null + [void]([Threading.Tasks.Task]::Delay([TimeSpan]::FromSeconds($delaySeconds), $deadline.Token).GetAwaiter().GetResult()) + continue + } + [void]$response.EnsureSuccessStatusCode() + $contentLength = $response.Content.Headers.ContentLength + if ($null -ne $contentLength -and [Int64]$contentLength -gt $MaxBytes) { + throw "Download exceeds the size limit: $Uri" + } + $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $output = [IO.File]::Open($partial, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $buffer = [byte[]]::new(65536) + $total = 0L + while (($count = $input.ReadAsync($buffer, 0, $buffer.Length, $deadline.Token).GetAwaiter().GetResult()) -gt 0) { + $total += $count + if ($total -gt $MaxBytes) { + throw "Download exceeds the size limit: $Uri" + } + $output.Write($buffer, 0, $count) + if ($script:InjectDownloadFailure) { + $script:InjectDownloadFailure = $false + throw 'Simulated bundle download failure after a partial write.' + } + } + } finally { + $output.Dispose() + $input.Dispose() + } + break + } + if (-not (Test-Path -LiteralPath $partial -PathType Leaf) -or (Get-Item -LiteralPath $partial).Length -le 0) { + throw "Downloaded file is empty: $Uri" + } + Move-Item -LiteralPath $partial -Destination $Path + } finally { + if ($null -ne $response) { $response.Dispose() } + $deadline.Dispose() + $client.Dispose() + $handler.Dispose() + Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue + } +} + +function Save-GitHubDigestAsset( + [object]$Release, + [string]$Name, + [string]$ExpectedUrl, + [string]$Destination, + [string]$FrozenHash, + [Int64]$FrozenSize +) { + $asset = Get-ReleaseAsset $Release $Name + if ($asset.browser_download_url -cne $ExpectedUrl) { + throw "Official asset URL changed for $Name." + } + $digestProperty = $asset.PSObject.Properties['digest'] + if ($null -eq $digestProperty -or $digestProperty.Value -cnotmatch '^sha256:([0-9a-f]{64})$') { + throw "GitHub did not provide an independent SHA-256 digest for $Name." + } + $expectedHash = $Matches[1] + if ($expectedHash -cne $FrozenHash -or [Int64]$asset.size -ne $FrozenSize) { + throw "GitHub release identity does not match the frozen baseline for $Name." + } + Save-Download $ExpectedUrl $Destination $FrozenSize + $item = Get-Item -LiteralPath $Destination + if ($item.Length -ne [Int64]$asset.size) { + throw "GitHub asset size mismatch for $Name." + } + $actualHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -cne $expectedHash) { + throw "GitHub digest mismatch for $Name." + } + return [PSCustomObject]@{ hash = $actualHash; size = $item.Length; url = $ExpectedUrl } +} + +function Save-PinnedAsset( + [string]$Uri, + [string]$Destination, + [string]$ExpectedHash, + [Int64]$ExpectedSize +) { + Save-Download $Uri $Destination $ExpectedSize + $item = Get-Item -LiteralPath $Destination + $actualHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($item.Length -ne $ExpectedSize -or $actualHash -cne $ExpectedHash) { + throw "Pinned asset identity mismatch for $($item.Name)." + } + return [PSCustomObject]@{ hash = $actualHash; size = $item.Length; url = $Uri } +} + +function Assert-AuthenticodePublisher([string]$Path, [string]$Publisher) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status -ne [Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate) { + throw "Authenticode signature is not valid for $(Split-Path -Leaf $Path)." + } + $subject = $signature.SignerCertificate.Subject + $escapedPublisher = [Regex]::Escape($Publisher) + if ( + -not [Regex]::IsMatch($subject, "(?:^|,\s*)CN=$escapedPublisher(?:,|$)", [Text.RegularExpressions.RegexOptions]::IgnoreCase) -or + -not [Regex]::IsMatch($subject, "(?:^|,\s*)O=$escapedPublisher(?:,|$)", [Text.RegularExpressions.RegexOptions]::IgnoreCase) + ) { + throw "Authenticode publisher mismatch for $(Split-Path -Leaf $Path)." + } +} + +function Assert-Unsigned([string]$Path) { + $signature = Get-AuthenticodeSignature -LiteralPath $Path + if ($signature.Status.ToString() -ne 'NotSigned') { + throw "Expected an unsigned pinned asset: $(Split-Path -Leaf $Path)." + } +} + +function Assert-ManagedAnyCpu([string]$Path) { + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -lt 256 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw 'WinSW is not a valid PE file.' + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ( + $peOffset -lt 0 -or $peOffset + 256 -gt $bytes.Length -or + [BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550 -or + [BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x014c + ) { + throw 'WinSW has an invalid PE header.' + } + $sectionCount = [BitConverter]::ToUInt16($bytes, $peOffset + 6) + $optionalSize = [BitConverter]::ToUInt16($bytes, $peOffset + 20) + $optionalOffset = $peOffset + 24 + if ([BitConverter]::ToUInt16($bytes, $optionalOffset) -ne 0x010b) { + throw 'WinSW must use the audited PE32 AnyCPU layout.' + } + $clrDirectoryOffset = $optionalOffset + 96 + (14 * 8) + if ($clrDirectoryOffset + 8 -gt $optionalOffset + $optionalSize) { + throw 'WinSW PE header has no CLR directory.' + } + $clrRva = [BitConverter]::ToUInt32($bytes, $clrDirectoryOffset) + if ($clrRva -eq 0) { + throw 'WinSW is not a managed assembly.' + } + $sectionOffset = $optionalOffset + $optionalSize + $clrFileOffset = $null + for ($index = 0; $index -lt $sectionCount; $index++) { + $offset = $sectionOffset + ($index * 40) + if ($offset + 40 -gt $bytes.Length) { throw 'WinSW PE section table is truncated.' } + $virtualSize = [BitConverter]::ToUInt32($bytes, $offset + 8) + $virtualAddress = [BitConverter]::ToUInt32($bytes, $offset + 12) + $rawSize = [BitConverter]::ToUInt32($bytes, $offset + 16) + $rawOffset = [BitConverter]::ToUInt32($bytes, $offset + 20) + $mappedSize = [Math]::Max([UInt64]$virtualSize, [UInt64]$rawSize) + if ([UInt64]$clrRva -ge [UInt64]$virtualAddress -and [UInt64]$clrRva -lt ([UInt64]$virtualAddress + $mappedSize)) { + $clrFileOffset = [Int64]$rawOffset + ([Int64]$clrRva - [Int64]$virtualAddress) + break + } + } + if ($null -eq $clrFileOffset -or $clrFileOffset + 20 -gt $bytes.Length) { + throw 'WinSW CLR header is outside the PE sections.' + } + $flags = [BitConverter]::ToUInt32($bytes, [int]$clrFileOffset + 16) + $ilOnly = ($flags -band 0x00000001) -ne 0 + $requires32Bit = ($flags -band 0x00000002) -ne 0 + $prefers32Bit = ($flags -band 0x00020000) -ne 0 + if (-not $ilOnly -or $requires32Bit -or $prefers32Bit) { + throw 'WinSW must be ILOnly AnyCPU without 32-bit preference flags.' + } + $metadataText = [Text.Encoding]::UTF8.GetString($bytes) + if ($metadataText.IndexOf('.NETFramework,Version=v4.6.1', [StringComparison]::Ordinal) -lt 0) { + throw 'WinSW must target the audited .NET Framework 4.6.1 runtime.' + } +} + +function Assert-PeBytesMachineX64([byte[]]$Bytes, [string]$Label) { + $bytes = $Bytes + if ($bytes.Length -lt 128 -or $bytes[0] -ne 0x4d -or $bytes[1] -ne 0x5a) { + throw "File is not a valid PE image: $Label" + } + $peOffset = [BitConverter]::ToInt32($bytes, 0x3c) + if ( + $peOffset -lt 0 -or $peOffset + 26 -gt $bytes.Length -or + [BitConverter]::ToUInt32($bytes, $peOffset) -ne 0x00004550 -or + [BitConverter]::ToUInt16($bytes, $peOffset + 4) -ne 0x8664 + ) { + throw "PE image is not x64: $Label" + } +} + +function Assert-PeMachineX64([string]$Path) { + Assert-PeBytesMachineX64 ([IO.File]::ReadAllBytes($Path)) (Split-Path -Leaf $Path) +} + +function Get-ZipEntryBytes([string]$Path, [string]$LeafName) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $matches = @($archive.Entries | Where-Object { $_.Name -ceq $LeafName }) + if ($matches.Count -ne 1) { + throw "Archive must contain exactly one $LeafName." + } + $input = $matches[0].Open() + $output = [IO.MemoryStream]::new() + try { + $input.CopyTo($output) + return ,$output.ToArray() + } finally { + $output.Dispose() + $input.Dispose() + } + } finally { + $archive.Dispose() + } +} + +function Get-ZipFullEntryBytes([string]$Path, [string]$FullName, [Int64]$MaxBytes) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $matches = @($archive.Entries | Where-Object { $_.FullName.Replace('\', '/') -ceq $FullName }) + if ($matches.Count -ne 1 -or $matches[0].Length -le 0 -or $matches[0].Length -gt $MaxBytes) { + throw "Document archive entry is missing or too large: $FullName" + } + $input = $matches[0].Open() + $output = [IO.MemoryStream]::new() + try { + $input.CopyTo($output) + return ,$output.ToArray() + } finally { + $output.Dispose() + $input.Dispose() + } + } finally { + $archive.Dispose() + } +} + +function Invoke-PinnedGit( + [string[]]$Arguments, + [string]$EmptyConfigPath, + [string]$Operation +) { + $git = Get-Command git.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 + $environmentValues = @{ + GIT_CONFIG_GLOBAL = $EmptyConfigPath + GIT_CONFIG_SYSTEM = $EmptyConfigPath + GIT_CONFIG_NOSYSTEM = '1' + GIT_CONFIG_COUNT = '0' + GIT_TERMINAL_PROMPT = '0' + GCM_INTERACTIVE = 'Never' + GIT_LFS_SKIP_SMUDGE = '1' + GIT_PROTOCOL_FROM_USER = '0' + } + $previous = @{} + foreach ($name in $environmentValues.Keys) { + $previous[$name] = [PSCustomObject]@{ + exists = Test-Path -LiteralPath "Env:$name" + value = [Environment]::GetEnvironmentVariable($name, 'Process') + } + [Environment]::SetEnvironmentVariable($name, $environmentValues[$name], 'Process') + } + $previousPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $git.Source @Arguments 2>&1) + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + throw "Pinned Git license acquisition failed during $Operation." + } + return @($output | ForEach-Object { $_.ToString() }) + } finally { + $ErrorActionPreference = $previousPreference + foreach ($name in $environmentValues.Keys) { + if ($previous[$name].exists) { + [Environment]::SetEnvironmentVariable($name, $previous[$name].value, 'Process') + } else { + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + } + } + } +} + +function Save-LicenseFromPinnedGit( + [string]$RepositoryUrl, + [string]$RepositoryKey, + [string]$Tag, + [string]$TagObject, + [string]$Commit, + [string]$LicenseName, + [string]$Destination, + [string]$WorkRoot +) { + $identity = "$RepositoryUrl|$RepositoryKey|$Tag|$TagObject|$Commit|$LicenseName" + $allowed = @( + 'https://github.com/wiresock/proxifyre.git|proxifyre|v2.4.0|dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed|dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed|LICENSE', + 'https://github.com/wiresock/ndisapi.git|ndisapi|v3.6.2|417b8734e844083a10236387fba705d94a2d6bc9|417b8734e844083a10236387fba705d94a2d6bc9|LICENSE', + 'https://github.com/SagerNet/sing-box.git|sing-box|v1.13.19|b5ebaa1fc0f2b94256180b95468e73ef53caa27d|b5ebaa1fc0f2b94256180b95468e73ef53caa27d|LICENSE', + 'https://github.com/winsw/winsw.git|winsw|v2.12.0|eef5bade59fca0254e387ac73ed7625ba6aa7147|eef5bade59fca0254e387ac73ed7625ba6aa7147|LICENSE.txt' + ) + if ($allowed -cnotcontains $identity) { + throw 'Pinned Git license identity is not allowlisted.' + } + Assert-NoReparseTree $WorkRoot + $emptyConfigPath = Join-Path $WorkRoot 'empty.gitconfig' + if (-not (Test-Path -LiteralPath $emptyConfigPath)) { + [IO.File]::WriteAllText($emptyConfigPath, '', [Text.UTF8Encoding]::new($false)) + } + $repoPath = Join-Path $WorkRoot "$RepositoryKey-repo" + $archivePath = Join-Path $WorkRoot "$RepositoryKey-license.zip" + if ((Test-Path -LiteralPath $repoPath) -or (Test-Path -LiteralPath $archivePath)) { + throw 'Pinned Git work path already exists.' + } + + $remoteTag = @(Invoke-PinnedGit @( + 'ls-remote', '--refs', '--', $RepositoryUrl, "refs/tags/$Tag" + ) $emptyConfigPath "verify remote $RepositoryKey tag") + $remoteTagLines = @($remoteTag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $expectedRemoteTag = "$TagObject`trefs/tags/$Tag" + if ($remoteTagLines.Count -ne 1 -or $remoteTagLines[0].TrimEnd() -cne $expectedRemoteTag) { + throw "Pinned Git remote tag object mismatch for $RepositoryKey." + } + + [void](Invoke-PinnedGit @( + '-c', 'init.templateDir=', 'init', '--quiet', $repoPath + ) $emptyConfigPath "initialize $RepositoryKey") + [void](Invoke-PinnedGit @( + '-C', $repoPath, '-c', 'core.hooksPath=NUL', 'remote', 'add', 'origin', $RepositoryUrl + ) $emptyConfigPath "configure $RepositoryKey origin") + [void](Invoke-PinnedGit @( + '-C', $repoPath, '-c', 'core.hooksPath=NUL', '-c', 'protocol.file.allow=never', + '-c', 'http.sslBackend=schannel', 'fetch', '--quiet', '--depth', '1', + '--no-tags', 'origin', "+refs/tags/$Tag`:refs/tags/$Tag" + ) $emptyConfigPath "fetch exact $RepositoryKey tag") + Assert-NoReparseTree $repoPath + $localTag = @(Invoke-PinnedGit @('-C', $repoPath, 'rev-parse', '--verify', "refs/tags/$Tag") $emptyConfigPath "verify fetched $RepositoryKey tag") + $localTagValue = (@($localTag | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join '').Trim() + if ($localTagValue -cne $TagObject) { + throw "Pinned Git fetched tag object mismatch for $RepositoryKey." + } + $head = @(Invoke-PinnedGit @('-C', $repoPath, 'rev-parse', '--verify', "refs/tags/$Tag`^{commit}") $emptyConfigPath "verify $RepositoryKey commit") + $headValue = (@($head | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join '').Trim() + if ($headValue -cne $Commit) { + throw "Pinned Git commit mismatch for $RepositoryKey." + } + [void](Invoke-PinnedGit @( + '-C', $repoPath, '-c', 'core.hooksPath=NUL', 'archive', '--format=zip', + "--output=$archivePath", $Commit, '--', $LicenseName + ) $emptyConfigPath "archive $RepositoryKey license") + + if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { + throw "Pinned Git license archive is missing for $RepositoryKey." + } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($archivePath) + try { + if ($archive.Entries.Count -ne 1) { + throw "Pinned Git archive must contain exactly one license for $RepositoryKey." + } + $entry = $archive.Entries[0] + $unixFileType = (($entry.ExternalAttributes -shr 16) -band 0xf000) + if ( + $entry.FullName -cne $LicenseName -or + [string]::IsNullOrEmpty($entry.Name) -or + $entry.Length -le 0 -or + $entry.Length -gt 1048576 -or + ($unixFileType -ne 0 -and $unixFileType -ne 0x8000) + ) { + throw "Pinned Git archive has an invalid license entry for $RepositoryKey." + } + $input = $entry.Open() + $output = [IO.File]::Open($Destination, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { + $buffer = [byte[]]::new(32768) + $total = 0L + while (($count = $input.Read($buffer, 0, $buffer.Length)) -gt 0) { + $total += $count + if ($total -gt 1048576) { throw 'Pinned Git license exceeds the size limit.' } + $output.Write($buffer, 0, $count) + } + } finally { + $output.Dispose() + $input.Dispose() + } + } finally { + $archive.Dispose() + } +} + +function Assert-LocalLicenseIdentity([string]$ComponentId, [string]$Path) { + if ($ComponentId -ceq 'vc-runtime') { + $stream = $null + $reader = $null + try { + $settings = [Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $stream = [IO.MemoryStream]::new((Get-ZipFullEntryBytes $Path 'word/document.xml' 2097152), $false) + $reader = [Xml.XmlReader]::Create($stream, $settings) + $document = [Xml.XmlDocument]::new() + $document.XmlResolver = $null + $document.Load($reader) + } catch { + throw "VC runtime license is not the expected official DOCX: $($_.Exception.Message)" + } finally { + if ($null -ne $reader) { $reader.Dispose() } + if ($null -ne $stream) { $stream.Dispose() } + } + if ($null -eq $document.DocumentElement) { + throw 'VC runtime license DOCX has no document element.' + } + $text = [string]$document.DocumentElement.InnerText + if ([string]::IsNullOrWhiteSpace($text)) { + throw 'VC runtime license DOCX has no readable text.' + } + foreach ($marker in @('Visual C++', 'Redistributable', 'Runtime')) { + if ($text.IndexOf($marker, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw "VC runtime license is missing the expected '$marker' marker." + } + } + return + } + + $content = Get-Content -Raw -LiteralPath $Path + $identityContent = [Regex]::Replace($content, '\s+', ' ') + switch -CaseSensitive ($ComponentId) { + 'proxifyre' { $markers = @('GNU AFFERO GENERAL PUBLIC LICENSE') } + 'windows-packet-filter' { $markers = @('MIT License') } + 'sing-box' { + $markers = @( + 'GNU GENERAL PUBLIC LICENSE', + 'In addition, no derivative work may use the name or imply association with this application without prior consent.' + ) + } + 'winsw' { $markers = @('MIT License') } + default { throw "Unknown license identity: $ComponentId" } + } + foreach ($marker in $markers) { + if ($identityContent.IndexOf($marker, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw "License identity mismatch for $ComponentId." + } + } +} + +function Get-MsiSummaryTemplate([string]$Path) { + $installer = $null + $database = $null + $summary = $null + try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($Path, 0) + $summary = $database.SummaryInformation(0) + return [string]$summary.Property(7) + } finally { + foreach ($value in @($summary, $database, $installer)) { + if ($null -ne $value -and [Runtime.InteropServices.Marshal]::IsComObject($value)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($value) + } + } + } +} + +function Assert-LocalPackageIdentity([string]$ComponentId, [string]$Path) { + switch ($ComponentId) { + 'proxifyre' { + Assert-ZipEntries $Path @('ProxiFyre.exe', 'socksify.dll') + Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'ProxiFyre.exe') 'ProxiFyre.exe' + Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'socksify.dll') 'socksify.dll' + } + 'windows-packet-filter' { + if ( + (Get-MsiProperty $Path 'ProductVersion') -cne '3.6.2.1' -or + (Get-MsiProperty $Path 'ProductName') -cne 'Windows Packet Filter x64' -or + (Get-MsiProperty $Path 'Manufacturer') -cne 'NT KERNEL' -or + (Get-MsiSummaryTemplate $Path) -cnotmatch '^x64;' + ) { throw 'Windows Packet Filter MSI local identity mismatch.' } + } + 'vc-runtime' { + Assert-FileVersion $Path '14.51.36247.0' '14.51.36247.0' 'Microsoft Visual C++ v14 Redistributable (x64) - 14.51.36247' + } + 'sing-box' { + Assert-ZipEntries $Path @('sing-box.exe') + Assert-PeBytesMachineX64 (Get-ZipEntryBytes $Path 'sing-box.exe') 'sing-box.exe' + } + 'winsw' { + Assert-ManagedAnyCpu $Path + Assert-FileVersion $Path '2.12.0.0' '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147' + } + default { throw "Unknown local package identity: $ComponentId" } + } +} + +function Assert-ZipEntries( + [string]$Path, + [string[]]$RequiredLeafNames, + [Int64]$MaxExpandedBytes = 536870912 +) { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($Path) + try { + $leafNames = @() + $seenNames = @{} + $expandedBytes = 0L + if ($archive.Entries.Count -gt 10000) { + throw 'Archive contains too many entries.' + } + foreach ($entry in $archive.Entries) { + $name = $entry.FullName.Replace('\', '/') + $trimmedName = $name.TrimEnd('/') + $segments = @($trimmedName.Split('/')) + if ( + [string]::IsNullOrEmpty($trimmedName) -or + $name.StartsWith('/') -or + $name.Contains(':') -or + @($segments | Where-Object { + $_.Length -eq 0 -or $_ -in @('.', '..') -or $_.Length -gt 128 -or + $_.EndsWith('.') -or $_.EndsWith(' ') -or (Test-WindowsReservedName $_) -or + $_ -notmatch '^[A-Za-z0-9._+ -]+$' + }).Count -gt 0 + ) { + throw "Archive contains an unsafe entry: $name" + } + $normalized = $trimmedName.ToLowerInvariant() + if ($seenNames.ContainsKey($normalized)) { + throw "Archive contains a duplicate entry: $name" + } + $seenNames[$normalized] = $true + if ($entry.Length -lt 0 -or $expandedBytes -gt ($MaxExpandedBytes - $entry.Length)) { + throw 'Archive exceeds the expanded size limit.' + } + $expandedBytes += $entry.Length + if (-not [string]::IsNullOrEmpty($entry.Name)) { + $leafNames += $entry.Name + } + } + foreach ($required in $RequiredLeafNames) { + if (@($leafNames | Where-Object { $_ -ceq $required }).Count -ne 1) { + throw "Archive must contain exactly one $required." + } + } + } finally { + $archive.Dispose() + } +} + +function Get-UniqueFile([string]$Root, [string]$Name) { + $matches = @(Get-ChildItem -LiteralPath $Root -Recurse -File | Where-Object { $_.Name -ceq $Name }) + if ($matches.Count -ne 1) { + throw "Expected exactly one $Name in the archive." + } + return $matches[0].FullName +} + +function Get-MsiProperty([string]$Path, [string]$Name) { + $installer = $null + $database = $null + $view = $null + $record = $null + try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $database = $installer.OpenDatabase($Path, 0) + $query = "SELECT ``Value`` FROM ``Property`` WHERE ``Property``='$Name'" + $view = $database.OpenView($query) + $view.Execute() + $record = $view.Fetch() + if ($null -eq $record) { + throw "MSI property is missing: $Name" + } + return [string]$record.StringData(1) + } finally { + foreach ($value in @($record, $view, $database, $installer)) { + if ($null -ne $value -and [Runtime.InteropServices.Marshal]::IsComObject($value)) { + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($value) + } + } + } +} + +function Assert-FileVersion( + [string]$Path, + [string]$FileVersion, + [string]$ProductVersion, + [string]$ProductName = '', + [string]$CompanyName = '' +) { + $info = [Diagnostics.FileVersionInfo]::GetVersionInfo($Path) + if ($info.FileVersion.Trim() -cne $FileVersion -or $info.ProductVersion.Trim() -cne $ProductVersion) { + throw "Version metadata mismatch for $(Split-Path -Leaf $Path)." + } + if (-not [string]::IsNullOrEmpty($ProductName) -and $info.ProductName.Trim() -cne $ProductName) { + throw "Product name mismatch for $(Split-Path -Leaf $Path)." + } + if (-not [string]::IsNullOrEmpty($CompanyName) -and $info.CompanyName.Trim() -cne $CompanyName) { + throw "Company name mismatch for $(Split-Path -Leaf $Path)." + } +} + +function Write-DeterministicJson([object]$Value, [string]$Path) { + $json = ($Value | ConvertTo-Json -Depth 20).Replace("`r`n", "`n") + "`n" + [IO.File]::WriteAllText($Path, $json, [Text.UTF8Encoding]::new($false)) +} + +function Test-DirectoryContentEqual([string]$First, [string]$Second) { + if (-not (Test-Path -LiteralPath $First -PathType Container) -or -not (Test-Path -LiteralPath $Second -PathType Container)) { + return $false + } + try { + [void](Test-ComponentBundle $First) + [void](Test-ComponentBundle $Second) + } catch { + return $false + } + $firstFiles = @{} + foreach ($file in Get-ChildItem -LiteralPath $First -Recurse -File) { + $relative = Get-RelativeBundlePath ([IO.Path]::GetFullPath($First)) $file.FullName + $firstFiles[$relative] = "{0}:{1}" -f $file.Length, (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + } + $secondFiles = @{} + foreach ($file in Get-ChildItem -LiteralPath $Second -Recurse -File) { + $relative = Get-RelativeBundlePath ([IO.Path]::GetFullPath($Second)) $file.FullName + $secondFiles[$relative] = "{0}:{1}" -f $file.Length, (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + } + if ($firstFiles.Count -ne $secondFiles.Count) { + return $false + } + foreach ($name in $firstFiles.Keys) { + if (-not $secondFiles.ContainsKey($name) -or $firstFiles[$name] -ne $secondFiles[$name]) { + return $false + } + } + return $true +} + +if ($PlanOnly -and $CheckOnly) { + throw '-PlanOnly and -CheckOnly are mutually exclusive.' +} +if ($CheckOnly -and $UseFrozenReleaseEvidence) { + throw '-UseFrozenReleaseEvidence is not applicable to local-only CheckOnly validation.' +} +if (($PlanOnly -or $CheckOnly) -and $SimulateFailure -ne 'None') { + throw '-SimulateFailure is only available for the update path.' +} + +$resolvedOutputDir = [IO.Path]::GetFullPath($OutputDir) +if ($resolvedOutputDir -eq [IO.Path]::GetPathRoot($resolvedOutputDir)) { + throw 'OutputDir must not be a filesystem root.' +} +$canonicalOutputDir = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\src-tauri\bundled\components')) +$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') +$outputLeaf = Split-Path -Leaf $resolvedOutputDir +$isCanonicalOutput = [string]::Equals($resolvedOutputDir, $canonicalOutputDir, [StringComparison]::OrdinalIgnoreCase) +$isTestOutput = ( + [string]::Equals((Split-Path -Parent $resolvedOutputDir).TrimEnd('\', '/'), $tempRoot, [StringComparison]::OrdinalIgnoreCase) -and + $outputLeaf -match '^proxywarden-component-bundle-test-[0-9a-f]{32}$' +) +if (-not $isCanonicalOutput -and -not $isTestOutput) { + throw 'OutputDir must be the canonical bundle or an isolated ProxyWarden test directory under the system temp root.' +} +if ($SimulateFailure -ne 'None' -and -not $isTestOutput) { + throw '-SimulateFailure is allowed only with an isolated test OutputDir.' +} +$releaseEvidenceLabel = if ($UseFrozenReleaseEvidence) { 'frozen-audited-2026-08-17' } else { 'live-official-api' } + +if ($PlanOnly) { + ConvertTo-ResultJson ([ordered]@{ + mode = 'plan' + changed = $false + network = $false + writes = $false + releaseEvidence = $releaseEvidenceLabel + schemaVersion = 1 + targetArch = 'x64' + outputDir = $resolvedOutputDir + components = @($ExpectedComponents | ForEach-Object { [ordered]@{ id = $_.id; version = $_.version } }) + }) + return +} + +if ($CheckOnly) { + $catalog = Test-ComponentBundle $resolvedOutputDir + ConvertTo-ResultJson ([ordered]@{ + mode = 'check' + changed = $false + network = $false + writes = $false + releaseEvidence = 'local-bundle-only' + valid = $true + schemaVersion = $catalog.schemaVersion + targetArch = $catalog.targetArch + outputDir = $resolvedOutputDir + componentCount = @($catalog.components).Count + }) + return +} + +$outputParent = Split-Path -Parent $resolvedOutputDir +Assert-ValidatedParent $outputParent +if (Test-Path -LiteralPath $resolvedOutputDir) { + if (-not (Test-Path -LiteralPath $resolvedOutputDir -PathType Container)) { + throw 'OutputDir exists but is not a directory.' + } + Assert-NoReparseTree $resolvedOutputDir +} + +$operationId = [Guid]::NewGuid().ToString('N') +$stagingDir = Join-Path $outputParent ".proxywarden-components-staging-$operationId" +$backupDir = "$resolvedOutputDir.previous" +$retiredBackupDir = Join-Path $outputParent ".proxywarden-components-previous-$operationId" +$lockPath = "$resolvedOutputDir.update.lock" +$lock = $null +$lockOwned = $false +$activeWasEmptyPlaceholder = $false +$backupWasEmptyPlaceholder = $false +$activeMoved = $false +$oldBackupMoved = $false +$stagingMoved = $false +$promoted = $false +$preserveRecoveryArtifacts = $false +$script:InjectDownloadFailure = ($SimulateFailure -eq 'Download') + +try { + $lock = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + $lockOwned = $true + if (Test-Path -LiteralPath $resolvedOutputDir) { + if (Test-SafeEmptyDirectory $resolvedOutputDir) { + $activeWasEmptyPlaceholder = $true + } else { + [void](Test-ComponentBundle $resolvedOutputDir) + } + } + if (Test-Path -LiteralPath $backupDir) { + if (-not (Test-Path -LiteralPath $backupDir -PathType Container)) { + throw 'The previous bundle backup is not a directory.' + } + if (Test-SafeEmptyDirectory $backupDir) { + $backupWasEmptyPlaceholder = $true + } else { + [void](Test-ComponentBundle $backupDir) + } + } + [void](New-Item -ItemType Directory -Path $stagingDir) + + $proxifyreName = 'ProxiFyre-v2.4.0-x64-signed.zip' + $proxifyreUrl = "https://github.com/wiresock/proxifyre/releases/download/v2.4.0/$proxifyreName" + $proxifyrePath = Join-Path $stagingDir "proxifyre\$proxifyreName" + $proxifyreRelease = Get-ReleaseEvidence 'wiresock/proxifyre' 'v2.4.0' ([bool]$UseFrozenReleaseEvidence) + $proxifyreAsset = Save-GitHubDigestAsset $proxifyreRelease $proxifyreName $proxifyreUrl $proxifyrePath 'eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac' 1519694 + + $packetFilterName = 'Windows.Packet.Filter.3.6.2.1.x64.msi' + $packetFilterUrl = "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/$packetFilterName" + $packetFilterPath = Join-Path $stagingDir "windows-packet-filter\$packetFilterName" + $packetFilterRelease = Get-ReleaseEvidence 'wiresock/ndisapi' 'v3.6.2' ([bool]$UseFrozenReleaseEvidence) + $packetFilterAsset = Save-GitHubDigestAsset $packetFilterRelease $packetFilterName $packetFilterUrl $packetFilterPath '9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c' 819200 + + $vcName = 'VC_redist.x64.exe' + $vcUrl = 'https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe' + $vcPath = Join-Path $stagingDir "vc-runtime\$vcName" + $vcAsset = Save-PinnedAsset $vcUrl $vcPath '843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c' 18731856 + + $singBoxName = 'sing-box-1.13.19-windows-amd64.zip' + $singBoxUrl = "https://github.com/SagerNet/sing-box/releases/download/v1.13.19/$singBoxName" + $singBoxPath = Join-Path $stagingDir "sing-box\$singBoxName" + $singBoxRelease = Get-ReleaseEvidence 'SagerNet/sing-box' 'v1.13.19' ([bool]$UseFrozenReleaseEvidence) + $singBoxAsset = Save-GitHubDigestAsset $singBoxRelease $singBoxName $singBoxUrl $singBoxPath 'e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d' 21046252 + + $winswName = 'WinSW.NET461.exe' + $winswUrl = "https://github.com/winsw/winsw/releases/download/v2.12.0/$winswName" + $winswPath = Join-Path $stagingDir "winsw\$winswName" + $winswRelease = Get-ReleaseEvidence 'winsw/winsw' 'v2.12.0' ([bool]$UseFrozenReleaseEvidence) + $winswReleaseAsset = Get-ReleaseAsset $winswRelease $winswName + $winswDigestProperty = $winswReleaseAsset.PSObject.Properties['digest'] + if ( + $winswReleaseAsset.browser_download_url -cne $winswUrl -or + [Int64]$winswReleaseAsset.size -ne 655872 -or + ($null -ne $winswDigestProperty -and -not [string]::IsNullOrWhiteSpace([string]$winswDigestProperty.Value)) + ) { + throw 'Official WinSW asset identity changed.' + } + $winswAsset = Save-PinnedAsset $winswUrl $winswPath 'b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f' 655872 + + $licenseSources = Join-Path $stagingDir '.license-sources' + [void](New-Item -ItemType Directory -Path $licenseSources) + try { + Save-LicenseFromPinnedGit 'https://github.com/wiresock/proxifyre.git' 'proxifyre' 'v2.4.0' 'dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed' 'dd1512840e1e3bc596b06b80eda4e2dcd6a9c9ed' 'LICENSE' (Join-Path $stagingDir 'proxifyre\LICENSE') $licenseSources + Save-LicenseFromPinnedGit 'https://github.com/wiresock/ndisapi.git' 'ndisapi' 'v3.6.2' '417b8734e844083a10236387fba705d94a2d6bc9' '417b8734e844083a10236387fba705d94a2d6bc9' 'LICENSE' (Join-Path $stagingDir 'windows-packet-filter\LICENSE') $licenseSources + Save-LicenseFromPinnedGit 'https://github.com/SagerNet/sing-box.git' 'sing-box' 'v1.13.19' 'b5ebaa1fc0f2b94256180b95468e73ef53caa27d' 'b5ebaa1fc0f2b94256180b95468e73ef53caa27d' 'LICENSE' (Join-Path $stagingDir 'sing-box\LICENSE') $licenseSources + Save-LicenseFromPinnedGit 'https://github.com/winsw/winsw.git' 'winsw' 'v2.12.0' 'eef5bade59fca0254e387ac73ed7625ba6aa7147' 'eef5bade59fca0254e387ac73ed7625ba6aa7147' 'LICENSE.txt' (Join-Path $stagingDir 'winsw\LICENSE.txt') $licenseSources + } finally { + Remove-SafeGeneratedDirectory $licenseSources $stagingDir '^\.license-sources$' + } + Save-Download 'https://visualstudio.microsoft.com/wp-content/uploads/2025/10/Visual-C-V14-License-Redistributable_and_Runtime_ENU.docx' (Join-Path $stagingDir 'vc-runtime\LICENSE.docx') 5242880 + + Assert-ZipEntries $proxifyrePath @('ProxiFyre.exe', 'socksify.dll') + Assert-ZipEntries $singBoxPath @('sing-box.exe') + $verificationRoot = Join-Path $stagingDir '.verification' + [void](New-Item -ItemType Directory -Path $verificationRoot) + try { + $proxifyreExtract = Join-Path $verificationRoot 'proxifyre' + Expand-Archive -LiteralPath $proxifyrePath -DestinationPath $proxifyreExtract + $proxifyreExe = Get-UniqueFile $proxifyreExtract 'ProxiFyre.exe' + $socksifyDll = Get-UniqueFile $proxifyreExtract 'socksify.dll' + Assert-AuthenticodePublisher $proxifyreExe 'The Anti-Cloud Corporation' + Assert-AuthenticodePublisher $socksifyDll 'The Anti-Cloud Corporation' + Assert-PeMachineX64 $proxifyreExe + Assert-PeMachineX64 $socksifyDll + Assert-FileVersion $proxifyreExe '2.4.0' '2.4.0' 'ProxiFyre' 'NT KERNEL' + + $singBoxExtract = Join-Path $verificationRoot 'sing-box' + Expand-Archive -LiteralPath $singBoxPath -DestinationPath $singBoxExtract + Assert-PeMachineX64 (Get-UniqueFile $singBoxExtract 'sing-box.exe') + } finally { + try { + Remove-SafeGeneratedDirectory $verificationRoot $stagingDir '^\.verification$' + } catch { + throw "Could not safely remove the package verification directory: $($_.Exception.Message)" + } + } + + Assert-AuthenticodePublisher $packetFilterPath 'The Anti-Cloud Corporation' + if ( + (Get-MsiProperty $packetFilterPath 'ProductVersion') -cne '3.6.2.1' -or + (Get-MsiProperty $packetFilterPath 'ProductName') -cne 'Windows Packet Filter x64' -or + (Get-MsiProperty $packetFilterPath 'Manufacturer') -cne 'NT KERNEL' + ) { + throw 'Windows Packet Filter MSI product identity mismatch.' + } + Assert-AuthenticodePublisher $vcPath 'Microsoft Corporation' + Assert-FileVersion $vcPath '14.51.36247.0' '14.51.36247.0' 'Microsoft Visual C++ v14 Redistributable (x64) - 14.51.36247' + Assert-Unsigned $winswPath + Assert-ManagedAnyCpu $winswPath + Assert-FileVersion $winswPath '2.12.0.0' '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147' + + Assert-LocalLicenseIdentity 'proxifyre' (Join-Path $stagingDir 'proxifyre\LICENSE') + Assert-LocalLicenseIdentity 'windows-packet-filter' (Join-Path $stagingDir 'windows-packet-filter\LICENSE') + Assert-LocalLicenseIdentity 'vc-runtime' (Join-Path $stagingDir 'vc-runtime\LICENSE.docx') + Assert-LocalLicenseIdentity 'sing-box' (Join-Path $stagingDir 'sing-box\LICENSE') + Assert-LocalLicenseIdentity 'winsw' (Join-Path $stagingDir 'winsw\LICENSE.txt') + + $catalog = [ordered]@{ + schemaVersion = 1 + targetArch = 'x64' + components = @( + [ordered]@{ + id = 'proxifyre'; version = '2.4.0'; fileVersion = '2.4.0'; productVersion = '2.4.0' + assetPath = "proxifyre/$proxifyreName"; assetArch = 'x64'; effectiveTarget = 'x64' + sha256 = $proxifyreAsset.hash; size = $proxifyreAsset.size; sourceUrl = $proxifyreUrl + license = [ordered]@{ id = 'AGPL-3.0-only'; path = 'proxifyre/LICENSE' } + installRole = 'proxifyre-runtime' + updateTrustPolicy = [ordered]@{ + type = 'githubReleaseDigest'; repository = 'wiresock/proxifyre'; tagPattern = 'v*' + assetPattern = 'ProxiFyre-v*-x64-signed.zip'; requireStable = $true + authenticodePublishers = @('The Anti-Cloud Corporation') + } + } + [ordered]@{ + id = 'windows-packet-filter'; version = '3.6.2'; fileVersion = '3.6.2.1'; productVersion = '3.6.2.1' + assetPath = "windows-packet-filter/$packetFilterName"; assetArch = 'x64'; effectiveTarget = 'x64' + sha256 = $packetFilterAsset.hash; size = $packetFilterAsset.size; sourceUrl = $packetFilterUrl + license = [ordered]@{ id = 'MIT'; path = 'windows-packet-filter/LICENSE' } + installRole = 'packet-filter-driver' + updateTrustPolicy = [ordered]@{ + type = 'githubReleaseDigest'; repository = 'wiresock/ndisapi'; tagPattern = 'v*' + assetPattern = 'Windows.Packet.Filter.*.x64.msi'; requireStable = $true + authenticodePublishers = @('The Anti-Cloud Corporation') + } + } + [ordered]@{ + id = 'vc-runtime'; version = '14.51.36247.0'; fileVersion = '14.51.36247.0'; productVersion = '14.51.36247.0' + assetPath = "vc-runtime/$vcName"; assetArch = 'x64'; effectiveTarget = 'x64' + sha256 = $vcAsset.hash; size = $vcAsset.size; sourceUrl = $vcUrl + license = [ordered]@{ id = 'LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026'; path = 'vc-runtime/LICENSE.docx' } + installRole = 'vc-runtime-prerequisite' + updateTrustPolicy = [ordered]@{ + type = 'buildTimeOnlyAuthenticode'; allowedSourceHosts = @('aka.ms') + assetPattern = 'VC_redist.x64.exe'; publishers = @('Microsoft Corporation') + } + } + [ordered]@{ + id = 'sing-box'; version = '1.13.19' + assetPath = "sing-box/$singBoxName"; assetArch = 'x64'; effectiveTarget = 'x64' + sha256 = $singBoxAsset.hash; size = $singBoxAsset.size; sourceUrl = $singBoxUrl + license = [ordered]@{ id = 'LicenseRef-Sing-Box-Project'; path = 'sing-box/LICENSE' } + installRole = 'sing-box-runtime' + updateTrustPolicy = [ordered]@{ + type = 'githubReleaseDigest'; repository = 'SagerNet/sing-box'; tagPattern = 'v*' + assetPattern = 'sing-box-*-windows-amd64.zip'; requireStable = $true + } + } + [ordered]@{ + id = 'winsw'; version = '2.12.0'; fileVersion = '2.12.0.0' + productVersion = '2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147' + assetPath = "winsw/$winswName"; assetArch = 'anycpu'; effectiveTarget = 'x64' + sha256 = $winswAsset.hash; size = $winswAsset.size; sourceUrl = $winswUrl + license = [ordered]@{ id = 'MIT'; path = 'winsw/LICENSE.txt' } + installRole = 'sing-box-service-wrapper' + updateTrustPolicy = [ordered]@{ + type = 'bundledOnlyNoIndependentProof' + reason = 'The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled.' + } + } + ) + } + Write-DeterministicJson $catalog (Join-Path $stagingDir 'catalog.json') + + if ($SimulateFailure -eq 'Validation') { + $corrupt = [IO.File]::Open($proxifyrePath, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + try { + $firstByte = $corrupt.ReadByte() + $corrupt.Position = 0 + $corrupt.WriteByte([byte]($firstByte -bxor 0xff)) + } finally { + $corrupt.Dispose() + } + } + [void](Test-ComponentBundle $stagingDir) + + if ($SimulateFailure -ne 'Promotion' -and (Test-DirectoryContentEqual $resolvedOutputDir $stagingDir)) { + if ($backupWasEmptyPlaceholder -and (Test-Path -LiteralPath $backupDir)) { + Remove-SafeEmptyDirectory $backupDir $backupDir + $backupWasEmptyPlaceholder = $false + } + ConvertTo-ResultJson ([ordered]@{ + mode = 'update'; changed = $false; schemaVersion = 1; targetArch = 'x64' + outputDir = $resolvedOutputDir; componentCount = 5; backupDir = $null + releaseEvidence = $releaseEvidenceLabel + }) + return + } + + if (Test-Path -LiteralPath $backupDir) { + if (-not (Test-Path -LiteralPath $backupDir -PathType Container)) { + throw 'The previous bundle backup is not a directory.' + } + if ($backupWasEmptyPlaceholder) { + if (-not (Test-SafeEmptyDirectory $backupDir)) { + throw 'The empty previous bundle placeholder changed during the update.' + } + } else { + [void](Test-ComponentBundle $backupDir) + } + [IO.Directory]::Move($backupDir, $retiredBackupDir) + $oldBackupMoved = $true + } + if (Test-Path -LiteralPath $resolvedOutputDir) { + if ($activeWasEmptyPlaceholder) { + if (-not (Test-SafeEmptyDirectory $resolvedOutputDir)) { + throw 'The empty active bundle placeholder changed during the update.' + } + } else { + [void](Test-ComponentBundle $resolvedOutputDir) + } + [IO.Directory]::Move($resolvedOutputDir, $backupDir) + $activeMoved = $true + } + [IO.Directory]::Move($stagingDir, $resolvedOutputDir) + $stagingMoved = $true + if ($SimulateFailure -eq 'Promotion') { + throw 'Simulated bundle promotion failure after activating the candidate.' + } + [void](Test-ComponentBundle $resolvedOutputDir) + if ($activeMoved -and $activeWasEmptyPlaceholder) { + Remove-SafeEmptyDirectory $backupDir $backupDir + $activeMoved = $false + $activeWasEmptyPlaceholder = $false + } + $promoted = $true + if ($oldBackupMoved -and (Test-Path -LiteralPath $retiredBackupDir)) { + try { + Remove-SafeGeneratedDirectory $retiredBackupDir $outputParent '^\.proxywarden-components-previous-[0-9a-f]{32}$' + } catch { + # The new active bundle and its immediate backup are already valid. Preserve an older + # recovery directory if safe cleanup cannot be proven. + } + if (-not (Test-Path -LiteralPath $retiredBackupDir)) { + $oldBackupMoved = $false + } + } + + ConvertTo-ResultJson ([ordered]@{ + mode = 'update'; changed = $true; schemaVersion = 1; targetArch = 'x64' + outputDir = $resolvedOutputDir; componentCount = 5 + backupDir = $(if ($activeMoved) { $backupDir } else { $null }) + releaseEvidence = $releaseEvidenceLabel + }) +} catch { + $updateError = $_ + try { + if (-not $promoted) { + if ($stagingMoved) { + if (-not (Test-Path -LiteralPath $resolvedOutputDir) -or (Test-Path -LiteralPath $stagingDir)) { + throw 'Cannot preserve the failed candidate before rollback.' + } + [IO.Directory]::Move($resolvedOutputDir, $stagingDir) + $stagingMoved = $false + } + if ($activeMoved) { + if ((Test-Path -LiteralPath $resolvedOutputDir) -or -not (Test-Path -LiteralPath $backupDir)) { + throw 'Cannot restore the previous active bundle.' + } + [IO.Directory]::Move($backupDir, $resolvedOutputDir) + $activeMoved = $false + } + if ($oldBackupMoved) { + if ((Test-Path -LiteralPath $backupDir) -or -not (Test-Path -LiteralPath $retiredBackupDir)) { + throw 'Cannot restore the older recovery bundle.' + } + [IO.Directory]::Move($retiredBackupDir, $backupDir) + $oldBackupMoved = $false + } + } + } catch { + $preserveRecoveryArtifacts = $true + throw [InvalidOperationException]::new( + "Component bundle update failed and rollback could not be completed. Recovery artifacts were preserved. $($_.Exception.Message)", + $_.Exception + ) + } + throw $updateError +} finally { + if ($lockOwned) { + if ($null -ne $lock) { + $lock.Dispose() + } + if (-not $preserveRecoveryArtifacts -and (Test-Path -LiteralPath $lockPath)) { + $lockItem = Get-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + if ($null -ne $lockItem -and ($lockItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + } + } + $lockOwned = $false + } + if (-not $preserveRecoveryArtifacts -and (Test-Path -LiteralPath $stagingDir)) { + try { + Remove-SafeGeneratedDirectory $stagingDir $outputParent '^\.proxywarden-components-staging-[0-9a-f]{32}$' + } catch { + # Refuse unsafe recursive cleanup and leave the generated directory for inspection. + } + } +} diff --git a/scripts/update-proxifyre-bundle.ps1 b/scripts/update-proxifyre-bundle.ps1 deleted file mode 100644 index c050d71..0000000 --- a/scripts/update-proxifyre-bundle.ps1 +++ /dev/null @@ -1,143 +0,0 @@ -param( - [string]$OutputDir = (Join-Path $PSScriptRoot '..\src-tauri\bundled\proxifyre'), - [ValidateSet('x64', 'x86', 'ARM64')] - [string[]]$Architectures = @('x64'), - [switch]$SkipVcRuntime -) - -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version Latest -$ProgressPreference = 'SilentlyContinue' - -function Invoke-JsonApi([string]$Uri) { - Invoke-RestMethod -Uri $Uri -Headers @{ - 'User-Agent' = 'proxywarden-bundle-updater' - 'Accept' = 'application/vnd.github+json' - } -TimeoutSec 60 -MaximumRedirection 10 -} - -function Invoke-FileDownload([string]$Uri, [string]$Path) { - $partialPath = "$Path.part" - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - - try { - Invoke-WebRequest -UseBasicParsing -Uri $Uri -OutFile $partialPath -Headers @{ - 'User-Agent' = 'proxywarden-bundle-updater' - 'Accept' = 'application/octet-stream,*/*' - } -TimeoutSec 240 -MaximumRedirection 10 - } catch { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - throw - } - - $item = Get-Item -LiteralPath $partialPath - if ($item.Length -le 0) { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - throw "Downloaded file is empty: $Uri" - } - - Move-Item -LiteralPath $partialPath -Destination $Path -Force -} - -function Select-ReleaseAsset($Release, [string]$Pattern, [string]$Label) { - $asset = $Release.assets | Where-Object { $_.name -match $Pattern } | Select-Object -First 1 - if ($null -eq $asset) { - throw "No asset found for $Label using pattern $Pattern" - } - - $asset -} - -function Save-Asset([string]$Id, [string]$Name, [string]$Url, [string]$ExpectedDigest = '') { - $path = Join-Path $OutputDir $Name - if (Test-Path -LiteralPath $path) { - $existing = Get-Item -LiteralPath $path - if ($existing.Length -gt 0) { - $existingHash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - $expectedHash = '' - if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') { - $expectedHash = $Matches[1].ToLowerInvariant() - } - - if ([string]::IsNullOrWhiteSpace($expectedHash) -or $existingHash -eq $expectedHash) { - Write-Host "Using existing $Name" - return [PSCustomObject]@{ - id = $Id - name = $Name - sha256 = $existingHash - size = $existing.Length - sourceUrl = $Url - } - } - } - } - - Write-Host "Downloading $Name" - Invoke-FileDownload $Url $path - - $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if (-not [string]::IsNullOrWhiteSpace($ExpectedDigest) -and $ExpectedDigest -match '^sha256:(.+)$') { - $expected = $Matches[1].ToLowerInvariant() - if ($hash -ne $expected) { - throw "SHA256 mismatch for $Name. Expected $expected, got $hash." - } - } - - [PSCustomObject]@{ - id = $Id - name = $Name - sha256 = $hash - size = (Get-Item -LiteralPath $path).Length - sourceUrl = $Url - } -} - -$resolvedOutputDir = [System.IO.Path]::GetFullPath($OutputDir) -New-Item -ItemType Directory -Force -Path $resolvedOutputDir | Out-Null -$OutputDir = $resolvedOutputDir - -$selectedArchitectures = $Architectures | - ForEach-Object { - if ($_ -eq 'ARM64') { 'ARM64' } elseif ($_ -eq 'x86') { 'x86' } else { 'x64' } - } | - Select-Object -Unique - -$proxifyreRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/proxifyre/releases/latest' -$ndisapiRelease = Invoke-JsonApi 'https://api.github.com/repos/wiresock/ndisapi/releases/latest' - -$files = New-Object System.Collections.Generic.List[object] - -foreach ($arch in $selectedArchitectures) { - $proxifyreAsset = Select-ReleaseAsset $proxifyreRelease "ProxiFyre-.*-$arch-signed\.zip$" "ProxiFyre $arch" - $files.Add((Save-Asset "proxifyre-$($arch.ToLowerInvariant())" $proxifyreAsset.name $proxifyreAsset.browser_download_url $proxifyreAsset.digest)) - - $ndisAsset = Select-ReleaseAsset $ndisapiRelease "Windows\.Packet\.Filter\..*\.$arch\.msi$" "Windows Packet Filter $arch" - $files.Add((Save-Asset "packet-filter-$($arch.ToLowerInvariant())" $ndisAsset.name $ndisAsset.browser_download_url $ndisAsset.digest)) -} - -if (-not $SkipVcRuntime) { - if ($selectedArchitectures | Where-Object { $_ -ne 'x86' }) { - $files.Add((Save-Asset 'vc-runtime-x64' 'vc_redist.x64.exe' 'https://aka.ms/vc14/vc_redist.x64.exe')) - } - if ($selectedArchitectures -contains 'x86') { - $files.Add((Save-Asset 'vc-runtime-x86' 'vc_redist.x86.exe' 'https://aka.ms/vc14/vc_redist.x86.exe')) - } -} - -$manifest = [PSCustomObject]@{ - generatedAt = (Get-Date).ToUniversalTime().ToString('o') - architectures = @($selectedArchitectures) - proxifyreRelease = $proxifyreRelease.tag_name - windowsPacketFilterRelease = $ndisapiRelease.tag_name - files = $files -} - -$manifestPath = Join-Path $OutputDir 'manifest.json' -$manifest | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $manifestPath -Encoding UTF8 - -$keepNames = @($files | ForEach-Object { $_.name }) + 'manifest.json' -Get-ChildItem -LiteralPath $OutputDir -File | - Where-Object { $keepNames -notcontains $_.Name } | - ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force } - -Write-Host "Bundle updated: $OutputDir" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9f65409..9b79576 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atk" version = "0.18.2" @@ -543,6 +552,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -783,6 +803,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2314,20 +2335,24 @@ dependencies = [ [[package]] name = "proxywarden" -version = "1.1.0" +version = "2.0.0" dependencies = [ "base64 0.22.1", "percent-encoding", + "quick-xml", "reqwest 0.12.28", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", "thiserror 2.0.18", "url", "uuid", + "windows-sys 0.61.2", "winreg", + "zip", ] [[package]] @@ -4847,6 +4872,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91872d5..d3b9c32 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxywarden" -version = "1.1.0" +version = "2.0.0" description = "Standalone Windows desktop proxy management app for ProxyWarden." authors = ["ProxyWarden"] edition = "2021" @@ -23,6 +23,26 @@ percent-encoding = "2" url = "2" uuid = { version = "1", features = ["v4"] } thiserror = "2" +sha2 = "0.10" +quick-xml = "0.39" +zip = { version = "4", default-features = false, features = ["deflate-flate2-zlib-rs"] } [target.'cfg(windows)'.dependencies] winreg = "0.55" +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Security_Cryptography", + "Win32_Security_Cryptography_Catalog", + "Win32_Security_Cryptography_Sip", + "Win32_Security_WinTrust", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Registry", + "Win32_System_Services", + "Win32_System_SystemInformation", + "Win32_System_Threading", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 b/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 deleted file mode 100644 index d277c01..0000000 --- a/src-tauri/bundled/cleanup/uninstall-managed-components.ps1 +++ /dev/null @@ -1,250 +0,0 @@ -param( - [string]$InstallRoot = "", - [switch]$ForceRemoveWindowsPacketFilter -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -function New-Result { - param( - [bool]$Success, - [string]$Message, - [hashtable]$Details = @{} - ) - - [ordered]@{ - success = $Success - message = $Message - details = $Details - } | ConvertTo-Json -Depth 8 -Compress -} - -function Get-FullPath([string]$Path) { - return [System.IO.Path]::GetFullPath($Path).TrimEnd("\") -} - -function Test-PathInside([string]$Path, [string]$Root) { - if ([string]::IsNullOrWhiteSpace($Path)) { return $false } - try { - $fullPath = Get-FullPath $Path - $fullRoot = Get-FullPath $Root - return $fullPath.StartsWith($fullRoot + "\", [StringComparison]::OrdinalIgnoreCase) - } catch { - return $false - } -} - -function Assert-SafeInstallRoot([string]$Root) { - if ([string]::IsNullOrWhiteSpace($Root)) { - throw "InstallRoot is empty." - } - - $full = Get-FullPath $Root - if ($full -match "^[A-Za-z]:\\?$") { - throw "Refusing to use drive root as InstallRoot: $full" - } - if ($full -match "\\Windows($|\\)" -or $full -match "\\ProgramData$" -or $full -match "\\Users$") { - throw "Refusing unsafe InstallRoot: $full" - } - - $knownAppFiles = @( - (Join-Path $full "proxywarden.exe"), - (Join-Path $full "uninstall.exe"), - (Join-Path $full "bundled\cleanup\uninstall-managed-components.ps1") - ) - foreach ($candidate in $knownAppFiles) { - if (Test-Path -LiteralPath $candidate) { return $full } - } - - throw "InstallRoot does not look like a ProxyWarden install directory: $full" -} - -function Resolve-SafeComponentDir([string]$Root, [string]$Leaf) { - $componentRoot = Join-Path $Root "components" - $path = Join-Path $componentRoot $Leaf - $full = Get-FullPath $path - $expectedParent = Get-FullPath $componentRoot - $actualLeaf = Split-Path -Leaf $full - - if ($actualLeaf -ne $Leaf) { - throw "Unexpected component directory leaf: $full" - } - if (-not $full.StartsWith($expectedParent + "\", [StringComparison]::OrdinalIgnoreCase)) { - throw "Component directory is outside ProxyWarden components root: $full" - } - - return $full -} - -function Read-ComponentMarker([string]$Dir) { - $markerPath = Join-Path $Dir "proxywarden-component.json" - if (-not (Test-Path -LiteralPath $markerPath)) { return $null } - try { - return Get-Content -LiteralPath $markerPath -Raw -Encoding UTF8 | ConvertFrom-Json - } catch { - return $null - } -} - -function Get-MarkerBool($Marker, [string]$Name) { - if ($null -eq $Marker) { return $false } - $property = $Marker.PSObject.Properties[$Name] - if ($null -eq $property) { return $false } - return [bool]$property.Value -} - -function Get-ServiceRecord([string]$Name) { - $escaped = $Name.Replace("'", "''") - return Get-CimInstance Win32_Service -Filter "Name='$escaped'" -ErrorAction SilentlyContinue -} - -function Get-ServiceImagePath($Record) { - if ($null -eq $Record -or [string]::IsNullOrWhiteSpace([string]$Record.PathName)) { - return $null - } - - $pathName = ([string]$Record.PathName).Trim() - if ($pathName -match '^"([^"]+)"') { return $Matches[1] } - if ($pathName -match '^(.+?\.exe)\b') { return $Matches[1].Trim() } - return $pathName -} - -function Stop-ServiceRecord($Record) { - if ($null -eq $Record) { return } - - $service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne "Stopped") { - Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue - $service = Get-Service -Name $Record.Name -ErrorAction SilentlyContinue - if ($null -ne $service) { - try { $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(12)) } catch {} - } - } - - $recordAfterStop = Get-ServiceRecord $Record.Name - if ($null -ne $recordAfterStop -and [int]$recordAfterStop.ProcessId -gt 0) { - taskkill.exe /PID ([int]$recordAfterStop.ProcessId) /F | Out-Null - Start-Sleep -Milliseconds 500 - } -} - -function Remove-ManagedService { - param( - [string[]]$Names, - [string]$InstallRoot, - [string]$UninstallExe = "" - ) - - $removed = @() - foreach ($name in $Names) { - $record = Get-ServiceRecord $name - if ($null -eq $record) { continue } - - $imagePath = Get-ServiceImagePath $record - if (-not [string]::IsNullOrWhiteSpace($imagePath) -and -not (Test-PathInside $imagePath $InstallRoot)) { - continue - } - - Stop-ServiceRecord $record - - if (-not [string]::IsNullOrWhiteSpace($UninstallExe) -and (Test-Path -LiteralPath $UninstallExe)) { - Push-Location (Split-Path -Parent $UninstallExe) - try { & $UninstallExe uninstall | Out-Null } finally { Pop-Location } - } - - $record = Get-ServiceRecord $name - if ($null -ne $record) { - sc.exe delete $name | Out-Null - } - $removed += $name - } - - return $removed -} - -function Remove-SafeDirectory([string]$Path, [string]$Root) { - if (-not (Test-Path -LiteralPath $Path)) { return $false } - if (-not (Test-PathInside $Path $Root)) { - throw "Refusing to remove directory outside InstallRoot: $Path" - } - Remove-Item -LiteralPath $Path -Recurse -Force - return $true -} - -function Remove-ManagedFirewallRules { - $removed = @() - foreach ($name in @("ProxyWarden.ProxiFyre.Inbound", "ProxyWarden.ProxiFyre.Outbound")) { - $rule = Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue - if ($null -eq $rule) { continue } - $rule | Remove-NetFirewallRule -ErrorAction Stop - $removed += $name - } - return $removed -} - -function Get-InstalledProgram([string]$Pattern) { - $paths = @( - "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*", - "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" - ) - return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match $Pattern } | - Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString -} - -function Resolve-MsiProductCode($Program, [string]$Label) { - if ($null -eq $Program) { return $null } - if ($Program.PSChildName -match "^\{[0-9A-Fa-f-]{36}\}$") { - return $Program.PSChildName - } - foreach ($candidate in @($Program.QuietUninstallString, $Program.UninstallString)) { - if ($candidate -match "\{[0-9A-Fa-f-]{36}\}") { - return $Matches[0] - } - } - throw "Could not resolve MSI product code for $Label." -} - -function Uninstall-MsiProgram($Program, [string]$Label) { - $productCode = Resolve-MsiProductCode $Program $Label - if ([string]::IsNullOrWhiteSpace($productCode)) { return $false } - - $logPath = Join-Path ([System.IO.Path]::GetTempPath()) "proxywarden-$Label-uninstall.log" - $process = Start-Process -FilePath "msiexec.exe" -ArgumentList @("/x", $productCode, "/qn", "/norestart", "/L*v", $logPath) -Wait -PassThru -WindowStyle Hidden - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { - throw "$Label uninstall exited with code $($process.ExitCode). MSI log: $logPath" - } - - return $true -} - -try { - $details = @{} - $root = Assert-SafeInstallRoot $InstallRoot - $details.installRoot = $root - - $proxifyreDir = Resolve-SafeComponentDir $root "ProxiFyre" - $singboxDir = Resolve-SafeComponentDir $root "sing-box" - $proxifyreMarker = Read-ComponentMarker $proxifyreDir - $removePacketFilter = [bool]$ForceRemoveWindowsPacketFilter -or (Get-MarkerBool $proxifyreMarker "packetFilterInstalledByProxyWarden") - - $details.removedProxiFyreServices = Remove-ManagedService -Names @("ProxiFyreService", "ProxiFyre") -InstallRoot $root -UninstallExe (Join-Path $proxifyreDir "ProxiFyre.exe") - $details.removedSingBoxServices = Remove-ManagedService -Names @("ProxyWardenSingBox") -InstallRoot $root -UninstallExe (Join-Path $singboxDir "ProxyWardenSingBox.exe") - $details.removedProxiFyreFirewallRules = Remove-ManagedFirewallRules - $details.removedProxiFyreDir = Remove-SafeDirectory $proxifyreDir $root - $details.removedSingBoxDir = Remove-SafeDirectory $singboxDir $root - - if ($removePacketFilter) { - $packetFilter = Get-InstalledProgram "Windows Packet Filter|WinpkFilter|NDISAPI" - $details.removedWindowsPacketFilter = Uninstall-MsiProgram $packetFilter "windows-packet-filter" - } else { - $details.removedWindowsPacketFilter = $false - } - - New-Result -Success $true -Message "ProxyWarden managed components cleanup completed." -Details $details - exit 0 -} catch { - New-Result -Success $false -Message $_.Exception.Message -Details @{} - exit 1 -} diff --git a/src-tauri/bundled/components/catalog.json b/src-tauri/bundled/components/catalog.json new file mode 100644 index 0000000..82c160f --- /dev/null +++ b/src-tauri/bundled/components/catalog.json @@ -0,0 +1,130 @@ +{ + "schemaVersion": 1, + "targetArch": "x64", + "components": [ + { + "id": "proxifyre", + "version": "2.4.0", + "fileVersion": "2.4.0", + "productVersion": "2.4.0", + "assetPath": "proxifyre/ProxiFyre-v2.4.0-x64-signed.zip", + "assetArch": "x64", + "effectiveTarget": "x64", + "sha256": "eab65fd7d8eeb716abedb5614618c641de3f9eb8326b99cee1da787141e30cac", + "size": 1519694, + "sourceUrl": "https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip", + "license": { + "id": "AGPL-3.0-only", + "path": "proxifyre/LICENSE" + }, + "installRole": "proxifyre-runtime", + "updateTrustPolicy": { + "type": "githubReleaseDigest", + "repository": "wiresock/proxifyre", + "tagPattern": "v*", + "assetPattern": "ProxiFyre-v*-x64-signed.zip", + "requireStable": true, + "authenticodePublishers": [ + "The Anti-Cloud Corporation" + ] + } + }, + { + "id": "windows-packet-filter", + "version": "3.6.2", + "fileVersion": "3.6.2.1", + "productVersion": "3.6.2.1", + "assetPath": "windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi", + "assetArch": "x64", + "effectiveTarget": "x64", + "sha256": "9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c", + "size": 819200, + "sourceUrl": "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi", + "license": { + "id": "MIT", + "path": "windows-packet-filter/LICENSE" + }, + "installRole": "packet-filter-driver", + "updateTrustPolicy": { + "type": "githubReleaseDigest", + "repository": "wiresock/ndisapi", + "tagPattern": "v*", + "assetPattern": "Windows.Packet.Filter.*.x64.msi", + "requireStable": true, + "authenticodePublishers": [ + "The Anti-Cloud Corporation" + ] + } + }, + { + "id": "vc-runtime", + "version": "14.51.36247.0", + "fileVersion": "14.51.36247.0", + "productVersion": "14.51.36247.0", + "assetPath": "vc-runtime/VC_redist.x64.exe", + "assetArch": "x64", + "effectiveTarget": "x64", + "sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", + "size": 18731856, + "sourceUrl": "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe", + "license": { + "id": "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026", + "path": "vc-runtime/LICENSE.docx" + }, + "installRole": "vc-runtime-prerequisite", + "updateTrustPolicy": { + "type": "buildTimeOnlyAuthenticode", + "allowedSourceHosts": [ + "aka.ms" + ], + "assetPattern": "VC_redist.x64.exe", + "publishers": [ + "Microsoft Corporation" + ] + } + }, + { + "id": "sing-box", + "version": "1.13.19", + "assetPath": "sing-box/sing-box-1.13.19-windows-amd64.zip", + "assetArch": "x64", + "effectiveTarget": "x64", + "sha256": "e011a4def2f5e2b143ed54adb2b1a20a6be407806ab4442f3667f1dd817a2c8d", + "size": 21046252, + "sourceUrl": "https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip", + "license": { + "id": "LicenseRef-Sing-Box-Project", + "path": "sing-box/LICENSE" + }, + "installRole": "sing-box-runtime", + "updateTrustPolicy": { + "type": "githubReleaseDigest", + "repository": "SagerNet/sing-box", + "tagPattern": "v*", + "assetPattern": "sing-box-*-windows-amd64.zip", + "requireStable": true + } + }, + { + "id": "winsw", + "version": "2.12.0", + "fileVersion": "2.12.0.0", + "productVersion": "2.12.0+eef5bade59fca0254e387ac73ed7625ba6aa7147", + "assetPath": "winsw/WinSW.NET461.exe", + "assetArch": "anycpu", + "effectiveTarget": "x64", + "sha256": "b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f", + "size": 655872, + "sourceUrl": "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe", + "license": { + "id": "MIT", + "path": "winsw/LICENSE.txt" + }, + "installRole": "sing-box-service-wrapper", + "updateTrustPolicy": { + "type": "bundledOnlyNoIndependentProof", + "reason": "The official v2.12.0 asset is unsigned and has no independent release digest; runtime network update is disabled." + } + } + ] +} diff --git a/src-tauri/bundled/components/proxifyre/LICENSE b/src-tauri/bundled/components/proxifyre/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/src-tauri/bundled/components/proxifyre/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip b/src-tauri/bundled/components/proxifyre/ProxiFyre-v2.4.0-x64-signed.zip similarity index 62% rename from src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip rename to src-tauri/bundled/components/proxifyre/ProxiFyre-v2.4.0-x64-signed.zip index 5abd3f4..3ff732a 100644 Binary files a/src-tauri/bundled/proxifyre/ProxiFyre-v2.2.1-x64-signed.zip and b/src-tauri/bundled/components/proxifyre/ProxiFyre-v2.4.0-x64-signed.zip differ diff --git a/src-tauri/bundled/components/sing-box/LICENSE b/src-tauri/bundled/components/sing-box/LICENSE new file mode 100644 index 0000000..175f350 --- /dev/null +++ b/src-tauri/bundled/components/sing-box/LICENSE @@ -0,0 +1,17 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . + +In addition, no derivative work may use the name or imply association +with this application without prior consent. diff --git a/src-tauri/bundled/components/sing-box/sing-box-1.13.19-windows-amd64.zip b/src-tauri/bundled/components/sing-box/sing-box-1.13.19-windows-amd64.zip new file mode 100644 index 0000000..d0c9e0b Binary files /dev/null and b/src-tauri/bundled/components/sing-box/sing-box-1.13.19-windows-amd64.zip differ diff --git a/src-tauri/bundled/components/vc-runtime/LICENSE.docx b/src-tauri/bundled/components/vc-runtime/LICENSE.docx new file mode 100644 index 0000000..c1d6c04 Binary files /dev/null and b/src-tauri/bundled/components/vc-runtime/LICENSE.docx differ diff --git a/src-tauri/bundled/proxifyre/vc_redist.x64.exe b/src-tauri/bundled/components/vc-runtime/VC_redist.x64.exe similarity index 100% rename from src-tauri/bundled/proxifyre/vc_redist.x64.exe rename to src-tauri/bundled/components/vc-runtime/VC_redist.x64.exe diff --git a/src-tauri/bundled/components/windows-packet-filter/LICENSE b/src-tauri/bundled/components/windows-packet-filter/LICENSE new file mode 100644 index 0000000..27c98ef --- /dev/null +++ b/src-tauri/bundled/components/windows-packet-filter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Vadim Smirnov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src-tauri/bundled/proxifyre/Windows.Packet.Filter.3.6.2.1.x64.msi b/src-tauri/bundled/components/windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi similarity index 100% rename from src-tauri/bundled/proxifyre/Windows.Packet.Filter.3.6.2.1.x64.msi rename to src-tauri/bundled/components/windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi diff --git a/src-tauri/bundled/components/winsw/LICENSE.txt b/src-tauri/bundled/components/winsw/LICENSE.txt new file mode 100644 index 0000000..59ea54d --- /dev/null +++ b/src-tauri/bundled/components/winsw/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees, Inc., Oleg Nenashev and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src-tauri/bundled/components/winsw/WinSW.NET461.exe b/src-tauri/bundled/components/winsw/WinSW.NET461.exe new file mode 100644 index 0000000..ece8691 Binary files /dev/null and b/src-tauri/bundled/components/winsw/WinSW.NET461.exe differ diff --git a/src-tauri/bundled/installer-hooks/installer-template.nsi b/src-tauri/bundled/installer-hooks/installer-template.nsi new file mode 100644 index 0000000..2f63f86 --- /dev/null +++ b/src-tauri/bundled/installer-hooks/installer-template.nsi @@ -0,0 +1,809 @@ +; Upstream: tauri-cli-v2.11.4 / tauri-bundler 2.9.4 +; Original SHA256: 20f4ecc730defb71f1342eaeaec4021df13be3d843abba0effe88ea5835fa079 +; ProxyWarden: upgrade in place; never run a previous uninstaller. +Unicode true +ManifestDPIAware true +; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) +; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see +; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 +; https://github.com/tauri-apps/tauri/pull/10106 +ManifestDPIAwareness PerMonitorV2 + +!if "{{compression}}" == "none" + SetCompress off +!else + ; Set the compression algorithm. We default to LZMA. + SetCompressor /SOLID "{{compression}}" +!endif + +; Keep above !include to stay ahead of any plugin command +; see https://github.com/tauri-apps/tauri/pull/15422#discussion_r3289239624 +{{#if signed_plugins_path}} +!addplugindir "{{signed_plugins_path}}" +{{/if}} + +!include MUI2.nsh +!include FileFunc.nsh +!include x64.nsh +!include WordFunc.nsh +!include "utils.nsh" +!include "FileAssociation.nsh" +!include "Win\COM.nsh" +!include "Win\Propkey.nsh" +!include "StrFunc.nsh" +${StrCase} +${StrLoc} + +{{#if installer_hooks}} +!include "{{installer_hooks}}" +{{/if}} + +!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" + +!define MANUFACTURER "{{manufacturer}}" +!define PRODUCTNAME "{{product_name}}" +!define VERSION "{{version}}" +!define VERSIONWITHBUILD "{{version_with_build}}" +!define HOMEPAGE "{{homepage}}" +!define INSTALLMODE "{{install_mode}}" +!define LICENSE "{{license}}" +!define INSTALLERICON "{{installer_icon}}" +!define SIDEBARIMAGE "{{sidebar_image}}" +!define HEADERIMAGE "{{header_image}}" +!define UNINSTALLERICON "{{uninstaller_icon}}" +!define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}" +!define MAINBINARYNAME "{{main_binary_name}}" +!define MAINBINARYSRCPATH "{{main_binary_path}}" +!define BUNDLEID "{{bundle_id}}" +!define COPYRIGHT "{{copyright}}" +!define OUTFILE "{{out_file}}" +!define ARCH "{{arch}}" +!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" +!define ALLOWDOWNGRADES "{{allow_downgrades}}" +!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" +!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" +!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" +!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" +!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" +!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" +!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" +!define MANUKEY "Software\${MANUFACTURER}" +!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" +!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" +!define ESTIMATEDSIZE "{{estimated_size}}" +!define STARTMENUFOLDER "{{start_menu_folder}}" + +Var PassiveMode +Var UpdateMode +Var NoShortcutMode +Var WixMode +Var OldMainBinaryName + +Name "${PRODUCTNAME}" +BrandingText "${COPYRIGHT}" +OutFile "${OUTFILE}" + +; We don't actually use this value as default install path, +; it's just for nsis to append the product name folder in the directory selector +; https://nsis.sourceforge.io/Reference/InstallDir +!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" +InstallDir "${PLACEHOLDER_INSTALL_DIR}" + +VIProductVersion "${VERSIONWITHBUILD}" +VIAddVersionKey "ProductName" "${PRODUCTNAME}" +VIAddVersionKey "FileDescription" "${PRODUCTNAME}" +VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" +VIAddVersionKey "FileVersion" "${VERSION}" +VIAddVersionKey "ProductVersion" "${VERSION}" + +# additional plugins +!addplugindir "${ADDITIONALPLUGINSPATH}" + +; Uninstaller signing command +!if "${UNINSTALLERSIGNCOMMAND}" != "" + !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' +!endif + +; Handle install mode, `perUser`, `perMachine` or `both` +!if "${INSTALLMODE}" == "perMachine" + RequestExecutionLevel admin +!endif + +!if "${INSTALLMODE}" == "currentUser" + RequestExecutionLevel user +!endif + +!if "${INSTALLMODE}" == "both" + !define MULTIUSER_MUI + !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" + !define MULTIUSER_INSTALLMODE_COMMANDLINE + !if "${ARCH}" == "x64" + !define MULTIUSER_USE_PROGRAMFILES64 + !else if "${ARCH}" == "arm64" + !define MULTIUSER_USE_PROGRAMFILES64 + !endif + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" + !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" + !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME + !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation + !define MULTIUSER_EXECUTIONLEVEL Highest + !include MultiUser.nsh +!endif + +; Installer icon +!if "${INSTALLERICON}" != "" + !define MUI_ICON "${INSTALLERICON}" +!endif + +; Installer sidebar image +!if "${SIDEBARIMAGE}" != "" + !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" +!endif + +; Enable header images for installer and uninstaller pages when either image is configured. +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!else if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE +!endif + +; Installer header image +!if "${HEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" +!endif + +; Uninstaller header image +!if "${UNINSTALLERHEADERIMAGE}" != "" + !define MUI_HEADERIMAGE_UNBITMAP "${UNINSTALLERHEADERIMAGE}" +!endif + +; Uninstaller icon +!if "${UNINSTALLERICON}" != "" + !define MUI_UNICON "${UNINSTALLERICON}" +!endif + +; Define registry key to store installer language +!define MUI_LANGDLL_REGISTRY_ROOT "HKCU" +!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" +!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" + +; Installer pages, must be ordered as they appear +; 1. Welcome Page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_WELCOME + +; 2. License Page (if defined) +!if "${LICENSE}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MUI_PAGE_LICENSE "${LICENSE}" +!endif + +; 3. Install mode (if it is set to `both`) +!if "${INSTALLMODE}" == "both" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !insertmacro MULTIUSER_PAGE_INSTALLMODE +!endif + +; 4. Custom page to ask user if he wants to reinstall/uninstall +; only if a previous installation was detected +; Reinstall page removed: previous uninstallers may delete managed data. + + +; 5. Choose install directory page +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_DIRECTORY + +; 6. Start menu shortcut page +Var AppStartMenuFolder +!if "${STARTMENUFOLDER}" != "" + !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive + !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" +!else + !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip +!endif +!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder + +; 7. Installation page +!insertmacro MUI_PAGE_INSTFILES + +; 8. Finish page +; +; Don't auto jump to finish page after installation page, +; because the installation page has useful info that can be used debug any issues with the installer. +!define MUI_FINISHPAGE_NOAUTOCLOSE +; Use show readme button in the finish page as a button create a desktop shortcut +!define MUI_FINISHPAGE_SHOWREADME +!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut +; Show run app after installation. +!define MUI_FINISHPAGE_RUN +!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary +!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive +!insertmacro MUI_PAGE_FINISH + +Function RunMainBinary + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" +FunctionEnd + +; Uninstaller Pages +; 1. Confirm uninstall page +Var DeleteAppDataCheckbox +Var DeleteAppDataCheckboxState +!define /ifndef WS_EX_LAYOUTRTL 0x00400000 +!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow +Function un.ConfirmShow ; Add add a `Delete app data` check box + ; $1 inner dialog HWND + ; $2 window DPI + ; $3 style + ; $4 x + ; $5 y + ; $6 width + ; $7 height + FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog + System::Call "user32::GetDpiForWindow(p r1) i .r2" + ${If} $(^RTL) = 1 + StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" + IntOp $4 50 * $2 + ${Else} + StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" + IntOp $4 0 * $2 + ${EndIf} + IntOp $5 100 * $2 + IntOp $6 400 * $2 + IntOp $7 25 * $2 + IntOp $4 $4 / 96 + IntOp $5 $5 / 96 + IntOp $6 $6 / 96 + IntOp $7 $7 / 96 + System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppData)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' + Pop $DeleteAppDataCheckbox + SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 + SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave +Function un.ConfirmLeave + SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState +FunctionEnd +!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive +!insertmacro MUI_UNPAGE_CONFIRM + +; 2. Uninstalling Page +!insertmacro MUI_UNPAGE_INSTFILES + +;Languages +{{#each languages}} +!insertmacro MUI_LANGUAGE "{{this}}" +{{/each}} +!insertmacro MUI_RESERVEFILE_LANGDLL +{{#each language_files}} + !include "{{this}}" +{{/each}} + + +; Read-only checks run from .onInit for interactive, passive, silent and /UPDATE. +!macro PWRejectMsi ROOT VIEW + SetRegView ${VIEW} + StrCpy $0 0 + ${Do} + EnumRegKey $1 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 + ${If} $1 == "" + ${Break} + ${EndIf} + IntOp $0 $0 + 1 + ReadRegStr $2 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" + ${If} $2 == "${PRODUCTNAME}" + ReadRegDWORD $3 ${ROOT} "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "WindowsInstaller" + ${If} $3 == 1 + IfSilent +2 + MessageBox MB_ICONSTOP "Обнаружена MSI-установка ProxyWarden. Автоматическое удаление старой версии заблокировано для сохранности компонентов и настроек. Требуется отдельный проверенный перенос MSI → NSIS." + SetErrorLevel 1603 + Quit + ${EndIf} + ${EndIf} + ${Loop} +!macroend + +Function .onInit + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/NS" $NoShortcutMode + ${IfNot} ${Errors} + StrCpy $NoShortcutMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} + + !if "${DISPLAYLANGUAGESELECTOR}" == "true" + !insertmacro MUI_LANGDLL_DISPLAY + !endif + + + !insertmacro PWRejectMsi HKLM 32 + !insertmacro PWRejectMsi HKCU 32 + ${If} ${RunningX64} + !insertmacro PWRejectMsi HKLM 64 + !insertmacro PWRejectMsi HKCU 64 + ${EndIf} + !insertmacro SetContext + StrCpy $WixMode 0 + ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" + ${If} $R0 != "" + nsis_tauri_utils::SemverCompare "${VERSION}" $R0 + Pop $R0 + ${If} $R0 = -1 + IfSilent +2 + MessageBox MB_ICONSTOP "Установлена более новая версия ProxyWarden. Понижение версии заблокировано." + SetErrorLevel 1603 + Quit + ${EndIf} + ${EndIf} + !insertmacro SetContext + + ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" + ; Set default install location + !if "${INSTALLMODE}" == "perMachine" + ${If} ${RunningX64} + !if "${ARCH}" == "x64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else if "${ARCH}" == "arm64" + StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" + !else + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + !endif + ${Else} + StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" + ${EndIf} + !else if "${INSTALLMODE}" == "currentUser" + StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" + !endif + + Call RestorePreviousInstallLocation + ${EndIf} + + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_INIT + !endif +FunctionEnd + + + + +Section WebView2 + ; Check if Webview2 is already installed and skip this section + ${If} ${RunningX64} + ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${Else} + ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + ${If} $4 == "" + ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" + ${EndIf} + + ${If} $4 == "" + ; Webview2 installation + ; + ; Skip if updating + ${If} $UpdateMode <> 1 + !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + DetailPrint "$(webview2Downloading)" + NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Pop $0 + ${If} $0 == "success" + DetailPrint "$(webview2DownloadSuccess)" + ${Else} + DetailPrint "$(webview2DownloadError)" + Abort "$(webview2AbortError)" + ${EndIf} + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" + Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" + File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" + Goto install_webview2 + !endif + + !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" + Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" + DetailPrint "$(installingWebview2)" + StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" + Goto install_webview2 + !endif + + Goto webview2_done + + install_webview2: + DetailPrint "$(installingWebview2)" + ; $6 holds the path to the webview2 installer + ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + DetailPrint "$(webview2InstallError)" + Abort "$(webview2AbortError)" + ${EndIf} + webview2_done: + ${EndIf} + ${Else} + !if "${MINIMUMWEBVIEW2VERSION}" != "" + ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 + ${If} $R0 = 1 + update_webview: + DetailPrint "$(installingWebview2)" + ${If} ${RunningX64} + ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" + ${Else} + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 == "" + ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" + ${EndIf} + ${If} $R1 != "" + ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md + ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" + ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 + ${If} $1 = 0 + DetailPrint "$(webview2InstallSuccess)" + ${Else} + MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview + Quit + ignore: + ${EndIf} + ${EndIf} + ${EndIf} + !endif + ${EndIf} +SectionEnd + +Section Install + SetOutPath $INSTDIR + + !ifmacrodef NSIS_HOOK_PREINSTALL + !insertmacro NSIS_HOOK_PREINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Copy main executable + File "${MAINBINARYSRCPATH}" + + ; Copy resources + {{#each resources_dirs}} + CreateDirectory "$INSTDIR\\{{this}}" + {{/each}} + {{#each resources}} + File /a "/oname={{this.[1]}}" "{{no-escape @key}}" + {{/each}} + + ; Copy external binaries + {{#each binaries}} + File /a "/oname={{this}}" "{{no-escape @key}}" + {{/each}} + + ; Create file associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" + {{/each}} + {{/each}} + + ; Register deep links + {{#each deep_link_protocols as |protocol| ~}} + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" + WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + {{/each}} + + ; Create uninstaller + WriteUninstaller "$INSTDIR\uninstall.exe" + + ; Save $INSTDIR in registry for future installations + WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR + + !if "${INSTALLMODE}" == "both" + ; Save install mode to be selected by default for the next installation such as updating + ; or when uninstalling + WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 + !endif + + ; Remove old main binary if it doesn't match new main binary name + ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" + ${If} $OldMainBinaryName != "" + ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" + Delete "$INSTDIR\$OldMainBinaryName" + ${EndIf} + + ; Save current MAINBINARYNAME for future updates + WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" + + ; Registry information for add/remove programs + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" + WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" + WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" + WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" + WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" + + ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 + IntOp $0 $0 + ${ESTIMATEDSIZE} + IntFmt $0 "0x%08X" $0 + WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" + + !if "${HOMEPAGE}" != "" + WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" + WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" + !endif + + ; Create start menu shortcut + !insertmacro MUI_STARTMENU_WRITE_BEGIN Application + Call CreateOrUpdateStartMenuShortcut + !insertmacro MUI_STARTMENU_WRITE_END + + ; Create desktop shortcut for silent and passive installers + ; because finish page will be skipped + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + Call CreateOrUpdateDesktopShortcut + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTINSTALL + !insertmacro NSIS_HOOK_POSTINSTALL + !endif + + ; Auto close this page for passive mode + ${If} $PassiveMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function .onInstSuccess + ; Check for `/R` flag only in silent and passive installers because + ; GUI installer has a toggle for the user to (re)start the app + ${If} $PassiveMode = 1 + ${OrIf} ${Silent} + ${GetOptions} $CMDLINE "/R" $R0 + ${IfNot} ${Errors} + ${GetOptions} $CMDLINE "/ARGS" $R0 + nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" + ${EndIf} + ${EndIf} +FunctionEnd + +Function un.onInit + !insertmacro SetContext + + !if "${INSTALLMODE}" == "both" + !insertmacro MULTIUSER_UNINIT + !endif + + !insertmacro MUI_UNGETLANGUAGE + + ${GetOptions} $CMDLINE "/P" $PassiveMode + ${IfNot} ${Errors} + StrCpy $PassiveMode 1 + ${EndIf} + + ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode + ${IfNot} ${Errors} + StrCpy $UpdateMode 1 + ${EndIf} +FunctionEnd + +Section Uninstall + + !ifmacrodef NSIS_HOOK_PREUNINSTALL + !insertmacro NSIS_HOOK_PREUNINSTALL + !endif + + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + + ; Delete the app directory and its content from disk + ; Copy main executable + Delete "$INSTDIR\${MAINBINARYNAME}.exe" + + ; Delete resources + {{#each resources}} + Delete "$INSTDIR\\{{this.[1]}}" + {{/each}} + + ; Delete external binaries + {{#each binaries}} + Delete "$INSTDIR\\{{this}}" + {{/each}} + + ; Delete app associations + {{#each file_associations as |association| ~}} + {{#each association.ext as |ext| ~}} + !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" + {{/each}} + {{/each}} + + ; Delete deep links + {{#each deep_link_protocols as |protocol| ~}} + ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" + ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" + DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" + ${EndIf} + {{/each}} + + + ; Delete uninstaller + Delete "$INSTDIR\uninstall.exe" + + {{#each resources_ancestors}} + RMDir /REBOOTOK "$INSTDIR\\{{this}}" + {{/each}} + RMDir "$INSTDIR" + + ; Remove shortcuts if not updating + ${If} $UpdateMode <> 1 + !insertmacro DeleteAppUserModelId + + ; Remove start menu shortcut + !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + RMDir "$SMPROGRAMS\$AppStartMenuFolder" + ${EndIf} + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" + Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" + ${EndIf} + + ; Remove desktop shortcuts + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Pop $0 + ${If} $0 = 1 + !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" + Delete "$DESKTOP\${PRODUCTNAME}.lnk" + ${EndIf} + ${EndIf} + + ; Remove registry information for add/remove programs + !if "${INSTALLMODE}" == "both" + DeleteRegKey SHCTX "${UNINSTKEY}" + !else if "${INSTALLMODE}" == "perMachine" + DeleteRegKey HKLM "${UNINSTKEY}" + !else + DeleteRegKey HKCU "${UNINSTKEY}" + !endif + + ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. + ; This ensures the program does not launch automatically after uninstallation if it exists. + ; If it doesn't exist, it does nothing. + ; We do this when not updating (to preserve the registry value on updates) + ${If} $UpdateMode <> 1 + DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${EndIf} + + ; Delete app data if the checkbox is selected + ; and if not updating + ${If} $DeleteAppDataCheckboxState = 1 + ${AndIf} $UpdateMode <> 1 + ; Clear the install location $INSTDIR from registry + DeleteRegKey SHCTX "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty SHCTX "${MANUKEY}" + + ; Clear the install language from registry + DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" + DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" + DeleteRegKey /ifempty HKCU "${MANUKEY}" + + SetShellVarContext current + RmDir /r "$APPDATA\${BUNDLEID}" + RmDir /r "$LOCALAPPDATA\${BUNDLEID}" + ${EndIf} + + !ifmacrodef NSIS_HOOK_POSTUNINSTALL + !insertmacro NSIS_HOOK_POSTUNINSTALL + !endif + + ; Auto close if passive mode or updating + ${If} $PassiveMode = 1 + ${OrIf} $UpdateMode = 1 + SetAutoClose true + ${EndIf} +SectionEnd + +Function RestorePreviousInstallLocation + ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" + StrCmp $4 "" +2 0 + StrCpy $INSTDIR $4 +FunctionEnd + +Function Skip + Abort +FunctionEnd + +Function SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd +Function un.SkipIfPassive + ${IfThen} $PassiveMode = 1 ${|} Abort ${|} +FunctionEnd + +Function CreateOrUpdateStartMenuShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + StrCpy $R0 0 + + !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + StrCpy $R0 1 + ${EndIf} + + ${If} $R0 = 1 + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + !if "${STARTMENUFOLDER}" != "" + CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" + CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" + !else + CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" + !endif +FunctionEnd + +Function CreateOrUpdateDesktopShortcut + ; We used to use product name as MAINBINARYNAME + ; migrate old shortcuts to target the new MAINBINARYNAME + !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" + Pop $0 + ${If} $0 = 1 + !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + Return + ${EndIf} + + ; Skip creating shortcut if in update mode or no shortcut mode + ; but always create if migrating from wix + ${If} $WixMode = 0 + ${If} $UpdateMode = 1 + ${OrIf} $NoShortcutMode = 1 + Return + ${EndIf} + ${EndIf} + + CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" + !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" +FunctionEnd diff --git a/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh b/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh index 15240a2..8832c2c 100644 --- a/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh +++ b/src-tauri/bundled/installer-hooks/proxywarden-hooks.nsh @@ -1,6 +1,35 @@ !macro NSIS_HOOK_PREUNINSTALL - DetailPrint "ProxyWarden: cleaning managed components" - nsExec::ExecToLog 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\bundled\cleanup\uninstall-managed-components.ps1" -InstallRoot "$INSTDIR"' - Pop $0 - DetailPrint "ProxyWarden cleanup exit code: $0" + ${If} $UpdateMode = 1 + DetailPrint "ProxyWarden: verifying managed component state before update" + ClearErrors + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --nsis-verify-upgrade' $0 + ${Else} + ; The generated Tauri guard normally runs after PREUNINSTALL. Repeat it + ; here so no service/filesystem mutation starts while the app is alive. + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + DetailPrint "ProxyWarden: uninstalling verified managed components" + ClearErrors + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --nsis-uninstall-managed' $0 + ${EndIf} + + IfErrors 0 +3 + DetailPrint "ProxyWarden native lifecycle helper could not be launched" + Abort "ProxyWarden could not start the native lifecycle verifier." + + ${If} $0 = 3010 + SetRebootFlag true + ; The helper keeps its exact durable reboot fact until this parent has + ; observed 3010. Delete only that fixed published marker, then fail closed + ; if acknowledgement cannot be persisted before uninstall continues. + ClearErrors + Delete "$INSTDIR\.proxywarden-nsis-reboot-required.json" + IfErrors 0 +3 + DetailPrint "ProxyWarden reboot acknowledgement could not be persisted" + Abort "ProxyWarden could not safely acknowledge the required reboot." + StrCpy $0 0 + ${EndIf} + ${If} $0 != 0 + DetailPrint "ProxyWarden native lifecycle check failed with exit code $0" + Abort "ProxyWarden could not safely verify or remove managed components." + ${EndIf} !macroend diff --git a/src-tauri/bundled/proxifyre/manifest.json b/src-tauri/bundled/proxifyre/manifest.json deleted file mode 100644 index d0fbe01..0000000 --- a/src-tauri/bundled/proxifyre/manifest.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "generatedAt": "2026-07-09T16:13:27.3087159Z", - "architectures": [ - "x64" - ], - "proxifyreRelease": "v2.2.1", - "windowsPacketFilterRelease": "v3.6.2", - "files": [ - { - "id": "proxifyre-x64", - "name": "ProxiFyre-v2.2.1-x64-signed.zip", - "sha256": "c38ca1caa68cd730712f5c0911e4240711bf9e7684988ae64ed04ec693cce899", - "size": 1372483, - "sourceUrl": "https://github.com/wiresock/proxifyre/releases/download/v2.2.1/ProxiFyre-v2.2.1-x64-signed.zip" - }, - { - "id": "packet-filter-x64", - "name": "Windows.Packet.Filter.3.6.2.1.x64.msi", - "sha256": "9c388c0b7f189f7fa98720bae2caecf7d64f30910838b80b438ecf8956b8502c", - "size": 819200, - "sourceUrl": "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi" - }, - { - "id": "vc-runtime-x64", - "name": "vc_redist.x64.exe", - "sha256": "843068991daaa1f73ad9f6239bce4d0f6a07a51f18c37ea2a867e9beca71295c", - "size": 18731856, - "sourceUrl": "https://aka.ms/vc14/vc_redist.x64.exe" - } - ] -} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 802d2a2..75dd438 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default capability for the main ProxyWarden Windows shell. Task 8 keeps helper/install launch explicit: no shell or sidecar permission is granted here until a packaged helper is declared.", + "description": "Default capability for the main ProxyWarden Windows shell. Privileged lifecycle work stays behind fixed native Rust modes; no shell or sidecar permission is granted.", "windows": ["main"], "permissions": ["core:default", "dialog:allow-open"] } diff --git a/src-tauri/src/adapters/proxifyre.rs b/src-tauri/src/adapters/proxifyre.rs index 70884d3..10b2eca 100644 --- a/src-tauri/src/adapters/proxifyre.rs +++ b/src-tauri/src/adapters/proxifyre.rs @@ -48,7 +48,11 @@ impl ProxiFyreAdapter { proxies.push(ProxiFyreProxy { app_names, - socks5_proxy_endpoint: format!("{}:{}", target.host, target.port), + socks5_proxy_endpoint: if target.host.contains(':') { + format!("[{}]:{}", target.host, target.port) + } else { + format!("{}:{}", target.host, target.port) + }, supported_protocols: protocols_for_profile(profile), }); } diff --git a/src-tauri/src/adapters/singbox.rs b/src-tauri/src/adapters/singbox.rs index 8f022ff..059acbc 100644 --- a/src-tauri/src/adapters/singbox.rs +++ b/src-tauri/src/adapters/singbox.rs @@ -1,8 +1,8 @@ use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer}; -use crate::process::command_no_window; +use crate::process::run_fixed_process; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::{env, fs, fs::OpenOptions, io::Write, path::Path}; +use std::{env, fs, path::Path, time::Duration}; pub const SINGBOX_ADAPTER_ID: &str = "singbox"; pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json"; @@ -41,31 +41,24 @@ impl SingBoxAdapter { where C: SingBoxConfigChecker + ?Sized, { - let selected_server = request - .config - .selected_server_id - .as_deref() - .and_then(|id| { - request - .subscription_cache - .servers - .iter() - .find(|server| server.id == id) - }) - .or_else(|| { - let tag = request.config.selected_server_tag.as_deref()?; - request - .subscription_cache - .servers - .iter() - .find(|server| server.tag == tag) - }) - .ok_or_else(|| { - SingBoxConfigError::new( - SingBoxConfigErrorKind::MissingSelectedServer, - "Сервер Local sing-box не выбран или отсутствует в текущей подписке", - ) - })?; + let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() { + request + .subscription_cache + .servers + .iter() + .find(|server| server.id == id) + } else { + let mut matches = request.subscription_cache.servers.iter().filter(|server| { + Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref() + }); + matches.next().filter(|_| matches.next().is_none()) + } + .ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::MissingSelectedServer, + "Сервер Local sing-box не выбран или отсутствует в текущей подписке", + ) + })?; let vpn_outbound = selected_outbound( &request.subscription_cache.config, selected_server, @@ -213,70 +206,50 @@ impl SingBoxConfigChecker for SingBoxCommandChecker { uuid::Uuid::new_v4().hyphenated() )); - { - let mut config_file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&config_path) - .map_err(|error| { - SingBoxConfigError::new( - SingBoxConfigErrorKind::CheckFailed, - format!( - "Не удалось создать временный конфиг sing-box '{}': {error}", - config_path.display() - ), - ) - })?; - let write_result = config_file.write_all(config_json.as_bytes()); - drop(config_file); - if let Err(error) = write_result { - let _ = fs::remove_file(&config_path); - return Err(SingBoxConfigError::new( - SingBoxConfigErrorKind::CheckFailed, - format!( - "Не удалось записать временный конфиг sing-box '{}': {error}", - config_path.display() - ), - )); + struct TemporaryConfig(std::path::PathBuf); + impl Drop for TemporaryConfig { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); } } - - let output = command_no_window(binary_path) - .arg("check") - .arg("-c") - .arg(&config_path) - .output() - .map_err(|error| { - let _ = fs::remove_file(&config_path); + let _temporary = TemporaryConfig(config_path.clone()); + crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err( + |_| { SingBoxConfigError::new( SingBoxConfigErrorKind::CheckFailed, - format!( - "Не удалось выполнить '{} check': {error}", - binary_path.display() - ), + "Не удалось безопасно создать временный конфиг sing-box", ) - })?; - let _ = fs::remove_file(&config_path); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let message = command_message(&stdout, &stderr); - - if !output.status.success() { - return Err(SingBoxConfigError::new( + }, + )?; + // Checker output can contain credentials from the outbound. The bounded + // native process runner discards both streams instead of exposing them. + let status = run_fixed_process( + binary_path, + &[ + "check".into(), + "-c".into(), + config_path.as_os_str().to_owned(), + ], + Duration::from_secs(30), + ) + .map_err(|error| { + SingBoxConfigError::new( SingBoxConfigErrorKind::CheckFailed, - format!("Проверка sing-box не прошла: {message}"), - )); + if error.kind() == std::io::ErrorKind::TimedOut { + "Проверка sing-box превысила 30 секунд" + } else { + "Не удалось выполнить проверку sing-box" + }, + ) + })?; + if !status.success() { + return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed, + "sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер.")); } - Ok(SingBoxCheckResult { checked: true, success: true, - message: if message.is_empty() { - "Проверка sing-box прошла успешно".to_string() - } else { - message - }, + message: "Проверка sing-box прошла успешно".to_string(), }) } } @@ -295,32 +268,39 @@ fn selected_outbound( "В cache подписки нет outbounds", ) })?; - let outbound = outbounds - .iter() - .find(|outbound| { - let tag_matches = outbound + let outbound = if selected_server.id.starts_with("pw-") { + outbounds.iter().find(|outbound| { + crate::subscription::outbound_server_id(outbound) == selected_server.id + }) + } else { + // Legacy endpoint IDs are readable only when they identify exactly one outbound. + let mut matches = outbounds.iter().filter(|outbound| { + outbound .get("tag") .and_then(Value::as_str) - .is_some_and(|tag| tag.trim() == selected_server.tag); - let server_matches = outbound - .get("server") - .and_then(Value::as_str) - .is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server)); - let port_matches = outbound - .get("server_port") - .and_then(Value::as_u64) - .is_some_and(|port| port == u64::from(selected_server.server_port)); - tag_matches && server_matches && port_matches - }) - .ok_or_else(|| { - SingBoxConfigError::new( - SingBoxConfigErrorKind::MissingSelectedOutbound, - format!( - "Outbound не найден: {} ({}:{})", - selected_server.tag, selected_server.server, selected_server.server_port - ), - ) - })?; + .is_some_and(|tag| { + crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag + }) + && outbound.get("type").and_then(Value::as_str) + == Some(selected_server.server_type.as_str()) + && outbound + .get("server") + .and_then(Value::as_str) + .is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server)) + && outbound.get("server_port").and_then(Value::as_u64) + == Some(u64::from(selected_server.server_port)) + }); + matches.next().filter(|_| matches.next().is_none()) + } + .ok_or_else(|| { + SingBoxConfigError::new( + SingBoxConfigErrorKind::MissingSelectedOutbound, + format!( + "Outbound не найден: {} ({}:{})", + selected_server.tag, selected_server.server, selected_server.server_port + ), + ) + })?; let outbound_type = outbound .get("type") .and_then(Value::as_str) @@ -359,15 +339,3 @@ fn selected_outbound( Ok(outbound) } - -fn command_message(stdout: &str, stderr: &str) -> String { - let stdout = stdout.trim(); - let stderr = stderr.trim(); - - match (stdout.is_empty(), stderr.is_empty()) { - (true, true) => String::new(), - (false, true) => stdout.to_string(), - (true, false) => stderr.to_string(), - (false, false) => format!("{stdout}\n{stderr}"), - } -} diff --git a/src-tauri/src/admin.rs b/src-tauri/src/admin.rs index 9587b7f..b8b521f 100644 --- a/src-tauri/src/admin.rs +++ b/src-tauri/src/admin.rs @@ -1,89 +1,23 @@ //! Administrator-state detection and explicit UAC restart boundary. -use crate::command_dto::{AdminStatusResponse, CommandError}; -use crate::powershell::{ - escape_single as escape_powershell_single, is_elevated as is_running_elevated, - output_message as powershell_output_message, run_command as run_powershell_command, -}; -use std::env; +use crate::command_dto::AdminStatusResponse; +use crate::process::is_process_elevated; pub fn admin_status() -> AdminStatusResponse { let is_windows = cfg!(windows); - let is_elevated = is_running_elevated(); + let is_elevated = is_process_elevated(); let message = if !is_windows { "Проверка прав администратора нужна только в Windows.".to_string() } else if is_elevated { "ProxyWarden уже запущен от имени администратора.".to_string() } else { - "Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string() + "Права администратора будут запрошены отдельно для выбранного действия.".to_string() }; AdminStatusResponse { is_windows, is_elevated, - can_restart_elevated: is_windows && !is_elevated, + can_restart_elevated: false, message, } } - -pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> { - if !cfg!(windows) { - return Err(CommandError::new( - "admin_restart_unsupported", - "Перезапуск от имени администратора доступен только в Windows.", - )); - } - - if is_running_elevated() { - return Ok(()); - } - - let exe_path = env::current_exe().map_err(|error| { - CommandError::new( - "admin_restart_failed", - format!("Не удалось определить путь текущего приложения: {error}"), - ) - })?; - let working_dir = env::current_dir().ok(); - let working_dir_arg = working_dir - .as_ref() - .map(|path| { - format!( - " -WorkingDirectory '{}'", - escape_powershell_single(&path.display().to_string()) - ) - }) - .unwrap_or_default(); - let script = format!( - r#" -$ErrorActionPreference = 'Stop' -try {{ - Start-Process -FilePath '{}' -Verb RunAs{} - exit 0 -}} catch {{ - Write-Error ($_ | Out-String) - exit 1 -}} -"#, - escape_powershell_single(&exe_path.display().to_string()), - working_dir_arg - ); - let output = run_powershell_command(&script).map_err(|error| { - CommandError::new( - "admin_restart_failed", - format!("Не удалось запросить права администратора: {error}"), - ) - })?; - - if output.status.success() { - return Ok(()); - } - - Err(CommandError::new( - "admin_restart_failed", - powershell_output_message( - &output, - "Перезапуск от имени администратора отменен или не был запущен.", - ), - )) -} diff --git a/src-tauri/src/apply_flow.rs b/src-tauri/src/apply_flow.rs index cc53260..91ad07c 100644 --- a/src-tauri/src/apply_flow.rs +++ b/src-tauri/src/apply_flow.rs @@ -22,7 +22,7 @@ use crate::safe_fs; use crate::storage::JsonStorage; use crate::validation::{normalize_profile, normalize_target, ValidationError}; use serde::{Deserialize, Serialize}; -use std::{fs, path::Path}; +use std::path::Path; use thiserror::Error; const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox"; @@ -37,10 +37,12 @@ pub enum ApplyRouteMode { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyConfigurationInput { + #[serde(default)] + pub expected_revision: Option, pub route_mode: ApplyRouteMode, pub profile: ProfileInput, pub external_target: Option, - #[serde(default = "default_true")] + #[serde(default)] pub disable_other_profiles: bool, } @@ -65,6 +67,7 @@ pub enum ApplyPhaseStatus { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyConfigurationResult { + pub saved_state: Option, pub success: bool, pub changed: bool, pub partial_state: bool, @@ -128,6 +131,19 @@ pub fn apply_configuration( input: ApplyConfigurationInput, services: ApplyServices<'_>, ) -> Result { + let read_guard = crate::configuration_transaction::read_guard(storage) + .map_err(|error| storage_error("configuration_locked", error))?; + if let Some(expected) = &input.expected_revision { + if crate::configuration_transaction::revision_locked(storage) + .map_err(|e| storage_error("configuration_read_failed", e))? + != *expected + { + return Err(ApplyFlowError::failure( + "configuration_changed", + "Настройки изменились. Обновите сохранённое состояние перед применением.", + )); + } + } let mut phases = Vec::new(); let old_profiles = storage .read_profiles() @@ -142,6 +158,18 @@ pub fn apply_configuration( proxy_config, singbox_config, } = prepare_apply(storage, input, &services)?; + let revision = crate::configuration_transaction::revision_locked(storage) + .map_err(|error| storage_error("configuration_read_failed", error))?; + drop(read_guard); + if let (Some(generated), Some(detected)) = (&singbox_config, &services.detected_singbox) { + services + .checker + .check_config(&detected.executable_path, &generated.contents) + .map_err(|error| ApplyFlowError::failure("singbox_preflight_failed", error.message))?; + } + let transaction = + crate::configuration_transaction::ConfigurationTransaction::begin(storage, Some(&revision)) + .map_err(|error| storage_error("configuration_changed", error))?; phases.push(phase( "preflight", ApplyPhaseStatus::Succeeded, @@ -156,132 +184,95 @@ pub fn apply_configuration( let singbox_path = singbox_config .as_ref() .map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE)); - let old_proxy_contents = fs::read(&proxy_path).ok(); - let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok()); - let rollback_state = RollbackState { - storage, - old_profiles: &old_profiles, - old_targets: &old_targets, - proxy_path: &proxy_path, - old_proxy_contents: old_proxy_contents.as_deref(), - singbox_path: singbox_path.as_deref(), - old_singbox_contents: old_singbox_contents.as_deref(), - }; - - if let Err(error) = storage.write_targets(&targets) { - let rollback = rollback_source(storage, &old_profiles, &old_targets); + let staged = (|| { + storage + .write_targets(&targets) + .map_err(|e| storage_error("targets_write_failed", e))?; + storage + .write_profiles(&profiles) + .map_err(|e| storage_error("profiles_write_failed", e))?; phases.push(phase( "source-state", - ApplyPhaseStatus::Failed, - "Не удалось сохранить targets.", - )); - phases.push(rollback_phase(&rollback)); - return Ok(failed_result( - "targets_write_failed", - format!("Не удалось сохранить цели: {error}"), - rollback.is_err(), - &proxy_path, - singbox_path.as_deref(), - phases, - )); - } - if let Err(error) = storage.write_profiles(&profiles) { - let rollback = rollback_source(storage, &old_profiles, &old_targets); - phases.push(phase( - "source-state", - ApplyPhaseStatus::Failed, - "Не удалось сохранить profiles.", - )); - phases.push(rollback_phase(&rollback)); - return Ok(failed_result( - "profiles_write_failed", - format!("Не удалось сохранить профили: {error}"), - rollback.is_err(), - &proxy_path, - singbox_path.as_deref(), - phases, - )); - } - phases.push(phase( - "source-state", - ApplyPhaseStatus::Succeeded, - "Profiles и targets сохранены.", - )); - - if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) { - if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) { - return Ok(rollback_after_failure( - &rollback_state, - "singbox_config_write_failed", - format!("Не удалось записать generated sing-box config: {error}"), - "singbox-config", - phases, - )); - } - phases.push(phase( - "singbox-config", ApplyPhaseStatus::Succeeded, - "Generated sing-box config записан; служба не перезапускалась.", + "Profiles и targets сохранены.", )); - } else { - phases.push(phase( - "singbox-config", - ApplyPhaseStatus::Skipped, - "External SOCKS5 не использует Local sing-box.", - )); - } - - if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) { - return Ok(rollback_after_failure( - &rollback_state, - "proxifyre_config_write_failed", - format!("Не удалось записать generated ProxiFyre config: {error}"), - "proxifyre-config", - phases, - )); - } - phases.push(phase( - "proxifyre-config", - ApplyPhaseStatus::Succeeded, - "Generated ProxiFyre config записан.", - )); - - let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest { - adapter_id: &proxy_config.adapter_id, - config_path: &proxy_path, - config_contents: &proxy_config.contents, - }) { - Ok(result) if result.success => result, - Ok(result) => { - return Ok(rollback_after_failure( - &rollback_state, + if let (Some(generated), Some(path)) = (&singbox_config, &singbox_path) { + safe_fs::write_restricted_with_backup(path, generated.contents.as_bytes()) + .map_err(|e| storage_error("singbox_config_write_failed", e))?; + } + safe_fs::write_restricted_with_backup(&proxy_path, proxy_config.contents.as_bytes()) + .map_err(|e| storage_error("proxifyre_config_write_failed", e))?; + let result = services + .helper + .apply_proxy_config(HelperApplyRequest { + adapter_id: &proxy_config.adapter_id, + config_path: &proxy_path, + config_contents: &proxy_config.contents, + }) + .map_err(|e| ApplyFlowError::failure(e.code, e.message))?; + if !result.success { + return Err(ApplyFlowError::failure( "proxifyre_apply_failed", result.message, - "runtime-apply", - phases, )); } + crate::route_state::record_prepared_locked( + storage, + crate::privileged_jobs::ManagedComponent::Proxifyre, + ) + .map_err(|e| storage_error("prepared_state_write_failed", e))?; + if singbox_config.is_some() { + crate::route_state::record_prepared_locked( + storage, + crate::privileged_jobs::ManagedComponent::SingBox, + ) + .map_err(|e| storage_error("prepared_state_write_failed", e))?; + } + Ok(result) + })(); + let (helper_result, committed_revision, artifacts) = match staged { + Ok(result) => { + let artifacts = crate::route_state::read_status_locked(storage) + .map_err(|e| storage_error("prepared_state_read_failed", e))?; + let revision = transaction + .commit_with_revision() + .map_err(|error| storage_error("configuration_commit_failed", error))?; + (result, revision, artifacts) + } Err(error) => { - return Ok(rollback_after_failure( - &rollback_state, - &error.code, - error.message, - "runtime-apply", + let rollback = transaction.abort(); + phases.push(phase( + "rollback", + if rollback.is_ok() { + ApplyPhaseStatus::RolledBack + } else { + ApplyPhaseStatus::Failed + }, + if rollback.is_ok() { + "Предыдущие настройки и конфиги восстановлены." + } else { + "Восстановление не завершено; новые операции заблокированы до recovery." + }, + )); + return Ok(failed_result( + if rollback.is_ok() { + error.code() + } else { + "configuration_recovery_required" + }, + error.to_string(), + rollback.is_err(), + &proxy_path, + singbox_path.as_deref(), phases, )); } }; - phases.push(phase( - "runtime-apply", - ApplyPhaseStatus::Succeeded, - "ProxiFyre config применён без управления службой.", - )); phases.push(phase( "service-control", ApplyPhaseStatus::Skipped, - "Apply не запускает, не останавливает и не перезапускает службы.", + "Apply не управляет службами.", )); - let mut restart_required = Vec::new(); if services.detected_proxyfier.is_some() { restart_required.push(ComponentId::Proxyfier); @@ -320,6 +311,19 @@ pub fn apply_configuration( } Ok(ApplyConfigurationResult { + saved_state: Some(crate::command_dto::SavedStateResponse { + artifacts, + revision: committed_revision, + profiles: profiles + .iter() + .map(crate::command_dto::ProfileDto::from) + .collect(), + targets: targets + .iter() + .map(crate::command_dto::TargetDto::from) + .collect(), + generated_config_path: proxy_path.display().to_string(), + }), success: true, changed: source_changed || helper_result.changed, partial_state: false, @@ -351,62 +355,72 @@ fn prepare_apply( )); } let mut profile_input = input.profile; + let mut profiles = storage + .read_profiles() + .map_err(|error| storage_error("profiles_read_failed", error))?; let mut targets = storage .read_targets() .map_err(|error| storage_error("targets_read_failed", error))?; - let singbox_config = match input.route_mode { - ApplyRouteMode::External => { - let target_input = input.external_target.ok_or_else(|| { - ApplyFlowError::failure( - "external_target_missing", - "Для external маршрута требуется SOCKS5 target.", - ) - })?; - let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?; - profile_input.target_id = target.id.clone(); - upsert_target(&mut targets, target); - None - } - ApplyRouteMode::LocalSingbox => { - let config = storage - .read_local_singbox_config() - .map_err(|error| storage_error("singbox_config_read_failed", error))?; - let cache = storage - .read_singbox_subscription_cache() - .map_err(|error| storage_error("singbox_cache_read_failed", error))? - .ok_or_else(|| { + let clearing_profile = !profile_input.enabled && profile_input.items.is_empty(); + let singbox_config = if clearing_profile { + None + } else { + match input.route_mode { + ApplyRouteMode::External => { + let target_input = input.external_target.ok_or_else(|| { ApplyFlowError::failure( - "singbox_subscription_cache_missing", - "Сначала загрузите подписку Local sing-box.", + "external_target_missing", + "Для external маршрута требуется SOCKS5 target.", ) })?; - profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string(); - upsert_target(&mut targets, local_singbox_target(&config)); - Some( - services - .singbox_adapter - .generate_config( - SingBoxGenerationRequest::new( - &config, - &cache, - services - .detected_singbox - .as_ref() - .map(|detected| detected.executable_path.as_path()), - ), - services.checker, - ) - .map_err(|error| { - ApplyFlowError::failure("singbox_preflight_failed", error.message) - })?, - ) + let mut target = + normalize_target(target_input).map_err(ApplyFlowError::validation)?; + let shared = profiles.iter().any(|existing| { + Some(existing.id.as_str()) != profile_input.id.as_deref() + && existing.target_id == target.id + }); + if shared + && targets + .iter() + .any(|existing| existing.id == target.id && existing != &target) + { + target.id = format!("target-{}", uuid::Uuid::new_v4()); + } + profile_input.target_id = target.id.clone(); + upsert_target(&mut targets, target); + None + } + ApplyRouteMode::LocalSingbox => { + let config = storage + .read_local_singbox_config() + .map_err(|error| storage_error("singbox_config_read_failed", error))?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(|error| storage_error("singbox_cache_read_failed", error))? + .ok_or_else(|| { + ApplyFlowError::failure( + "singbox_subscription_cache_missing", + "Сначала загрузите подписку Local sing-box.", + ) + })?; + profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string(); + upsert_target(&mut targets, local_singbox_target(&config)); + Some( + services + .singbox_adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + services.checker, + ) + .map_err(|error| { + ApplyFlowError::failure("singbox_preflight_failed", error.message) + })?, + ) + } } }; let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?; - let mut profiles = storage - .read_profiles() - .map_err(|error| storage_error("profiles_read_failed", error))?; if input.disable_other_profiles { for existing in &mut profiles { if existing.id != profile.id { @@ -415,6 +429,14 @@ fn prepare_apply( } } upsert_profile(&mut profiles, profile); + if !profiles.iter().any(|profile| profile.enabled) + && proxyfier_component_from_detection(services.detected_proxyfier.as_ref()).running + { + return Err(ApplyFlowError::failure( + "stop_before_clearing_route", + "Сначала явно остановите ProxiFyre, затем примените удаление последних правил.", + )); + } let components = vec![ proxyfier_component_from_detection(services.detected_proxyfier.as_ref()), @@ -462,96 +484,6 @@ fn upsert_target(targets: &mut Vec, target: Target) { } } -struct RollbackState<'a> { - storage: &'a JsonStorage, - old_profiles: &'a [Profile], - old_targets: &'a [Target], - proxy_path: &'a Path, - old_proxy_contents: Option<&'a [u8]>, - singbox_path: Option<&'a Path>, - old_singbox_contents: Option<&'a [u8]>, -} - -fn rollback_after_failure( - state: &RollbackState<'_>, - code: &str, - message: String, - failed_phase: &str, - mut phases: Vec, -) -> ApplyConfigurationResult { - phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message)); - let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets); - let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents); - let singbox_rollback = state - .singbox_path - .map(|path| restore_generated(path, state.old_singbox_contents)) - .unwrap_or(Ok(())); - let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok(); - phases.push(if rollback_ok { - phase( - "rollback", - ApplyPhaseStatus::RolledBack, - "Source state и generated artifacts восстановлены.", - ) - } else { - phase( - "rollback", - ApplyPhaseStatus::Failed, - "Rollback завершился не полностью; проверьте файлы config/generated.", - ) - }); - failed_result( - code, - message, - !rollback_ok, - state.proxy_path, - state.singbox_path, - phases, - ) -} - -fn rollback_source( - storage: &JsonStorage, - profiles: &[Profile], - targets: &[Target], -) -> Result<(), String> { - let targets_result = storage - .write_targets(targets) - .map_err(|error| error.to_string()); - let profiles_result = storage - .write_profiles(profiles) - .map_err(|error| error.to_string()); - targets_result.and(profiles_result) -} - -fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> { - match previous { - Some(contents) => { - safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string()) - } - None => match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.to_string()), - }, - } -} - -fn rollback_phase(result: &Result<(), String>) -> ApplyPhase { - match result { - Ok(()) => phase( - "rollback", - ApplyPhaseStatus::RolledBack, - "Source state восстановлен.", - ), - Err(error) => phase( - "rollback", - ApplyPhaseStatus::Failed, - format!("Не удалось полностью восстановить source state: {error}"), - ), - } -} - fn failed_result( code: &str, message: String, @@ -561,6 +493,7 @@ fn failed_result( phases: Vec, ) -> ApplyConfigurationResult { ApplyConfigurationResult { + saved_state: None, success: false, changed: false, partial_state, @@ -588,7 +521,3 @@ fn phase( fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError { ApplyFlowError::failure(code, format!("Ошибка storage: {error}")) } - -fn default_true() -> bool { - true -} diff --git a/src-tauri/src/command_dto.rs b/src-tauri/src/command_dto.rs index b22405d..727315a 100644 --- a/src-tauri/src/command_dto.rs +++ b/src-tauri/src/command_dto.rs @@ -4,6 +4,11 @@ //! camelCase contract exposed to the React webview. use crate::adapters::singbox::SingBoxCheckResult; +use crate::component_catalog::ComponentId as CatalogComponentId; +use crate::component_packages::{ + ComponentInstallSource, ComponentUpdateState, ComponentUpdateStatus, PackageSource, + UpdateCheckTrust, UpdateFreshness, +}; use crate::models::{ ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol, @@ -74,6 +79,8 @@ pub struct StatusResponse { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SavedStateResponse { + pub artifacts: Vec, + pub revision: String, pub profiles: Vec, pub targets: Vec, pub generated_config_path: String, @@ -83,6 +90,7 @@ pub struct SavedStateResponse { #[serde(rename_all = "camelCase")] pub struct StartupSnapshotResponse { pub admin_status: AdminStatusResponse, + pub migration_status: StorageMigrationStatusDto, pub saved_state: SavedStateResponse, pub components: Vec, pub proxifyre_setup_status: ProxiFyreSetupStatusDto, @@ -90,6 +98,18 @@ pub struct StartupSnapshotResponse { pub singbox_setup_status: SingBoxSetupStatusDto, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StorageMigrationStatusDto { + pub storage_schema_version: u32, + pub component_layout_version: Option, + pub outcome: String, + pub changed: bool, + pub blocking: bool, + pub notice_code: Option, + pub message: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProxiFyreSetupStatusDto { @@ -108,22 +128,12 @@ pub struct ProxiFyreSetupItemDto { pub details: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProxiFyreSetupProgressDto { - pub operation: String, - pub status: String, - pub active_step: Option, - pub percent: u8, - pub message: String, - pub updated_at: Option, -} - pub type SingBoxSetupStatusDto = SingBoxSetupStatus; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LocalSingBoxStatusResponse { + pub saved_state: SavedStateResponse, pub config: LocalSingBoxConfigDto, pub cache: Option, pub component: ComponentStatusDto, @@ -356,6 +366,285 @@ pub struct ComponentStatusDto { pub actions: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentLifecycleResponseDto { + pub component: ComponentStatusDto, + pub changed: bool, + pub reboot_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ManagedPackageComponentDto { + Proxifyre, + SingBox, +} + +impl ManagedPackageComponentDto { + pub(crate) const fn catalog_id(self) -> CatalogComponentId { + match self { + Self::Proxifyre => CatalogComponentId::Proxifyre, + Self::SingBox => CatalogComponentId::SingBox, + } + } + + pub(crate) const fn model_id(self) -> ComponentId { + match self { + Self::Proxifyre => ComponentId::Proxyfier, + Self::SingBox => ComponentId::Singbox, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentUpdateFreshnessDto { + NeverChecked, + Fresh, + Stale, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentUpdateStateDto { + Current, + UpdateAvailable, + CheckStale, + UnknownOffline, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentInstallSourceDto { + Bundled, + Cache, + External, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentPackageSourceDto { + Bundled, + Cache, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentUpdateTrustDto { + Trusted, + MissingIndependentDigest, + MalformedIndependentDigest, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentPackageRequestDto { + pub component_id: ManagedPackageComponentDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentPackageStatusDto { + pub component_id: ManagedPackageComponentDto, + pub installed_version: Option, + pub bundled_version: String, + pub available_offline_version: String, + pub latest_known_version: Option, + pub last_checked_at: Option, + pub freshness: ComponentUpdateFreshnessDto, + pub update_state: ComponentUpdateStateDto, + pub install_source: ComponentInstallSourceDto, + pub offline_package_source: ComponentPackageSourceDto, + pub can_install_offline: bool, + pub offline_unavailable_reason: Option, + pub can_download: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentUpdateCheckResponseDto { + pub trust: ComponentUpdateTrustDto, + pub update_available: bool, + pub status: ComponentPackageStatusDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentUpdateDownloadResponseDto { + pub downloaded_version: String, + pub source: ComponentPackageSourceDto, + pub status: ComponentPackageStatusDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentUpdateResponseDto { + pub component: ComponentStatusDto, + pub package: ComponentPackageStatusDto, + pub changed: bool, + pub reboot_required: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentCutoverStateDto { + NotNeeded, + Ready, + ManualMigrationRequired, + InProgress, + AwaitingNextStart, + AwaitingRouteSmoke, + CleanupReady, + CleanupPending, + Complete, + RolledBack, + RecoveryRequired, + Blocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentCutoverModeDto { + ServiceSwitch, + ManualOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentCutoverServiceStateDto { + Running, + Stopped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentCutoverStatusDto { + pub component_id: ManagedPackageComponentDto, + pub state: ComponentCutoverStateDto, + pub mode: ComponentCutoverModeDto, + pub legacy_version: Option, + pub current_version: Option, + pub bundled_version: Option, + pub original_service_state: Option, + pub legacy_path_label: Option, + pub current_path_label: Option, + pub steps: Vec, + pub next_start_verified: bool, + pub route_smoke_confirmed: bool, + pub can_cutover: bool, + pub can_confirm_route_smoke: bool, + pub can_cleanup: bool, + pub disabled_code: Option, + pub disabled_message: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentCutoverRequestDto { + pub component_id: ManagedPackageComponentDto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmComponentRouteSmokeInputDto { + pub component_id: ManagedPackageComponentDto, + pub confirmed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ComponentCutoverResponseDto { + pub status: ComponentCutoverStatusDto, + pub changed: bool, + pub reboot_required: bool, +} + +impl TryFrom<&ComponentUpdateStatus> for ComponentPackageStatusDto { + type Error = (); + + fn try_from(status: &ComponentUpdateStatus) -> Result { + let component_id = match status.component_id { + CatalogComponentId::Proxifyre => ManagedPackageComponentDto::Proxifyre, + CatalogComponentId::SingBox => ManagedPackageComponentDto::SingBox, + CatalogComponentId::WindowsPacketFilter + | CatalogComponentId::VcRuntime + | CatalogComponentId::Winsw => return Err(()), + }; + Ok(Self { + component_id, + installed_version: status.installed_version.clone(), + bundled_version: status.bundled_version.clone(), + available_offline_version: status.available_offline_version.clone(), + latest_known_version: status.latest_known_version.clone(), + last_checked_at: status.last_checked_at_unix, + freshness: status.freshness.into(), + update_state: status.update_state.into(), + install_source: status.install_source.into(), + offline_package_source: status.offline_package_source.into(), + can_install_offline: status.can_install_offline, + offline_unavailable_reason: status.offline_unavailable_reason.clone(), + can_download: status.can_download, + }) + } +} + +impl From for ComponentUpdateFreshnessDto { + fn from(value: UpdateFreshness) -> Self { + match value { + UpdateFreshness::NeverChecked => Self::NeverChecked, + UpdateFreshness::Fresh => Self::Fresh, + UpdateFreshness::Stale => Self::Stale, + } + } +} + +impl From for ComponentUpdateStateDto { + fn from(value: ComponentUpdateState) -> Self { + match value { + ComponentUpdateState::Current => Self::Current, + ComponentUpdateState::UpdateAvailable => Self::UpdateAvailable, + ComponentUpdateState::CheckStale => Self::CheckStale, + ComponentUpdateState::UnknownOffline => Self::UnknownOffline, + ComponentUpdateState::Unsupported => Self::Unsupported, + } + } +} + +impl From for ComponentInstallSourceDto { + fn from(value: ComponentInstallSource) -> Self { + match value { + ComponentInstallSource::Bundled => Self::Bundled, + ComponentInstallSource::Cache => Self::Cache, + ComponentInstallSource::External => Self::External, + ComponentInstallSource::None => Self::None, + } + } +} + +impl From for ComponentPackageSourceDto { + fn from(value: PackageSource) -> Self { + match value { + PackageSource::Bundled => Self::Bundled, + PackageSource::Cache => Self::Cache, + } + } +} + +impl From for ComponentUpdateTrustDto { + fn from(value: UpdateCheckTrust) -> Self { + match value { + UpdateCheckTrust::Trusted => Self::Trusted, + UpdateCheckTrust::MissingIndependentDigest => Self::MissingIndependentDigest, + UpdateCheckTrust::MalformedIndependentDigest => Self::MalformedIndependentDigest, + UpdateCheckTrust::Unsupported => Self::Unsupported, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ActivityEntryDto { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 798f7f4..97de39b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,32 +1,47 @@ use crate::adapters::proxifyre::ProxiFyreAdapter; use crate::adapters::singbox::{SingBoxAdapter, SingBoxCommandChecker}; pub use crate::admin::admin_status; -use crate::admin::launch_app_as_admin; use crate::apply_flow::{self, ApplyConfigurationInput, ApplyConfigurationResult, ApplyFlowError}; -use crate::component_detection::{detect_proxyfier_install, detect_singbox_install}; -use crate::singbox_service::{build_singbox_setup_status_with_install_root, SingBoxServiceAction}; +use crate::component_cutover::{ + ComponentCutoverObservation, CutoverDisplayState, CutoverPhase, LegacyServiceState, +}; +use crate::component_detection::{ + detect_proxyfier_install, detect_singbox_install, inventory_proxyfier, inventory_singbox, +}; +use crate::component_inventory::{ + authorize_component_action, component_inventory_fingerprint_for_cutover, CandidateRole, + ComponentClassification, ComponentInventory, InventoryAction, InventoryIssue, +}; +use crate::component_packages::{ + ComponentInstallSource, ComponentPackageService, ComponentPackagesError, + InstalledComponentSnapshot, NativePackageSignatureVerifier, ReqwestUpdateTransport, +}; +use crate::privileged_jobs::{ + launch_privileged_job, read_install_receipt, CanonicalComponentRoot, EpochClock, + InstalledPackageSource, ManagedComponent, PrivilegedAction, PrivilegedJobLaunchState, + PrivilegedJobResult, PrivilegedJobStatus, PrivilegedJobStore, PrivilegedJobsError, + PrivilegedResultCode, PrivilegedRunnerFailure, ShellExecuteElevatedJobLauncher, + SystemEpochClock, +}; +use crate::privileged_runtime::{installed_cutover_external_status, LocalPrivilegedPlanResolver}; +use crate::singbox_service::build_singbox_setup_status_with_install_root; use crate::storage::{default_config_root, JsonStorage}; +#[cfg(debug_assertions)] +use std::path::Path; use std::path::PathBuf; +use uuid::Uuid; pub use crate::clock::{Clock, SystemClock}; pub use crate::command_dto::*; pub use crate::component_status::resolve_component_statuses; pub use crate::configuration_use_case::{ - build_status, read_activity, read_components, read_profiles, read_saved_state, - read_saved_state_with_proxifyre_config, read_startup_snapshot, read_targets, resolve_preview, + build_status, ensure_proxifyre_generated_config_ready, read_activity, read_live_components, + read_profiles, read_saved_state, read_startup_snapshot, read_targets, resolve_preview, save_profile_to_storage, save_target_to_storage, }; -pub use crate::proxifyre_runtime::wrap_elevated_package_script; use crate::proxifyre_runtime::{ - build_proxifyre_setup_status_for_install_dir, configure_proxifyre_firewall, - control_proxifyre_service, install_proxifyre_component, proxifyre_install_dir_for_app, - read_proxifyre_setup_progress, singbox_install_dir_for_app, uninstall_proxifyre_component, - ServiceControlAction, -}; -pub use crate::proxifyre_scripts::{ - configure_proxifyre_firewall_script, install_proxifyre_script, - install_proxifyre_script_for_target, install_proxifyre_script_with_bundle, - uninstall_proxifyre_script, + build_proxifyre_setup_status_for_install_dir, proxifyre_install_dir_for_app, + singbox_install_dir_for_app, }; pub use crate::proxy_apply::{ apply_profiles_with_services, apply_profiles_with_services_and_detection, @@ -37,10 +52,7 @@ pub use crate::proxy_probe::{ ping_proxy_target_endpoint, ping_proxy_target_endpoint_with_probes, ProxyProbeEndpoint, }; pub use crate::singbox_config::generate_singbox_config_with_services; -pub use crate::singbox_runtime::singbox_installer_runner_script; -use crate::singbox_runtime::{ - control_singbox_service, install_singbox_component, uninstall_singbox_component, -}; +use crate::singbox_runtime::run_singbox_config_check_entrypoint; pub use crate::singbox_subscription::{ fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage, ping_all_singbox_servers_in_storage, ping_singbox_server_in_storage, read_singbox_status, @@ -51,16 +63,24 @@ pub use crate::singbox_subscription::{ #[derive(Debug, Clone)] pub struct CommandState { root: PathBuf, + startup_session_id: String, } impl CommandState { pub fn new(root: impl Into) -> Self { - Self { root: root.into() } + Self { + root: root.into(), + startup_session_id: Uuid::new_v4().hyphenated().to_string(), + } } pub fn storage(&self) -> JsonStorage { JsonStorage::new(self.root.clone()) } + + pub fn startup_session_id(&self) -> &str { + &self.startup_session_id + } } impl Default for CommandState { @@ -70,10 +90,11 @@ impl Default for CommandState { } #[tauri::command] -pub fn restart_as_admin(app: tauri::AppHandle) -> Result<(), CommandError> { - launch_app_as_admin()?; - app.exit(0); - Ok(()) +pub fn restart_as_admin() -> Result<(), CommandError> { + Err(CommandError::new( + "whole_app_elevation_disabled", + "Перезапуск всего ProxyWarden от имени администратора отключен. Права запрашиваются отдельно для выбранного системного действия.", + )) } #[tauri::command] @@ -81,26 +102,31 @@ pub async fn get_startup_snapshot( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || read_startup_snapshot(&storage)) - .await - .map_err(background_task_error)? + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + read_startup_snapshot(&storage, &startup_session_id) + }) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn get_saved_state( +pub async fn get_saved_state( state: tauri::State<'_, CommandState>, ) -> Result { - read_saved_state(&state.storage()) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || read_saved_state(&storage)) + .await + .map_err(background_task_error)? } #[tauri::command] pub async fn get_components( - state: tauri::State<'_, CommandState>, + _state: tauri::State<'_, CommandState>, ) -> Result, CommandError> { - let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || read_components(&storage)) + tauri::async_runtime::spawn_blocking(read_live_components) .await - .map_err(background_task_error)? + .map_err(background_task_error) } #[tauri::command] @@ -115,16 +141,6 @@ pub async fn get_proxifyre_setup_status( .map_err(background_task_error) } -#[tauri::command] -pub async fn get_proxifyre_setup_progress( - state: tauri::State<'_, CommandState>, -) -> Result { - let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || read_proxifyre_setup_progress(&storage)) - .await - .map_err(background_task_error)? -} - #[tauri::command] pub async fn get_singbox_status( state: tauri::State<'_, CommandState>, @@ -150,39 +166,47 @@ pub async fn get_singbox_setup_status( .map_err(background_task_error) } -#[tauri::command] -pub fn save_singbox_subscription( - state: tauri::State<'_, CommandState>, - input: SaveSingBoxSubscriptionInputDto, -) -> Result { - save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock) -} - #[tauri::command] pub async fn fetch_singbox_subscription( state: tauri::State<'_, CommandState>, + subscription_url: Option, ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { - fetch_singbox_subscription_with_fetcher(&storage, &SystemSubscriptionFetcher, &SystemClock) + crate::singbox_subscription::fetch_singbox_subscription_candidate( + &storage, + subscription_url.as_deref(), + &SystemSubscriptionFetcher, + &SystemClock, + ) }) .await .map_err(background_task_error)? } #[tauri::command] -pub fn forget_singbox_subscription( +pub async fn forget_singbox_subscription( state: tauri::State<'_, CommandState>, ) -> Result { - forget_singbox_subscription_in_storage(&state.storage(), &SystemClock) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + forget_singbox_subscription_in_storage(&storage, &SystemClock) + }) + .await + .map_err(background_task_error)? } #[tauri::command] -pub fn select_singbox_server( +pub async fn select_singbox_server( state: tauri::State<'_, CommandState>, input: SelectSingBoxServerInputDto, ) -> Result { - select_singbox_server_in_storage(&state.storage(), input, &SystemClock) + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + select_singbox_server_in_storage(&storage, input, &SystemClock) + }) + .await + .map_err(background_task_error)? } #[tauri::command] @@ -221,17 +245,16 @@ pub async fn generate_singbox_config( ) -> Result { let storage = state.storage(); tauri::async_runtime::spawn_blocking(move || { - let detected = detect_singbox_install(); - let binary_path = detected - .as_ref() - .map(|detected| detected.executable_path.as_path()); - generate_singbox_config_with_services( - &storage, - &SingBoxAdapter::default(), - &SingBoxCommandChecker, - &SystemClock, - binary_path, - ) + let inventory = inventory_singbox(); + run_singbox_config_check_entrypoint(&inventory, |binary_path| { + generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + ) + }) }) .await .map_err(background_task_error)? @@ -266,44 +289,105 @@ pub async fn apply_configuration( } #[tauri::command] -pub async fn start_proxifyre_service() -> Result { - tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Start)) - .await - .map_err(background_task_error)? +pub async fn start_proxifyre_service( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_proxyfier(); + lifecycle_route(&inventory, InventoryAction::Start)?; + ensure_proxifyre_generated_config_ready(&storage)?; + run_privileged_and_refresh( + &storage, + PrivilegedAction::StartProxifyre, + crate::models::ComponentId::Proxyfier, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? } #[tauri::command] -pub async fn stop_proxifyre_service() -> Result { - tauri::async_runtime::spawn_blocking(|| control_proxifyre_service(ServiceControlAction::Stop)) - .await - .map_err(background_task_error)? +pub async fn stop_proxifyre_service( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_proxyfier(); + lifecycle_route(&inventory, InventoryAction::Stop)?; + run_privileged_and_refresh( + &storage, + PrivilegedAction::StopProxifyre, + crate::models::ComponentId::Proxyfier, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? } #[tauri::command] pub async fn install_proxifyre( - app: tauri::AppHandle, state: tauri::State<'_, CommandState>, -) -> Result { +) -> Result { let storage = state.storage(); - tauri::async_runtime::spawn_blocking(move || install_proxifyre_component(&storage, &app)) - .await - .map_err(background_task_error)? + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_proxyfier(); + lifecycle_route(&inventory, InventoryAction::Install)?; + run_privileged_lifecycle_and_refresh( + &storage, + PrivilegedAction::InstallProxifyre, + crate::models::ComponentId::Proxyfier, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? } #[tauri::command] -pub async fn configure_proxifyre_firewall_rules(app: tauri::AppHandle) -> Result<(), CommandError> { - tauri::async_runtime::spawn_blocking(move || configure_proxifyre_firewall(&app)) - .await - .map_err(background_task_error)? +pub async fn configure_proxifyre_firewall_rules( + state: tauri::State<'_, CommandState>, +) -> Result<(), CommandError> { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_proxyfier(); + lifecycle_route(&inventory, InventoryAction::ConfigureFirewall)?; + run_privileged_and_refresh( + &storage, + PrivilegedAction::ConfigureProxifyreFirewall, + crate::models::ComponentId::Proxyfier, + &startup_session_id, + )?; + Ok(()) + }) + .await + .map_err(background_task_error)? } #[tauri::command] pub async fn uninstall_proxifyre( - app: tauri::AppHandle, -) -> Result { - tauri::async_runtime::spawn_blocking(move || uninstall_proxifyre_component(&app)) - .await - .map_err(background_task_error)? + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_proxyfier(); + lifecycle_route(&inventory, InventoryAction::Uninstall)?; + run_privileged_lifecycle_and_refresh( + &storage, + PrivilegedAction::UninstallProxifyre, + crate::models::ComponentId::Proxyfier, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? } #[tauri::command] @@ -311,29 +395,47 @@ pub async fn start_singbox_service( state: tauri::State<'_, CommandState>, ) -> Result { let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); tauri::async_runtime::spawn_blocking(move || { - let detected = detect_singbox_install(); - let binary_path = detected - .as_ref() - .map(|detected| detected.executable_path.as_path()); - let generated = generate_singbox_config_with_services( + let inventory = inventory_singbox(); + lifecycle_route(&inventory, InventoryAction::Start)?; + if inventory.classification() == ComponentClassification::ManagedCurrent { + run_singbox_config_check_entrypoint(&inventory, |binary_path| { + generate_singbox_config_with_services( + &storage, + &SingBoxAdapter::default(), + &SingBoxCommandChecker, + &SystemClock, + binary_path, + ) + })?; + } + run_privileged_and_refresh( &storage, - &SingBoxAdapter::default(), - &SingBoxCommandChecker, - &SystemClock, - binary_path, - )?; - let generated_path = PathBuf::from(generated.generated_config_path); - control_singbox_service(SingBoxServiceAction::Start, Some(generated_path.as_path())) + PrivilegedAction::StartSingBox, + crate::models::ComponentId::Singbox, + &startup_session_id, + ) }) .await .map_err(background_task_error)? } #[tauri::command] -pub async fn stop_singbox_service() -> Result { - tauri::async_runtime::spawn_blocking(|| { - control_singbox_service(SingBoxServiceAction::Stop, None) +pub async fn stop_singbox_service( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_singbox(); + lifecycle_route(&inventory, InventoryAction::Stop)?; + run_privileged_and_refresh( + &storage, + PrivilegedAction::StopSingBox, + crate::models::ComponentId::Singbox, + &startup_session_id, + ) }) .await .map_err(background_task_error)? @@ -341,21 +443,1079 @@ pub async fn stop_singbox_service() -> Result #[tauri::command] pub async fn install_singbox( - app: tauri::AppHandle, state: tauri::State<'_, CommandState>, -) -> Result { +) -> Result { let storage = state.storage(); - let install_dir = singbox_install_dir_for_app(&app)?; - tauri::async_runtime::spawn_blocking(move || install_singbox_component(&storage, &install_dir)) + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_singbox(); + lifecycle_route(&inventory, InventoryAction::Install)?; + run_privileged_lifecycle_and_refresh( + &storage, + PrivilegedAction::InstallSingBox, + crate::models::ComponentId::Singbox, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn uninstall_singbox( + state: tauri::State<'_, CommandState>, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_singbox(); + lifecycle_route(&inventory, InventoryAction::Uninstall)?; + run_privileged_lifecycle_and_refresh( + &storage, + PrivilegedAction::UninstallSingBox, + crate::models::ComponentId::Singbox, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn get_component_package_statuses( + state: tauri::State<'_, CommandState>, +) -> Result, CommandError> { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || component_package_statuses(&storage)) .await .map_err(background_task_error)? } #[tauri::command] -pub async fn uninstall_singbox() -> Result { - tauri::async_runtime::spawn_blocking(uninstall_singbox_component) - .await - .map_err(background_task_error)? +pub async fn check_component_update( + state: tauri::State<'_, CommandState>, + input: ComponentPackageRequestDto, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + let service = open_component_package_service(&storage)?; + let transport = ReqwestUpdateTransport::new().map_err(|_| { + CommandError::new( + "component_update_check_failed", + "Не удалось подготовить безопасную проверку обновлений.", + ) + })?; + let now = SystemEpochClock.now_epoch_seconds(); + let check = service + .check_for_update(input.component_id.catalog_id(), now, &transport) + .map_err(component_packages_error)?; + let status = component_package_status(&service, input.component_id, now)?; + Ok(ComponentUpdateCheckResponseDto { + trust: check.trust.into(), + update_available: check.update_available, + status, + }) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn download_component_update( + state: tauri::State<'_, CommandState>, + input: ComponentPackageRequestDto, +) -> Result { + let storage = state.storage(); + tauri::async_runtime::spawn_blocking(move || { + let service = open_component_package_service(&storage)?; + let transport = ReqwestUpdateTransport::new().map_err(|_| { + CommandError::new( + "component_update_download_failed", + "Не удалось подготовить безопасное скачивание обновления.", + ) + })?; + let selected = service + .download_checked_update( + input.component_id.catalog_id(), + &transport, + &NativePackageSignatureVerifier, + ) + .map_err(component_packages_error)?; + let status = component_package_status( + &service, + input.component_id, + SystemEpochClock.now_epoch_seconds(), + )?; + Ok(ComponentUpdateDownloadResponseDto { + downloaded_version: selected.version, + source: selected.source.into(), + status, + }) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn update_component( + state: tauri::State<'_, CommandState>, + input: ComponentPackageRequestDto, +) -> Result { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let inventory = inventory_for_package_component(input.component_id); + lifecycle_route(&inventory, InventoryAction::Update)?; + let action = match input.component_id { + ManagedPackageComponentDto::Proxifyre => PrivilegedAction::UpdateProxifyre, + ManagedPackageComponentDto::SingBox => PrivilegedAction::UpdateSingBox, + }; + let outcome = run_privileged_action(action, &startup_session_id)?; + let component = refresh_component(&storage, input.component_id.model_id())?; + let service = open_component_package_service(&storage)?; + let package = component_package_status( + &service, + input.component_id, + SystemEpochClock.now_epoch_seconds(), + )?; + Ok(ComponentUpdateResponseDto { + component, + package, + changed: outcome.changed, + reboot_required: outcome.reboot_required, + }) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn get_component_cutover_statuses( + state: tauri::State<'_, CommandState>, +) -> Result, CommandError> { + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + component_cutover_statuses(&storage, &startup_session_id) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn cutover_component( + state: tauri::State<'_, CommandState>, + input: ComponentCutoverRequestDto, +) -> Result { + require_proxifyre_cutover_component(input.component_id)?; + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let outcome = + run_privileged_action(PrivilegedAction::CutoverProxifyre, &startup_session_id)?; + Ok(ComponentCutoverResponseDto { + status: component_cutover_status( + &storage, + ManagedPackageComponentDto::Proxifyre, + &startup_session_id, + )?, + changed: outcome.changed, + reboot_required: outcome.reboot_required, + }) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn confirm_component_route_smoke( + state: tauri::State<'_, CommandState>, + input: ConfirmComponentRouteSmokeInputDto, +) -> Result { + require_proxifyre_cutover_component(input.component_id)?; + if !input.confirmed { + return Err(CommandError::new( + "component_route_confirmation_required", + "Очистка старой установки требует явного подтверждения проверенного маршрута.", + )); + } + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + confirm_route_smoke_evidence(&storage, &startup_session_id)?; + component_cutover_status( + &storage, + ManagedPackageComponentDto::Proxifyre, + &startup_session_id, + ) + }) + .await + .map_err(background_task_error)? +} + +#[tauri::command] +pub async fn cleanup_component_quarantine( + state: tauri::State<'_, CommandState>, + input: ComponentCutoverRequestDto, +) -> Result { + require_proxifyre_cutover_component(input.component_id)?; + let storage = state.storage(); + let startup_session_id = state.startup_session_id().to_string(); + tauri::async_runtime::spawn_blocking(move || { + let outcome = run_privileged_action( + PrivilegedAction::CleanupProxifyreQuarantine, + &startup_session_id, + )?; + Ok(ComponentCutoverResponseDto { + status: component_cutover_status( + &storage, + ManagedPackageComponentDto::Proxifyre, + &startup_session_id, + )?, + changed: outcome.changed, + reboot_required: outcome.reboot_required, + }) + }) + .await + .map_err(background_task_error)? +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LifecycleRoute { + Privileged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PrivilegedCommandOutcome { + changed: bool, + reboot_required: bool, +} + +fn lifecycle_route( + inventory: &ComponentInventory, + action: InventoryAction, +) -> Result { + authorize_component_action(inventory, action).map_err(|issue| inventory_issue_error(&issue))?; + match inventory.classification() { + ComponentClassification::ManagedCurrent | ComponentClassification::Missing => { + Ok(LifecycleRoute::Privileged) + } + ComponentClassification::ManagedLegacy => Err(CommandError::new( + "legacy_cutover_required", + "Старая управляемая установка требует отдельного явного переноса.", + )), + ComponentClassification::Foreign | ComponentClassification::Incomplete => inventory + .selected_candidate() + .and_then(|candidate| candidate.issues.first()) + .map(inventory_issue_error) + .map_or_else( + || { + Err(CommandError::new( + "component_ownership_mismatch", + "Управление компонентом заблокировано: его принадлежность ProxyWarden не подтверждена.", + )) + }, + Err, + ), + } +} + +fn inventory_issue_error(issue: &InventoryIssue) -> CommandError { + CommandError::new(issue.code.clone(), issue.message.clone()) +} + +fn run_privileged_and_refresh( + storage: &JsonStorage, + action: PrivilegedAction, + component_id: crate::models::ComponentId, + startup_session_id: &str, +) -> Result { + run_privileged_action(action, startup_session_id)?; + refresh_component(storage, component_id) +} + +fn run_privileged_lifecycle_and_refresh( + storage: &JsonStorage, + action: PrivilegedAction, + component_id: crate::models::ComponentId, + startup_session_id: &str, +) -> Result { + let outcome = run_privileged_action(action, startup_session_id)?; + Ok(ComponentLifecycleResponseDto { + component: refresh_component(storage, component_id)?, + changed: outcome.changed, + reboot_required: outcome.reboot_required, + }) +} + +fn run_privileged_action( + action: PrivilegedAction, + startup_session_id: &str, +) -> Result { + let store = PrivilegedJobStore::production().map_err(privileged_jobs_error)?; + let resolver = LocalPrivilegedPlanResolver::production(startup_session_id) + .map_err(privileged_runner_error)?; + let executable = std::env::current_exe().map_err(|_| { + CommandError::new( + "privileged_launch_failed", + "Не удалось определить исполняемый файл ProxyWarden для системного действия.", + ) + })?; + let launched = launch_privileged_job( + &store, + action, + &executable, + &SystemEpochClock, + &resolver, + &ShellExecuteElevatedJobLauncher, + ) + .map_err(privileged_jobs_error)?; + + match launched.state { + PrivilegedJobLaunchState::Canceled => Err(CommandError::new( + "privileged_uac_cancelled", + "Запрос прав администратора отменен пользователем.", + )), + PrivilegedJobLaunchState::Failed => Err(CommandError::new( + "privileged_launch_failed", + "Не удалось запустить подтвержденное системное действие.", + )), + PrivilegedJobLaunchState::Indeterminate => Err(CommandError::new( + "privileged_result_unknown", + "Системное действие запущено, но его итог пока нельзя подтвердить.", + )), + PrivilegedJobLaunchState::Completed { .. } => { + let result = store.read_result(&launched.request).map_err(|_| { + CommandError::new( + "privileged_result_unknown", + "Системное действие завершилось без проверяемого итогового результата.", + ) + })?; + privileged_terminal_result(&result) + } + } +} + +fn privileged_terminal_result( + result: &PrivilegedJobResult, +) -> Result { + match (result.status, result.code) { + ( + PrivilegedJobStatus::Succeeded, + PrivilegedResultCode::Completed + | PrivilegedResultCode::NoChange + | PrivilegedResultCode::CutoverAwaitingNextStart + | PrivilegedResultCode::CutoverQuarantinePending + | PrivilegedResultCode::CutoverRolledBack + | PrivilegedResultCode::CutoverCleanupPending + | PrivilegedResultCode::CutoverComplete, + ) => Ok(PrivilegedCommandOutcome { + changed: result.changed, + reboot_required: result.reboot_required, + }), + (PrivilegedJobStatus::Failed, code) => Err(privileged_result_error(code)), + (PrivilegedJobStatus::Running, _) => Err(CommandError::new( + "privileged_result_running", + "Системное действие еще выполняется; успешный итог пока не подтвержден.", + )), + _ => Err(CommandError::new( + "privileged_result_unknown", + "Системное действие вернуло неподтвержденный итог.", + )), + } +} + +fn privileged_result_error(code: PrivilegedResultCode) -> CommandError { + match code { + PrivilegedResultCode::PreconditionFailed => CommandError::new( + "component_precondition_failed", + "Состояние компонента изменилось или не позволяет выполнить действие.", + ), + PrivilegedResultCode::OwnershipMismatch => CommandError::new( + "component_ownership_mismatch", + "Принадлежность компонента ProxyWarden не подтверждена; действие заблокировано.", + ), + PrivilegedResultCode::PackageVerificationFailed => CommandError::new( + "component_package_verification_failed", + "Пакет компонента не прошел проверку подлинности и целостности.", + ), + PrivilegedResultCode::ServiceCollision => CommandError::new( + "component_service_collision", + "Служба с таким именем уже принадлежит другому компоненту.", + ), + PrivilegedResultCode::CleanupPending => CommandError::new( + "component_cleanup_pending", + "Основное действие выполнено, но безопасная очистка будет повторена позже.", + ), + PrivilegedResultCode::CutoverRecoveryRequired => CommandError::new( + "component_cutover_recovery_required", + "Перенос компонента требует безопасного восстановления; другие действия временно заблокированы.", + ), + PrivilegedResultCode::CutoverStateConflict => CommandError::new( + "component_cutover_state_conflict", + "Состояние переноса изменилось; перечитайте статус и повторите подходящее действие.", + ), + PrivilegedResultCode::CutoverIdentityRejected => CommandError::new( + "component_cutover_identity_rejected", + "Старая установка не совпала с доказанной конфигурацией для автоматического переноса.", + ), + PrivilegedResultCode::RunnerUnavailable + | PrivilegedResultCode::OperationFailed + | PrivilegedResultCode::Running + | PrivilegedResultCode::Completed + | PrivilegedResultCode::NoChange + | PrivilegedResultCode::CutoverAwaitingNextStart + | PrivilegedResultCode::CutoverQuarantinePending + | PrivilegedResultCode::CutoverRolledBack + | PrivilegedResultCode::CutoverCleanupPending + | PrivilegedResultCode::CutoverComplete => CommandError::new( + "component_operation_failed", + "Системное действие с компонентом не завершилось успешно.", + ), + } +} + +fn privileged_runner_error(error: PrivilegedRunnerFailure) -> CommandError { + let code = match error { + PrivilegedRunnerFailure::PreconditionFailed => PrivilegedResultCode::PreconditionFailed, + PrivilegedRunnerFailure::OwnershipMismatch => PrivilegedResultCode::OwnershipMismatch, + PrivilegedRunnerFailure::PackageVerificationFailed => { + PrivilegedResultCode::PackageVerificationFailed + } + PrivilegedRunnerFailure::ServiceCollision => PrivilegedResultCode::ServiceCollision, + PrivilegedRunnerFailure::CleanupPending => PrivilegedResultCode::CleanupPending, + PrivilegedRunnerFailure::CutoverRecoveryRequired => { + PrivilegedResultCode::CutoverRecoveryRequired + } + PrivilegedRunnerFailure::CutoverStateConflict => PrivilegedResultCode::CutoverStateConflict, + PrivilegedRunnerFailure::CutoverIdentityRejected => { + PrivilegedResultCode::CutoverIdentityRejected + } + PrivilegedRunnerFailure::RunnerUnavailable | PrivilegedRunnerFailure::OperationFailed => { + PrivilegedResultCode::OperationFailed + } + }; + privileged_result_error(code) +} + +fn privileged_jobs_error(error: PrivilegedJobsError) -> CommandError { + match error { + PrivilegedJobsError::PlanChanged + | PrivilegedJobsError::FutureDated + | PrivilegedJobsError::Expired => CommandError::new( + "component_precondition_failed", + "Состояние компонента изменилось до подтверждения действия; повторите попытку.", + ), + PrivilegedJobsError::LifecycleBusy => CommandError::new( + "component_operation_busy", + "Другое системное действие с компонентами еще выполняется.", + ), + PrivilegedJobsError::InvalidArguments + | PrivilegedJobsError::InvalidJobId + | PrivilegedJobsError::InvalidNonce + | PrivilegedJobsError::UnsupportedSchema + | PrivilegedJobsError::RequestMismatch + | PrivilegedJobsError::NotElevated + | PrivilegedJobsError::Replay + | PrivilegedJobsError::ResultAlreadyExists + | PrivilegedJobsError::RecordTooLarge + | PrivilegedJobsError::InvalidRecord + | PrivilegedJobsError::InvalidReceipt + | PrivilegedJobsError::Io(_) => CommandError::new( + "component_operation_failed", + "Не удалось безопасно подготовить системное действие с компонентом.", + ), + } +} + +fn refresh_component( + _storage: &JsonStorage, + component_id: crate::models::ComponentId, +) -> Result { + read_live_components() + .into_iter() + .find(|component| component.id == component_id) + .ok_or_else(|| { + CommandError::new( + "component_status_unavailable", + "Системное действие завершилось, но обновленный статус компонента не найден.", + ) + }) +} + +fn component_cutover_statuses( + storage: &JsonStorage, + startup_session_id: &str, +) -> Result, CommandError> { + [ + ManagedPackageComponentDto::Proxifyre, + ManagedPackageComponentDto::SingBox, + ] + .into_iter() + .map(|component| component_cutover_status(storage, component, startup_session_id)) + .collect() +} + +fn component_cutover_status( + storage: &JsonStorage, + component: ManagedPackageComponentDto, + startup_session_id: &str, +) -> Result { + let inventory = inventory_for_package_component(component); + let bundled_version = open_component_package_service(storage) + .ok() + .and_then(|service| { + component_package_status(&service, component, SystemEpochClock.now_epoch_seconds()).ok() + }) + .map(|status| status.bundled_version); + let current_version = inventory + .candidates + .iter() + .find(|candidate| candidate.role == CandidateRole::Current) + .and_then(|candidate| candidate.binary_version.as_deref()) + .and_then(safe_cutover_version); + let legacy_version = inventory + .candidates + .iter() + .find(|candidate| candidate.role == CandidateRole::Legacy) + .and_then(|candidate| candidate.binary_version.as_deref()) + .and_then(safe_cutover_version); + let legacy_candidate = inventory + .candidates + .iter() + .find(|candidate| candidate.role == CandidateRole::Legacy); + let legacy_discovered = legacy_candidate.is_some(); + + if component == ManagedPackageComponentDto::Proxifyre { + let external_status = installed_cutover_external_status(); + if external_status != Some(crate::component_cutover::CutoverExternalMutationStatus::Absent) + { + if let Ok(Some(observation)) = storage.read_component_cutover_observation() { + let terminal_retirement_pending = matches!( + observation.state, + CutoverDisplayState::Complete | CutoverDisplayState::RolledBack + ); + return Ok(cutover_status_from_observation( + storage, + startup_session_id, + observation, + &inventory, + current_version, + terminal_retirement_pending, + true, + )); + } + if external_status.is_some() { + return Ok(ComponentCutoverStatusDto { + component_id: component, + state: ComponentCutoverStateDto::RecoveryRequired, + mode: ComponentCutoverModeDto::ServiceSwitch, + legacy_version, + current_version, + bundled_version, + original_service_state: legacy_candidate.and_then(|candidate| { + candidate.service.as_ref().and_then(|service| { + match service.status.trim().to_ascii_lowercase().as_str() { + "running" => Some(ComponentCutoverServiceStateDto::Running), + "stopped" => Some(ComponentCutoverServiceStateDto::Stopped), + _ => None, + } + }) + }), + legacy_path_label: legacy_discovered + .then(|| "Старая установка компонента".to_string()), + current_path_label: Some("Управляемые компоненты ProxyWarden".to_string()), + steps: fixed_cutover_steps(component), + next_start_verified: false, + route_smoke_confirmed: false, + can_cutover: true, + can_confirm_route_smoke: false, + can_cleanup: false, + disabled_code: None, + disabled_message: None, + }); + } + } + } + + let exact_auto_proxifyre = component == ManagedPackageComponentDto::Proxifyre + && inventory.classification() == ComponentClassification::ManagedLegacy + && legacy_candidate.is_some_and(|candidate| { + candidate + .root + .as_os_str() + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .eq_ignore_ascii_case(r"C:\Tools\ProxiFyre") + }); + let (state, mode, can_cutover, disabled_code, disabled_message) = if exact_auto_proxifyre + && bundled_version.is_some() + { + ( + ComponentCutoverStateDto::Ready, + ComponentCutoverModeDto::ServiceSwitch, + true, + None, + None, + ) + } else if exact_auto_proxifyre { + ( + ComponentCutoverStateDto::Blocked, + ComponentCutoverModeDto::ServiceSwitch, + false, + Some("component_package_unavailable".to_string()), + Some( + "Встроенный пакет ProxiFyre недоступен или не прошел локальную проверку." + .to_string(), + ), + ) + } else if legacy_discovered { + ( + ComponentCutoverStateDto::ManualMigrationRequired, + ComponentCutoverModeDto::ManualOnly, + false, + Some("manual_migration_required".to_string()), + Some(if component == ManagedPackageComponentDto::SingBox { + "Для старой установки sing-box нет доказанной неизменяемой версии; автоматический перенос отключен." + } else { + "Эта старая установка ProxiFyre не входит в доказанный автоматический сценарий переноса." + } + .to_string()), + ) + } else if matches!( + inventory.classification(), + ComponentClassification::ManagedCurrent | ComponentClassification::Missing + ) { + ( + ComponentCutoverStateDto::NotNeeded, + ComponentCutoverModeDto::ServiceSwitch, + false, + None, + None, + ) + } else { + ( + ComponentCutoverStateDto::Blocked, + ComponentCutoverModeDto::ManualOnly, + false, + Some("component_identity_unconfirmed".to_string()), + Some( + "Принадлежность найденной установки не подтверждена; автоматический перенос заблокирован." + .to_string(), + ), + ) + }; + Ok(ComponentCutoverStatusDto { + component_id: component, + state, + mode, + legacy_version, + current_version, + bundled_version, + original_service_state: legacy_candidate.and_then(|candidate| { + candidate.service.as_ref().and_then(|service| { + match service.status.trim().to_ascii_lowercase().as_str() { + "running" => Some(ComponentCutoverServiceStateDto::Running), + "stopped" => Some(ComponentCutoverServiceStateDto::Stopped), + _ => None, + } + }) + }), + legacy_path_label: legacy_discovered.then(|| "Старая установка компонента".to_string()), + current_path_label: Some("Управляемые компоненты ProxyWarden".to_string()), + steps: fixed_cutover_steps(component), + next_start_verified: false, + route_smoke_confirmed: false, + can_cutover, + can_confirm_route_smoke: false, + can_cleanup: false, + disabled_code, + disabled_message, + }) +} + +fn cutover_status_from_observation( + storage: &JsonStorage, + startup_session_id: &str, + observation: ComponentCutoverObservation, + inventory: &ComponentInventory, + current_version: Option, + terminal_retirement_pending: bool, + recovery_probe_available: bool, +) -> ComponentCutoverStatusDto { + let live_inventory_fingerprint = (inventory.classification() + == ComponentClassification::ManagedCurrent) + .then(|| component_inventory_fingerprint_for_cutover(inventory)); + let evidence = storage + .read_component_cutover_user_evidence() + .ok() + .flatten() + .filter(|evidence| { + evidence.cutover_id == observation.cutover_id + && evidence.startup_session_id == startup_session_id + && live_inventory_fingerprint.as_deref() + == Some(evidence.current_inventory_fingerprint.as_str()) + }); + let accepts_user_evidence = matches!( + (observation.state, observation.phase), + ( + CutoverDisplayState::AwaitingNextStart, + CutoverPhase::LegacyQuarantined + ) | ( + CutoverDisplayState::AwaitingRouteSmoke, + CutoverPhase::NextStartVerified + ) + ); + let locally_confirmed = accepts_user_evidence + && evidence + .as_ref() + .is_some_and(|evidence| evidence.route_smoke_confirmed); + let mut state = if terminal_retirement_pending { + ComponentCutoverStateDto::RecoveryRequired + } else { + match observation.state { + CutoverDisplayState::InProgress => ComponentCutoverStateDto::InProgress, + CutoverDisplayState::AwaitingNextStart => ComponentCutoverStateDto::AwaitingNextStart, + CutoverDisplayState::AwaitingRouteSmoke => ComponentCutoverStateDto::AwaitingRouteSmoke, + CutoverDisplayState::CleanupReady => ComponentCutoverStateDto::CleanupReady, + CutoverDisplayState::CleanupPending => ComponentCutoverStateDto::CleanupPending, + CutoverDisplayState::Complete => ComponentCutoverStateDto::Complete, + CutoverDisplayState::RolledBack => ComponentCutoverStateDto::RolledBack, + CutoverDisplayState::RecoveryRequired => ComponentCutoverStateDto::RecoveryRequired, + } + }; + if accepts_user_evidence && evidence.is_some() { + state = if locally_confirmed { + ComponentCutoverStateDto::CleanupReady + } else { + ComponentCutoverStateDto::AwaitingRouteSmoke + }; + } + let can_cutover = recovery_probe_available + || terminal_retirement_pending + || matches!( + observation.state, + CutoverDisplayState::InProgress | CutoverDisplayState::RecoveryRequired + ); + ComponentCutoverStatusDto { + component_id: ManagedPackageComponentDto::Proxifyre, + state, + mode: ComponentCutoverModeDto::ServiceSwitch, + legacy_version: safe_cutover_version(&observation.legacy_version), + current_version: current_version.and_then(|version| safe_cutover_version(&version)), + bundled_version: safe_cutover_version(&observation.bundled_version), + original_service_state: Some(match observation.original_service_state { + LegacyServiceState::Running => ComponentCutoverServiceStateDto::Running, + LegacyServiceState::Stopped => ComponentCutoverServiceStateDto::Stopped, + }), + legacy_path_label: Some("Старая установка ProxiFyre".to_string()), + current_path_label: Some("Управляемые компоненты ProxyWarden".to_string()), + steps: fixed_cutover_steps(ManagedPackageComponentDto::Proxifyre), + next_start_verified: observation.next_start_verified, + route_smoke_confirmed: observation.route_smoke_confirmed + || (accepts_user_evidence && locally_confirmed), + can_cutover, + can_confirm_route_smoke: accepts_user_evidence + && evidence + .as_ref() + .is_some_and(|evidence| !evidence.route_smoke_confirmed), + can_cleanup: (matches!( + observation.state, + CutoverDisplayState::CleanupReady | CutoverDisplayState::CleanupPending + ) && observation.phase == CutoverPhase::CleanupConfirmed + && observation.can_cleanup) + || (accepts_user_evidence && locally_confirmed), + disabled_code: observation + .disabled_code + .as_ref() + .map(|_| "component_cutover_recovery_required".to_string()), + disabled_message: observation.disabled_code.map(|_| { + "Перенос требует безопасного восстановления перед другими действиями.".to_string() + }), + } +} + +fn confirm_route_smoke_evidence( + storage: &JsonStorage, + startup_session_id: &str, +) -> Result<(), CommandError> { + let inventory = inventory_proxyfier(); + confirm_route_smoke_evidence_with_inventory( + storage, + startup_session_id, + &inventory, + SystemEpochClock.now_epoch_seconds(), + ) +} + +fn confirm_route_smoke_evidence_with_inventory( + storage: &JsonStorage, + startup_session_id: &str, + inventory: &ComponentInventory, + now: u64, +) -> Result<(), CommandError> { + let observation = storage + .read_component_cutover_observation() + .map_err(|_| cutover_state_error())? + .filter(|observation| { + observation.component == "proxifyre" + && matches!( + (observation.state, observation.phase), + ( + CutoverDisplayState::AwaitingNextStart, + CutoverPhase::LegacyQuarantined + ) | ( + CutoverDisplayState::AwaitingRouteSmoke, + CutoverPhase::NextStartVerified + ) + ) + }) + .ok_or_else(cutover_state_error)?; + let mut evidence = storage + .read_component_cutover_user_evidence() + .map_err(|_| cutover_state_error())? + .filter(|evidence| { + evidence.cutover_id == observation.cutover_id + && evidence.startup_session_id == startup_session_id + }) + .ok_or_else(|| { + CommandError::new( + "component_cutover_next_start_required", + "Сначала перезапустите ProxyWarden и проверьте маршрут на новой установке.", + ) + })?; + if inventory.classification() != ComponentClassification::ManagedCurrent + || component_inventory_fingerprint_for_cutover(inventory) + != evidence.current_inventory_fingerprint + { + return Err(cutover_state_error()); + } + evidence.route_smoke_confirmed = true; + evidence.confirmed_at_epoch_seconds = Some(now); + storage + .write_component_cutover_user_evidence(&evidence) + .map_err(|_| cutover_state_error()) +} + +fn fixed_cutover_steps(component: ManagedPackageComponentDto) -> Vec { + if component == ManagedPackageComponentDto::SingBox { + return Vec::new(); + } + [ + "Проверить точную старую установку и сохранить состояние службы", + "Подготовить встроенный runtime в управляемом каталоге", + "Переключить службу с сохранением состояния Running/Stopped", + "Проверить новую установку и переместить старую в карантин", + "После следующего запуска и проверки маршрута отдельно очистить карантин", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +fn safe_cutover_version(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && value.len() <= 32 + && value.split('.').all(|part| { + !part.is_empty() && part.len() <= 10 && part.bytes().all(|byte| byte.is_ascii_digit()) + })) + .then(|| value.to_string()) +} + +fn require_proxifyre_cutover_component( + component: ManagedPackageComponentDto, +) -> Result<(), CommandError> { + if component == ManagedPackageComponentDto::Proxifyre { + Ok(()) + } else { + Err(CommandError::new( + "manual_migration_required", + "Автоматический перенос старой установки sing-box не поддерживается.", + )) + } +} + +fn cutover_state_error() -> CommandError { + CommandError::new( + "component_cutover_state_conflict", + "Состояние переноса изменилось; обновите статус и повторите действие.", + ) +} + +fn inventory_for_package_component(component: ManagedPackageComponentDto) -> ComponentInventory { + match component { + ManagedPackageComponentDto::Proxifyre => inventory_proxyfier(), + ManagedPackageComponentDto::SingBox => inventory_singbox(), + } +} + +fn component_package_statuses( + storage: &JsonStorage, +) -> Result, CommandError> { + let service = open_component_package_service(storage)?; + let now = SystemEpochClock.now_epoch_seconds(); + [ + ManagedPackageComponentDto::Proxifyre, + ManagedPackageComponentDto::SingBox, + ] + .into_iter() + .map(|component| component_package_status(&service, component, now)) + .collect() +} + +fn component_package_status( + service: &ComponentPackageService, + component: ManagedPackageComponentDto, + now: u64, +) -> Result { + let inventory = inventory_for_package_component(component); + let (installed_version, install_source) = installed_package_snapshot(component, &inventory); + let installed = InstalledComponentSnapshot::new(installed_version.as_deref(), install_source) + .unwrap_or_else(|_| InstalledComponentSnapshot::not_installed()); + let status = service + .update_status(component.catalog_id(), now, installed) + .map_err(component_packages_error)?; + ComponentPackageStatusDto::try_from(&status).map_err(|_| { + CommandError::new( + "component_package_status_failed", + "Получен статус вспомогательного пакета вместо runtime-компонента.", + ) + }) +} + +fn installed_package_snapshot( + component: ManagedPackageComponentDto, + inventory: &ComponentInventory, +) -> (Option, ComponentInstallSource) { + let Some(candidate) = inventory.selected_candidate() else { + return (None, ComponentInstallSource::None); + }; + + if candidate.classification == ComponentClassification::ManagedCurrent { + if let Ok(current_exe) = std::env::current_exe() { + let managed = match component { + ManagedPackageComponentDto::Proxifyre => ManagedComponent::Proxifyre, + ManagedPackageComponentDto::SingBox => ManagedComponent::SingBox, + }; + if let Ok(root) = CanonicalComponentRoot::from_current_exe(¤t_exe, managed) { + if let Ok(receipt) = read_install_receipt(&root) { + let source = match receipt.source { + InstalledPackageSource::Bundled => ComponentInstallSource::Bundled, + InstalledPackageSource::Cache => ComponentInstallSource::Cache, + }; + return (Some(receipt.version), source); + } + } + } + } + + let version = candidate.binary_version.clone(); + if InstalledComponentSnapshot::new(version.as_deref(), ComponentInstallSource::External).is_ok() + { + (version, ComponentInstallSource::External) + } else { + (None, ComponentInstallSource::None) + } +} + +fn open_component_package_service( + storage: &JsonStorage, +) -> Result { + ComponentPackageService::open(bundled_components_root()?, storage.paths()) + .map_err(component_packages_error) +} + +fn bundled_components_root() -> Result { + let current_exe = std::env::current_exe().map_err(|_| { + CommandError::new( + "component_package_unavailable", + "Не удалось определить локальный каталог компонентов ProxyWarden.", + ) + })?; + let installed = current_exe + .parent() + .map(|parent| parent.join("bundled").join("components")); + if installed + .as_deref() + .is_some_and(|root| root.join("catalog.json").is_file()) + { + return installed.ok_or_else(|| { + CommandError::new( + "component_package_unavailable", + "Локальный каталог компонентов ProxyWarden недоступен.", + ) + }); + } + + #[cfg(debug_assertions)] + { + let development = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components"); + if development.join("catalog.json").is_file() { + return Ok(development); + } + } + + Err(CommandError::new( + "component_package_unavailable", + "В установленном приложении не найден локальный каталог компонентов.", + )) +} + +fn component_packages_error(error: ComponentPackagesError) -> CommandError { + match error { + ComponentPackagesError::Transport + | ComponentPackagesError::InvalidResponse + | ComponentPackagesError::InvalidRedirect + | ComponentPackagesError::InvalidReleaseMetadata + | ComponentPackagesError::MissingOrAmbiguousAsset => CommandError::new( + "component_update_check_failed", + "Не удалось получить проверяемые данные об обновлении компонента.", + ), + ComponentPackagesError::NoTrustedUpdate => CommandError::new( + "component_update_unavailable", + "Для компонента нет отдельно проверенного обновления.", + ), + ComponentPackagesError::UpdateStateBusy => CommandError::new( + "component_update_busy", + "Другая операция обновления компонента еще выполняется.", + ), + ComponentPackagesError::InvalidUpdateState | ComponentPackagesError::UpdateStateIo(_) => { + CommandError::new( + "component_update_state_invalid", + "Локальное состояние обновлений повреждено или недоступно.", + ) + } + ComponentPackagesError::SizeMismatch + | ComponentPackagesError::DigestMismatch + | ComponentPackagesError::SignatureVerification + | ComponentPackagesError::PublisherMismatch + | ComponentPackagesError::InvalidArchive + | ComponentPackagesError::PromotionConflict + | ComponentPackagesError::InvalidPrivilegedUpdatePlan + | ComponentPackagesError::UntrustedPrivilegedStaging + | ComponentPackagesError::UntrustedBundleRoot => CommandError::new( + "component_package_verification_failed", + "Пакет компонента не прошел проверку подлинности и целостности.", + ), + ComponentPackagesError::StagingIo(_) => CommandError::new( + "component_update_download_failed", + "Не удалось безопасно сохранить проверенный пакет обновления.", + ), + ComponentPackagesError::Catalog(_) | ComponentPackagesError::MissingBundledComponent => { + CommandError::new( + "component_package_unavailable", + "Локальный каталог компонентов отсутствует или поврежден.", + ) + } + ComponentPackagesError::InvalidTimestamp + | ComponentPackagesError::InvalidInstalledSnapshot => CommandError::new( + "component_package_status_failed", + "Не удалось определить состояние установленного пакета компонента.", + ), + } } fn background_task_error(error: impl std::fmt::Display) -> CommandError { @@ -381,3 +1541,394 @@ fn apply_flow_error(error: ApplyFlowError) -> CommandError { .collect(), ) } + +#[cfg(test)] +mod task5_command_wiring_tests { + use super::*; + use crate::component_catalog::ComponentId as CatalogComponentId; + use crate::component_cutover::{ + ComponentCutoverUserEvidence, CutoverPhase, CUTOVER_OBSERVATION_SCHEMA_VERSION, + CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + }; + use crate::component_inventory::{ + CandidateRole, ComponentCandidate, MarkerEvidence, ServiceEvidence, + }; + use crate::component_packages::{ + ComponentUpdateState, ComponentUpdateStatus, PackageSource, UpdateFreshness, + }; + + #[test] + fn whole_app_elevation_is_disabled_without_relaunching() { + let error = restart_as_admin().expect_err("whole-app elevation must stay disabled"); + + assert_eq!(error.code, "whole_app_elevation_disabled"); + } + + #[test] + fn lifecycle_route_requires_cutover_for_all_legacy_lifecycle_actions() { + let legacy = inventory_with(ComponentClassification::ManagedLegacy, Vec::new()); + + assert_eq!( + lifecycle_route(&legacy, InventoryAction::Start) + .expect_err("legacy start requires cutover") + .code, + "legacy_cutover_required" + ); + assert_eq!( + lifecycle_route(&legacy, InventoryAction::Update) + .expect_err("legacy mutation requires cutover") + .code, + "legacy_cutover_required" + ); + assert_eq!( + lifecycle_route( + &ComponentInventory::missing(crate::models::ComponentId::Proxyfier), + InventoryAction::Install, + ) + .expect("missing install uses privileged preflight"), + LifecycleRoute::Privileged + ); + assert_eq!( + lifecycle_route( + &ComponentInventory::missing(crate::models::ComponentId::Proxyfier), + InventoryAction::Start, + ) + .expect_err("missing start fails before UAC") + .code, + "component_missing" + ); + + let current = inventory_with(ComponentClassification::ManagedCurrent, Vec::new()); + assert_eq!( + lifecycle_route(¤t, InventoryAction::Start) + .expect("current start uses one-shot privileged route"), + LifecycleRoute::Privileged + ); + assert_eq!( + lifecycle_route(¤t, InventoryAction::Install) + .expect_err("current install must fail before UAC") + .code, + "component_already_current" + ); + } + + #[test] + fn lifecycle_route_rejects_foreign_component_before_uac() { + let foreign = inventory_with( + ComponentClassification::Foreign, + vec![InventoryIssue::new( + "ownership_mismatch", + "foreign component", + )], + ); + + let error = lifecycle_route(&foreign, InventoryAction::Update) + .expect_err("foreign component must fail"); + + assert_eq!(error.code, "ownership_mismatch"); + } + + #[test] + fn package_status_dto_contains_no_package_paths_or_digests() { + let status = ComponentUpdateStatus { + component_id: CatalogComponentId::Proxifyre, + installed_version: Some("2.4.0".to_string()), + bundled_version: "2.4.0".to_string(), + available_offline_version: "2.5.0".to_string(), + latest_known_version: Some("2.5.0".to_string()), + last_checked_at_unix: Some(1_787_000_000), + freshness: UpdateFreshness::Fresh, + update_state: ComponentUpdateState::UpdateAvailable, + install_source: ComponentInstallSource::Bundled, + offline_package_source: PackageSource::Cache, + can_install_offline: true, + offline_unavailable_reason: None, + can_download: false, + }; + + let dto = ComponentPackageStatusDto::try_from(&status).expect("runtime DTO"); + let json = serde_json::to_string(&dto).expect("serialize DTO"); + + assert!(json.contains("installedVersion")); + assert!(!json.contains("path")); + assert!(!json.contains("sha256")); + assert!(!json.contains("digest")); + } + + #[test] + fn privileged_terminal_codes_map_to_stable_command_errors() { + assert_eq!( + privileged_result_error(PrivilegedResultCode::PreconditionFailed).code, + "component_precondition_failed" + ); + assert_eq!( + privileged_result_error(PrivilegedResultCode::OwnershipMismatch).code, + "component_ownership_mismatch" + ); + assert_eq!( + privileged_result_error(PrivilegedResultCode::PackageVerificationFailed).code, + "component_package_verification_failed" + ); + assert_eq!( + privileged_result_error(PrivilegedResultCode::ServiceCollision).code, + "component_service_collision" + ); + assert_eq!( + privileged_result_error(PrivilegedResultCode::CleanupPending).code, + "component_cleanup_pending" + ); + } + + #[test] + fn cutover_status_is_redacted_and_singbox_remains_manual_only() { + let storage = JsonStorage::new( + std::env::temp_dir().join(format!("proxywarden-cutover-status-{}", Uuid::new_v4())), + ); + let status = cutover_status_from_observation( + &storage, + "22222222-2222-4222-8222-222222222222", + ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: "11111111-1111-4111-8111-111111111111".to_string(), + state: CutoverDisplayState::AwaitingNextStart, + phase: CutoverPhase::LegacyQuarantined, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "1".repeat(64), + transaction_fingerprint: "2".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "Старая установка ProxiFyre".to_string(), + current_path_label: "Управляемые компоненты ProxyWarden".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 1, + }, + &inventory_with(ComponentClassification::ManagedCurrent, Vec::new()), + Some("2.4.0".to_string()), + false, + false, + ); + let json = serde_json::to_string(&status).expect("serialize cutover status"); + assert!(json.contains("awaiting_next_start")); + assert!(!json.contains("operationFingerprint")); + assert!(!json.contains("transactionFingerprint")); + assert!(!json.contains(r"C:\")); + + assert_eq!( + require_proxifyre_cutover_component(ManagedPackageComponentDto::SingBox) + .expect_err("sing-box cutover stays manual") + .code, + "manual_migration_required" + ); + } + + #[test] + fn recovery_status_never_promotes_untrusted_local_cleanup_evidence() { + let root = std::env::temp_dir().join(format!( + "proxywarden-cutover-recovery-status-{}", + Uuid::new_v4() + )); + let storage = JsonStorage::new(&root); + let cutover_id = "11111111-1111-4111-8111-111111111111"; + let session_id = "22222222-2222-4222-8222-222222222222"; + let inventory = inventory_with(ComponentClassification::ManagedCurrent, Vec::new()); + storage + .write_component_cutover_user_evidence(&ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: cutover_id.to_string(), + startup_session_id: session_id.to_string(), + current_inventory_fingerprint: component_inventory_fingerprint_for_cutover( + &inventory, + ), + route_smoke_confirmed: true, + observed_at_epoch_seconds: 10, + confirmed_at_epoch_seconds: Some(11), + }) + .expect("write forged UX cleanup hint"); + + let status = cutover_status_from_observation( + &storage, + session_id, + ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: cutover_id.to_string(), + state: CutoverDisplayState::RecoveryRequired, + phase: CutoverPhase::RecoveryRequired, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "1".repeat(64), + transaction_fingerprint: "2".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "ignored".to_string(), + current_path_label: "ignored".to_string(), + can_recover: true, + can_cleanup: false, + disabled_code: Some("recovery_required".to_string()), + updated_at_epoch_seconds: 12, + }, + &inventory, + Some("2.4.0".to_string()), + false, + false, + ); + + assert_eq!(status.state, ComponentCutoverStateDto::RecoveryRequired); + assert!(status.can_cutover); + assert!(!status.can_confirm_route_smoke); + assert!(!status.can_cleanup); + assert!(!status.route_smoke_confirmed); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn stale_cleanup_hint_keeps_generic_sealed_recovery_reachable() { + let storage = JsonStorage::new(std::env::temp_dir().join(format!( + "proxywarden-cutover-stale-cleanup-{}", + Uuid::new_v4() + ))); + let status = cutover_status_from_observation( + &storage, + "22222222-2222-4222-8222-222222222222", + ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: "11111111-1111-4111-8111-111111111111".to_string(), + state: CutoverDisplayState::CleanupPending, + phase: CutoverPhase::CleanupConfirmed, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "1".repeat(64), + transaction_fingerprint: "2".repeat(64), + evidence_fingerprint: Some("3".repeat(64)), + next_start_verified: true, + route_smoke_confirmed: true, + legacy_path_label: "ignored".to_string(), + current_path_label: "ignored".to_string(), + can_recover: false, + can_cleanup: true, + disabled_code: None, + updated_at_epoch_seconds: 12, + }, + &inventory_with(ComponentClassification::ManagedCurrent, Vec::new()), + Some("2.4.0".to_string()), + false, + true, + ); + + assert_eq!(status.state, ComponentCutoverStateDto::CleanupPending); + assert!(status.can_cleanup); + assert!(status.can_cutover); + } + + #[test] + fn route_smoke_confirmation_binds_current_session_and_exact_live_inventory() { + let root = + std::env::temp_dir().join(format!("proxywarden-route-confirmation-{}", Uuid::new_v4())); + let storage = JsonStorage::new(&root); + let cutover_id = "11111111-1111-4111-8111-111111111111"; + let session_id = "22222222-2222-4222-8222-222222222222"; + storage + .write_component_cutover_observation(&ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: cutover_id.to_string(), + state: CutoverDisplayState::AwaitingNextStart, + phase: CutoverPhase::LegacyQuarantined, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "1".repeat(64), + transaction_fingerprint: "2".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "ignored".to_string(), + current_path_label: "ignored".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 1, + }) + .expect("write observation"); + let inventory = inventory_with(ComponentClassification::ManagedCurrent, Vec::new()); + storage + .write_component_cutover_user_evidence(&ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: cutover_id.to_string(), + startup_session_id: session_id.to_string(), + current_inventory_fingerprint: component_inventory_fingerprint_for_cutover( + &inventory, + ), + route_smoke_confirmed: false, + observed_at_epoch_seconds: 10, + confirmed_at_epoch_seconds: None, + }) + .expect("write startup evidence"); + + assert_eq!( + confirm_route_smoke_evidence_with_inventory( + &storage, + "33333333-3333-4333-8333-333333333333", + &inventory, + 20, + ) + .expect_err("another startup session must not confirm") + .code, + "component_cutover_next_start_required" + ); + confirm_route_smoke_evidence_with_inventory(&storage, session_id, &inventory, 20) + .expect("confirm exact route evidence"); + let confirmed = storage + .read_component_cutover_user_evidence() + .expect("read confirmed evidence") + .expect("confirmed evidence exists"); + assert!(confirmed.route_smoke_confirmed); + assert_eq!(confirmed.confirmed_at_epoch_seconds, Some(20)); + let _ = std::fs::remove_dir_all(root); + } + + fn inventory_with( + classification: ComponentClassification, + issues: Vec, + ) -> ComponentInventory { + let root = PathBuf::from(r"C:\Legacy\ProxiFyre"); + let executable_path = root.join("ProxiFyre.exe"); + ComponentInventory { + component_id: crate::models::ComponentId::Proxyfier, + candidates: vec![ComponentCandidate { + component_id: crate::models::ComponentId::Proxyfier, + classification, + role: if classification == ComponentClassification::ManagedCurrent { + CandidateRole::Current + } else { + CandidateRole::Legacy + }, + root, + executable_path: Some(executable_path.clone()), + binary_version: Some("2.4.0".to_string()), + service: Some(ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "stopped".to_string(), + path_name: Some(format!(r#""{}" --service"#, executable_path.display())), + executable_path: Some(executable_path), + path_matches_candidate: true, + binary_version: Some("2.4.0".to_string()), + }), + marker: MarkerEvidence::Valid, + issues, + }], + selected: Some(0), + issues: Vec::new(), + } + } +} diff --git a/src-tauri/src/component_catalog.rs b/src-tauri/src/component_catalog.rs new file mode 100644 index 0000000..365a78c --- /dev/null +++ b/src-tauri/src/component_catalog.rs @@ -0,0 +1,869 @@ +use crate::safe_fs::ensure_no_reparse_ancestors; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::{self, Read}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use url::Url; + +pub const COMPONENT_CATALOG_SCHEMA_VERSION: u32 = 1; +pub const COMPONENT_CATALOG_FILENAME: &str = "catalog.json"; + +#[derive(Debug, Error)] +pub enum ComponentCatalogError { + #[error("component catalog JSON is invalid: {0}")] + Json(#[from] serde_json::Error), + #[error("component catalog is invalid: {0}")] + Invalid(String), + #[error("component bundle cannot be read: {0}")] + Io(#[from] io::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TargetArch { + X64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AssetArch { + X64, + Anycpu, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ComponentId { + Proxifyre, + WindowsPacketFilter, + VcRuntime, + SingBox, + Winsw, +} + +impl ComponentId { + pub const ALL: [Self; 5] = [ + Self::Proxifyre, + Self::WindowsPacketFilter, + Self::VcRuntime, + Self::SingBox, + Self::Winsw, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Proxifyre => "proxifyre", + Self::WindowsPacketFilter => "windows-packet-filter", + Self::VcRuntime => "vc-runtime", + Self::SingBox => "sing-box", + Self::Winsw => "winsw", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum InstallRole { + ProxifyreRuntime, + PacketFilterDriver, + VcRuntimePrerequisite, + SingBoxRuntime, + SingBoxServiceWrapper, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentCatalog { + pub schema_version: u32, + pub target_arch: TargetArch, + pub components: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentPackage { + pub id: ComponentId, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub product_version: Option, + pub asset_path: String, + pub asset_arch: AssetArch, + pub effective_target: TargetArch, + pub sha256: String, + pub size: u64, + pub source_url: String, + pub license: ComponentLicense, + pub install_role: InstallRole, + pub update_trust_policy: UpdateTrustPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentLicense { + pub id: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub enum UpdateTrustPolicy { + GithubReleaseDigest { + repository: String, + tag_pattern: String, + asset_pattern: String, + require_stable: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + authenticode_publishers: Option>, + }, + BuildTimeOnlyAuthenticode { + allowed_source_hosts: Vec, + asset_pattern: String, + publishers: Vec, + }, + BundledOnlyNoIndependentProof { + reason: String, + }, +} + +pub fn parse_catalog(bytes: &[u8]) -> Result { + let catalog: ComponentCatalog = serde_json::from_slice(bytes)?; + validate_catalog(&catalog)?; + Ok(catalog) +} + +pub fn validate_bundle(root: &Path) -> Result { + ensure_no_reparse_ancestors(root)?; + let catalog_path = root.join(COMPONENT_CATALOG_FILENAME); + require_regular_file(&catalog_path, "catalog")?; + let catalog = parse_catalog(&fs::read(&catalog_path)?)?; + + let mut expected_files = HashSet::from([COMPONENT_CATALOG_FILENAME.to_string()]); + for component in &catalog.components { + if !expected_files.insert(component.asset_path.clone()) { + return Err(invalid("two components reference the same asset path")); + } + expected_files.insert(component.license.path.clone()); + + let asset_path = root.join(relative_path(&component.asset_path)); + require_regular_file(&asset_path, "component asset")?; + let metadata = fs::metadata(&asset_path)?; + if metadata.len() != component.size { + return Err(invalid(format!( + "asset size does not match catalog for {}", + component.id.as_str() + ))); + } + if sha256_file(&asset_path)? != component.sha256 { + return Err(invalid(format!( + "asset SHA-256 does not match catalog for {}", + component.id.as_str() + ))); + } + + let license_path = root.join(relative_path(&component.license.path)); + require_regular_file(&license_path, "license")?; + if fs::metadata(license_path)?.len() == 0 { + return Err(invalid(format!( + "license file is empty for {}", + component.id.as_str() + ))); + } + } + + let actual_files = collect_bundle_files(root)?; + if actual_files != expected_files { + let missing = expected_files.difference(&actual_files).count(); + let extra = actual_files.difference(&expected_files).count(); + return Err(invalid(format!( + "bundle file set does not match catalog (missing: {missing}, extra: {extra})" + ))); + } + + Ok(catalog) +} + +pub fn parse_bundled_catalog_if_present( + root: &Path, +) -> Result, ComponentCatalogError> { + ensure_no_reparse_ancestors(root)?; + match fs::symlink_metadata(root.join(COMPONENT_CATALOG_FILENAME)) { + Ok(_) => validate_bundle(root).map(Some), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn validate_catalog(catalog: &ComponentCatalog) -> Result<(), ComponentCatalogError> { + if catalog.schema_version != COMPONENT_CATALOG_SCHEMA_VERSION { + return Err(invalid("unsupported schemaVersion")); + } + if catalog.target_arch != TargetArch::X64 { + return Err(invalid("targetArch must be x64")); + } + if catalog.components.len() != ComponentId::ALL.len() { + return Err(invalid("catalog must contain exactly five components")); + } + + let mut component_ids = HashSet::new(); + let mut install_roles = HashSet::new(); + let mut asset_paths = HashSet::new(); + let mut license_paths = HashSet::new(); + for component in &catalog.components { + if !component_ids.insert(component.id) { + return Err(invalid("component IDs must be unique")); + } + if !install_roles.insert(component.install_role) { + return Err(invalid("install roles must be unique")); + } + if !asset_paths.insert(component.asset_path.as_str()) { + return Err(invalid("asset paths must be unique")); + } + if !license_paths.insert(component.license.path.as_str()) { + return Err(invalid("license paths must be unique")); + } + validate_component(component)?; + } + + if ComponentId::ALL + .iter() + .any(|component_id| !component_ids.contains(component_id)) + { + return Err(invalid("catalog is missing a required component")); + } + + Ok(()) +} + +fn validate_component(component: &ComponentPackage) -> Result<(), ComponentCatalogError> { + let (expected_role, expected_arch) = expected_role_and_arch(component.id); + if component.install_role != expected_role { + return Err(invalid(format!( + "installRole does not match component {}", + component.id.as_str() + ))); + } + if component.asset_arch != expected_arch || component.effective_target != TargetArch::X64 { + return Err(invalid(format!( + "asset architecture does not match component {}", + component.id.as_str() + ))); + } + if !is_stable_numeric_version(&component.version) + || component + .file_version + .as_deref() + .is_some_and(|version| !is_stable_numeric_version(version)) + || component + .product_version + .as_deref() + .is_some_and(|version| !is_stable_product_version(version)) + { + return Err(invalid(format!( + "version metadata is invalid for {}", + component.id.as_str() + ))); + } + validate_relative_path(&component.asset_path, "assetPath")?; + if component.asset_path.split('/').next() != Some(component.id.as_str()) { + return Err(invalid(format!( + "assetPath must be inside the {} directory", + component.id.as_str() + ))); + } + validate_relative_path(&component.license.path, "license.path")?; + if component.license.path.split('/').next() != Some(component.id.as_str()) { + return Err(invalid(format!( + "license.path must be inside the {} directory", + component.id.as_str() + ))); + } + if component.asset_path == component.license.path { + return Err(invalid("assetPath and license.path must be different")); + } + if !is_valid_sha256(&component.sha256) { + return Err(invalid(format!( + "SHA-256 is invalid for {}", + component.id.as_str() + ))); + } + if component.size == 0 { + return Err(invalid(format!( + "asset size must be positive for {}", + component.id.as_str() + ))); + } + if !is_valid_license_id(&component.license.id) { + return Err(invalid(format!( + "license ID is invalid for {}", + component.id.as_str() + ))); + } + validate_component_contract(component)?; + + let source = validate_source_url(&component.source_url)?; + let asset_name = component + .asset_path + .rsplit('/') + .next() + .ok_or_else(|| invalid("assetPath has no filename"))?; + if source + .path_segments() + .and_then(|mut segments| segments.next_back()) + != Some(asset_name) + { + return Err(invalid(format!( + "sourceUrl filename does not match assetPath for {}", + component.id.as_str() + ))); + } + validate_official_source(component, &source, asset_name)?; + validate_trust_policy(&component.update_trust_policy, &source, asset_name)?; + + Ok(()) +} + +fn validate_component_contract(component: &ComponentPackage) -> Result<(), ComponentCatalogError> { + let expected_license = match component.id { + ComponentId::Proxifyre => "AGPL-3.0-only", + ComponentId::WindowsPacketFilter => "MIT", + ComponentId::VcRuntime => "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026", + ComponentId::SingBox => "LicenseRef-Sing-Box-Project", + ComponentId::Winsw => "MIT", + }; + if component.license.id != expected_license { + return Err(invalid(format!( + "license ID does not match component {}", + component.id.as_str() + ))); + } + + let policy_matches_component = match (component.id, &component.update_trust_policy) { + ( + ComponentId::Proxifyre, + UpdateTrustPolicy::GithubReleaseDigest { + repository, + tag_pattern, + asset_pattern, + require_stable, + authenticode_publishers, + }, + ) => { + repository == "wiresock/proxifyre" + && tag_pattern == "v*" + && asset_pattern == "ProxiFyre-v*-x64-signed.zip" + && *require_stable + && authenticode_publishers + .as_deref() + .is_some_and(|publishers| { + publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation" + }) + } + ( + ComponentId::WindowsPacketFilter, + UpdateTrustPolicy::GithubReleaseDigest { + repository, + tag_pattern, + asset_pattern, + require_stable, + authenticode_publishers, + }, + ) => { + repository == "wiresock/ndisapi" + && tag_pattern == "v*" + && asset_pattern == "Windows.Packet.Filter.*.x64.msi" + && *require_stable + && authenticode_publishers + .as_deref() + .is_some_and(|publishers| { + publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation" + }) + } + ( + ComponentId::SingBox, + UpdateTrustPolicy::GithubReleaseDigest { + repository, + tag_pattern, + asset_pattern, + require_stable, + authenticode_publishers, + }, + ) => { + repository == "SagerNet/sing-box" + && tag_pattern == "v*" + && asset_pattern == "sing-box-*-windows-amd64.zip" + && *require_stable + && authenticode_publishers.is_none() + } + ( + ComponentId::VcRuntime, + UpdateTrustPolicy::BuildTimeOnlyAuthenticode { + allowed_source_hosts, + asset_pattern, + publishers, + }, + ) => { + allowed_source_hosts.len() == 1 + && allowed_source_hosts[0] == "aka.ms" + && asset_pattern == "VC_redist.x64.exe" + && publishers.len() == 1 + && publishers[0] == "Microsoft Corporation" + } + (ComponentId::Winsw, UpdateTrustPolicy::BundledOnlyNoIndependentProof { .. }) => true, + _ => false, + }; + if !policy_matches_component { + return Err(invalid(format!( + "trust policy does not match component {}", + component.id.as_str() + ))); + } + + Ok(()) +} + +fn validate_official_source( + component: &ComponentPackage, + source: &Url, + asset_name: &str, +) -> Result<(), ComponentCatalogError> { + let expected_repository = match component.id { + ComponentId::Proxifyre => Some("wiresock/proxifyre"), + ComponentId::WindowsPacketFilter => Some("wiresock/ndisapi"), + ComponentId::SingBox => Some("SagerNet/sing-box"), + ComponentId::Winsw => Some("winsw/winsw"), + ComponentId::VcRuntime => None, + }; + + if let Some(expected_repository) = expected_repository { + if source.host_str() != Some("github.com") { + return Err(invalid( + "component source is not its official GitHub repository", + )); + } + let segments = github_release_segments(source)?; + if !segments[0..2] + .join("/") + .eq_ignore_ascii_case(expected_repository) + || segments[5] != asset_name + || segments[4].strip_prefix('v').unwrap_or(segments[4]) != component.version + { + return Err(invalid( + "component source is not its pinned official release", + )); + } + } else if component.version != "14.51.36247.0" + || source.as_str() != "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe" + { + return Err(invalid( + "VC runtime must use the pinned Microsoft 14.51.36247.0 source", + )); + } + + Ok(()) +} + +const fn expected_role_and_arch(component_id: ComponentId) -> (InstallRole, AssetArch) { + match component_id { + ComponentId::Proxifyre => (InstallRole::ProxifyreRuntime, AssetArch::X64), + ComponentId::WindowsPacketFilter => (InstallRole::PacketFilterDriver, AssetArch::X64), + ComponentId::VcRuntime => (InstallRole::VcRuntimePrerequisite, AssetArch::X64), + ComponentId::SingBox => (InstallRole::SingBoxRuntime, AssetArch::X64), + ComponentId::Winsw => (InstallRole::SingBoxServiceWrapper, AssetArch::Anycpu), + } +} + +fn validate_trust_policy( + policy: &UpdateTrustPolicy, + source: &Url, + asset_name: &str, +) -> Result<(), ComponentCatalogError> { + match policy { + UpdateTrustPolicy::GithubReleaseDigest { + repository, + tag_pattern, + asset_pattern, + require_stable, + authenticode_publishers, + } => { + if !*require_stable { + return Err(invalid( + "GitHub release policy must require a stable release", + )); + } + validate_repository(repository)?; + validate_pattern(tag_pattern, "tagPattern")?; + validate_pattern(asset_pattern, "assetPattern")?; + validate_optional_publishers(authenticode_publishers)?; + if source.host_str() != Some("github.com") { + return Err(invalid("GitHub release source must use github.com")); + } + + let segments = github_release_segments(source)?; + if !segments[0..2].join("/").eq_ignore_ascii_case(repository) + || segments[5] != asset_name + || !pattern_matches(tag_pattern, segments[4]) + || !pattern_matches(asset_pattern, asset_name) + { + return Err(invalid( + "GitHub source URL does not match repository/tag/asset policy", + )); + } + } + UpdateTrustPolicy::BuildTimeOnlyAuthenticode { + allowed_source_hosts, + asset_pattern, + publishers, + } => { + validate_hosts(allowed_source_hosts)?; + validate_pattern(asset_pattern, "assetPattern")?; + validate_publishers(publishers)?; + let source_host = source + .host_str() + .ok_or_else(|| invalid("sourceUrl has no host"))?; + if !allowed_source_hosts + .iter() + .any(|host| host.eq_ignore_ascii_case(source_host)) + || !pattern_matches(asset_pattern, asset_name) + { + return Err(invalid( + "build-time Authenticode policy does not match source asset", + )); + } + } + UpdateTrustPolicy::BundledOnlyNoIndependentProof { reason } => { + if reason.trim().is_empty() + || reason.trim() != reason + || reason.chars().count() > 240 + || reason.chars().any(char::is_control) + { + return Err(invalid("bundled-only policy must contain a safe reason")); + } + } + } + + Ok(()) +} + +fn github_release_segments(source: &Url) -> Result, ComponentCatalogError> { + let segments: Vec<_> = source + .path_segments() + .ok_or_else(|| invalid("GitHub source URL has no path"))? + .collect(); + if segments.len() != 6 || segments[2] != "releases" || segments[3] != "download" { + return Err(invalid("GitHub source URL is not a release asset URL")); + } + Ok(segments) +} + +fn validate_source_url(raw: &str) -> Result { + let parsed = Url::parse(raw).map_err(|_| invalid("sourceUrl is not a valid URL"))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.port().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(invalid("sourceUrl must be a plain HTTPS official URL")); + } + Ok(parsed) +} + +fn validate_repository(repository: &str) -> Result<(), ComponentCatalogError> { + let mut segments = repository.split('/'); + let owner = segments.next().unwrap_or_default(); + let name = segments.next().unwrap_or_default(); + if segments.next().is_some() + || !is_safe_repository_segment(owner) + || !is_safe_repository_segment(name) + || name.ends_with(".git") + { + return Err(invalid("GitHub repository identity is invalid")); + } + Ok(()) +} + +fn is_safe_repository_segment(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + && value != "." + && value != ".." +} + +fn validate_pattern(pattern: &str, field: &str) -> Result<(), ComponentCatalogError> { + if pattern.is_empty() + || pattern.len() > 160 + || pattern.matches('*').count() > 1 + || pattern.contains(['/', '\\']) + || pattern.bytes().any(|byte| { + !(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*' | b'+')) + }) + { + return Err(invalid(format!("{field} is invalid"))); + } + Ok(()) +} + +fn pattern_matches(pattern: &str, value: &str) -> bool { + match pattern.split_once('*') { + Some((prefix, suffix)) => { + value.len() >= prefix.len() + suffix.len() + && value.starts_with(prefix) + && value.ends_with(suffix) + } + None => pattern == value, + } +} + +/// Validates a discovered GitHub release asset against the immutable policy +/// embedded in the bundled component catalog. +pub fn validate_github_update_asset( + component: &ComponentPackage, + version: &str, + asset_name: &str, + source_url: &str, +) -> Result<(), ComponentCatalogError> { + let UpdateTrustPolicy::GithubReleaseDigest { + repository, + tag_pattern, + asset_pattern, + require_stable, + .. + } = &component.update_trust_policy + else { + return Err(invalid("component does not allow GitHub runtime updates")); + }; + + if !*require_stable || !is_stable_numeric_version(version) { + return Err(invalid("update version is not stable")); + } + validate_relative_path(asset_name, "update asset name")?; + if asset_name.contains('/') || !pattern_matches(asset_pattern, asset_name) { + return Err(invalid("update asset name does not match policy")); + } + + let source = validate_source_url(source_url)?; + if source.host_str() != Some("github.com") { + return Err(invalid("update asset is not hosted by GitHub")); + } + let segments = github_release_segments(&source)?; + let tag = segments[4]; + if !segments[0..2].join("/").eq_ignore_ascii_case(repository) + || segments[5] != asset_name + || !pattern_matches(tag_pattern, tag) + || tag.strip_prefix('v').unwrap_or(tag) != version + { + return Err(invalid( + "update asset does not match the pinned repository policy", + )); + } + + Ok(()) +} + +fn validate_hosts(hosts: &[String]) -> Result<(), ComponentCatalogError> { + let mut unique = HashSet::new(); + if hosts.is_empty() + || hosts.iter().any(|host| { + host.is_empty() + || host.len() > 253 + || host != &host.to_ascii_lowercase() + || host.starts_with('.') + || host.ends_with('.') + || !host + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.')) + || !unique.insert(host.as_str()) + }) + { + return Err(invalid("allowedSourceHosts is invalid")); + } + Ok(()) +} + +fn validate_optional_publishers( + publishers: &Option>, +) -> Result<(), ComponentCatalogError> { + if let Some(publishers) = publishers { + validate_publishers(publishers)?; + } + Ok(()) +} + +fn validate_publishers(publishers: &[String]) -> Result<(), ComponentCatalogError> { + let mut unique = HashSet::new(); + if publishers.is_empty() + || publishers.iter().any(|publisher| { + publisher.trim().is_empty() + || publisher.trim() != publisher + || publisher.chars().count() > 128 + || publisher.chars().any(char::is_control) + || !unique.insert(publisher.as_str()) + }) + { + return Err(invalid("Authenticode publishers are invalid")); + } + Ok(()) +} + +fn validate_relative_path(value: &str, field: &str) -> Result<(), ComponentCatalogError> { + if value.is_empty() + || value.len() > 512 + || value.contains('\\') + || value.starts_with('/') + || value.ends_with('/') + || value.split('/').any(|segment| { + segment.is_empty() + || segment == "." + || segment == ".." + || segment.len() > 128 + || segment.ends_with('.') + || is_windows_reserved_name(segment) + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + { + return Err(invalid(format!("{field} is not a safe relative path"))); + } + Ok(()) +} + +fn is_windows_reserved_name(segment: &str) -> bool { + let stem = segment.split('.').next().unwrap_or_default(); + let upper = stem.to_ascii_uppercase(); + matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper + .strip_prefix("COM") + .or_else(|| upper.strip_prefix("LPT")) + .is_some_and(|suffix| suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9')) +} + +fn is_valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +fn is_valid_license_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'+' | b'_')) +} + +fn is_stable_numeric_version(value: &str) -> bool { + let segments: Vec<_> = value.split('.').collect(); + (2..=4).contains(&segments.len()) + && segments.iter().all(|segment| { + !segment.is_empty() + && segment.len() <= 10 + && segment.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +fn is_stable_product_version(value: &str) -> bool { + let Some((numeric, metadata)) = value.split_once('+') else { + return is_stable_numeric_version(value); + }; + is_stable_numeric_version(numeric) + && !metadata.is_empty() + && metadata.len() <= 128 + && !metadata.contains('+') + && metadata.split('.').all(|segment| { + !segment.is_empty() + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) +} + +fn relative_path(value: &str) -> PathBuf { + value.split('/').collect() +} + +fn require_regular_file(path: &Path, label: &str) -> Result<(), ComponentCatalogError> { + ensure_no_reparse_ancestors(path)?; + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(invalid(format!("{label} must be a regular file"))); + } + Ok(()) +} + +fn collect_bundle_files(root: &Path) -> Result, ComponentCatalogError> { + ensure_no_reparse_ancestors(root)?; + let mut files = HashSet::new(); + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + ensure_no_reparse_ancestors(&directory)?; + for entry in fs::read_dir(directory)? { + let entry = entry?; + ensure_no_reparse_ancestors(&entry.path())?; + let file_type = entry.file_type()?; + if file_type.is_symlink() { + return Err(invalid("bundle must not contain symbolic links")); + } + if file_type.is_dir() { + directories.push(entry.path()); + } else if file_type.is_file() { + let relative = entry + .path() + .strip_prefix(root) + .map_err(|_| invalid("bundle entry escaped the root directory"))? + .to_string_lossy() + .replace('\\', "/"); + validate_relative_path(&relative, "bundle entry")?; + files.insert(relative); + } else { + return Err(invalid("bundle contains a non-regular filesystem entry")); + } + } + } + Ok(files) +} + +pub fn sha256_file(path: &Path) -> Result { + ensure_no_reparse_ancestors(path)?; + let mut file = File::open(path)?; + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + Ok(hex_lower(&digest.finalize())) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn invalid(message: impl Into) -> ComponentCatalogError { + ComponentCatalogError::Invalid(message.into()) +} diff --git a/src-tauri/src/component_cutover.rs b/src-tauri/src/component_cutover.rs new file mode 100644 index 0000000..0d7c443 --- /dev/null +++ b/src-tauri/src/component_cutover.rs @@ -0,0 +1,6454 @@ +use crate::component_detection::{ + matches_legacy_proxifyre_2_2_1_manifest, LegacyPackageFileIdentity, + LEGACY_PROXIFYRE_2_2_1_MANIFEST, PROXIFYRE_COMPONENT_DIR_NAME, PROXYWARDEN_COMPONENTS_DIR_NAME, +}; +use crate::component_inventory::{ + classify_component_candidates, legacy_proxifyre_topshelf_path_matches, prove_legacy_cutover, + BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, LegacyCutoverEvidence, + LegacyCutoverProof, LegacyProxifyreScmProfile, MarkerEvidence, ServiceEvidence, + LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT, LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION, +}; +use crate::models::ComponentId; +use crate::privileged_jobs::INSTALL_RECEIPT_FILENAME; +use crate::process::{ + ServiceConfig2Kind, ServiceConfig2Snapshot, ServiceRestoreSnapshot, ServiceStableState, + SERVICE_CONFIG2_KINDS, +}; +use crate::proxifyre_ownership::PROXIFYRE_MARKER_FILE; +use crate::safe_fs::{self, SecurityDescriptorSnapshot, StableObjectIdentity, StableObjectKind}; +use crate::storage::JsonStorage; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Component, Path, PathBuf}; +use thiserror::Error; +use uuid::{Uuid, Version}; + +pub const CUTOVER_JOURNAL_SCHEMA_VERSION: u32 = 1; +pub const CUTOVER_OBSERVATION_SCHEMA_VERSION: u32 = 1; +pub const CUTOVER_USER_EVIDENCE_SCHEMA_VERSION: u32 = 1; +pub const CUTOVER_JOURNAL_DIRECTORY: &str = ".proxywarden-cutover"; +const CUTOVER_QUARANTINE_DIRECTORY: &str = ".proxywarden-quarantine"; +const PROXIFYRE_JOURNAL_FILENAME: &str = "proxifyre.json"; +const PROXIFYRE_RETIREMENT_FILENAME: &str = ".proxywarden-proxifyre-retirement.json"; +const MAX_CUTOVER_JOURNAL_BYTES: u64 = 1024 * 1024; +const MAX_SECURITY_DESCRIPTOR_BYTES: usize = 256 * 1024; +const MAX_MUTABLE_CONFIG_BYTES: u64 = 1024 * 1024; +const MAX_LEGACY_LOG_BYTES: u64 = 16 * 1024 * 1024; +const MAX_LEGACY_LOG_FILES: usize = 128; +const MAX_CURRENT_CANDIDATE_FILES: usize = 64; +const MAX_CURRENT_CANDIDATE_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_CURRENT_CANDIDATE_BYTES: u64 = 256 * 1024 * 1024; +pub const CURRENT_PROXIFYRE_PACKAGE_FILES: [&str; 11] = [ + "app-config.sample.json", + "Newtonsoft.Json.dll", + "Newtonsoft.Json.xml", + "NLog.config", + "NLog.dll", + "NLog.xml", + "ProxiFyre.exe", + "ProxiFyre.exe.config", + "socksify.dll", + "Topshelf.dll", + "Topshelf.xml", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxifyreLegacyIdentity { + V2_2_1ToolsPrimaryService, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LegacyServiceState { + Running, + Stopped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProxifyreCutoverPlan { + pub cutover_id: String, + pub identity: ProxifyreLegacyIdentity, + pub legacy_root: PathBuf, + pub current_root: PathBuf, + pub current_candidate_path: PathBuf, + pub current_tombstone_path: PathBuf, + pub quarantine_path: PathBuf, + pub tombstone_path: PathBuf, + pub legacy_version: String, + pub bundled_version: String, + pub original_service_state: LegacyServiceState, + pub package_fingerprint: String, + pub config_fingerprint: String, + pub operation_fingerprint: String, + pub created_startup_session_id: String, +} + +impl ProxifyreCutoverPlan { + #[allow(clippy::too_many_arguments)] + pub fn new( + app_root: &Path, + legacy_root: PathBuf, + original_service_state: LegacyServiceState, + bundled_version: String, + package_fingerprint: String, + config_fingerprint: String, + operation_fingerprint: String, + created_startup_session_id: String, + ) -> Self { + let cutover_id = Uuid::new_v4().hyphenated().to_string(); + let quarantine_root = app_root.join(CUTOVER_QUARANTINE_DIRECTORY); + let journal_root = app_root.join(CUTOVER_JOURNAL_DIRECTORY); + Self { + cutover_id: cutover_id.clone(), + identity: ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService, + legacy_root, + current_root: app_root + .join(PROXYWARDEN_COMPONENTS_DIR_NAME) + .join(PROXIFYRE_COMPONENT_DIR_NAME), + current_candidate_path: journal_root.join(format!("proxifyre-{cutover_id}.candidate")), + current_tombstone_path: journal_root.join(format!("proxifyre-{cutover_id}.rollback")), + quarantine_path: quarantine_root.join(&cutover_id), + tombstone_path: quarantine_root.join(format!("{cutover_id}.deleting")), + legacy_version: LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION.to_string(), + bundled_version, + original_service_state, + package_fingerprint, + config_fingerprint, + operation_fingerprint, + created_startup_session_id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SealedPathSnapshot { + pub identity: StableObjectIdentity, + pub security: SecurityDescriptorSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LegacyManifestEntry { + pub relative_path: PathBuf, + pub identity: StableObjectIdentity, + pub sha256: Option, + pub security: SecurityDescriptorSnapshot, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LegacyFilesystemSnapshot { + pub root: SealedPathSnapshot, + pub config: SealedPathSnapshot, + pub config_relative_path: PathBuf, + pub entries: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CurrentCandidateFileRole { + Package, + Config, + Marker, + Receipt, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CurrentCandidateFile { + pub relative_path: PathBuf, + pub role: CurrentCandidateFileRole, + pub size: u64, + pub sha256: String, +} + +/// Immutable destination material captured from verified package/config leases. +/// No candidate filesystem object exists when this snapshot is produced. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CurrentCandidateSnapshot { + pub manifest_fingerprint: String, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", content = "snapshot", rename_all = "snake_case")] +pub enum CurrentComponentsParentBeforeState { + Absent, + Present(SealedPathSnapshot), +} + +pub fn current_candidate_manifest_fingerprint( + files: &[CurrentCandidateFile], +) -> Result { + validate_current_candidate_files(files)?; + let mut records: Vec<_> = files + .iter() + .map(|file| { + let role = match file.role { + CurrentCandidateFileRole::Package => "package", + CurrentCandidateFileRole::Config => "config", + CurrentCandidateFileRole::Marker => "marker", + CurrentCandidateFileRole::Receipt => "receipt", + }; + format!( + "{role}|{}|{}|{}", + normalized_relative_path(&file.relative_path).unwrap_or_default(), + file.size, + file.sha256 + ) + }) + .collect(); + records.sort_unstable(); + Ok(StateFingerprint::digest( + "proxywarden:proxifyre-cutover-candidate:v1", + records.join("\n").as_bytes(), + ) + .as_str() + .to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrerequisiteBeforeState { + pub windows_packet_filter_present: bool, + pub vc_runtime_present: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LegacyBeforeState { + pub identity: ProxifyreLegacyIdentity, + pub identity_fingerprint: String, + pub service: ServiceRestoreSnapshot, + pub filesystem: LegacyFilesystemSnapshot, + pub current_components_parent: CurrentComponentsParentBeforeState, + pub current_candidate: CurrentCandidateSnapshot, + pub prerequisites: PrerequisiteBeforeState, + pub package_fingerprint: String, + pub config_fingerprint: String, + pub additional_matching_service: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum CutoverOperation { + HardenLegacyRootSecurity, + HardenLegacyEntrySecurity(PathBuf), + InstallWindowsPacketFilterPrerequisite, + InstallVcRuntimePrerequisite, + CreateCurrentComponentsParent, + CreateCurrentCandidateRoot, + WriteCurrentCandidatePackageEntry(PathBuf), + WriteCurrentCandidateConfig, + WriteCurrentCandidateMarker, + WriteCurrentCandidateReceipt, + PromoteCurrentCandidate, + StopLegacyService, + DeleteLegacyService, + CreateCurrentService, + SetCurrentServicePolicy(ServiceConfig2Kind), + SetCurrentServiceSecurity, + StartCurrentService, + QuarantineLegacyRoot, + RestoreLegacyRoot, + StopCurrentService, + DeleteCurrentService, + TombstoneCurrentRoot, + TombstoneCurrentCandidate, + DeleteCurrentEntry(PathBuf), + DeleteCurrentTombstoneRoot, + DeleteTransactionCurrentComponentsParent, + UninstallTransactionWindowsPacketFilter, + CreateLegacyService, + RestoreLegacyServicePolicy(ServiceConfig2Kind), + RestoreLegacyServiceSecurity, + RestoreLegacyEntrySecurity(PathBuf), + RestoreLegacyRootSecurity, + StartLegacyService, + TombstoneQuarantine, + DeleteQuarantineEntry(PathBuf), + DeleteQuarantineRoot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationDirection { + Forward, + Rollback, + Cleanup, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StateFingerprint(String); + +impl StateFingerprint { + pub fn from_sha256(value: String) -> Result { + if is_sha256(&value) { + Ok(Self(value)) + } else { + Err(CutoverError::InvalidPlan) + } + } + + pub fn digest(domain: &str, bytes: &[u8]) -> Self { + let mut digest = Sha256::new(); + digest.update(domain.as_bytes()); + digest.update([0]); + digest.update(bytes); + let digest = digest.finalize(); + Self(digest.iter().map(|byte| format!("{byte:02x}")).collect()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EffectDisposition { + ExpectedEffect, + AlreadySatisfied, + NoEffect, +} + +/// Narrow, host-produced authority evidence for an external mutation whose +/// ownership cannot be recovered from live presence alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MutationAuthorityEvidence { + WindowsPacketFilterInstalledByTransaction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MutationEffect { + pub disposition: EffectDisposition, + pub observed: StateFingerprint, + pub object_identity: Option, + pub observed_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MutationRecord { + pub sequence: u32, + pub direction: MutationDirection, + pub operation: CutoverOperation, + pub before_state: StateFingerprint, + pub expected_effect: StateFingerprint, + pub intent_written_at_epoch_seconds: u64, + pub authority_evidence: Option, + pub effect: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CutoverPhase { + ComponentPlanned, + PrivilegeGranted, + ComponentStaged, + ServiceSwitched, + ComponentVerified, + LegacyQuarantined, + NextStartVerified, + CleanupConfirmed, + ComponentComplete, + RolledBack, + RecoveryRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CutoverExecutionMode { + Forward, + RollingBack, + CleaningUp, + AwaitingUser, + Finished, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NextStartEvidence { + pub startup_session_id: String, + pub current_inventory_fingerprint: String, + pub observed_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RouteSmokeEvidence { + pub confirmed_by_user: bool, + pub confirmed_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CutoverJournal { + pub schema_version: u32, + pub plan: ProxifyreCutoverPlan, + pub phase: CutoverPhase, + pub execution_mode: CutoverExecutionMode, + pub before_state: LegacyBeforeState, + pub mutations: Vec, + pub verified_current_fingerprint: Option, + pub next_start: Option, + pub route_smoke: Option, + pub recovery_code: Option, + pub updated_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CutoverDisplayState { + InProgress, + AwaitingNextStart, + AwaitingRouteSmoke, + CleanupReady, + CleanupPending, + Complete, + RolledBack, + RecoveryRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentCutoverObservation { + pub schema_version: u32, + pub component: String, + pub cutover_id: String, + pub state: CutoverDisplayState, + pub phase: CutoverPhase, + pub original_service_state: LegacyServiceState, + pub legacy_version: String, + pub bundled_version: String, + pub operation_fingerprint: String, + pub transaction_fingerprint: String, + pub evidence_fingerprint: Option, + pub next_start_verified: bool, + pub route_smoke_confirmed: bool, + pub legacy_path_label: String, + pub current_path_label: String, + pub can_recover: bool, + pub can_cleanup: bool, + pub disabled_code: Option, + pub updated_at_epoch_seconds: u64, +} + +/// Normal-process evidence carrier. This file is intentionally untrusted: an +/// elevated consumer must bind `cutover_id` to the sealed journal and must +/// independently re-observe the startup/current inventory before advancing the +/// durable transaction. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentCutoverUserEvidence { + pub schema_version: u32, + pub cutover_id: String, + pub startup_session_id: String, + pub current_inventory_fingerprint: String, + pub route_smoke_confirmed: bool, + pub observed_at_epoch_seconds: u64, + pub confirmed_at_epoch_seconds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CutoverRunOutcome { + AwaitingNextStart, + QuarantinePending, + RolledBack, + CleanupPending, + Complete, +} + +/// Read-only, redacted gate for mutations owned by installers or other +/// components. Only `Absent` permits an external mutation; every other value +/// requires Task6 cutover handling and carries no deletion authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CutoverExternalMutationStatus { + Absent, + Active, + TerminalRetirementPending, + RolledBackRetained, + RecoveryRequired, +} + +/// Read-only resolution binds an explicit terminal-retirement request to one +/// exact sealed transaction, or to the separate no-record empty-root case. +/// This value narrows mutation and is never authority by itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CutoverTerminalRetirementExpectation { + Journal { + cutover_id: String, + phase: CutoverPhase, + terminal_record_fingerprint: String, + }, + EmptyInfrastructure, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum CutoverHostError { + #[error("cutover precondition failed")] + PreconditionFailed, + #[error("cutover mutation failed")] + MutationFailed, + #[error("cutover observation failed")] + ObservationFailed, + #[error("cutover verification failed")] + VerificationFailed, + #[error("cutover operation is unsupported")] + Unsupported, +} + +#[derive(Debug, Error)] +pub enum CutoverError { + #[error("cutover plan is invalid")] + InvalidPlan, + #[error("another cutover already exists")] + AlreadyExists, + #[error("cutover journal is missing")] + Missing, + #[error("cutover state blocks this operation")] + StateConflict, + #[error("cutover needs explicit recovery")] + RecoveryRequired, + #[error("cutover was interrupted after a host mutation")] + Interrupted, + #[error("cutover host rejected the operation")] + Host(#[from] CutoverHostError), + #[error("cutover journal storage failed")] + Storage(#[source] io::Error), +} + +impl From for CutoverError { + fn from(value: io::Error) -> Self { + Self::Storage(value) + } +} + +pub trait CutoverHost { + /// Must capture exact SCM/filesystem rollback material through retained + /// handles and complete all read-only identity checks before returning. + fn capture_and_lease( + &mut self, + plan: &ProxifyreCutoverPlan, + ) -> Result; + + /// Reopens every lease by the exact identities persisted in the journal. + fn reacquire_leases(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError>; + + fn observe( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result; + + /// Returns the stable identity retained by the same observation lease. + /// Candidate create/write effects require this value so recovery can reject + /// a path replacement even when its bytes are identical. + fn observed_object_identity( + &mut self, + _operation: &CutoverOperation, + _journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + Ok(None) + } + + fn expected_effect( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result; + + /// Exactly one externally visible mutation per call. The coordinator writes + /// and flushes the matching intent before entering this method. + fn mutate( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result, CutoverHostError>; + + fn verify_current( + &mut self, + journal: &CutoverJournal, + ) -> Result; + + /// Canonical redacted `ComponentInventory` identity observed live at the + /// new startup. This domain is intentionally distinct from the sealed + /// runtime/package/service verification fingerprint above. + fn verify_current_inventory( + &mut self, + journal: &CutoverJournal, + ) -> Result; + + /// Final exact check after every rollback mutation. Returning success means + /// the legacy filesystem/service and rollback-owned packet-filter state + /// equal the sealed before state, every transaction current artifact is + /// absent, and the deliberately conservative VC-runtime policy still holds. + fn verify_rollback(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError>; + + fn verify_cleanup(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError>; +} + +pub trait CutoverJournalStore { + fn load(&self) -> Result, CutoverError>; + fn create(&self, journal: &CutoverJournal) -> Result<(), CutoverError>; + fn replace(&self, journal: &CutoverJournal) -> Result<(), CutoverError>; + fn write_observation( + &self, + observation: &ComponentCutoverObservation, + ) -> Result<(), CutoverError>; + + /// Retires only the already-durable `ComponentComplete` record. In-memory + /// test stores may keep the terminal record, while the sealed production + /// store removes it and its exact empty infrastructure crash-safely. + fn retire_completed(&self, journal: &CutoverJournal) -> Result<(), CutoverError>; +} + +pub trait CutoverFaultInjector { + fn after_host_mutation( + &mut self, + direction: MutationDirection, + operation: &CutoverOperation, + ) -> Result<(), CutoverError>; +} + +#[derive(Debug, Default)] +pub struct NoCutoverFault; + +impl CutoverFaultInjector for NoCutoverFault { + fn after_host_mutation( + &mut self, + _direction: MutationDirection, + _operation: &CutoverOperation, + ) -> Result<(), CutoverError> { + Ok(()) + } +} + +#[derive(Debug, Clone)] +pub struct SealedCutoverStore { + app_root: PathBuf, + journal_root: PathBuf, + quarantine_root: PathBuf, + retirement_file: PathBuf, + observation_file: PathBuf, +} + +impl SealedCutoverStore { + pub fn prepare(app_root: &Path, storage: &JsonStorage) -> Result { + safe_fs::verify_path_under_trusted_program_files(app_root)?; + let journal_root = app_root.join(CUTOVER_JOURNAL_DIRECTORY); + let quarantine_root = app_root.join(CUTOVER_QUARANTINE_DIRECTORY); + let retirement_file = app_root.join(PROXIFYRE_RETIREMENT_FILENAME); + let retirement_exists = path_exists_for_cutover(&retirement_file)?; + if retirement_exists { + safe_fs::verify_admin_system_only_file(&retirement_file)?; + } + let journal_root_exists = path_exists_for_cutover(&journal_root)?; + let quarantine_root_exists = path_exists_for_cutover(&quarantine_root)?; + verify_existing_sealed_directory(&journal_root)?; + verify_existing_sealed_directory(&quarantine_root)?; + // Cutover execution owns these bootstrap roots. A verify-only external + // gate must use `verify_cutover_external_mutation_status` instead of + // `prepare`, so ordinary lifecycle checks never create them. + let mut journal_root_created = false; + let mut quarantine_root_created = false; + let bootstrap_result = (|| -> Result<(), CutoverError> { + if !journal_root_exists { + safe_fs::create_new_directory_admin_system_only(&journal_root)?; + journal_root_created = true; + } + if !quarantine_root_exists { + safe_fs::create_new_directory_admin_system_only(&quarantine_root)?; + quarantine_root_created = true; + } + safe_fs::verify_path_under_trusted_program_files(app_root)?; + safe_fs::verify_directory_admin_system_only(&journal_root)?; + safe_fs::verify_directory_admin_system_only(&quarantine_root)?; + Ok(()) + })(); + if let Err(error) = bootstrap_result { + let quarantine_cleanup_failed = + quarantine_root_created && cleanup_created_cutover_root(&quarantine_root).is_err(); + let journal_cleanup_failed = + journal_root_created && cleanup_created_cutover_root(&journal_root).is_err(); + return Err(if quarantine_cleanup_failed || journal_cleanup_failed { + CutoverError::RecoveryRequired + } else { + error + }); + } + Ok(Self { + app_root: app_root.to_path_buf(), + journal_root, + quarantine_root, + retirement_file, + observation_file: storage.paths().component_cutover_observation_file.clone(), + }) + } + + fn journal_path(&self) -> PathBuf { + self.journal_root.join(PROXIFYRE_JOURNAL_FILENAME) + } + + fn verify_roots(&self) -> Result<(), CutoverError> { + safe_fs::verify_path_under_trusted_program_files(&self.app_root)?; + safe_fs::verify_directory_admin_system_only(&self.journal_root)?; + safe_fs::verify_directory_admin_system_only(&self.quarantine_root)?; + Ok(()) + } + + fn verify_existing_infrastructure(&self) -> Result<(), CutoverError> { + safe_fs::verify_path_under_trusted_program_files(&self.app_root)?; + verify_existing_sealed_directory(&self.journal_root)?; + verify_existing_sealed_directory(&self.quarantine_root)?; + if path_exists_for_cutover(&self.retirement_file)? { + safe_fs::verify_admin_system_only_file(&self.retirement_file)?; + } + Ok(()) + } + + fn validate_anchors(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + let expected_current = self + .app_root + .join(PROXYWARDEN_COMPONENTS_DIR_NAME) + .join(PROXIFYRE_COMPONENT_DIR_NAME); + let expected_candidate = self + .journal_root + .join(format!("proxifyre-{}.candidate", journal.plan.cutover_id)); + let expected_current_tombstone = self + .journal_root + .join(format!("proxifyre-{}.rollback", journal.plan.cutover_id)); + let expected_quarantine = self.quarantine_root.join(&journal.plan.cutover_id); + let expected_tombstone = self + .quarantine_root + .join(format!("{}.deleting", journal.plan.cutover_id)); + if !windows_path_eq_path(&journal.plan.current_root, &expected_current) + || !windows_path_eq_path(&journal.plan.current_candidate_path, &expected_candidate) + || !windows_path_eq_path( + &journal.plan.current_tombstone_path, + &expected_current_tombstone, + ) + || !windows_path_eq_path(&journal.plan.quarantine_path, &expected_quarantine) + || !windows_path_eq_path(&journal.plan.tombstone_path, &expected_tombstone) + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) + } + + fn encode(journal: &CutoverJournal) -> Result, CutoverError> { + validate_journal(journal)?; + let bytes = serde_json::to_vec_pretty(journal) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if bytes.len() as u64 > MAX_CUTOVER_JOURNAL_BYTES { + return Err(CutoverError::InvalidPlan); + } + Ok(bytes) + } + + fn read_optional_journal_at( + &self, + path: &Path, + ) -> Result, CutoverError> { + let file = match safe_fs::open_admin_system_only_file_read_lease(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + if file.metadata()?.len() > MAX_CUTOVER_JOURNAL_BYTES { + return Err(CutoverError::InvalidPlan); + } + let mut bytes = Vec::new(); + file.take(MAX_CUTOVER_JOURNAL_BYTES + 1) + .read_to_end(&mut bytes)?; + let journal: CutoverJournal = serde_json::from_slice(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + validate_journal(&journal)?; + self.validate_anchors(&journal)?; + Ok(Some(journal)) + } +} + +impl CutoverJournalStore for SealedCutoverStore { + fn load(&self) -> Result, CutoverError> { + self.verify_existing_infrastructure()?; + let active = self.read_optional_journal_at(&self.journal_path())?; + let retirement = match self.read_optional_journal_at(&self.retirement_file) { + Ok(retirement) => retirement, + Err(_) if active.as_ref().is_some_and(is_retirable_terminal_journal) => None, + Err(error) => return Err(error), + }; + match (active, retirement) { + (Some(active), Some(retirement)) if active == retirement => Ok(Some(active)), + (Some(_), Some(_)) => Err(CutoverError::InvalidPlan), + (Some(active), None) => Ok(Some(active)), + (None, Some(retirement)) if is_retirable_terminal_journal(&retirement) => { + Ok(Some(retirement)) + } + (None, Some(_)) => Err(CutoverError::InvalidPlan), + (None, None) => Ok(None), + } + } + + fn create(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + self.verify_roots()?; + self.validate_anchors(journal)?; + let path = self.journal_path(); + let bytes = Self::encode(journal)?; + let mut file = safe_fs::create_new_admin_system_only_file(&path).map_err(|error| { + if error.kind() == io::ErrorKind::AlreadyExists { + CutoverError::AlreadyExists + } else { + error.into() + } + })?; + let result = file.write_all(&bytes).and_then(|()| file.sync_all()); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(&path); + return Err(error.into()); + } + safe_fs::verify_admin_system_only_file(&path)?; + Ok(()) + } + + fn replace(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + self.verify_roots()?; + self.validate_anchors(journal)?; + let bytes = Self::encode(journal)?; + safe_fs::write_admin_system_only_atomic(&self.journal_path(), &bytes)?; + Ok(()) + } + + fn write_observation( + &self, + observation: &ComponentCutoverObservation, + ) -> Result<(), CutoverError> { + let bytes = serde_json::to_vec_pretty(observation) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + safe_fs::write_restricted_with_backup(&self.observation_file, &bytes)?; + Ok(()) + } + + fn retire_completed(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + self.retire_completed_with_fault(journal, &mut NoCutoverRetirementFault) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RetirementRootShape { + Missing, + Empty, + ActiveJournalOnly, + Unexpected, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RetirementRecordState { + Missing, + Valid(RetirementRecordIdentity), + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RetirementRecordIdentity { + phase: CutoverPhase, + execution_mode: CutoverExecutionMode, + fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CutoverRetirementSnapshot { + journal_root: RetirementRootShape, + quarantine_root: RetirementRootShape, + active_record: RetirementRecordState, + retirement_record: RetirementRecordState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CutoverRetirementOperation { + DeleteInvalidRetirementRecord, + WriteRetirementRecord, + DeleteActiveJournal, + DeleteJournalRoot, + DeleteQuarantineRoot, + DeleteRetirementRecord, +} + +trait CutoverRetirementFaultInjector { + fn after_retirement_mutation( + &mut self, + operation: CutoverRetirementOperation, + ) -> Result<(), CutoverError>; +} + +struct NoCutoverRetirementFault; + +impl CutoverRetirementFaultInjector for NoCutoverRetirementFault { + fn after_retirement_mutation( + &mut self, + _operation: CutoverRetirementOperation, + ) -> Result<(), CutoverError> { + Ok(()) + } +} + +trait CutoverRetirementIo { + fn snapshot(&mut self) -> Result; + + fn apply( + &mut self, + operation: CutoverRetirementOperation, + retirement_record: Option<(&[u8], &RetirementRecordIdentity)>, + ) -> Result<(), CutoverError>; +} + +struct SealedCutoverRetirementIo<'a> { + store: &'a SealedCutoverStore, +} + +impl SealedCutoverStore { + fn retire_completed_with_fault( + &self, + journal: &CutoverJournal, + fault: &mut dyn CutoverRetirementFaultInjector, + ) -> Result<(), CutoverError> { + if !is_completed_terminal_journal(journal) { + return Err(CutoverError::StateConflict); + } + let mut io = SealedCutoverRetirementIo { store: self }; + run_terminal_cutover_retirement(&mut io, journal, fault) + } +} + +impl CutoverRetirementIo for SealedCutoverRetirementIo<'_> { + fn snapshot(&mut self) -> Result { + self.store.verify_existing_infrastructure()?; + Ok(CutoverRetirementSnapshot { + journal_root: observe_retirement_root( + &self.store.journal_root, + Some(OsStr::new(PROXIFYRE_JOURNAL_FILENAME)), + )?, + quarantine_root: observe_retirement_root(&self.store.quarantine_root, None)?, + active_record: observe_retirement_record(self.store, &self.store.journal_path())?, + retirement_record: observe_retirement_record(self.store, &self.store.retirement_file)?, + }) + } + + fn apply( + &mut self, + operation: CutoverRetirementOperation, + retirement_record: Option<(&[u8], &RetirementRecordIdentity)>, + ) -> Result<(), CutoverError> { + match operation { + CutoverRetirementOperation::DeleteInvalidRetirementRecord + | CutoverRetirementOperation::DeleteRetirementRecord => { + delete_exact_admin_system_only_file(&self.store.retirement_file) + } + CutoverRetirementOperation::WriteRetirementRecord => { + let (bytes, expected_identity) = + retirement_record.ok_or(CutoverError::StateConflict)?; + let mut file = + safe_fs::create_new_admin_system_only_file(&self.store.retirement_file)?; + file.write_all(bytes)?; + file.sync_all()?; + drop(file); + safe_fs::verify_admin_system_only_file(&self.store.retirement_file)?; + match observe_retirement_record(self.store, &self.store.retirement_file)? { + RetirementRecordState::Valid(observed) if &observed == expected_identity => { + Ok(()) + } + _ => Err(CutoverError::RecoveryRequired), + } + } + CutoverRetirementOperation::DeleteActiveJournal => { + delete_exact_admin_system_only_file(&self.store.journal_path()) + } + CutoverRetirementOperation::DeleteJournalRoot => { + delete_exact_empty_admin_system_only_directory(&self.store.journal_root) + } + CutoverRetirementOperation::DeleteQuarantineRoot => { + delete_exact_empty_admin_system_only_directory(&self.store.quarantine_root) + } + } + } +} + +fn run_terminal_cutover_retirement( + io: &mut dyn CutoverRetirementIo, + journal: &CutoverJournal, + fault: &mut dyn CutoverRetirementFaultInjector, +) -> Result<(), CutoverError> { + if !is_retirable_terminal_journal(journal) { + return Err(CutoverError::StateConflict); + } + let bytes = SealedCutoverStore::encode(journal)?; + let expected = retirement_record_identity_from_bytes(journal, &bytes); + run_terminal_retirement_record(io, &expected, &bytes, fault) +} + +fn run_terminal_retirement_record( + io: &mut dyn CutoverRetirementIo, + expected: &RetirementRecordIdentity, + bytes: &[u8], + fault: &mut dyn CutoverRetirementFaultInjector, +) -> Result<(), CutoverError> { + if !is_retirable_terminal_identity(expected) { + return Err(CutoverError::StateConflict); + } + for _ in 0..8 { + let snapshot = io.snapshot()?; + let Some(operation) = next_completed_retirement_operation(&snapshot, expected)? else { + return Ok(()); + }; + io.apply(operation, Some((bytes, expected)))?; + fault.after_retirement_mutation(operation)?; + } + Err(CutoverError::RecoveryRequired) +} + +fn next_completed_retirement_operation( + snapshot: &CutoverRetirementSnapshot, + expected: &RetirementRecordIdentity, +) -> Result, CutoverError> { + if snapshot.journal_root == RetirementRootShape::Unexpected + || snapshot.quarantine_root == RetirementRootShape::Unexpected + { + return Err(CutoverError::RecoveryRequired); + } + + let active_matches = match &snapshot.active_record { + RetirementRecordState::Valid(observed) => observed == expected, + RetirementRecordState::Missing | RetirementRecordState::Invalid => false, + }; + let retirement_matches = match &snapshot.retirement_record { + RetirementRecordState::Valid(observed) => observed == expected, + RetirementRecordState::Missing | RetirementRecordState::Invalid => false, + }; + if matches!(snapshot.active_record, RetirementRecordState::Valid(_)) && !active_matches + || matches!(snapshot.retirement_record, RetirementRecordState::Valid(_)) + && !retirement_matches + { + return Err(CutoverError::RecoveryRequired); + } + + match &snapshot.retirement_record { + RetirementRecordState::Invalid if active_matches => { + return Ok(Some( + CutoverRetirementOperation::DeleteInvalidRetirementRecord, + )); + } + RetirementRecordState::Invalid => return Err(CutoverError::RecoveryRequired), + RetirementRecordState::Missing if active_matches => { + if snapshot.journal_root != RetirementRootShape::ActiveJournalOnly + || !matches!( + snapshot.quarantine_root, + RetirementRootShape::Empty | RetirementRootShape::Missing + ) + { + return Err(CutoverError::RecoveryRequired); + } + return Ok(Some(CutoverRetirementOperation::WriteRetirementRecord)); + } + RetirementRecordState::Missing => { + return if snapshot.active_record == RetirementRecordState::Missing + && snapshot.journal_root == RetirementRootShape::Missing + && snapshot.quarantine_root == RetirementRootShape::Missing + { + Ok(None) + } else { + Err(CutoverError::RecoveryRequired) + }; + } + RetirementRecordState::Valid(_) => {} + } + + if !matches!( + snapshot.quarantine_root, + RetirementRootShape::Empty | RetirementRootShape::Missing + ) { + return Err(CutoverError::RecoveryRequired); + } + match &snapshot.active_record { + RetirementRecordState::Valid(_) | RetirementRecordState::Invalid => { + if snapshot.journal_root != RetirementRootShape::ActiveJournalOnly { + return Err(CutoverError::RecoveryRequired); + } + Ok(Some(CutoverRetirementOperation::DeleteActiveJournal)) + } + RetirementRecordState::Missing => match snapshot.journal_root { + RetirementRootShape::Empty => Ok(Some(CutoverRetirementOperation::DeleteJournalRoot)), + RetirementRootShape::Missing => match snapshot.quarantine_root { + RetirementRootShape::Empty => { + Ok(Some(CutoverRetirementOperation::DeleteQuarantineRoot)) + } + RetirementRootShape::Missing => { + Ok(Some(CutoverRetirementOperation::DeleteRetirementRecord)) + } + _ => Err(CutoverError::RecoveryRequired), + }, + _ => Err(CutoverError::RecoveryRequired), + }, + } +} + +fn retire_empty_cutover_infrastructure( + io: &mut dyn CutoverRetirementIo, + fault: &mut dyn CutoverRetirementFaultInjector, +) -> Result<(), CutoverError> { + for _ in 0..3 { + let snapshot = io.snapshot()?; + if snapshot.active_record != RetirementRecordState::Missing + || snapshot.retirement_record != RetirementRecordState::Missing + || !matches!( + snapshot.journal_root, + RetirementRootShape::Missing | RetirementRootShape::Empty + ) + || !matches!( + snapshot.quarantine_root, + RetirementRootShape::Missing | RetirementRootShape::Empty + ) + { + return Err(CutoverError::RecoveryRequired); + } + let operation = if snapshot.journal_root == RetirementRootShape::Empty { + Some(CutoverRetirementOperation::DeleteJournalRoot) + } else if snapshot.quarantine_root == RetirementRootShape::Empty { + Some(CutoverRetirementOperation::DeleteQuarantineRoot) + } else { + None + }; + let Some(operation) = operation else { + return Ok(()); + }; + io.apply(operation, None)?; + fault.after_retirement_mutation(operation)?; + } + Err(CutoverError::RecoveryRequired) +} + +/// Verifies the complete Task6 filesystem boundary without creating roots, +/// deleting files, repairing records, or consulting the untrusted ProgramData +/// observation. External owners must allow mutation only for `Absent`. +pub fn verify_cutover_external_mutation_status(app_root: &Path) -> CutoverExternalMutationStatus { + let store = sealed_cutover_store_view(app_root); + let mut io = SealedCutoverRetirementIo { store: &store }; + let snapshot = match io.snapshot() { + Ok(snapshot) => snapshot, + Err(_) => return CutoverExternalMutationStatus::RecoveryRequired, + }; + let status = classify_external_mutation_status(&snapshot); + if status != CutoverExternalMutationStatus::Active { + return status; + } + match store.load() { + Ok(Some(journal)) if active_cutover_root_entries_are_exact(&store, &journal) => status, + _ => CutoverExternalMutationStatus::RecoveryRequired, + } +} + +/// Internal read-only journal view for the narrow stopped-service route-smoke +/// exception. It does not create bootstrap roots or repair retirement state. +pub(crate) fn read_existing_sealed_cutover_journal( + app_root: &Path, +) -> Result, CutoverError> { + let store = sealed_cutover_store_view(app_root); + let mut io = SealedCutoverRetirementIo { store: &store }; + let status = classify_external_mutation_status(&io.snapshot()?); + let journal = store.load()?; + let Some(journal) = journal else { + return Ok(None); + }; + let accepted = existing_journal_status_is_strictly_accepted( + status, + is_retirable_terminal_journal(&journal), + active_cutover_root_entries_are_exact(&store, &journal), + ); + if accepted { + Ok(Some(journal)) + } else { + Err(CutoverError::RecoveryRequired) + } +} + +fn existing_journal_status_is_strictly_accepted( + status: CutoverExternalMutationStatus, + terminal: bool, + exact_active_entries: bool, +) -> bool { + if terminal { + matches!( + status, + CutoverExternalMutationStatus::TerminalRetirementPending + | CutoverExternalMutationStatus::RolledBackRetained + ) + } else { + exact_active_entries + } +} + +/// Resumes the Task6-owned terminal retirement. Callers receive no path or +/// deletion primitive; all authority is re-derived from the sealed completed +/// journal or its durable retirement record. +pub fn retire_proxifyre_cutover_terminal_state( + store: &SealedCutoverStore, +) -> Result<(), CutoverError> { + let Some(journal) = store.load()? else { + let mut io = SealedCutoverRetirementIo { store }; + return retire_empty_cutover_infrastructure(&mut io, &mut NoCutoverRetirementFault); + }; + let mut io = SealedCutoverRetirementIo { store }; + run_terminal_cutover_retirement(&mut io, &journal, &mut NoCutoverRetirementFault) +} + +/// Executes an already-resolved terminal retirement without creating bootstrap +/// roots. The exact sealed journal binding is revalidated immediately before +/// the first deletion; a missing or replaced transaction causes zero mutation. +pub fn retire_existing_proxifyre_cutover_terminal_state( + app_root: &Path, + expected: &CutoverTerminalRetirementExpectation, +) -> Result<(), CutoverError> { + let store = sealed_cutover_store_view(app_root); + match expected { + CutoverTerminalRetirementExpectation::Journal { + cutover_id, + phase, + terminal_record_fingerprint, + } => { + let journal = store.load()?.ok_or(CutoverError::StateConflict)?; + if !is_retirable_terminal_journal(&journal) + || journal.plan.cutover_id != *cutover_id + || journal.phase != *phase + || cutover_terminal_record_fingerprint(&journal)? != *terminal_record_fingerprint + { + return Err(CutoverError::StateConflict); + } + let mut io = SealedCutoverRetirementIo { store: &store }; + run_terminal_cutover_retirement(&mut io, &journal, &mut NoCutoverRetirementFault) + } + CutoverTerminalRetirementExpectation::EmptyInfrastructure => { + if store.load()?.is_some() { + return Err(CutoverError::StateConflict); + } + let mut io = SealedCutoverRetirementIo { store: &store }; + retire_empty_cutover_infrastructure(&mut io, &mut NoCutoverRetirementFault) + } + } +} + +fn classify_external_mutation_status( + snapshot: &CutoverRetirementSnapshot, +) -> CutoverExternalMutationStatus { + let valid_active = match &snapshot.active_record { + RetirementRecordState::Valid(identity) => Some(identity), + RetirementRecordState::Missing | RetirementRecordState::Invalid => None, + }; + let valid_retirement = match &snapshot.retirement_record { + RetirementRecordState::Valid(identity) => Some(identity), + RetirementRecordState::Missing | RetirementRecordState::Invalid => None, + }; + + if let Some(active) = valid_active { + if active.phase == CutoverPhase::RecoveryRequired { + return CutoverExternalMutationStatus::RecoveryRequired; + } + if !matches!( + active.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) { + return if valid_retirement.is_none() + && snapshot.retirement_record == RetirementRecordState::Missing + { + CutoverExternalMutationStatus::Active + } else { + CutoverExternalMutationStatus::RecoveryRequired + }; + } + if active.execution_mode != CutoverExecutionMode::Finished + || snapshot.journal_root != RetirementRootShape::ActiveJournalOnly + || !matches!( + snapshot.quarantine_root, + RetirementRootShape::Empty | RetirementRootShape::Missing + ) + { + return CutoverExternalMutationStatus::RecoveryRequired; + } + if active.phase == CutoverPhase::RolledBack { + return match &snapshot.retirement_record { + RetirementRecordState::Missing => CutoverExternalMutationStatus::RolledBackRetained, + RetirementRecordState::Invalid => { + CutoverExternalMutationStatus::TerminalRetirementPending + } + RetirementRecordState::Valid(retirement) if retirement == active => { + CutoverExternalMutationStatus::TerminalRetirementPending + } + RetirementRecordState::Valid(_) => CutoverExternalMutationStatus::RecoveryRequired, + }; + } + if valid_retirement.is_some_and(|retirement| retirement != active) { + return CutoverExternalMutationStatus::RecoveryRequired; + } + return CutoverExternalMutationStatus::TerminalRetirementPending; + } + + if let Some(retirement) = valid_retirement { + if !is_retirable_terminal_identity(retirement) + || !matches!( + snapshot.journal_root, + RetirementRootShape::Empty | RetirementRootShape::Missing + ) + || !matches!( + snapshot.quarantine_root, + RetirementRootShape::Empty | RetirementRootShape::Missing + ) + { + return CutoverExternalMutationStatus::RecoveryRequired; + } + return CutoverExternalMutationStatus::TerminalRetirementPending; + } + + if snapshot.active_record == RetirementRecordState::Missing + && snapshot.retirement_record == RetirementRecordState::Missing + { + return match (snapshot.journal_root, snapshot.quarantine_root) { + (RetirementRootShape::Missing, RetirementRootShape::Missing) => { + CutoverExternalMutationStatus::Absent + } + ( + RetirementRootShape::Missing | RetirementRootShape::Empty, + RetirementRootShape::Missing | RetirementRootShape::Empty, + ) => CutoverExternalMutationStatus::TerminalRetirementPending, + _ => CutoverExternalMutationStatus::RecoveryRequired, + }; + } + CutoverExternalMutationStatus::RecoveryRequired +} + +fn active_cutover_root_entries_are_exact( + store: &SealedCutoverStore, + journal: &CutoverJournal, +) -> bool { + let journal_entries = match read_direct_child_names(&store.journal_root) { + Ok(entries) => entries, + Err(_) => return false, + }; + let quarantine_entries = match read_direct_child_names(&store.quarantine_root) { + Ok(entries) => entries, + Err(_) => return false, + }; + active_cutover_entry_names_are_exact(journal, &journal_entries, &quarantine_entries) +} + +fn active_cutover_entry_names_are_exact( + journal: &CutoverJournal, + journal_entries: &[OsString], + quarantine_entries: &[OsString], +) -> bool { + let candidate_name = match journal.plan.current_candidate_path.file_name() { + Some(name) => name, + None => return false, + }; + let current_tombstone_name = match journal.plan.current_tombstone_path.file_name() { + Some(name) => name, + None => return false, + }; + let quarantine_name = match journal.plan.quarantine_path.file_name() { + Some(name) => name, + None => return false, + }; + let quarantine_tombstone_name = match journal.plan.tombstone_path.file_name() { + Some(name) => name, + None => return false, + }; + + active_cutover_entry_names_match( + journal.phase, + journal.execution_mode, + [candidate_name, current_tombstone_name], + [quarantine_name, quarantine_tombstone_name], + journal_entries, + quarantine_entries, + ) +} + +fn active_cutover_entry_names_match( + phase: CutoverPhase, + execution_mode: CutoverExecutionMode, + journal_artifacts: [&OsStr; 2], + quarantine_artifacts: [&OsStr; 2], + journal_entries: &[OsString], + quarantine_entries: &[OsString], +) -> bool { + let [candidate_name, current_tombstone_name] = journal_artifacts; + let [quarantine_name, quarantine_tombstone_name] = quarantine_artifacts; + let journal_name = OsStr::new(PROXIFYRE_JOURNAL_FILENAME); + let journal_allowed = [journal_name, candidate_name, current_tombstone_name]; + let quarantine_allowed = [quarantine_name, quarantine_tombstone_name]; + if !contains_exact_name(journal_entries, journal_name) + || !all_names_are_allowed(journal_entries, &journal_allowed) + || !all_names_are_allowed(quarantine_entries, &quarantine_allowed) + || contains_exact_name(journal_entries, candidate_name) + && contains_exact_name(journal_entries, current_tombstone_name) + || contains_exact_name(quarantine_entries, quarantine_name) + && contains_exact_name(quarantine_entries, quarantine_tombstone_name) + { + return false; + } + + if phase == CutoverPhase::LegacyQuarantined + && execution_mode == CutoverExecutionMode::AwaitingUser + { + return journal_entries.len() == 1 + && quarantine_entries.len() == 1 + && contains_exact_name(quarantine_entries, quarantine_name); + } + true +} + +fn read_direct_child_names(path: &Path) -> Result, CutoverError> { + let mut names = Vec::new(); + for entry in fs::read_dir(path)? { + names.push(entry?.file_name()); + } + Ok(names) +} + +fn all_names_are_allowed(names: &[OsString], allowed: &[&OsStr]) -> bool { + names + .iter() + .all(|name| allowed.iter().any(|allowed| name == allowed)) +} + +fn contains_exact_name(names: &[OsString], expected: &OsStr) -> bool { + names.iter().any(|name| name == expected) +} + +fn observe_retirement_root( + path: &Path, + active_journal_name: Option<&OsStr>, +) -> Result { + if !path_exists_for_cutover(path)? { + return Ok(RetirementRootShape::Missing); + } + let lease = safe_fs::open_path_runtime_read_identity_lease(path, StableObjectKind::Directory)?; + safe_fs::verify_identity_lease_admin_system_only(&lease)?; + let mut names = Vec::new(); + for entry in fs::read_dir(path)? { + names.push(entry?.file_name()); + } + if names.is_empty() { + Ok(RetirementRootShape::Empty) + } else if active_journal_name + .is_some_and(|expected| names.len() == 1 && names[0].as_os_str() == expected) + { + Ok(RetirementRootShape::ActiveJournalOnly) + } else { + Ok(RetirementRootShape::Unexpected) + } +} + +fn observe_retirement_record( + store: &SealedCutoverStore, + path: &Path, +) -> Result { + if !path_exists_for_cutover(path)? { + return Ok(RetirementRecordState::Missing); + } + safe_fs::verify_admin_system_only_file(path)?; + match store.read_optional_journal_at(path) { + Ok(Some(journal)) => Ok(RetirementRecordState::Valid(retirement_record_identity( + &journal, + )?)), + Ok(None) => Ok(RetirementRecordState::Missing), + Err(CutoverError::InvalidPlan) => Ok(RetirementRecordState::Invalid), + Err(CutoverError::Storage(error)) if error.kind() == io::ErrorKind::InvalidData => { + Ok(RetirementRecordState::Invalid) + } + Err(error) => Err(error), + } +} + +fn delete_exact_admin_system_only_file(path: &Path) -> Result<(), CutoverError> { + let lease = safe_fs::open_path_identity_lease(path, StableObjectKind::RegularFile)?; + safe_fs::verify_identity_lease_admin_system_only(&lease)?; + safe_fs::delete_identity_lease(lease)?; + Ok(()) +} + +fn delete_exact_empty_admin_system_only_directory(path: &Path) -> Result<(), CutoverError> { + let lease = safe_fs::open_path_identity_lease(path, StableObjectKind::Directory)?; + safe_fs::verify_identity_lease_admin_system_only(&lease)?; + if fs::read_dir(path)?.next().transpose()?.is_some() { + return Err(CutoverError::RecoveryRequired); + } + safe_fs::delete_identity_lease(lease)?; + Ok(()) +} + +fn cleanup_created_cutover_root(path: &Path) -> Result<(), CutoverError> { + if !path_exists_for_cutover(path)? { + return Ok(()); + } + delete_exact_empty_admin_system_only_directory(path) +} + +fn verify_existing_sealed_directory(path: &Path) -> Result<(), CutoverError> { + if path_exists_for_cutover(path)? { + safe_fs::verify_directory_admin_system_only(path)?; + } + Ok(()) +} + +fn path_exists_for_cutover(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(CutoverError::from(error)), + } +} + +fn is_completed_terminal_journal(journal: &CutoverJournal) -> bool { + journal.phase == CutoverPhase::ComponentComplete + && journal.execution_mode == CutoverExecutionMode::Finished +} + +fn sealed_cutover_store_view(app_root: &Path) -> SealedCutoverStore { + SealedCutoverStore { + app_root: app_root.to_path_buf(), + journal_root: app_root.join(CUTOVER_JOURNAL_DIRECTORY), + quarantine_root: app_root.join(CUTOVER_QUARANTINE_DIRECTORY), + retirement_file: app_root.join(PROXIFYRE_RETIREMENT_FILENAME), + observation_file: PathBuf::new(), + } +} + +fn is_retirable_terminal_journal(journal: &CutoverJournal) -> bool { + matches!( + journal.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) && journal.execution_mode == CutoverExecutionMode::Finished +} + +fn is_retirable_terminal_identity(identity: &RetirementRecordIdentity) -> bool { + matches!( + identity.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) && identity.execution_mode == CutoverExecutionMode::Finished +} + +fn retirement_record_identity( + journal: &CutoverJournal, +) -> Result { + let bytes = SealedCutoverStore::encode(journal)?; + Ok(retirement_record_identity_from_bytes(journal, &bytes)) +} + +fn retirement_record_identity_from_bytes( + journal: &CutoverJournal, + bytes: &[u8], +) -> RetirementRecordIdentity { + RetirementRecordIdentity { + phase: journal.phase, + execution_mode: journal.execution_mode, + fingerprint: StateFingerprint::digest( + "proxywarden:component-cutover-retirement-record:v1", + bytes, + ) + .as_str() + .to_string(), + } +} + +/// Windows-only filesystem half of the production cutover host. It owns all +/// parent/root/entry leases and performs only exact ACL, rename, and delete +/// operations. Package bytes, service control, and prerequisite installers stay +/// with their dedicated owners. +#[derive(Debug)] +pub struct SystemProxifyreCutoverFilesystem { + plan: ProxifyreCutoverPlan, + legacy_parent: safe_fs::PathIdentityLease, + current_parent: Option, + journal_parent: safe_fs::PathIdentityLease, + quarantine_parent: safe_fs::PathIdentityLease, + legacy_root: Option, + legacy_entries: BTreeMap, + current_root: Option, + current_entries: BTreeMap, + last_observed_identity: Option, +} + +impl SystemProxifyreCutoverFilesystem { + pub fn open(plan: &ProxifyreCutoverPlan) -> Result { + validate_plan(plan).map_err(|_| CutoverHostError::PreconditionFailed)?; + let legacy_parent = open_parent_lease(&plan.legacy_root)?; + let journal_parent = open_parent_lease(&plan.current_candidate_path)?; + let quarantine_parent = open_parent_lease(&plan.quarantine_path)?; + if legacy_parent.identity().volume_serial_number + != quarantine_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + Ok(Self { + plan: plan.clone(), + legacy_parent, + current_parent: None, + journal_parent, + quarantine_parent, + legacy_root: None, + legacy_entries: BTreeMap::new(), + current_root: None, + current_entries: BTreeMap::new(), + last_observed_identity: None, + }) + } + + pub fn capture_legacy_filesystem( + &mut self, + ) -> Result { + if self.legacy_root.is_some() + || any_path_exists(&[ + &self.plan.current_root, + &self.plan.current_candidate_path, + &self.plan.current_tombstone_path, + &self.plan.quarantine_path, + &self.plan.tombstone_path, + ])? + { + return Err(CutoverHostError::PreconditionFailed); + } + let root = safe_fs::open_path_runtime_read_identity_lease( + &self.plan.legacy_root, + StableObjectKind::Directory, + ) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if root.identity().volume_serial_number + != self.legacy_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + let (entries, leases) = capture_legacy_entries(&self.plan.legacy_root)?; + let config_key = normalized_relative_path(Path::new("app-config.json")) + .ok_or(CutoverHostError::PreconditionFailed)?; + let config = entries + .iter() + .find(|entry| { + normalized_relative_path(&entry.relative_path).as_ref() == Some(&config_key) + }) + .ok_or(CutoverHostError::PreconditionFailed)?; + let snapshot = LegacyFilesystemSnapshot { + root: SealedPathSnapshot { + identity: root.identity().clone(), + security: root.security().clone(), + }, + config: SealedPathSnapshot { + identity: config.identity.clone(), + security: config.security.clone(), + }, + config_relative_path: PathBuf::from("app-config.json"), + entries, + }; + validate_legacy_root_manifest(&snapshot) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.legacy_root = Some(root); + self.legacy_entries = leases; + Ok(snapshot) + } + + pub fn capture_current_components_parent( + &mut self, + ) -> Result { + if self.current_parent.is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + let path = self + .plan + .current_root + .parent() + .ok_or(CutoverHostError::PreconditionFailed)?; + match path.try_exists() { + Ok(false) => Ok(CurrentComponentsParentBeforeState::Absent), + Ok(true) => { + safe_fs::verify_path_admin_owned_user_read_only(path) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let lease = safe_fs::open_path_identity_lease(path, StableObjectKind::Directory) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if lease.identity().volume_serial_number + != self.journal_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + let snapshot = SealedPathSnapshot { + identity: lease.identity().clone(), + security: lease.security().clone(), + }; + self.current_parent = Some(lease); + Ok(CurrentComponentsParentBeforeState::Present(snapshot)) + } + Err(_) => Err(CutoverHostError::ObservationFailed), + } + } + + pub fn adopt_current_candidate( + &mut self, + journal: &CutoverJournal, + root: safe_fs::PathIdentityLease, + entries: Vec<(PathBuf, safe_fs::PathIdentityLease)>, + ) -> Result<(), CutoverHostError> { + self.adopt_current_leases(journal, root, entries, true) + } + + /// Recovery handoff for a journaled but incomplete candidate. Only entries + /// with a durable ExpectedEffect are accepted; this path can never authorize + /// promotion, but lets the filesystem owner tombstone/delete exact partial + /// transaction artifacts during rollback. + pub fn adopt_partial_current_candidate_for_rollback( + &mut self, + journal: &CutoverJournal, + root: safe_fs::PathIdentityLease, + entries: Vec<(PathBuf, safe_fs::PathIdentityLease)>, + ) -> Result<(), CutoverHostError> { + self.adopt_current_leases(journal, root, entries, false) + } + + fn adopt_current_leases( + &mut self, + journal: &CutoverJournal, + root: safe_fs::PathIdentityLease, + entries: Vec<(PathBuf, safe_fs::PathIdentityLease)>, + require_complete: bool, + ) -> Result<(), CutoverHostError> { + if self.current_root.is_some() + || !windows_path_eq_path(root.path(), &journal.plan.current_candidate_path) + || effect_identity( + journal, + MutationDirection::Forward, + &CutoverOperation::CreateCurrentCandidateRoot, + ) != Some(root.identity()) + || root.identity().volume_serial_number + != self.journal_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + safe_fs::verify_identity_lease_admin_owned_user_read_only(&root) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let expected_count = journal + .before_state + .current_candidate + .files + .iter() + .filter(|file| { + operation_applied( + journal, + MutationDirection::Forward, + &candidate_write_operation(file), + ) + }) + .count(); + if require_complete && expected_count != journal.before_state.current_candidate.files.len() + { + return Err(CutoverHostError::PreconditionFailed); + } + let mut adopted = BTreeMap::new(); + for (relative_path, lease) in entries { + let key = normalized_relative_path(&relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + let file = journal + .before_state + .current_candidate + .files + .iter() + .find(|file| normalized_relative_path(&file.relative_path).as_ref() == Some(&key)) + .ok_or(CutoverHostError::PreconditionFailed)?; + let operation = candidate_write_operation(file); + if !windows_path_eq_path( + lease.path(), + &journal.plan.current_candidate_path.join(&relative_path), + ) || effect_identity(journal, MutationDirection::Forward, &operation) + != Some(lease.identity()) + || lease.identity().size != file.size + || lease.identity().volume_serial_number != root.identity().volume_serial_number + || adopted.contains_key(&key) + { + return Err(CutoverHostError::PreconditionFailed); + } + let acl_result = if file.role == CurrentCandidateFileRole::Config { + safe_fs::verify_identity_lease_admin_system_only(&lease) + } else { + safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease) + }; + acl_result.map_err(|_| CutoverHostError::PreconditionFailed)?; + if hash_identity_lease(&lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? != file.sha256 { + return Err(CutoverHostError::PreconditionFailed); + } + adopted.insert(key, lease); + } + if adopted.len() != expected_count { + return Err(CutoverHostError::PreconditionFailed); + } + let expected_paths: Vec<_> = adopted + .values() + .map(|lease| { + lease + .path() + .strip_prefix(root.path()) + .map(Path::to_path_buf) + .map_err(|_| CutoverHostError::PreconditionFailed) + }) + .collect::>()?; + ensure_shape_exact(root.path(), &expected_paths) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.current_root = Some(root); + self.current_entries = adopted; + Ok(()) + } + + pub fn reacquire_leases(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + self.legacy_root = None; + self.legacy_entries.clear(); + self.current_parent = None; + self.current_root = None; + self.current_entries.clear(); + self.reacquire_current_parent(journal)?; + self.reacquire_legacy(journal)?; + self.reacquire_current(journal)?; + Ok(()) + } + + pub fn handles_operation(operation: &CutoverOperation) -> bool { + matches!( + operation, + CutoverOperation::CreateCurrentComponentsParent + | CutoverOperation::DeleteTransactionCurrentComponentsParent + | CutoverOperation::HardenLegacyRootSecurity + | CutoverOperation::HardenLegacyEntrySecurity(_) + | CutoverOperation::PromoteCurrentCandidate + | CutoverOperation::QuarantineLegacyRoot + | CutoverOperation::RestoreLegacyRoot + | CutoverOperation::TombstoneCurrentRoot + | CutoverOperation::TombstoneCurrentCandidate + | CutoverOperation::DeleteCurrentEntry(_) + | CutoverOperation::DeleteCurrentTombstoneRoot + | CutoverOperation::RestoreLegacyEntrySecurity(_) + | CutoverOperation::RestoreLegacyRootSecurity + | CutoverOperation::TombstoneQuarantine + | CutoverOperation::DeleteQuarantineEntry(_) + | CutoverOperation::DeleteQuarantineRoot + ) + } + + pub fn observe_operation( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + self.last_observed_identity = None; + match operation { + CutoverOperation::CreateCurrentComponentsParent + | CutoverOperation::DeleteTransactionCurrentComponentsParent => { + self.observe_current_parent(operation, journal) + } + CutoverOperation::HardenLegacyRootSecurity + | CutoverOperation::RestoreLegacyRootSecurity => { + let lease = self + .legacy_root + .as_ref() + .ok_or(CutoverHostError::ObservationFailed)?; + self.last_observed_identity = Some(lease.identity().clone()); + observe_security( + operation, + lease, + &journal.before_state.filesystem.root.security, + ) + } + CutoverOperation::HardenLegacyEntrySecurity(path) + | CutoverOperation::RestoreLegacyEntrySecurity(path) => { + let (identity, observed) = { + let (lease, snapshot) = self.legacy_entry(journal, path)?; + ( + lease.identity().clone(), + observe_security(operation, lease, &snapshot.security)?, + ) + }; + self.last_observed_identity = Some(identity); + Ok(observed) + } + CutoverOperation::PromoteCurrentCandidate + | CutoverOperation::TombstoneCurrentRoot + | CutoverOperation::TombstoneCurrentCandidate => { + self.observe_current_root_location(operation, journal) + } + CutoverOperation::DeleteCurrentEntry(path) => { + self.observe_current_entry(operation, journal, path) + } + CutoverOperation::DeleteCurrentTombstoneRoot => { + self.observe_current_root_deleted(operation, journal) + } + CutoverOperation::QuarantineLegacyRoot + | CutoverOperation::RestoreLegacyRoot + | CutoverOperation::TombstoneQuarantine => { + self.observe_legacy_root_location(operation, journal) + } + CutoverOperation::DeleteQuarantineEntry(path) => { + self.observe_legacy_entry(operation, journal, path) + } + CutoverOperation::DeleteQuarantineRoot => { + self.observe_legacy_root_deleted(operation, journal) + } + _ => Err(CutoverHostError::Unsupported), + } + } + + /// Package-free recovery observer for the single pending candidate + /// create/write intent. `reacquire_leases` has already rehashed and leased + /// every present object against the sealed candidate manifest. + pub fn observe_recovered_candidate_operation( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + self.last_observed_identity = None; + if !pending_operation_is(journal, MutationDirection::Forward, operation) { + return Err(CutoverHostError::PreconditionFailed); + } + let present = match operation { + CutoverOperation::CreateCurrentCandidateRoot => { + if let Some(root) = self.current_root.as_ref() { + if !windows_path_eq_path(root.path(), &journal.plan.current_candidate_path) { + return Err(CutoverHostError::ObservationFailed); + } + self.last_observed_identity = Some(root.identity().clone()); + true + } else { + if journal + .plan + .current_candidate_path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + return Err(CutoverHostError::ObservationFailed); + } + false + } + } + CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt => { + let file = candidate_file_for_operation(operation, journal) + .ok_or(CutoverHostError::ObservationFailed)?; + let key = normalized_relative_path(&file.relative_path) + .ok_or(CutoverHostError::ObservationFailed)?; + if let Some(lease) = self.current_entries.get(&key) { + self.last_observed_identity = Some(lease.identity().clone()); + true + } else { + if journal + .plan + .current_candidate_path + .join(&file.relative_path) + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + return Err(CutoverHostError::ObservationFailed); + } + false + } + } + _ => return Err(CutoverHostError::Unsupported), + }; + candidate_operation_state_fingerprint(operation, present) + } + + pub fn observed_object_identity(&self) -> Option { + self.last_observed_identity.clone() + } + + pub fn expected_operation_effect( + &self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + match operation { + CutoverOperation::CreateCurrentComponentsParent => { + Ok(filesystem_state_fingerprint(operation, "present", None)) + } + CutoverOperation::DeleteTransactionCurrentComponentsParent => { + Ok(filesystem_state_fingerprint(operation, "absent", None)) + } + CutoverOperation::HardenLegacyRootSecurity + | CutoverOperation::HardenLegacyEntrySecurity(_) => { + Ok(filesystem_state_fingerprint(operation, "hardened", None)) + } + CutoverOperation::RestoreLegacyRootSecurity + | CutoverOperation::RestoreLegacyEntrySecurity(_) => { + Ok(filesystem_state_fingerprint(operation, "original", None)) + } + CutoverOperation::PromoteCurrentCandidate => Ok(filesystem_state_fingerprint( + operation, + "current", + current_root_identity(journal), + )), + CutoverOperation::TombstoneCurrentRoot + | CutoverOperation::TombstoneCurrentCandidate => Ok(filesystem_state_fingerprint( + operation, + "tombstone", + current_root_identity(journal), + )), + CutoverOperation::DeleteCurrentEntry(_) + | CutoverOperation::DeleteCurrentTombstoneRoot + | CutoverOperation::DeleteQuarantineEntry(_) + | CutoverOperation::DeleteQuarantineRoot => { + Ok(filesystem_state_fingerprint(operation, "absent", None)) + } + CutoverOperation::QuarantineLegacyRoot => Ok(filesystem_state_fingerprint( + operation, + "quarantine", + Some(&journal.before_state.filesystem.root.identity), + )), + CutoverOperation::RestoreLegacyRoot => Ok(filesystem_state_fingerprint( + operation, + "legacy", + Some(&journal.before_state.filesystem.root.identity), + )), + CutoverOperation::TombstoneQuarantine => Ok(filesystem_state_fingerprint( + operation, + "tombstone", + Some(&journal.before_state.filesystem.root.identity), + )), + _ => Err(CutoverHostError::Unsupported), + } + } + + pub fn mutate_operation( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + match operation { + CutoverOperation::CreateCurrentComponentsParent => { + let path = journal + .plan + .current_root + .parent() + .ok_or(CutoverHostError::MutationFailed)?; + if self.current_parent.is_some() { + return Err(CutoverHostError::MutationFailed); + } + safe_fs::create_new_admin_owned_user_read_only_directory(path) + .map_err(|_| CutoverHostError::MutationFailed)?; + let lease = safe_fs::open_path_identity_lease(path, StableObjectKind::Directory) + .map_err(|_| CutoverHostError::MutationFailed)?; + if lease.identity().volume_serial_number + != self.journal_parent.identity().volume_serial_number + { + return Err(CutoverHostError::MutationFailed); + } + self.current_parent = Some(lease); + Ok(()) + } + CutoverOperation::DeleteTransactionCurrentComponentsParent => { + let lease = self + .current_parent + .as_ref() + .ok_or(CutoverHostError::MutationFailed)?; + if fs::read_dir(lease.path()) + .map_err(|_| CutoverHostError::MutationFailed)? + .next() + .is_some() + { + return Err(CutoverHostError::MutationFailed); + } + delete_leased_root(&mut self.current_parent) + } + CutoverOperation::HardenLegacyRootSecurity => { + self.prepare_legacy_root_mutation_lease(journal)?; + let lease = self + .legacy_root + .as_mut() + .ok_or(CutoverHostError::MutationFailed)?; + safe_fs::harden_identity_lease_admin_system_only(lease) + .map_err(|_| CutoverHostError::MutationFailed)?; + ensure_legacy_shape(&self.plan.legacy_root, &self.legacy_entries)?; + self.reopen_hardened_legacy_root_runtime_read(journal) + } + CutoverOperation::HardenLegacyEntrySecurity(path) => { + self.prepare_legacy_entry_mutation_leases(journal)?; + let key = normalized_relative_path(path).ok_or(CutoverHostError::MutationFailed)?; + let lease = self + .legacy_entries + .get_mut(&key) + .ok_or(CutoverHostError::MutationFailed)?; + safe_fs::harden_identity_lease_admin_system_only(lease) + .map_err(|_| CutoverHostError::MutationFailed) + } + CutoverOperation::RestoreLegacyRootSecurity => { + self.prepare_legacy_root_restore_mutation_lease(journal)?; + let lease = self + .legacy_root + .as_mut() + .ok_or(CutoverHostError::MutationFailed)?; + safe_fs::restore_identity_lease_security( + lease, + &journal.before_state.filesystem.root.security, + ) + .map_err(|_| CutoverHostError::MutationFailed)?; + if journal.plan.original_service_state == LegacyServiceState::Running + && !operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::StopLegacyService, + ) + { + self.reopen_restored_legacy_root_runtime_read(journal) + } else { + Ok(()) + } + } + CutoverOperation::RestoreLegacyEntrySecurity(path) => { + let key = normalized_relative_path(path).ok_or(CutoverHostError::MutationFailed)?; + let lease = self + .legacy_entries + .get_mut(&key) + .ok_or(CutoverHostError::MutationFailed)?; + let snapshot = journal + .before_state + .filesystem + .entries + .iter() + .find(|entry| manifest_path_eq(&entry.relative_path, path)) + .ok_or(CutoverHostError::MutationFailed)?; + safe_fs::restore_identity_lease_security(lease, &snapshot.security) + .map_err(|_| CutoverHostError::MutationFailed) + } + CutoverOperation::PromoteCurrentCandidate + | CutoverOperation::TombstoneCurrentRoot + | CutoverOperation::TombstoneCurrentCandidate => { + self.rename_current_tree(operation, journal) + } + CutoverOperation::DeleteCurrentEntry(path) => { + delete_leased_entry(&mut self.current_entries, path) + } + CutoverOperation::DeleteCurrentTombstoneRoot => { + delete_leased_root(&mut self.current_root) + } + CutoverOperation::QuarantineLegacyRoot + | CutoverOperation::RestoreLegacyRoot + | CutoverOperation::TombstoneQuarantine => self.rename_legacy_tree(operation, journal), + CutoverOperation::DeleteQuarantineEntry(path) => { + delete_leased_entry(&mut self.legacy_entries, path) + } + CutoverOperation::DeleteQuarantineRoot => delete_leased_root(&mut self.legacy_root), + _ => Err(CutoverHostError::Unsupported), + } + } + + /// Proves that the active current tree is retained only with runtime-compatible + /// read/query leases. Production service-start dispatch must call this gate + /// immediately before starting the current service. + pub fn verify_current_runtime_read_leases( + &self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + self.verify_current_tree_for_rename(journal, &journal.plan.current_root)?; + let all_runtime_read = self + .current_root + .as_ref() + .is_some_and(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead) + && self + .current_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead); + if all_runtime_read { + Ok(()) + } else { + Err(CutoverHostError::PreconditionFailed) + } + } + + /// Drops privileged mutation handles and exact-reopens the fully restored + /// legacy tree with runtime-compatible read/query leases. Production + /// rollback dispatch must call this after every ACL restore and immediately + /// before starting a legacy service that was originally running. + pub fn prepare_legacy_service_start_leases( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + if !pending_operation_is( + journal, + MutationDirection::Rollback, + &CutoverOperation::StartLegacyService, + ) { + return Err(CutoverHostError::PreconditionFailed); + } + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + self.legacy_entries.clear(); + self.legacy_root = None; + self.reacquire_legacy(journal)?; + self.verify_legacy_runtime_read_leases(journal) + } + + /// Exact-upgrades only the legacy root lease before applying its protected + /// DACL. Child read leases remain live and compatible with a running service; + /// after this operation the protected root prevents new child injection. + pub fn prepare_legacy_root_mutation_lease( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + if !pending_operation_is( + journal, + MutationDirection::Forward, + &CutoverOperation::HardenLegacyRootSecurity, + ) { + return Err(CutoverHostError::PreconditionFailed); + } + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + let root = self + .legacy_root + .take() + .ok_or(CutoverHostError::PreconditionFailed)?; + let identity = root.identity().clone(); + drop(root); + self.legacy_root = Some(open_exact_identity(&journal.plan.legacy_root, &identity)?); + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + if self + .legacy_root + .as_ref() + .is_some_and(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::Mutation) + && self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead) + { + return Ok(()); + } + Err(CutoverHostError::PreconditionFailed) + } + + /// After the durable service stop, exact-upgrades every legacy entry before + /// its first ACL mutation. The already-protected root closes the path race + /// while descendant handles are dropped and reopened. + pub fn prepare_legacy_entry_mutation_leases( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + if journal.plan.original_service_state == LegacyServiceState::Running + && !operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::StopLegacyService, + ) + { + return Err(CutoverHostError::PreconditionFailed); + } + if self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::Mutation) + { + return Ok(()); + } + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + self.legacy_entries.clear(); + self.legacy_root = None; + self.reacquire_legacy(journal)?; + if self + .legacy_root + .as_ref() + .is_some_and(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::Mutation) + && self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::Mutation) + { + return Ok(()); + } + Err(CutoverHostError::PreconditionFailed) + } + + fn prepare_legacy_root_restore_mutation_lease( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + if !pending_operation_is( + journal, + MutationDirection::Rollback, + &CutoverOperation::RestoreLegacyRootSecurity, + ) { + return Err(CutoverHostError::PreconditionFailed); + } + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + if self + .legacy_root + .as_ref() + .is_some_and(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::Mutation) + { + return Ok(()); + } + let root = self + .legacy_root + .take() + .ok_or(CutoverHostError::PreconditionFailed)?; + let identity = root.identity().clone(); + drop(root); + self.legacy_root = Some(open_exact_identity(&journal.plan.legacy_root, &identity)?); + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root) + } + + fn reopen_hardened_legacy_root_runtime_read( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let root = self + .legacy_root + .take() + .ok_or(CutoverHostError::MutationFailed)?; + let identity = root.identity().clone(); + drop(root); + let reopened = open_exact_identity_with_mode(&journal.plan.legacy_root, &identity, true) + .map_err(|_| CutoverHostError::MutationFailed)?; + safe_fs::verify_identity_lease_admin_system_only(&reopened) + .map_err(|_| CutoverHostError::MutationFailed)?; + if !self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead) + { + return Err(CutoverHostError::MutationFailed); + } + self.legacy_root = Some(reopened); + ensure_legacy_shape(&journal.plan.legacy_root, &self.legacy_entries) + } + + fn reopen_restored_legacy_root_runtime_read( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let root = self + .legacy_root + .take() + .ok_or(CutoverHostError::MutationFailed)?; + let identity = root.identity().clone(); + drop(root); + let reopened = open_exact_identity_with_mode(&journal.plan.legacy_root, &identity, true) + .map_err(|_| CutoverHostError::MutationFailed)?; + if safe_fs::current_identity_lease_security(&reopened) + .map_err(|_| CutoverHostError::MutationFailed)? + != journal.before_state.filesystem.root.security + || !self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead) + { + return Err(CutoverHostError::MutationFailed); + } + self.legacy_root = Some(reopened); + ensure_legacy_shape(&journal.plan.legacy_root, &self.legacy_entries) + } + + pub fn verify_legacy_runtime_read_leases( + &self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + self.verify_legacy_tree_for_rename(journal, &journal.plan.legacy_root)?; + let all_runtime_read = self + .legacy_root + .as_ref() + .is_some_and(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead) + && self + .legacy_entries + .values() + .all(|lease| lease.mode() == safe_fs::PathIdentityLeaseMode::RuntimeRead); + if all_runtime_read { + Ok(()) + } else { + Err(CutoverHostError::PreconditionFailed) + } + } + + fn rename_current_tree( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let (expected_source, destination_name, destination_is_current) = match operation { + CutoverOperation::PromoteCurrentCandidate => ( + journal.plan.current_candidate_path.as_path(), + journal + .plan + .current_root + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + true, + ), + CutoverOperation::TombstoneCurrentRoot => ( + journal.plan.current_root.as_path(), + journal + .plan + .current_tombstone_path + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + false, + ), + CutoverOperation::TombstoneCurrentCandidate => ( + journal.plan.current_candidate_path.as_path(), + journal + .plan + .current_tombstone_path + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + false, + ), + _ => return Err(CutoverHostError::Unsupported), + }; + self.verify_current_tree_for_rename(journal, expected_source)?; + + // Windows refuses to rename a nonempty directory while any descendant handle is + // retained, even when that child handle shares delete. The sealed parent/final ACLs + // protect the short close/reopen interval; exact identities and hashes are rechecked + // at the destination before this operation can report success. + self.current_entries.clear(); + let mut root = self + .current_root + .take() + .ok_or(CutoverHostError::MutationFailed)?; + if root.mode() != safe_fs::PathIdentityLeaseMode::Mutation { + let identity = root.identity().clone(); + drop(root); + root = open_exact_identity(expected_source, &identity) + .map_err(|_| CutoverHostError::MutationFailed)?; + } + let rename_result = if destination_is_current { + let parent = self + .current_parent + .as_ref() + .ok_or(CutoverHostError::MutationFailed)?; + safe_fs::rename_identity_lease_same_volume(&mut root, parent, destination_name) + } else { + safe_fs::rename_identity_lease_same_volume( + &mut root, + &self.journal_parent, + destination_name, + ) + }; + drop(root); + self.current_root = None; + self.current_entries.clear(); + let reacquired = self.reacquire_current(journal); + if rename_result.is_err() || reacquired.is_err() { + return Err(CutoverHostError::MutationFailed); + } + if destination_is_current { + self.verify_current_runtime_read_leases(journal)?; + } + Ok(()) + } + + fn rename_legacy_tree( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let (expected_source, destination_name, destination_parent) = match operation { + CutoverOperation::QuarantineLegacyRoot => ( + journal.plan.legacy_root.as_path(), + journal + .plan + .quarantine_path + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + &self.quarantine_parent, + ), + CutoverOperation::RestoreLegacyRoot => ( + journal.plan.quarantine_path.as_path(), + journal + .plan + .legacy_root + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + &self.legacy_parent, + ), + CutoverOperation::TombstoneQuarantine => ( + journal.plan.quarantine_path.as_path(), + journal + .plan + .tombstone_path + .file_name() + .ok_or(CutoverHostError::MutationFailed)?, + &self.quarantine_parent, + ), + _ => return Err(CutoverHostError::Unsupported), + }; + self.verify_legacy_tree_for_rename(journal, expected_source)?; + self.legacy_entries.clear(); + let mut root = self + .legacy_root + .take() + .ok_or(CutoverHostError::MutationFailed)?; + let rename_result = safe_fs::rename_identity_lease_same_volume( + &mut root, + destination_parent, + destination_name, + ); + drop(root); + self.legacy_root = None; + self.legacy_entries.clear(); + let reacquired = self.reacquire_legacy(journal); + if rename_result.is_err() || reacquired.is_err() { + return Err(CutoverHostError::MutationFailed); + } + Ok(()) + } + + fn verify_current_tree_for_rename( + &self, + journal: &CutoverJournal, + source: &Path, + ) -> Result<(), CutoverHostError> { + let root = self + .current_root + .as_ref() + .ok_or(CutoverHostError::PreconditionFailed)?; + if !windows_path_eq_path(root.path(), source) + || Some(root.identity()) != current_root_identity(journal) + { + return Err(CutoverHostError::PreconditionFailed); + } + safe_fs::verify_identity_lease_admin_owned_user_read_only(root) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let mut expected_paths = Vec::new(); + let mut expected_count = 0usize; + for file in &journal.before_state.current_candidate.files { + let write = candidate_write_operation(file); + let delete = CutoverOperation::DeleteCurrentEntry(file.relative_path.clone()); + if !operation_applied(journal, MutationDirection::Forward, &write) + || operation_applied(journal, MutationDirection::Rollback, &delete) + { + continue; + } + expected_count += 1; + expected_paths.push(file.relative_path.clone()); + let key = normalized_relative_path(&file.relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + let lease = self + .current_entries + .get(&key) + .ok_or(CutoverHostError::PreconditionFailed)?; + let identity = effect_identity(journal, MutationDirection::Forward, &write) + .ok_or(CutoverHostError::PreconditionFailed)?; + if !windows_path_eq_path(lease.path(), &source.join(&file.relative_path)) + || lease.identity() != identity + || hash_identity_lease(lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? != file.sha256 + { + return Err(CutoverHostError::PreconditionFailed); + } + let acl_result = if file.role == CurrentCandidateFileRole::Config { + safe_fs::verify_identity_lease_admin_system_only(lease) + } else { + safe_fs::verify_identity_lease_admin_owned_user_read_only(lease) + }; + acl_result.map_err(|_| CutoverHostError::PreconditionFailed)?; + } + if self.current_entries.len() != expected_count { + return Err(CutoverHostError::PreconditionFailed); + } + ensure_shape_exact(source, &expected_paths) + .map_err(|_| CutoverHostError::PreconditionFailed) + } + + fn verify_legacy_tree_for_rename( + &self, + journal: &CutoverJournal, + source: &Path, + ) -> Result<(), CutoverHostError> { + let root = self + .legacy_root + .as_ref() + .ok_or(CutoverHostError::PreconditionFailed)?; + if !windows_path_eq_path(root.path(), source) + || root.identity() != &journal.before_state.filesystem.root.identity + { + return Err(CutoverHostError::PreconditionFailed); + } + verify_reacquired_legacy_security( + journal, + root, + &journal.before_state.filesystem.root.security, + &CutoverOperation::HardenLegacyRootSecurity, + &CutoverOperation::RestoreLegacyRootSecurity, + )?; + let mut expected_paths = Vec::new(); + let mut expected_count = 0usize; + for entry in &journal.before_state.filesystem.entries { + let delete = CutoverOperation::DeleteQuarantineEntry(entry.relative_path.clone()); + if operation_applied(journal, MutationDirection::Cleanup, &delete) { + continue; + } + expected_count += 1; + expected_paths.push(entry.relative_path.clone()); + let key = normalized_relative_path(&entry.relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + let lease = self + .legacy_entries + .get(&key) + .ok_or(CutoverHostError::PreconditionFailed)?; + if !windows_path_eq_path(lease.path(), &source.join(&entry.relative_path)) + || lease.identity() != &entry.identity + || (entry.identity.kind == StableObjectKind::RegularFile + && hash_identity_lease(lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? + != entry + .sha256 + .as_deref() + .ok_or(CutoverHostError::PreconditionFailed)?) + { + return Err(CutoverHostError::PreconditionFailed); + } + verify_reacquired_legacy_security( + journal, + lease, + &entry.security, + &CutoverOperation::HardenLegacyEntrySecurity(entry.relative_path.clone()), + &CutoverOperation::RestoreLegacyEntrySecurity(entry.relative_path.clone()), + )?; + } + if self.legacy_entries.len() != expected_count { + return Err(CutoverHostError::PreconditionFailed); + } + ensure_shape_exact(source, &expected_paths) + .map_err(|_| CutoverHostError::PreconditionFailed) + } + + pub fn verify_cleanup_ready(&self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + let root = self + .legacy_root + .as_ref() + .ok_or(CutoverHostError::VerificationFailed)?; + if !windows_path_eq_path(root.path(), &journal.plan.quarantine_path) + || self.legacy_entries.len() != journal.before_state.filesystem.entries.len() + || observed_path_exists(journal.plan.tombstone_path.try_exists())? + { + return Err(CutoverHostError::VerificationFailed); + } + Ok(()) + } + + pub fn verify_rollback_restored( + &self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let root = self + .legacy_root + .as_ref() + .ok_or(CutoverHostError::VerificationFailed)?; + if !windows_path_eq_path(root.path(), &journal.plan.legacy_root) + || root.identity() != &journal.before_state.filesystem.root.identity + || safe_fs::current_identity_lease_security(root) + .map_err(|_| CutoverHostError::ObservationFailed)? + != journal.before_state.filesystem.root.security + || self.legacy_entries.len() != journal.before_state.filesystem.entries.len() + || self.current_root.is_some() + || !self.current_entries.is_empty() + || any_path_exists(&[ + &journal.plan.current_candidate_path, + &journal.plan.current_root, + &journal.plan.current_tombstone_path, + &journal.plan.quarantine_path, + &journal.plan.tombstone_path, + ])? + { + return Err(CutoverHostError::VerificationFailed); + } + for snapshot in &journal.before_state.filesystem.entries { + let key = normalized_relative_path(&snapshot.relative_path) + .ok_or(CutoverHostError::VerificationFailed)?; + let lease = self + .legacy_entries + .get(&key) + .ok_or(CutoverHostError::VerificationFailed)?; + if !windows_path_eq_path( + lease.path(), + &journal.plan.legacy_root.join(&snapshot.relative_path), + ) || lease.identity() != &snapshot.identity + || safe_fs::current_identity_lease_security(lease) + .map_err(|_| CutoverHostError::ObservationFailed)? + != snapshot.security + || (snapshot.identity.kind == StableObjectKind::RegularFile + && hash_identity_lease(lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? + != snapshot + .sha256 + .as_deref() + .ok_or(CutoverHostError::VerificationFailed)?) + { + return Err(CutoverHostError::VerificationFailed); + } + } + ensure_shape_exact( + &journal.plan.legacy_root, + &journal + .before_state + .filesystem + .entries + .iter() + .map(|entry| entry.relative_path.clone()) + .collect::>(), + )?; + let current_parent_path = journal + .plan + .current_root + .parent() + .ok_or(CutoverHostError::VerificationFailed)?; + match ( + &journal.before_state.current_components_parent, + self.current_parent.as_ref(), + ) { + (CurrentComponentsParentBeforeState::Absent, None) => { + if current_parent_path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + return Err(CutoverHostError::VerificationFailed); + } + } + (CurrentComponentsParentBeforeState::Present(snapshot), Some(lease)) => { + if !windows_path_eq_path(lease.path(), current_parent_path) + || lease.identity() != &snapshot.identity + || safe_fs::current_identity_lease_security(lease) + .map_err(|_| CutoverHostError::ObservationFailed)? + != snapshot.security + { + return Err(CutoverHostError::VerificationFailed); + } + } + _ => return Err(CutoverHostError::VerificationFailed), + } + Ok(()) + } +} + +impl SystemProxifyreCutoverFilesystem { + fn observe_current_parent( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + if let Some(parent) = self.current_parent.as_ref() { + self.last_observed_identity = Some(parent.identity().clone()); + return Ok(filesystem_state_fingerprint(operation, "present", None)); + } + let path = journal + .plan + .current_root + .parent() + .ok_or(CutoverHostError::ObservationFailed)?; + observe_absent_path(operation, path) + } + + fn reacquire_current_parent( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let path = journal + .plan + .current_root + .parent() + .ok_or(CutoverHostError::PreconditionFailed)?; + match &journal.before_state.current_components_parent { + CurrentComponentsParentBeforeState::Present(snapshot) => { + safe_fs::verify_path_admin_owned_user_read_only(path) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let lease = open_exact_identity(path, &snapshot.identity)?; + if lease.security() != &snapshot.security { + return Err(CutoverHostError::PreconditionFailed); + } + self.current_parent = Some(lease); + } + CurrentComponentsParentBeforeState::Absent => { + let create = CutoverOperation::CreateCurrentComponentsParent; + let delete = CutoverOperation::DeleteTransactionCurrentComponentsParent; + let created = operation_applied(journal, MutationDirection::Forward, &create); + let deleted = operation_applied(journal, MutationDirection::Rollback, &delete); + let create_pending = + pending_operation_is(journal, MutationDirection::Forward, &create); + let delete_pending = + pending_operation_is(journal, MutationDirection::Rollback, &delete); + if deleted || (!created && !create_pending) { + if path.try_exists().unwrap_or(true) { + return Err(CutoverHostError::PreconditionFailed); + } + } else if created && !delete_pending { + let identity = effect_identity(journal, MutationDirection::Forward, &create) + .ok_or(CutoverHostError::PreconditionFailed)?; + safe_fs::verify_path_admin_owned_user_read_only(path) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.current_parent = Some(open_exact_identity(path, identity)?); + } else if path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + safe_fs::verify_path_admin_owned_user_read_only(path) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if create_pending { + ensure_shape_exact(path, &[]) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + } + let lease = if created { + let identity = + effect_identity(journal, MutationDirection::Forward, &create) + .ok_or(CutoverHostError::PreconditionFailed)?; + open_exact_identity(path, identity)? + } else { + safe_fs::open_path_identity_lease(path, StableObjectKind::Directory) + .map_err(|_| CutoverHostError::PreconditionFailed)? + }; + self.current_parent = Some(lease); + } + } + } + if let Some(parent) = self.current_parent.as_ref() { + if parent.identity().volume_serial_number + != self.journal_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + } + Ok(()) + } + + fn reacquire_legacy(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + let candidates = [ + journal.plan.legacy_root.as_path(), + journal.plan.quarantine_path.as_path(), + journal.plan.tombstone_path.as_path(), + ]; + let root_deleted = operation_applied( + journal, + MutationDirection::Cleanup, + &CutoverOperation::DeleteQuarantineRoot, + ); + if root_deleted { + if single_existing_location(&candidates)?.is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + return Ok(()); + } + let pending = pending_mutation(journal); + let root_delete_pending = pending.is_some_and(|record| { + record.direction == MutationDirection::Cleanup + && record.operation == CutoverOperation::DeleteQuarantineRoot + }); + let tombstoned = operation_applied( + journal, + MutationDirection::Cleanup, + &CutoverOperation::TombstoneQuarantine, + ); + let quarantined = operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::QuarantineLegacyRoot, + ) && !operation_applied( + journal, + MutationDirection::Rollback, + &CutoverOperation::RestoreLegacyRoot, + ); + let durable_location = if tombstoned { + journal.plan.tombstone_path.as_path() + } else if quarantined { + journal.plan.quarantine_path.as_path() + } else { + journal.plan.legacy_root.as_path() + }; + let allowed_locations: Vec<&Path> = match pending.map(|record| &record.operation) { + Some(CutoverOperation::QuarantineLegacyRoot) => vec![ + journal.plan.legacy_root.as_path(), + journal.plan.quarantine_path.as_path(), + ], + Some(CutoverOperation::RestoreLegacyRoot) => vec![ + journal.plan.quarantine_path.as_path(), + journal.plan.legacy_root.as_path(), + ], + Some(CutoverOperation::TombstoneQuarantine) => vec![ + journal.plan.quarantine_path.as_path(), + journal.plan.tombstone_path.as_path(), + ], + _ => vec![durable_location], + }; + let Some(location) = single_existing_location(&candidates)? else { + return if root_delete_pending { + Ok(()) + } else { + Err(CutoverHostError::PreconditionFailed) + }; + }; + if !allowed_locations + .iter() + .any(|allowed| windows_path_eq_path(location, allowed)) + { + return Err(CutoverHostError::PreconditionFailed); + } + let harden_root = CutoverOperation::HardenLegacyRootSecurity; + let legacy_start_pending_or_applied = pending_operation_is( + journal, + MutationDirection::Rollback, + &CutoverOperation::StartLegacyService, + ) || operation_applied( + journal, + MutationDirection::Rollback, + &CutoverOperation::StartLegacyService, + ); + let hardening_not_started = + !pending_operation_is(journal, MutationDirection::Forward, &harden_root) + && !operation_applied(journal, MutationDirection::Forward, &harden_root); + let stop_not_applied = journal.plan.original_service_state == LegacyServiceState::Running + && !operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::StopLegacyService, + ); + let entry_hardening_started = + pending_mutation(journal).is_some_and(|record| { + record.direction == MutationDirection::Forward + && matches!( + record.operation, + CutoverOperation::HardenLegacyEntrySecurity(_) + ) + }) || journal.before_state.filesystem.entries.iter().any(|entry| { + operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::HardenLegacyEntrySecurity(entry.relative_path.clone()), + ) + }); + let root_runtime_read = windows_path_eq_path(location, &journal.plan.legacy_root) + && (hardening_not_started || stop_not_applied || legacy_start_pending_or_applied); + let entries_runtime_read = windows_path_eq_path(location, &journal.plan.legacy_root) + && (!entry_hardening_started || legacy_start_pending_or_applied); + let root = open_exact_identity_with_mode( + location, + &journal.before_state.filesystem.root.identity, + root_runtime_read, + )?; + verify_reacquired_legacy_security( + journal, + &root, + &journal.before_state.filesystem.root.security, + &CutoverOperation::HardenLegacyRootSecurity, + &CutoverOperation::RestoreLegacyRootSecurity, + )?; + let mut expected_paths = Vec::new(); + let mut entries = BTreeMap::new(); + for entry in &journal.before_state.filesystem.entries { + let delete = CutoverOperation::DeleteQuarantineEntry(entry.relative_path.clone()); + if operation_applied(journal, MutationDirection::Cleanup, &delete) { + continue; + } + let absolute = location.join(&entry.relative_path); + let delete_pending = pending_operation_is(journal, MutationDirection::Cleanup, &delete); + let exists = absolute + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)?; + if !exists { + if delete_pending { + continue; + } + return Err(CutoverHostError::PreconditionFailed); + } + let lease = + open_exact_identity_with_mode(&absolute, &entry.identity, entries_runtime_read)?; + if entry.identity.kind == StableObjectKind::RegularFile + && hash_identity_lease(&lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? + != entry + .sha256 + .as_deref() + .ok_or(CutoverHostError::PreconditionFailed)? + { + return Err(CutoverHostError::PreconditionFailed); + } + verify_reacquired_legacy_security( + journal, + &lease, + &entry.security, + &CutoverOperation::HardenLegacyEntrySecurity(entry.relative_path.clone()), + &CutoverOperation::RestoreLegacyEntrySecurity(entry.relative_path.clone()), + )?; + let key = normalized_relative_path(&entry.relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + expected_paths.push(entry.relative_path.clone()); + if entries.insert(key, lease).is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + } + ensure_shape_exact(location, &expected_paths)?; + self.legacy_root = Some(root); + self.legacy_entries = entries; + Ok(()) + } + + fn reacquire_current(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + let pending_candidate_build = pending_mutation(journal).is_some_and(|record| { + record.direction == MutationDirection::Forward + && matches!( + record.operation, + CutoverOperation::CreateCurrentCandidateRoot + | CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt + ) + }); + if pending_candidate_build { + if journal + .plan + .current_root + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + || journal + .plan + .current_tombstone_path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + return Err(CutoverHostError::PreconditionFailed); + } + let root_created = operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::CreateCurrentCandidateRoot, + ); + let candidate_exists = journal + .plan + .current_candidate_path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let pending_operation = &pending_mutation(journal) + .ok_or(CutoverHostError::PreconditionFailed)? + .operation; + if *pending_operation == CutoverOperation::CreateCurrentCandidateRoot { + if !candidate_exists { + return Ok(()); + } + let root = safe_fs::open_path_identity_lease( + &journal.plan.current_candidate_path, + StableObjectKind::Directory, + ) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if root.identity().volume_serial_number + != self.journal_parent.identity().volume_serial_number + { + return Err(CutoverHostError::PreconditionFailed); + } + safe_fs::verify_identity_lease_admin_owned_user_read_only(&root) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + ensure_shape_exact(&journal.plan.current_candidate_path, &[]) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.current_root = Some(root); + return Ok(()); + } + if !root_created || !candidate_exists { + return Err(CutoverHostError::PreconditionFailed); + } + let root_identity = + current_root_identity(journal).ok_or(CutoverHostError::PreconditionFailed)?; + let root = open_exact_identity(&journal.plan.current_candidate_path, root_identity)?; + safe_fs::verify_identity_lease_admin_owned_user_read_only(&root) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let mut expected_paths = Vec::new(); + let mut entries = BTreeMap::new(); + for file in &journal.before_state.current_candidate.files { + let write = candidate_write_operation(file); + let applied = operation_applied(journal, MutationDirection::Forward, &write); + let is_pending = &write == pending_operation; + if !applied && !is_pending { + continue; + } + let absolute = journal + .plan + .current_candidate_path + .join(&file.relative_path); + let exists = absolute + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)?; + if !exists { + if is_pending { + continue; + } + return Err(CutoverHostError::PreconditionFailed); + } + let lease = if applied { + let identity = effect_identity(journal, MutationDirection::Forward, &write) + .ok_or(CutoverHostError::PreconditionFailed)?; + open_exact_identity(&absolute, identity)? + } else { + safe_fs::open_path_identity_lease(&absolute, StableObjectKind::RegularFile) + .map_err(|_| CutoverHostError::PreconditionFailed)? + }; + if lease.identity().size != file.size + || lease.identity().volume_serial_number != root.identity().volume_serial_number + || hash_identity_lease(&lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? != file.sha256 + { + return Err(CutoverHostError::PreconditionFailed); + } + let acl_result = if file.role == CurrentCandidateFileRole::Config { + safe_fs::verify_identity_lease_admin_system_only(&lease) + } else { + safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease) + }; + acl_result.map_err(|_| CutoverHostError::PreconditionFailed)?; + let key = normalized_relative_path(&file.relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + expected_paths.push(file.relative_path.clone()); + if entries.insert(key, lease).is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + } + ensure_shape_exact(&journal.plan.current_candidate_path, &expected_paths) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.current_root = Some(root); + self.current_entries = entries; + return Ok(()); + } + let candidates = [ + journal.plan.current_candidate_path.as_path(), + journal.plan.current_root.as_path(), + journal.plan.current_tombstone_path.as_path(), + ]; + let root_created = operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::CreateCurrentCandidateRoot, + ); + let root_deleted = operation_applied( + journal, + MutationDirection::Rollback, + &CutoverOperation::DeleteCurrentTombstoneRoot, + ); + if !root_created || root_deleted { + if single_existing_location(&candidates)?.is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + return Ok(()); + } + let pending = pending_mutation(journal); + let root_delete_pending = pending.is_some_and(|record| { + record.direction == MutationDirection::Rollback + && record.operation == CutoverOperation::DeleteCurrentTombstoneRoot + }); + let tombstoned = operation_applied( + journal, + MutationDirection::Rollback, + &CutoverOperation::TombstoneCurrentRoot, + ) || operation_applied( + journal, + MutationDirection::Rollback, + &CutoverOperation::TombstoneCurrentCandidate, + ); + let promoted = operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::PromoteCurrentCandidate, + ); + let durable_location = if tombstoned { + journal.plan.current_tombstone_path.as_path() + } else if promoted { + journal.plan.current_root.as_path() + } else { + journal.plan.current_candidate_path.as_path() + }; + let allowed_locations: Vec<&Path> = match pending.map(|record| &record.operation) { + Some(CutoverOperation::PromoteCurrentCandidate) => vec![ + journal.plan.current_candidate_path.as_path(), + journal.plan.current_root.as_path(), + ], + Some(CutoverOperation::TombstoneCurrentRoot) => vec![ + journal.plan.current_root.as_path(), + journal.plan.current_tombstone_path.as_path(), + ], + Some(CutoverOperation::TombstoneCurrentCandidate) => vec![ + journal.plan.current_candidate_path.as_path(), + journal.plan.current_tombstone_path.as_path(), + ], + _ => vec![durable_location], + }; + let Some(location) = single_existing_location(&candidates)? else { + return if root_delete_pending { + Ok(()) + } else { + Err(CutoverHostError::PreconditionFailed) + }; + }; + if !allowed_locations + .iter() + .any(|allowed| windows_path_eq_path(location, allowed)) + { + return Err(CutoverHostError::PreconditionFailed); + } + let root_identity = + current_root_identity(journal).ok_or(CutoverHostError::PreconditionFailed)?; + let runtime_read = windows_path_eq_path(location, &journal.plan.current_root); + let root = open_exact_identity_with_mode(location, root_identity, runtime_read)?; + safe_fs::verify_identity_lease_admin_owned_user_read_only(&root) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let mut expected_paths = Vec::new(); + let mut entries = BTreeMap::new(); + for file in &journal.before_state.current_candidate.files { + let write = candidate_write_operation(file); + let delete = CutoverOperation::DeleteCurrentEntry(file.relative_path.clone()); + if !operation_applied(journal, MutationDirection::Forward, &write) + || operation_applied(journal, MutationDirection::Rollback, &delete) + { + continue; + } + let identity = effect_identity(journal, MutationDirection::Forward, &write) + .ok_or(CutoverHostError::PreconditionFailed)?; + let absolute = location.join(&file.relative_path); + let delete_pending = + pending_operation_is(journal, MutationDirection::Rollback, &delete); + let exists = absolute + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)?; + if !exists { + if delete_pending { + continue; + } + return Err(CutoverHostError::PreconditionFailed); + } + let lease = open_exact_identity_with_mode(&absolute, identity, runtime_read)?; + let acl_result = if file.role == CurrentCandidateFileRole::Config { + safe_fs::verify_identity_lease_admin_system_only(&lease) + } else { + safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease) + }; + acl_result.map_err(|_| CutoverHostError::PreconditionFailed)?; + if hash_identity_lease(&lease, MAX_CURRENT_CANDIDATE_FILE_BYTES)? != file.sha256 { + return Err(CutoverHostError::PreconditionFailed); + } + let key = normalized_relative_path(&file.relative_path) + .ok_or(CutoverHostError::PreconditionFailed)?; + expected_paths.push(file.relative_path.clone()); + if entries.insert(key, lease).is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + } + ensure_shape_exact(location, &expected_paths)?; + self.current_root = Some(root); + self.current_entries = entries; + Ok(()) + } + + fn legacy_entry<'a>( + &'a self, + journal: &'a CutoverJournal, + path: &Path, + ) -> Result<(&'a safe_fs::PathIdentityLease, &'a LegacyManifestEntry), CutoverHostError> { + let key = normalized_relative_path(path).ok_or(CutoverHostError::ObservationFailed)?; + let lease = self + .legacy_entries + .get(&key) + .ok_or(CutoverHostError::ObservationFailed)?; + let snapshot = journal + .before_state + .filesystem + .entries + .iter() + .find(|entry| manifest_path_eq(&entry.relative_path, path)) + .ok_or(CutoverHostError::ObservationFailed)?; + if lease.identity() != &snapshot.identity { + return Err(CutoverHostError::ObservationFailed); + } + Ok((lease, snapshot)) + } + + fn observe_current_root_location( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + let identity = current_root_identity(journal).ok_or(CutoverHostError::ObservationFailed)?; + let root = self + .current_root + .as_ref() + .ok_or(CutoverHostError::ObservationFailed)?; + if root.identity() != identity { + return Err(CutoverHostError::ObservationFailed); + } + self.last_observed_identity = Some(identity.clone()); + let label = if windows_path_eq_path(root.path(), &journal.plan.current_candidate_path) { + "candidate" + } else if windows_path_eq_path(root.path(), &journal.plan.current_root) { + "current" + } else if windows_path_eq_path(root.path(), &journal.plan.current_tombstone_path) { + "tombstone" + } else { + return Err(CutoverHostError::ObservationFailed); + }; + Ok(filesystem_state_fingerprint( + operation, + label, + Some(identity), + )) + } + + fn observe_current_entry( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + path: &Path, + ) -> Result { + let key = normalized_relative_path(path).ok_or(CutoverHostError::ObservationFailed)?; + if let Some(lease) = self.current_entries.get(&key) { + self.last_observed_identity = Some(lease.identity().clone()); + return Ok(filesystem_state_fingerprint( + operation, + "present", + Some(lease.identity()), + )); + } + observe_absent_path(operation, &journal.plan.current_tombstone_path.join(path)) + } + + fn observe_current_root_deleted( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + if let Some(root) = self.current_root.as_ref() { + self.last_observed_identity = Some(root.identity().clone()); + return Ok(filesystem_state_fingerprint( + operation, + "tombstone", + Some(root.identity()), + )); + } + observe_absent_path(operation, &journal.plan.current_tombstone_path) + } + + fn observe_legacy_root_location( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + let root = self + .legacy_root + .as_ref() + .ok_or(CutoverHostError::ObservationFailed)?; + if root.identity() != &journal.before_state.filesystem.root.identity { + return Err(CutoverHostError::ObservationFailed); + } + self.last_observed_identity = Some(root.identity().clone()); + let label = if windows_path_eq_path(root.path(), &journal.plan.legacy_root) { + "legacy" + } else if windows_path_eq_path(root.path(), &journal.plan.quarantine_path) { + "quarantine" + } else if windows_path_eq_path(root.path(), &journal.plan.tombstone_path) { + "tombstone" + } else { + return Err(CutoverHostError::ObservationFailed); + }; + Ok(filesystem_state_fingerprint( + operation, + label, + Some(root.identity()), + )) + } + + fn observe_legacy_entry( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + path: &Path, + ) -> Result { + let key = normalized_relative_path(path).ok_or(CutoverHostError::ObservationFailed)?; + if let Some(lease) = self.legacy_entries.get(&key) { + self.last_observed_identity = Some(lease.identity().clone()); + return Ok(filesystem_state_fingerprint( + operation, + "present", + Some(lease.identity()), + )); + } + observe_absent_path(operation, &journal.plan.tombstone_path.join(path)) + } + + fn observe_legacy_root_deleted( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + if let Some(root) = self.legacy_root.as_ref() { + self.last_observed_identity = Some(root.identity().clone()); + return Ok(filesystem_state_fingerprint( + operation, + "tombstone", + Some(root.identity()), + )); + } + observe_absent_path(operation, &journal.plan.tombstone_path) + } +} + +fn open_parent_lease(path: &Path) -> Result { + let parent = path + .parent() + .filter(|value| !value.as_os_str().is_empty()) + .ok_or(CutoverHostError::PreconditionFailed)?; + safe_fs::open_path_identity_lease(parent, StableObjectKind::Directory) + .map_err(|_| CutoverHostError::PreconditionFailed) +} + +fn any_path_exists(paths: &[&PathBuf]) -> Result { + for path in paths { + if path + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + return Ok(true); + } + } + Ok(false) +} + +fn capture_legacy_entries( + root: &Path, +) -> Result< + ( + Vec, + BTreeMap, + ), + CutoverHostError, +> { + let relative_paths = enumerate_cutover_tree(root)?; + let mut entries = Vec::with_capacity(relative_paths.len()); + let mut leases = BTreeMap::new(); + let mut total_size = 0u64; + for (relative_path, kind) in relative_paths { + let full_path = root.join(&relative_path); + let lease = safe_fs::open_path_runtime_read_identity_lease(&full_path, kind) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + total_size = total_size + .checked_add(lease.identity().size) + .ok_or(CutoverHostError::PreconditionFailed)?; + if total_size > MAX_CURRENT_CANDIDATE_BYTES { + return Err(CutoverHostError::PreconditionFailed); + } + let sha256 = if kind == StableObjectKind::RegularFile { + Some(hash_identity_lease( + &lease, + MAX_CURRENT_CANDIDATE_FILE_BYTES, + )?) + } else { + None + }; + let key = + normalized_relative_path(&relative_path).ok_or(CutoverHostError::PreconditionFailed)?; + entries.push(LegacyManifestEntry { + relative_path, + identity: lease.identity().clone(), + sha256, + security: lease.security().clone(), + }); + if leases.insert(key, lease).is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + } + entries.sort_by(|left, right| { + normalized_relative_path(&left.relative_path) + .cmp(&normalized_relative_path(&right.relative_path)) + }); + Ok((entries, leases)) +} + +fn enumerate_cutover_tree( + root: &Path, +) -> Result, CutoverHostError> { + let mut paths = Vec::new(); + let root_entries = fs::read_dir(root).map_err(|_| CutoverHostError::ObservationFailed)?; + for entry in root_entries { + let entry = entry.map_err(|_| CutoverHostError::ObservationFailed)?; + let relative = PathBuf::from(entry.file_name()); + let kind = object_kind_without_reparse(&entry.path())?; + paths.push((relative.clone(), kind)); + if kind == StableObjectKind::Directory && manifest_path_eq(&relative, Path::new("logs")) { + let log_entries = + fs::read_dir(entry.path()).map_err(|_| CutoverHostError::ObservationFailed)?; + for log_entry in log_entries { + let log_entry = log_entry.map_err(|_| CutoverHostError::ObservationFailed)?; + paths.push(( + relative.join(log_entry.file_name()), + object_kind_without_reparse(&log_entry.path())?, + )); + if paths.len() > MAX_LEGACY_LOG_FILES + MAX_CURRENT_CANDIDATE_FILES { + return Err(CutoverHostError::PreconditionFailed); + } + } + } + if paths.len() > MAX_LEGACY_LOG_FILES + MAX_CURRENT_CANDIDATE_FILES { + return Err(CutoverHostError::PreconditionFailed); + } + } + paths.sort_by(|left, right| { + normalized_relative_path(&left.0).cmp(&normalized_relative_path(&right.0)) + }); + Ok(paths) +} + +fn object_kind_without_reparse(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|_| CutoverHostError::ObservationFailed)?; + if metadata.file_type().is_symlink() { + return Err(CutoverHostError::PreconditionFailed); + } + if metadata.is_file() { + Ok(StableObjectKind::RegularFile) + } else if metadata.is_dir() { + Ok(StableObjectKind::Directory) + } else { + Err(CutoverHostError::PreconditionFailed) + } +} + +fn hash_identity_lease( + lease: &safe_fs::PathIdentityLease, + max_bytes: u64, +) -> Result { + if lease.identity().kind != StableObjectKind::RegularFile || lease.identity().size > max_bytes { + return Err(CutoverHostError::PreconditionFailed); + } + let mut file = lease + .file() + .try_clone() + .map_err(|_| CutoverHostError::ObservationFailed)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| CutoverHostError::ObservationFailed)?; + let mut remaining = lease.identity().size; + let mut buffer = [0u8; 64 * 1024]; + let mut digest = Sha256::new(); + while remaining != 0 { + let take = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| CutoverHostError::ObservationFailed)?; + let read = file + .read(&mut buffer[..take]) + .map_err(|_| CutoverHostError::ObservationFailed)?; + if read == 0 { + return Err(CutoverHostError::ObservationFailed); + } + digest.update(&buffer[..read]); + remaining -= read as u64; + } + let mut extra = [0u8; 1]; + if file + .read(&mut extra) + .map_err(|_| CutoverHostError::ObservationFailed)? + != 0 + { + return Err(CutoverHostError::ObservationFailed); + } + Ok(digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn ensure_legacy_shape( + root: &Path, + entries: &BTreeMap, +) -> Result<(), CutoverHostError> { + let expected: Vec<_> = entries + .values() + .map(|lease| { + lease + .path() + .strip_prefix(root) + .map(Path::to_path_buf) + .map_err(|_| CutoverHostError::ObservationFailed) + }) + .collect::>()?; + ensure_shape_exact(root, &expected) +} + +fn ensure_shape_exact(root: &Path, expected: &[PathBuf]) -> Result<(), CutoverHostError> { + let actual = enumerate_cutover_tree(root)?; + let actual: HashSet<_> = actual + .into_iter() + .map(|(path, _)| normalized_relative_path(&path)) + .collect::>() + .ok_or(CutoverHostError::ObservationFailed)?; + let expected: HashSet<_> = expected + .iter() + .map(|path| normalized_relative_path(path)) + .collect::>() + .ok_or(CutoverHostError::ObservationFailed)?; + classify_exact_shape(&actual, &expected) +} + +fn classify_exact_shape( + actual: &HashSet, + expected: &HashSet, +) -> Result<(), CutoverHostError> { + if actual == expected { + Ok(()) + } else { + Err(CutoverHostError::VerificationFailed) + } +} + +fn observed_path_exists(observed: std::io::Result) -> Result { + observed.map_err(|_| CutoverHostError::ObservationFailed) +} + +fn open_exact_identity( + path: &Path, + expected: &StableObjectIdentity, +) -> Result { + open_exact_identity_with_mode(path, expected, false) +} + +fn open_exact_identity_with_mode( + path: &Path, + expected: &StableObjectIdentity, + runtime_read: bool, +) -> Result { + let lease = if runtime_read { + safe_fs::open_path_runtime_read_identity_lease(path, expected.kind) + } else { + safe_fs::open_path_identity_lease(path, expected.kind) + } + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if lease.identity() != expected { + return Err(CutoverHostError::PreconditionFailed); + } + Ok(lease) +} + +fn single_existing_location<'a>( + candidates: &[&'a Path], +) -> Result, CutoverHostError> { + let mut existing = None; + for candidate in candidates { + if candidate + .try_exists() + .map_err(|_| CutoverHostError::ObservationFailed)? + { + if existing.is_some() { + return Err(CutoverHostError::PreconditionFailed); + } + existing = Some(*candidate); + } + } + Ok(existing) +} + +fn pending_operation_is( + journal: &CutoverJournal, + direction: MutationDirection, + operation: &CutoverOperation, +) -> bool { + pending_mutation(journal) + .is_some_and(|record| record.direction == direction && &record.operation == operation) +} + +fn effect_identity<'a>( + journal: &'a CutoverJournal, + direction: MutationDirection, + operation: &CutoverOperation, +) -> Option<&'a StableObjectIdentity> { + journal + .mutations + .iter() + .find(|record| { + record.direction == direction + && &record.operation == operation + && record + .effect + .as_ref() + .is_some_and(|effect| effect.disposition == EffectDisposition::ExpectedEffect) + }) + .and_then(|record| record.effect.as_ref()) + .and_then(|effect| effect.object_identity.as_ref()) +} + +fn current_root_identity(journal: &CutoverJournal) -> Option<&StableObjectIdentity> { + effect_identity( + journal, + MutationDirection::Forward, + &CutoverOperation::CreateCurrentCandidateRoot, + ) +} + +fn filesystem_state_fingerprint( + operation: &CutoverOperation, + label: &str, + identity: Option<&StableObjectIdentity>, +) -> StateFingerprint { + let identity = identity.map_or_else( + || "none".to_string(), + |value| { + format!( + "{}:{}:{:?}:{}", + value.volume_serial_number, value.file_id, value.kind, value.size + ) + }, + ); + StateFingerprint::digest( + "proxywarden:cutover-filesystem-state:v1", + format!("{operation:?}|{label}|{identity}").as_bytes(), + ) +} + +fn observe_security( + operation: &CutoverOperation, + lease: &safe_fs::PathIdentityLease, + original: &SecurityDescriptorSnapshot, +) -> Result { + let current = safe_fs::current_identity_lease_security(lease) + .map_err(|_| CutoverHostError::ObservationFailed)?; + if ¤t == original { + return Ok(filesystem_state_fingerprint(operation, "original", None)); + } + if safe_fs::verify_identity_lease_admin_system_only(lease).is_ok() { + return Ok(filesystem_state_fingerprint(operation, "hardened", None)); + } + Ok(StateFingerprint::digest( + "proxywarden:cutover-filesystem-security-unknown:v1", + current.as_bytes(), + )) +} + +fn verify_reacquired_legacy_security( + journal: &CutoverJournal, + lease: &safe_fs::PathIdentityLease, + original: &SecurityDescriptorSnapshot, + harden: &CutoverOperation, + restore: &CutoverOperation, +) -> Result<(), CutoverHostError> { + let current = safe_fs::current_identity_lease_security(lease) + .map_err(|_| CutoverHostError::ObservationFailed)?; + let is_original = ¤t == original; + let is_hardened = safe_fs::verify_identity_lease_admin_system_only(lease).is_ok(); + let harden_applied = operation_applied(journal, MutationDirection::Forward, harden); + let restore_applied = operation_applied(journal, MutationDirection::Rollback, restore); + let harden_pending = pending_operation_is(journal, MutationDirection::Forward, harden); + let restore_pending = pending_operation_is(journal, MutationDirection::Rollback, restore); + let valid = if restore_applied { + is_original + } else if restore_pending { + is_original || is_hardened + } else if harden_applied { + is_hardened + } else if harden_pending { + is_original || is_hardened + } else { + is_original + }; + if valid { + Ok(()) + } else { + Err(CutoverHostError::PreconditionFailed) + } +} + +fn observe_absent_path( + operation: &CutoverOperation, + path: &Path, +) -> Result { + match path.try_exists() { + Ok(false) => Ok(filesystem_state_fingerprint(operation, "absent", None)), + Ok(true) => Ok(filesystem_state_fingerprint( + operation, + "unleased_present", + None, + )), + Err(_) => Err(CutoverHostError::ObservationFailed), + } +} + +fn delete_leased_entry( + entries: &mut BTreeMap, + path: &Path, +) -> Result<(), CutoverHostError> { + let key = normalized_relative_path(path).ok_or(CutoverHostError::MutationFailed)?; + let lease = entries + .remove(&key) + .ok_or(CutoverHostError::MutationFailed)?; + let absolute = lease.path().to_path_buf(); + let identity = lease.identity().clone(); + match safe_fs::delete_identity_lease(lease) { + Ok(()) => Ok(()), + Err(_) => { + if let Ok(reopened) = open_exact_identity(&absolute, &identity) { + entries.insert(key, reopened); + } + Err(CutoverHostError::MutationFailed) + } + } +} + +fn delete_leased_root( + root: &mut Option, +) -> Result<(), CutoverHostError> { + let lease = root.take().ok_or(CutoverHostError::MutationFailed)?; + let absolute = lease.path().to_path_buf(); + let identity = lease.identity().clone(); + match safe_fs::delete_identity_lease(lease) { + Ok(()) => Ok(()), + Err(_) => { + if let Ok(reopened) = open_exact_identity(&absolute, &identity) { + *root = Some(reopened); + } + Err(CutoverHostError::MutationFailed) + } + } +} + +pub fn read_redacted_observation( + storage: &JsonStorage, +) -> io::Result> { + storage.read_component_cutover_observation() +} + +pub fn read_sealed_cutover_status( + store: &dyn CutoverJournalStore, +) -> Result, CutoverError> { + let journal = store.load()?; + if let Some(journal) = journal.as_ref() { + validate_journal(journal)?; + } + journal.as_ref().map(observation_from_journal).transpose() +} + +/// Display hint only. Privileged lifecycle authorization must load the sealed +/// journal and must never trust this user-writable observation cache. +pub fn observation_suggests_lifecycle_blocked( + observation: Option<&ComponentCutoverObservation>, +) -> bool { + observation.is_some_and(|value| { + !matches!( + value.state, + CutoverDisplayState::Complete | CutoverDisplayState::RolledBack + ) + }) +} + +/// Narrow display/planning hint for the explicit route-smoke exception. The +/// caller must still prove the live component is the exact managed current +/// ProxiFyre service, and must restore Stopped before submitting evidence. +pub fn observation_allows_stopped_route_smoke_lifecycle( + observation: Option<&ComponentCutoverObservation>, +) -> bool { + observation.is_some_and(|value| { + value.component == "proxifyre" + && value.state == CutoverDisplayState::AwaitingNextStart + && value.phase == CutoverPhase::LegacyQuarantined + && value.original_service_state == LegacyServiceState::Stopped + && value.disabled_code.is_none() + }) +} + +pub fn begin_proxifyre_cutover( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + plan: ProxifyreCutoverPlan, + now_epoch_seconds: u64, +) -> Result { + let mut no_fault = NoCutoverFault; + begin_proxifyre_cutover_with_fault(store, host, plan, now_epoch_seconds, &mut no_fault) +} + +pub fn begin_proxifyre_cutover_with_fault( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + plan: ProxifyreCutoverPlan, + now_epoch_seconds: u64, + fault: &mut dyn CutoverFaultInjector, +) -> Result { + validate_plan(&plan)?; + if store.load()?.is_some() { + return Err(CutoverError::AlreadyExists); + } + let mut before_state = host.capture_and_lease(&plan)?; + validate_before_state_structure(&plan, &before_state)?; + before_state.identity_fingerprint = prove_captured_legacy_cutover(&plan, &before_state)? + .fingerprint() + .to_string(); + validate_before_state(&plan, &before_state)?; + let mut journal = CutoverJournal { + schema_version: CUTOVER_JOURNAL_SCHEMA_VERSION, + plan, + phase: CutoverPhase::ComponentPlanned, + execution_mode: CutoverExecutionMode::Forward, + before_state, + mutations: Vec::new(), + verified_current_fingerprint: None, + next_start: None, + route_smoke: None, + recovery_code: None, + updated_at_epoch_seconds: now_epoch_seconds, + }; + store.create(&journal)?; + if let Ok(observation) = observation_from_journal(&journal) { + let _ = store.write_observation(&observation); + } + journal.phase = CutoverPhase::PrivilegeGranted; + persist(store, &mut journal, now_epoch_seconds)?; + run_forward(store, host, &mut journal, now_epoch_seconds, fault) +} + +pub fn recover_proxifyre_cutover( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + now_epoch_seconds: u64, +) -> Result { + let mut journal = store.load()?.ok_or(CutoverError::Missing)?; + validate_journal(&journal)?; + if matches!( + journal.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) { + if journal.phase == CutoverPhase::ComponentComplete { + store.retire_completed(&journal)?; + return Ok(CutoverRunOutcome::Complete); + } + return Ok(CutoverRunOutcome::RolledBack); + } + if host.reacquire_leases(&journal).is_err() { + if !matches!( + journal.execution_mode, + CutoverExecutionMode::CleaningUp + | CutoverExecutionMode::RollingBack + | CutoverExecutionMode::AwaitingUser + ) { + journal.execution_mode = CutoverExecutionMode::RollingBack; + } + mark_recovery_required( + store, + &mut journal, + "lease_reacquire_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + reconcile_pending(store, host, &mut journal, now_epoch_seconds)?; + + match journal.execution_mode { + CutoverExecutionMode::RollingBack => rollback(store, host, &mut journal, now_epoch_seconds), + CutoverExecutionMode::CleaningUp => { + cleanup_internal(store, host, &mut journal, now_epoch_seconds) + } + CutoverExecutionMode::AwaitingUser => match journal.phase { + CutoverPhase::LegacyQuarantined + | CutoverPhase::NextStartVerified + | CutoverPhase::CleanupConfirmed => Ok(CutoverRunOutcome::AwaitingNextStart), + CutoverPhase::RecoveryRequired => { + recover_awaiting_user(store, host, &mut journal, now_epoch_seconds) + } + _ => Err(CutoverError::StateConflict), + }, + CutoverExecutionMode::Forward if journal.phase == CutoverPhase::ComponentVerified => { + let mut no_fault = NoCutoverFault; + finish_quarantine(store, host, &mut journal, now_epoch_seconds, &mut no_fault) + } + CutoverExecutionMode::Forward => rollback(store, host, &mut journal, now_epoch_seconds), + CutoverExecutionMode::Finished => Err(CutoverError::StateConflict), + } +} + +fn recover_awaiting_user( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now_epoch_seconds: u64, +) -> Result { + let milestone = if journal + .route_smoke + .as_ref() + .is_some_and(|evidence| evidence.confirmed_by_user) + { + CutoverPhase::CleanupConfirmed + } else if journal.next_start.is_some() { + CutoverPhase::NextStartVerified + } else { + CutoverPhase::LegacyQuarantined + }; + let current_is_exact = match host.verify_current(journal) { + Ok(fingerprint) => { + journal.verified_current_fingerprint.as_deref() == Some(fingerprint.as_str()) + } + Err(CutoverHostError::VerificationFailed) => false, + Err(_) => { + mark_recovery_required( + store, + journal, + "awaiting_user_current_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + }; + if !current_is_exact { + journal.execution_mode = CutoverExecutionMode::RollingBack; + return rollback(store, host, journal, now_epoch_seconds); + } + let live_inventory = match host.verify_current_inventory(journal) { + Ok(fingerprint) => fingerprint, + Err(CutoverHostError::VerificationFailed) => { + journal.execution_mode = CutoverExecutionMode::RollingBack; + return rollback(store, host, journal, now_epoch_seconds); + } + Err(_) => { + mark_recovery_required( + store, + journal, + "awaiting_user_inventory_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + }; + if journal + .next_start + .as_ref() + .is_some_and(|evidence| evidence.current_inventory_fingerprint != live_inventory.as_str()) + { + journal.execution_mode = CutoverExecutionMode::RollingBack; + return rollback(store, host, journal, now_epoch_seconds); + } + if milestone == CutoverPhase::CleanupConfirmed { + match host.verify_cleanup(journal) { + Ok(()) => {} + Err(CutoverHostError::VerificationFailed) => { + mark_recovery_required( + store, + journal, + "awaiting_cleanup_verification_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + Err(_) => { + mark_recovery_required( + store, + journal, + "awaiting_cleanup_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + } + } + journal.phase = milestone; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + journal.recovery_code = None; + persist(store, journal, now_epoch_seconds)?; + Ok(CutoverRunOutcome::AwaitingNextStart) +} + +pub fn mark_proxifyre_next_start_verified( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + evidence: &ComponentCutoverUserEvidence, + now_epoch_seconds: u64, +) -> Result { + validate_component_cutover_user_evidence(evidence)?; + let mut journal = store.load()?.ok_or(CutoverError::Missing)?; + validate_journal(&journal)?; + if journal.phase != CutoverPhase::LegacyQuarantined + || evidence.cutover_id != journal.plan.cutover_id + || evidence.startup_session_id == journal.plan.created_startup_session_id + || evidence.observed_at_epoch_seconds > now_epoch_seconds + { + return Err(CutoverError::StateConflict); + } + if host.reacquire_leases(&journal).is_err() { + mark_recovery_required( + store, + &mut journal, + "next_start_lease_reacquire_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + let live = match host.verify_current(&journal) { + Ok(fingerprint) => fingerprint, + Err(CutoverHostError::VerificationFailed) => { + journal.execution_mode = CutoverExecutionMode::RollingBack; + mark_recovery_required( + store, + &mut journal, + "next_start_current_verification_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + Err(_) => { + mark_recovery_required( + store, + &mut journal, + "next_start_current_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + }; + if journal.verified_current_fingerprint.as_deref() != Some(live.as_str()) { + journal.execution_mode = CutoverExecutionMode::RollingBack; + mark_recovery_required( + store, + &mut journal, + "next_start_current_fingerprint_changed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + let live_inventory = match host.verify_current_inventory(&journal) { + Ok(fingerprint) => fingerprint, + Err(CutoverHostError::VerificationFailed) => { + journal.execution_mode = CutoverExecutionMode::RollingBack; + mark_recovery_required( + store, + &mut journal, + "next_start_inventory_verification_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + Err(_) => { + mark_recovery_required( + store, + &mut journal, + "next_start_inventory_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + }; + if evidence.current_inventory_fingerprint != live_inventory.as_str() { + return Err(CutoverError::StateConflict); + } + journal.next_start = Some(NextStartEvidence { + startup_session_id: evidence.startup_session_id.clone(), + current_inventory_fingerprint: evidence.current_inventory_fingerprint.clone(), + observed_at_epoch_seconds: evidence.observed_at_epoch_seconds, + }); + journal.phase = CutoverPhase::NextStartVerified; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + persist(store, &mut journal, now_epoch_seconds)?; + observation_from_journal(&journal) +} + +pub fn confirm_proxifyre_cleanup( + store: &dyn CutoverJournalStore, + evidence: &ComponentCutoverUserEvidence, + now_epoch_seconds: u64, +) -> Result { + validate_component_cutover_user_evidence(evidence)?; + let mut journal = store.load()?.ok_or(CutoverError::Missing)?; + validate_journal(&journal)?; + let next_start = journal.next_start.as_ref(); + if journal.phase != CutoverPhase::NextStartVerified + || evidence.cutover_id != journal.plan.cutover_id + || !evidence.route_smoke_confirmed + || evidence.confirmed_at_epoch_seconds > Some(now_epoch_seconds) + || next_start.is_none_or(|next_start| { + next_start.startup_session_id != evidence.startup_session_id + || next_start.current_inventory_fingerprint + != evidence.current_inventory_fingerprint + || next_start.observed_at_epoch_seconds != evidence.observed_at_epoch_seconds + }) + { + return Err(CutoverError::StateConflict); + } + journal.route_smoke = Some(RouteSmokeEvidence { + confirmed_by_user: true, + confirmed_at_epoch_seconds: evidence + .confirmed_at_epoch_seconds + .ok_or(CutoverError::StateConflict)?, + }); + journal.phase = CutoverPhase::CleanupConfirmed; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + persist(store, &mut journal, now_epoch_seconds)?; + observation_from_journal(&journal) +} + +pub fn cleanup_proxifyre_quarantine( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + now_epoch_seconds: u64, +) -> Result { + let mut journal = store.load()?.ok_or(CutoverError::Missing)?; + validate_journal(&journal)?; + if journal.phase != CutoverPhase::CleanupConfirmed + || journal.next_start.is_none() + || !journal + .route_smoke + .as_ref() + .is_some_and(|evidence| evidence.confirmed_by_user) + { + return Err(CutoverError::StateConflict); + } + if host.reacquire_leases(&journal).is_err() { + mark_recovery_required( + store, + &mut journal, + "cleanup_lease_reacquire_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + reconcile_pending(store, host, &mut journal, now_epoch_seconds)?; + match host.verify_cleanup(&journal) { + Ok(()) => {} + Err(CutoverHostError::VerificationFailed) => { + mark_recovery_required( + store, + &mut journal, + "cleanup_verification_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + Err(_) => { + mark_recovery_required( + store, + &mut journal, + "cleanup_observation_failed", + now_epoch_seconds, + )?; + return Err(CutoverError::RecoveryRequired); + } + } + journal.execution_mode = CutoverExecutionMode::CleaningUp; + persist(store, &mut journal, now_epoch_seconds)?; + cleanup_internal(store, host, &mut journal, now_epoch_seconds) +} + +fn run_forward( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now: u64, + fault: &mut dyn CutoverFaultInjector, +) -> Result { + let operations = forward_operations(journal); + for operation in operations { + match execute_mutation( + store, + host, + journal, + MutationDirection::Forward, + operation.clone(), + now, + fault, + ) { + Ok(()) => update_forward_phase(store, journal, &operation, now)?, + Err(CutoverError::Interrupted) => return Err(CutoverError::Interrupted), + Err(CutoverError::RecoveryRequired) => { + return Err(CutoverError::RecoveryRequired); + } + Err(_) if operation == CutoverOperation::QuarantineLegacyRoot => { + reconcile_pending(store, host, journal, now)?; + if operation_applied(journal, MutationDirection::Forward, &operation) { + journal.phase = CutoverPhase::LegacyQuarantined; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + persist(store, journal, now)?; + return Ok(CutoverRunOutcome::AwaitingNextStart); + } + journal.phase = CutoverPhase::ComponentVerified; + journal.execution_mode = CutoverExecutionMode::Forward; + persist(store, journal, now)?; + return Ok(CutoverRunOutcome::QuarantinePending); + } + Err(_) => { + reconcile_pending(store, host, journal, now)?; + return rollback(store, host, journal, now); + } + } + } + + let verified = match host.verify_current(journal) { + Ok(verified) => verified, + Err(CutoverHostError::VerificationFailed) => { + return rollback(store, host, journal, now); + } + Err(_) => { + mark_recovery_required(store, journal, "current_verification_ambiguous", now)?; + return Err(CutoverError::RecoveryRequired); + } + }; + journal.verified_current_fingerprint = Some(verified.as_str().to_string()); + journal.phase = CutoverPhase::ComponentVerified; + persist(store, journal, now)?; + finish_quarantine(store, host, journal, now, fault) +} + +fn finish_quarantine( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now: u64, + fault: &mut dyn CutoverFaultInjector, +) -> Result { + let operation = CutoverOperation::QuarantineLegacyRoot; + match execute_mutation( + store, + host, + journal, + MutationDirection::Forward, + operation.clone(), + now, + fault, + ) { + Ok(()) => { + journal.phase = CutoverPhase::LegacyQuarantined; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + persist(store, journal, now)?; + Ok(CutoverRunOutcome::AwaitingNextStart) + } + Err(CutoverError::Interrupted) => Err(CutoverError::Interrupted), + Err(CutoverError::RecoveryRequired) => Err(CutoverError::RecoveryRequired), + Err(_) => { + reconcile_pending(store, host, journal, now)?; + if operation_applied(journal, MutationDirection::Forward, &operation) { + journal.phase = CutoverPhase::LegacyQuarantined; + journal.execution_mode = CutoverExecutionMode::AwaitingUser; + persist(store, journal, now)?; + Ok(CutoverRunOutcome::AwaitingNextStart) + } else { + journal.phase = CutoverPhase::ComponentVerified; + persist(store, journal, now)?; + Ok(CutoverRunOutcome::QuarantinePending) + } + } + } +} + +fn update_forward_phase( + store: &dyn CutoverJournalStore, + journal: &mut CutoverJournal, + operation: &CutoverOperation, + now: u64, +) -> Result<(), CutoverError> { + match operation { + CutoverOperation::PromoteCurrentCandidate => { + journal.phase = CutoverPhase::ComponentStaged; + } + CutoverOperation::StartCurrentService | CutoverOperation::SetCurrentServiceSecurity + if journal.plan.original_service_state == LegacyServiceState::Stopped => + { + journal.phase = CutoverPhase::ServiceSwitched; + } + CutoverOperation::StartCurrentService => journal.phase = CutoverPhase::ServiceSwitched, + _ => return Ok(()), + } + persist(store, journal, now) +} + +fn execute_mutation( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + direction: MutationDirection, + operation: CutoverOperation, + now: u64, + fault: &mut dyn CutoverFaultInjector, +) -> Result<(), CutoverError> { + if operation_applied(journal, direction, &operation) { + return Ok(()); + } + if pending_mutation(journal).is_some() { + return Err(CutoverError::StateConflict); + } + let before = host.observe(&operation, journal)?; + let expected = host.expected_effect(&operation, journal)?; + let sequence = u32::try_from(journal.mutations.len()).map_err(|_| CutoverError::InvalidPlan)?; + if before == expected { + if operation_allows_already_satisfied(journal, direction, &operation, sequence) { + journal.mutations.push(MutationRecord { + sequence, + direction, + operation, + before_state: before.clone(), + expected_effect: expected, + intent_written_at_epoch_seconds: now, + authority_evidence: None, + effect: Some(MutationEffect { + disposition: EffectDisposition::AlreadySatisfied, + observed: before, + object_identity: None, + observed_at_epoch_seconds: now, + }), + }); + return persist(store, journal, now); + } + mark_recovery_required(store, journal, "operation_already_at_expected_effect", now)?; + return Err(CutoverError::RecoveryRequired); + } + journal.mutations.push(MutationRecord { + sequence, + direction, + operation: operation.clone(), + before_state: before, + expected_effect: expected, + intent_written_at_epoch_seconds: now, + authority_evidence: None, + effect: None, + }); + persist(store, journal, now)?; + + let authority_evidence = host.mutate(&operation, journal)?; + let required_authority = required_mutation_authority(direction, &operation); + if authority_evidence != required_authority { + mark_recovery_required(store, journal, "mutation_authority_invalid", now)?; + return Err(CutoverError::RecoveryRequired); + } + if let Some(authority_evidence) = authority_evidence { + journal + .mutations + .last_mut() + .ok_or(CutoverError::StateConflict)? + .authority_evidence = Some(authority_evidence); + persist(store, journal, now)?; + } + fault.after_host_mutation(direction, &operation)?; + let observed = host.observe(&operation, journal)?; + let expected_effect = journal + .mutations + .last() + .ok_or(CutoverError::StateConflict)? + .expected_effect + .clone(); + if observed != expected_effect { + mark_recovery_required(store, journal, "unexpected_mutation_effect", now)?; + return Err(CutoverError::RecoveryRequired); + } + let object_identity = match host.observed_object_identity(&operation, journal) { + Ok(identity) => identity, + Err(_) => { + mark_recovery_required(store, journal, "mutation_identity_observation_failed", now)?; + return Err(CutoverError::RecoveryRequired); + } + }; + if validate_observed_identity(&operation, journal, object_identity.as_ref()).is_err() { + mark_recovery_required(store, journal, "mutation_identity_invalid", now)?; + return Err(CutoverError::RecoveryRequired); + } + let record = journal + .mutations + .last_mut() + .ok_or(CutoverError::StateConflict)?; + record.effect = Some(MutationEffect { + disposition: EffectDisposition::ExpectedEffect, + observed, + object_identity, + observed_at_epoch_seconds: now, + }); + persist(store, journal, now) +} + +fn required_mutation_authority( + direction: MutationDirection, + operation: &CutoverOperation, +) -> Option { + (direction == MutationDirection::Forward + && operation == &CutoverOperation::InstallWindowsPacketFilterPrerequisite) + .then_some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction) +} + +fn reconcile_pending( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now: u64, +) -> Result<(), CutoverError> { + let Some(index) = journal + .mutations + .iter() + .position(|record| record.effect.is_none()) + else { + return Ok(()); + }; + if journal + .mutations + .iter() + .skip(index + 1) + .any(|record| record.effect.is_some()) + { + mark_recovery_required(store, journal, "journal_sequence_invalid", now)?; + return Err(CutoverError::RecoveryRequired); + } + let operation = journal.mutations[index].operation.clone(); + let observed = match host.observe(&operation, journal) { + Ok(observed) => observed, + Err(_) => { + mark_recovery_required(store, journal, "pending_observation_failed", now)?; + return Err(CutoverError::RecoveryRequired); + } + }; + let required_authority = required_mutation_authority( + journal.mutations[index].direction, + &journal.mutations[index].operation, + ); + let recorded_authority = journal.mutations[index].authority_evidence; + if (observed == journal.mutations[index].expected_effect + && recorded_authority != required_authority) + || (observed == journal.mutations[index].before_state && recorded_authority.is_some()) + { + mark_recovery_required(store, journal, "mutation_authority_ambiguous", now)?; + return Err(CutoverError::RecoveryRequired); + } + let disposition = if observed == journal.mutations[index].expected_effect { + EffectDisposition::ExpectedEffect + } else if observed == journal.mutations[index].before_state { + EffectDisposition::NoEffect + } else { + mark_recovery_required(store, journal, "ambiguous_live_state", now)?; + return Err(CutoverError::RecoveryRequired); + }; + let object_identity = if disposition == EffectDisposition::ExpectedEffect { + let identity = match host.observed_object_identity(&operation, journal) { + Ok(identity) => identity, + Err(_) => { + mark_recovery_required(store, journal, "pending_identity_observation_failed", now)?; + return Err(CutoverError::RecoveryRequired); + } + }; + if validate_observed_identity(&operation, journal, identity.as_ref()).is_err() { + mark_recovery_required(store, journal, "pending_identity_invalid", now)?; + return Err(CutoverError::RecoveryRequired); + } + identity + } else { + None + }; + journal.mutations[index].effect = Some(MutationEffect { + disposition, + observed, + object_identity, + observed_at_epoch_seconds: now, + }); + persist(store, journal, now) +} + +fn rollback( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now: u64, +) -> Result { + journal.execution_mode = CutoverExecutionMode::RollingBack; + persist(store, journal, now)?; + let mut no_fault = NoCutoverFault; + for operation in rollback_operations(journal) { + if let Err(error) = execute_mutation( + store, + host, + journal, + MutationDirection::Rollback, + operation, + now, + &mut no_fault, + ) { + let _ = reconcile_pending(store, host, journal, now); + mark_recovery_required(store, journal, "rollback_failed", now)?; + return Err(match error { + CutoverError::Interrupted => CutoverError::Interrupted, + _ => CutoverError::RecoveryRequired, + }); + } + } + if host.verify_rollback(journal).is_err() { + mark_recovery_required(store, journal, "rollback_verification_failed", now)?; + return Err(CutoverError::RecoveryRequired); + } + journal.phase = CutoverPhase::RolledBack; + journal.execution_mode = CutoverExecutionMode::Finished; + journal.recovery_code = None; + persist(store, journal, now)?; + Ok(CutoverRunOutcome::RolledBack) +} + +fn cleanup_internal( + store: &dyn CutoverJournalStore, + host: &mut dyn CutoverHost, + journal: &mut CutoverJournal, + now: u64, +) -> Result { + let mut no_fault = NoCutoverFault; + for operation in cleanup_operations(journal) { + if let Err(error) = execute_mutation( + store, + host, + journal, + MutationDirection::Cleanup, + operation, + now, + &mut no_fault, + ) { + if matches!(error, CutoverError::RecoveryRequired) { + return Err(error); + } + let _ = reconcile_pending(store, host, journal, now); + journal.execution_mode = CutoverExecutionMode::CleaningUp; + persist(store, journal, now)?; + return Ok(CutoverRunOutcome::CleanupPending); + } + } + journal.phase = CutoverPhase::ComponentComplete; + journal.execution_mode = CutoverExecutionMode::Finished; + journal.recovery_code = None; + persist(store, journal, now)?; + store.retire_completed(journal)?; + Ok(CutoverRunOutcome::Complete) +} + +fn forward_operations(journal: &CutoverJournal) -> Vec { + let state = journal.plan.original_service_state; + let mut operations = vec![CutoverOperation::HardenLegacyRootSecurity]; + if !journal + .before_state + .prerequisites + .windows_packet_filter_present + { + operations.push(CutoverOperation::InstallWindowsPacketFilterPrerequisite); + } + if !journal.before_state.prerequisites.vc_runtime_present { + operations.push(CutoverOperation::InstallVcRuntimePrerequisite); + } + if journal.before_state.current_components_parent == CurrentComponentsParentBeforeState::Absent + { + operations.push(CutoverOperation::CreateCurrentComponentsParent); + } + operations.push(CutoverOperation::CreateCurrentCandidateRoot); + operations.extend( + journal + .before_state + .current_candidate + .files + .iter() + .filter(|file| file.role == CurrentCandidateFileRole::Package) + .map(|file| { + CutoverOperation::WriteCurrentCandidatePackageEntry(file.relative_path.clone()) + }), + ); + operations.push(CutoverOperation::WriteCurrentCandidateConfig); + operations.push(CutoverOperation::WriteCurrentCandidateMarker); + operations.push(CutoverOperation::WriteCurrentCandidateReceipt); + operations.push(CutoverOperation::PromoteCurrentCandidate); + if state == LegacyServiceState::Running { + operations.push(CutoverOperation::StopLegacyService); + } + operations.extend( + journal + .before_state + .filesystem + .entries + .iter() + .map(|entry| CutoverOperation::HardenLegacyEntrySecurity(entry.relative_path.clone())), + ); + operations.push(CutoverOperation::DeleteLegacyService); + operations.push(CutoverOperation::CreateCurrentService); + operations.push(CutoverOperation::SetCurrentServiceSecurity); + if state == LegacyServiceState::Running { + operations.push(CutoverOperation::StartCurrentService); + } + operations +} + +fn rollback_operations(journal: &CutoverJournal) -> Vec { + let applied = |operation: &CutoverOperation| { + operation_applied(journal, MutationDirection::Forward, operation) + }; + let mut operations = Vec::new(); + if applied(&CutoverOperation::StartCurrentService) { + operations.push(CutoverOperation::StopCurrentService); + } + if applied(&CutoverOperation::CreateCurrentService) { + operations.push(CutoverOperation::DeleteCurrentService); + } + if applied(&CutoverOperation::QuarantineLegacyRoot) { + operations.push(CutoverOperation::RestoreLegacyRoot); + } + if applied(&CutoverOperation::DeleteLegacyService) { + operations.push(CutoverOperation::CreateLegacyService); + operations.extend( + SERVICE_CONFIG2_KINDS + .iter() + .copied() + .map(CutoverOperation::RestoreLegacyServicePolicy), + ); + operations.push(CutoverOperation::RestoreLegacyServiceSecurity); + } + for entry in journal.before_state.filesystem.entries.iter().rev() { + let harden = CutoverOperation::HardenLegacyEntrySecurity(entry.relative_path.clone()); + if applied(&harden) { + operations.push(CutoverOperation::RestoreLegacyEntrySecurity( + entry.relative_path.clone(), + )); + } + } + if applied(&CutoverOperation::HardenLegacyRootSecurity) { + operations.push(CutoverOperation::RestoreLegacyRootSecurity); + } + if journal.plan.original_service_state == LegacyServiceState::Running + && applied(&CutoverOperation::StopLegacyService) + { + operations.push(CutoverOperation::StartLegacyService); + } + // Transaction artifacts are cleaned only after the exact legacy service, + // filesystem security, and original Running/Stopped state are restored. + if applied(&CutoverOperation::CreateCurrentCandidateRoot) { + if applied(&CutoverOperation::PromoteCurrentCandidate) { + operations.push(CutoverOperation::TombstoneCurrentRoot); + } else { + operations.push(CutoverOperation::TombstoneCurrentCandidate); + } + let mut current_entries: Vec<_> = journal + .before_state + .current_candidate + .files + .iter() + .filter(|file| applied(&candidate_write_operation(file))) + .map(|file| file.relative_path.clone()) + .collect(); + current_entries.sort_by(|left, right| right.as_os_str().cmp(left.as_os_str())); + operations.extend( + current_entries + .into_iter() + .map(CutoverOperation::DeleteCurrentEntry), + ); + operations.push(CutoverOperation::DeleteCurrentTombstoneRoot); + } + if applied(&CutoverOperation::CreateCurrentComponentsParent) + && journal.before_state.current_components_parent + == CurrentComponentsParentBeforeState::Absent + { + operations.push(CutoverOperation::DeleteTransactionCurrentComponentsParent); + } + if applied(&CutoverOperation::InstallWindowsPacketFilterPrerequisite) + && !journal + .before_state + .prerequisites + .windows_packet_filter_present + { + operations.push(CutoverOperation::UninstallTransactionWindowsPacketFilter); + } + operations +} + +fn cleanup_operations(journal: &CutoverJournal) -> Vec { + let mut entries: Vec<_> = journal + .before_state + .filesystem + .entries + .iter() + .map(|entry| entry.relative_path.clone()) + .collect(); + entries.sort_by(|left, right| { + let left_depth = left.components().count(); + let right_depth = right.components().count(); + right_depth + .cmp(&left_depth) + .then_with(|| right.as_os_str().cmp(left.as_os_str())) + }); + let mut operations = vec![CutoverOperation::TombstoneQuarantine]; + operations.extend( + entries + .into_iter() + .map(CutoverOperation::DeleteQuarantineEntry), + ); + operations.push(CutoverOperation::DeleteQuarantineRoot); + operations +} + +fn candidate_write_operation(file: &CurrentCandidateFile) -> CutoverOperation { + match file.role { + CurrentCandidateFileRole::Package => { + CutoverOperation::WriteCurrentCandidatePackageEntry(file.relative_path.clone()) + } + CurrentCandidateFileRole::Config => CutoverOperation::WriteCurrentCandidateConfig, + CurrentCandidateFileRole::Marker => CutoverOperation::WriteCurrentCandidateMarker, + CurrentCandidateFileRole::Receipt => CutoverOperation::WriteCurrentCandidateReceipt, + } +} + +pub fn candidate_operation_state_fingerprint( + operation: &CutoverOperation, + present: bool, +) -> Result { + if !matches!( + operation, + CutoverOperation::CreateCurrentCandidateRoot + | CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt + ) { + return Err(CutoverHostError::Unsupported); + } + let value = serde_json::to_vec(&(operation, if present { "expected" } else { "absent" })) + .map_err(|_| CutoverHostError::ObservationFailed)?; + Ok(StateFingerprint::digest( + "proxywarden:privileged-cutover-candidate-state:v1", + &value, + )) +} + +fn candidate_file_for_operation<'a>( + operation: &CutoverOperation, + journal: &'a CutoverJournal, +) -> Option<&'a CurrentCandidateFile> { + let role = match operation { + CutoverOperation::WriteCurrentCandidatePackageEntry(path) => { + return journal + .before_state + .current_candidate + .files + .iter() + .find(|file| { + file.role == CurrentCandidateFileRole::Package + && manifest_path_eq(&file.relative_path, path) + }); + } + CutoverOperation::WriteCurrentCandidateConfig => CurrentCandidateFileRole::Config, + CutoverOperation::WriteCurrentCandidateMarker => CurrentCandidateFileRole::Marker, + CutoverOperation::WriteCurrentCandidateReceipt => CurrentCandidateFileRole::Receipt, + _ => return None, + }; + journal + .before_state + .current_candidate + .files + .iter() + .find(|file| file.role == role) +} + +fn validate_observed_identity( + operation: &CutoverOperation, + journal: &CutoverJournal, + identity: Option<&StableObjectIdentity>, +) -> Result<(), CutoverError> { + match operation { + CutoverOperation::CreateCurrentComponentsParent + | CutoverOperation::CreateCurrentCandidateRoot => { + if !identity.is_some_and(|value| value.kind == StableObjectKind::Directory) { + return Err(CutoverError::RecoveryRequired); + } + } + CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt => { + let file = candidate_file_for_operation(operation, journal) + .ok_or(CutoverError::InvalidPlan)?; + if !identity.is_some_and(|value| { + value.kind == StableObjectKind::RegularFile && value.size == file.size + }) { + return Err(CutoverError::RecoveryRequired); + } + } + _ => {} + } + Ok(()) +} + +fn operation_applied( + journal: &CutoverJournal, + direction: MutationDirection, + operation: &CutoverOperation, +) -> bool { + journal.mutations.iter().any(|record| { + record.direction == direction + && &record.operation == operation + && record.effect.as_ref().is_some_and(|effect| { + matches!( + effect.disposition, + EffectDisposition::ExpectedEffect | EffectDisposition::AlreadySatisfied + ) + }) + }) +} + +fn operation_allows_already_satisfied( + _journal: &CutoverJournal, + direction: MutationDirection, + _operation: &CutoverOperation, + _before_sequence: u32, +) -> bool { + matches!( + direction, + MutationDirection::Rollback | MutationDirection::Cleanup + ) +} + +fn pending_mutation(journal: &CutoverJournal) -> Option<&MutationRecord> { + journal + .mutations + .iter() + .find(|record| record.effect.is_none()) +} + +fn persist( + store: &dyn CutoverJournalStore, + journal: &mut CutoverJournal, + now: u64, +) -> Result<(), CutoverError> { + journal.updated_at_epoch_seconds = now; + validate_journal(journal)?; + store.replace(journal)?; + if let Ok(observation) = observation_from_journal(journal) { + let _ = store.write_observation(&observation); + } + Ok(()) +} + +fn mark_recovery_required( + store: &dyn CutoverJournalStore, + journal: &mut CutoverJournal, + code: &str, + now: u64, +) -> Result<(), CutoverError> { + journal.phase = CutoverPhase::RecoveryRequired; + journal.recovery_code = Some(code.to_string()); + persist(store, journal, now) +} + +fn validate_plan(plan: &ProxifyreCutoverPlan) -> Result<(), CutoverError> { + if !canonical_uuid_v4(&plan.cutover_id) + || plan.identity != ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService + || !windows_path_eq(&plan.legacy_root, LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT) + || plan.legacy_version != LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION + || !version_is_at_least(&plan.bundled_version, LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION) + || !matches!( + plan.original_service_state, + LegacyServiceState::Running | LegacyServiceState::Stopped + ) + || !is_sha256(&plan.package_fingerprint) + || !is_sha256(&plan.config_fingerprint) + || !is_sha256(&plan.operation_fingerprint) + || !canonical_uuid_v4(&plan.created_startup_session_id) + || !plan.current_root.is_absolute() + || !plan.current_candidate_path.is_absolute() + || !plan.current_tombstone_path.is_absolute() + || !plan.quarantine_path.is_absolute() + || !plan.tombstone_path.is_absolute() + || plan.current_root == plan.legacy_root + || plan.current_candidate_path == plan.current_root + || plan.current_tombstone_path == plan.current_root + || plan.current_candidate_path == plan.current_tombstone_path + || plan.current_candidate_path.parent() != plan.current_tombstone_path.parent() + || plan.quarantine_path == plan.legacy_root + || plan.tombstone_path == plan.quarantine_path + || plan.quarantine_path.parent() != plan.tombstone_path.parent() + || plan.quarantine_path.file_name() != Some(OsStr::new(&plan.cutover_id)) + || plan.current_candidate_path.file_name() + != Some(OsStr::new(&format!( + "proxifyre-{}.candidate", + plan.cutover_id + ))) + || plan.current_tombstone_path.file_name() + != Some(OsStr::new(&format!( + "proxifyre-{}.rollback", + plan.cutover_id + ))) + || plan.tombstone_path.file_name() + != Some(OsStr::new(&format!("{}.deleting", plan.cutover_id))) + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +fn validate_before_state_structure( + plan: &ProxifyreCutoverPlan, + state: &LegacyBeforeState, +) -> Result<(), CutoverError> { + let service_state = match state.service.original_state { + ServiceStableState::Running => LegacyServiceState::Running, + ServiceStableState::Stopped => LegacyServiceState::Stopped, + }; + if state.identity != plan.identity + || state.package_fingerprint != plan.package_fingerprint + || state.config_fingerprint != plan.config_fingerprint + || state.service.service_name != "ProxiFyreService" + || state.additional_matching_service + || state.service.security.untrusted_mutation_rights + || !state.service.has_complete_config2() + || service_state != plan.original_service_state + || state.filesystem.root.identity.kind != StableObjectKind::Directory + || state.filesystem.config.identity.kind != StableObjectKind::RegularFile + || !safe_relative_path(&state.filesystem.config_relative_path) + || state.filesystem.entries.is_empty() + { + return Err(CutoverError::InvalidPlan); + } + let mut paths = HashSet::new(); + for entry in &state.filesystem.entries { + let normalized = + normalized_relative_path(&entry.relative_path).ok_or(CutoverError::InvalidPlan)?; + if !paths.insert(normalized) + || entry.security.as_bytes().is_empty() + || entry.security.as_bytes().len() > MAX_SECURITY_DESCRIPTOR_BYTES + || (entry.identity.kind == StableObjectKind::RegularFile + && !entry.sha256.as_deref().is_some_and(is_sha256)) + || (entry.identity.kind == StableObjectKind::Directory && entry.sha256.is_some()) + { + return Err(CutoverError::InvalidPlan); + } + } + let normalized_config = normalized_relative_path(&state.filesystem.config_relative_path) + .ok_or(CutoverError::InvalidPlan)?; + if !paths.contains(&normalized_config) + || state.filesystem.root.security.as_bytes().is_empty() + || state.filesystem.config.security.as_bytes().is_empty() + || state.service.security.self_relative_descriptor.is_empty() + || state.service.security.self_relative_descriptor.len() > MAX_SECURITY_DESCRIPTOR_BYTES + { + return Err(CutoverError::InvalidPlan); + } + if let CurrentComponentsParentBeforeState::Present(snapshot) = &state.current_components_parent + { + if snapshot.identity.kind != StableObjectKind::Directory + || snapshot.security.as_bytes().is_empty() + || snapshot.security.as_bytes().len() > MAX_SECURITY_DESCRIPTOR_BYTES + { + return Err(CutoverError::InvalidPlan); + } + } + validate_legacy_root_manifest(&state.filesystem)?; + validate_current_candidate(plan, &state.current_candidate)?; + Ok(()) +} + +fn validate_before_state( + plan: &ProxifyreCutoverPlan, + state: &LegacyBeforeState, +) -> Result<(), CutoverError> { + validate_before_state_structure(plan, state)?; + let proof = prove_captured_legacy_cutover(plan, state)?; + if state.identity_fingerprint != proof.fingerprint() { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +/// Runs the closed proof gate over the exact service/filesystem snapshot +/// returned by `capture_and_lease`. This must succeed before a journal exists; +/// no caller-supplied proof can authorize the mutation entrypoint. +fn prove_captured_legacy_cutover( + plan: &ProxifyreCutoverPlan, + state: &LegacyBeforeState, +) -> Result { + let root = plan.legacy_root.clone(); + let executable = root.join("ProxiFyre.exe"); + let status = match state.service.original_state { + ServiceStableState::Running => "running", + ServiceStableState::Stopped => "stopped", + }; + let path_matches = + legacy_proxifyre_topshelf_path_matches(&state.service.base.binary_path_name, &executable); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: CandidateRole::Legacy, + root, + root_exists: true, + has_reparse_point: false, + executable_path: Some(executable.clone()), + missing_files: Vec::new(), + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some(plan.legacy_version.clone()), + service: Some(ServiceEvidence { + name: state.service.service_name.clone(), + status: status.to_string(), + path_name: Some(state.service.base.binary_path_name.clone()), + executable_path: Some(executable), + path_matches_candidate: path_matches, + binary_version: Some(plan.legacy_version.clone()), + }), + service_required: true, + legacy_identity_complete: true, + }], + ); + let evidence = LegacyCutoverEvidence { + proxifyre_manifest_matches: true, + proxifyre_scm_profile: legacy_proxifyre_scm_profile_from_snapshot(&state.service), + proxifyre_scm_snapshot_fingerprint: legacy_service_snapshot_fingerprint(&state.service), + additional_matching_service: state.additional_matching_service, + }; + prove_legacy_cutover(&inventory, &evidence).map_err(|_| CutoverError::InvalidPlan) +} + +/// Stable domain-separated identity for every field captured from SCM. It is +/// public so read-only preflight/integration code can build the UX proof from +/// the same DTO; mutation authority is still recomputed after capture+lease. +pub fn legacy_service_snapshot_fingerprint(service: &ServiceRestoreSnapshot) -> String { + let value = serde_json::json!({ + "domain": "proxywarden-legacy-service-snapshot-v1", + "snapshot": service, + }); + format!("{:x}", Sha256::digest(value.to_string().as_bytes())) +} + +/// Maps the complete SCM restore DTO into the frozen fields used by the +/// ProxiFyre 2.2.1 cutover gate. Missing or mistyped CONFIG2 data fails closed. +pub fn legacy_proxifyre_scm_profile_from_snapshot( + service: &ServiceRestoreSnapshot, +) -> Option { + let description = match service.config2(ServiceConfig2Kind::Description)? { + ServiceConfig2Snapshot::Description(Some(value)) => value.clone(), + _ => return None, + }; + let has_failure_actions = match service.config2(ServiceConfig2Kind::FailureActions)? { + ServiceConfig2Snapshot::FailureActions(value) => { + !value.actions.is_empty() + || value + .reboot_message + .as_deref() + .is_some_and(|text| !text.is_empty()) + || value + .command + .as_deref() + .is_some_and(|text| !text.is_empty()) + } + _ => return None, + }; + let failure_actions_on_non_crash = + match service.config2(ServiceConfig2Kind::FailureActionsFlag)? { + ServiceConfig2Snapshot::FailureActionsFlag(value) => *value, + _ => return None, + }; + let delayed_auto_start = match service.config2(ServiceConfig2Kind::DelayedAutoStart)? { + ServiceConfig2Snapshot::DelayedAutoStart(value) => *value, + _ => return None, + }; + let sid_type = match service.config2(ServiceConfig2Kind::SidType)? { + ServiceConfig2Snapshot::SidType(value) => *value, + _ => return None, + }; + let required_privileges = match service.config2(ServiceConfig2Kind::RequiredPrivileges)? { + ServiceConfig2Snapshot::RequiredPrivileges(value) => value.clone(), + _ => return None, + }; + let has_triggers = match service.config2(ServiceConfig2Kind::Triggers)? { + ServiceConfig2Snapshot::Triggers(value) => !value.is_empty(), + _ => return None, + }; + + Some(LegacyProxifyreScmProfile { + service_type: service.base.service_type, + start_type: service.base.start_type, + error_control: service.base.error_control, + account_name: service.base.service_start_name.clone(), + display_name: service.base.display_name.clone(), + description, + dependencies: service.base.dependencies.clone(), + load_order_group: service.base.load_order_group.clone(), + has_failure_actions, + failure_actions_on_non_crash, + delayed_auto_start, + sid_type, + required_privileges, + has_triggers, + untrusted_mutation_rights: service.security.untrusted_mutation_rights, + }) +} + +fn validate_legacy_root_manifest( + filesystem: &LegacyFilesystemSnapshot, +) -> Result<(), CutoverError> { + let immutable: Vec<_> = filesystem + .entries + .iter() + .filter(|entry| { + LEGACY_PROXIFYRE_2_2_1_MANIFEST.iter().any(|expected| { + manifest_path_eq(&entry.relative_path, Path::new(expected.relative_path)) + }) + }) + .map(|entry| LegacyPackageFileIdentity { + relative_path: entry.relative_path.clone(), + size: entry.identity.size, + sha256: entry.sha256.clone().unwrap_or_default(), + }) + .collect(); + if !matches_legacy_proxifyre_2_2_1_manifest(&immutable) + || !manifest_path_eq( + &filesystem.config_relative_path, + Path::new("app-config.json"), + ) + { + return Err(CutoverError::InvalidPlan); + } + + let config_entry = filesystem + .entries + .iter() + .find(|entry| manifest_path_eq(&entry.relative_path, Path::new("app-config.json"))) + .ok_or(CutoverError::InvalidPlan)?; + if config_entry.identity != filesystem.config.identity + || config_entry.security != filesystem.config.security + || config_entry.identity.size > MAX_MUTABLE_CONFIG_BYTES + { + return Err(CutoverError::InvalidPlan); + } + + let mut log_count = 0usize; + let mut has_logs_directory = false; + for entry in &filesystem.entries { + if LEGACY_PROXIFYRE_2_2_1_MANIFEST.iter().any(|expected| { + manifest_path_eq(&entry.relative_path, Path::new(expected.relative_path)) + }) || manifest_path_eq(&entry.relative_path, Path::new("app-config.json")) + { + continue; + } + if manifest_path_eq(&entry.relative_path, Path::new("app-config.json.bak")) { + if entry.identity.kind != StableObjectKind::RegularFile + || entry.identity.size > MAX_MUTABLE_CONFIG_BYTES + { + return Err(CutoverError::InvalidPlan); + } + continue; + } + if manifest_path_eq(&entry.relative_path, Path::new("logs")) { + if entry.identity.kind != StableObjectKind::Directory { + return Err(CutoverError::InvalidPlan); + } + has_logs_directory = true; + continue; + } + let components: Vec<_> = entry.relative_path.components().collect(); + let valid_log = components.len() == 2 + && matches!(components[0], Component::Normal(value) if value.to_string_lossy().eq_ignore_ascii_case("logs")) + && matches!(components[1], Component::Normal(value) if legacy_log_name_is_allowed(value)); + if !valid_log + || entry.identity.kind != StableObjectKind::RegularFile + || entry.identity.size > MAX_LEGACY_LOG_BYTES + { + return Err(CutoverError::InvalidPlan); + } + log_count += 1; + } + if log_count > MAX_LEGACY_LOG_FILES || (log_count != 0 && !has_logs_directory) { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +fn legacy_log_name_is_allowed(name: &OsStr) -> bool { + let value = name.to_string_lossy(); + let bytes = value.as_bytes(); + bytes.len() == 22 + && bytes.starts_with(b"logfile_") + && bytes.ends_with(b".txt") + && bytes[8..18].iter().enumerate().all(|(index, byte)| { + matches!(index, 4 | 7) && *byte == b'-' + || !matches!(index, 4 | 7) && byte.is_ascii_digit() + }) +} + +fn manifest_path_eq(left: &Path, right: &Path) -> bool { + left.as_os_str() + .to_string_lossy() + .replace('\\', "/") + .eq_ignore_ascii_case(&right.as_os_str().to_string_lossy().replace('\\', "/")) +} + +fn validate_current_candidate( + plan: &ProxifyreCutoverPlan, + candidate: &CurrentCandidateSnapshot, +) -> Result<(), CutoverError> { + validate_current_candidate_files(&candidate.files)?; + let expected = current_candidate_manifest_fingerprint(&candidate.files)?; + let config = candidate + .files + .iter() + .find(|file| file.role == CurrentCandidateFileRole::Config) + .ok_or(CutoverError::InvalidPlan)?; + if candidate.manifest_fingerprint != expected || config.sha256 != plan.config_fingerprint { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +fn validate_current_candidate_files(files: &[CurrentCandidateFile]) -> Result<(), CutoverError> { + if files.is_empty() || files.len() > MAX_CURRENT_CANDIDATE_FILES { + return Err(CutoverError::InvalidPlan); + } + let mut paths = HashSet::new(); + let mut package_paths = HashSet::new(); + let mut config_count = 0usize; + let mut marker_count = 0usize; + let mut receipt_count = 0usize; + let mut total_bytes = 0u64; + for file in files { + let normalized = + normalized_relative_path(&file.relative_path).ok_or(CutoverError::InvalidPlan)?; + if file.relative_path.components().count() != 1 + || !paths.insert(normalized) + || file.size == 0 + || file.size > MAX_CURRENT_CANDIDATE_FILE_BYTES + || !is_sha256(&file.sha256) + { + return Err(CutoverError::InvalidPlan); + } + total_bytes = total_bytes + .checked_add(file.size) + .ok_or(CutoverError::InvalidPlan)?; + match file.role { + CurrentCandidateFileRole::Package => { + package_paths.insert( + normalized_relative_path(&file.relative_path) + .ok_or(CutoverError::InvalidPlan)?, + ); + } + CurrentCandidateFileRole::Config => { + config_count += 1; + if !manifest_path_eq(&file.relative_path, Path::new("app-config.json")) { + return Err(CutoverError::InvalidPlan); + } + } + CurrentCandidateFileRole::Marker => { + marker_count += 1; + if !manifest_path_eq(&file.relative_path, Path::new(PROXIFYRE_MARKER_FILE)) { + return Err(CutoverError::InvalidPlan); + } + } + CurrentCandidateFileRole::Receipt => { + receipt_count += 1; + if !manifest_path_eq(&file.relative_path, Path::new(INSTALL_RECEIPT_FILENAME)) { + return Err(CutoverError::InvalidPlan); + } + } + } + } + let expected_package_paths: HashSet<_> = CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .map(|path| path.to_ascii_lowercase()) + .collect(); + if package_paths != expected_package_paths + || config_count != 1 + || marker_count != 1 + || receipt_count != 1 + || total_bytes > MAX_CURRENT_CANDIDATE_BYTES + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +fn normalized_relative_path(path: &Path) -> Option { + if !safe_relative_path(path) { + return None; + } + Some(path.to_str()?.replace('\\', "/").to_ascii_lowercase()) +} + +fn validate_journal(journal: &CutoverJournal) -> Result<(), CutoverError> { + if journal.schema_version != CUTOVER_JOURNAL_SCHEMA_VERSION { + return Err(CutoverError::InvalidPlan); + } + validate_plan(&journal.plan)?; + validate_before_state(&journal.plan, &journal.before_state)?; + if journal.mutations.len() > 512 + || journal.mutations.iter().enumerate().any(|(index, record)| { + record.sequence != index as u32 + || !is_sha256(record.before_state.as_str()) + || !is_sha256(record.expected_effect.as_str()) + || (record.before_state == record.expected_effect + && !record.effect.as_ref().is_some_and(|effect| { + effect.disposition == EffectDisposition::AlreadySatisfied + && operation_allows_already_satisfied( + journal, + record.direction, + &record.operation, + record.sequence, + ) + })) + || record.intent_written_at_epoch_seconds > journal.updated_at_epoch_seconds + || match record.effect.as_ref().map(|effect| effect.disposition) { + Some(EffectDisposition::ExpectedEffect) => { + record.authority_evidence + != required_mutation_authority(record.direction, &record.operation) + } + Some(EffectDisposition::AlreadySatisfied | EffectDisposition::NoEffect) => { + record.authority_evidence.is_some() + } + None => { + record.authority_evidence.is_some() + && record.authority_evidence + != required_mutation_authority(record.direction, &record.operation) + } + } + || record.effect.as_ref().is_some_and(|effect| { + !is_sha256(effect.observed.as_str()) + || effect.observed_at_epoch_seconds < record.intent_written_at_epoch_seconds + || effect.observed_at_epoch_seconds > journal.updated_at_epoch_seconds + || match effect.disposition { + EffectDisposition::ExpectedEffect => { + effect.observed != record.expected_effect + } + EffectDisposition::AlreadySatisfied => { + record.before_state != record.expected_effect + || effect.observed != record.expected_effect + || !operation_allows_already_satisfied( + journal, + record.direction, + &record.operation, + record.sequence, + ) + } + EffectDisposition::NoEffect => effect.observed != record.before_state, + } + }) + || !operation_is_safe(&record.operation) + || !operation_is_bound(&record.operation, journal) + }) + || journal + .mutations + .iter() + .filter(|record| record.effect.is_none()) + .count() + > 1 + || !validate_mutation_history(journal) + { + return Err(CutoverError::InvalidPlan); + } + for record in &journal.mutations { + if let Some(effect) = &record.effect { + if matches!( + effect.disposition, + EffectDisposition::NoEffect | EffectDisposition::AlreadySatisfied + ) && effect.object_identity.is_some() + { + return Err(CutoverError::InvalidPlan); + } + if effect.disposition == EffectDisposition::ExpectedEffect + && validate_observed_identity( + &record.operation, + journal, + effect.object_identity.as_ref(), + ) + .is_err() + { + return Err(CutoverError::InvalidPlan); + } + } + } + let verified_current_valid = journal + .verified_current_fingerprint + .as_deref() + .is_none_or(is_sha256); + let next_start_valid = journal.next_start.as_ref().is_none_or(|evidence| { + canonical_uuid_v4(&evidence.startup_session_id) + && evidence.startup_session_id != journal.plan.created_startup_session_id + && is_sha256(&evidence.current_inventory_fingerprint) + && evidence.observed_at_epoch_seconds <= journal.updated_at_epoch_seconds + }); + let route_valid = journal.route_smoke.as_ref().is_none_or(|evidence| { + evidence.confirmed_by_user + && journal.next_start.as_ref().is_some_and(|next_start| { + evidence.confirmed_at_epoch_seconds >= next_start.observed_at_epoch_seconds + && evidence.confirmed_at_epoch_seconds <= journal.updated_at_epoch_seconds + }) + }); + let recovery_code_valid = match (journal.phase, journal.recovery_code.as_deref()) { + (CutoverPhase::RecoveryRequired, Some(code)) => { + !code.is_empty() + && code.len() <= 64 + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + } + (CutoverPhase::RecoveryRequired, None) => false, + (_, None) => true, + (_, Some(_)) => false, + }; + let mode_valid = match journal.execution_mode { + CutoverExecutionMode::Forward => matches!( + journal.phase, + CutoverPhase::ComponentPlanned + | CutoverPhase::PrivilegeGranted + | CutoverPhase::ComponentStaged + | CutoverPhase::ServiceSwitched + | CutoverPhase::ComponentVerified + | CutoverPhase::RecoveryRequired + ), + CutoverExecutionMode::RollingBack => !matches!( + journal.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ), + CutoverExecutionMode::CleaningUp => matches!( + journal.phase, + CutoverPhase::CleanupConfirmed | CutoverPhase::RecoveryRequired + ), + CutoverExecutionMode::AwaitingUser => matches!( + journal.phase, + CutoverPhase::LegacyQuarantined + | CutoverPhase::NextStartVerified + | CutoverPhase::CleanupConfirmed + | CutoverPhase::RecoveryRequired + ), + CutoverExecutionMode::Finished => matches!( + journal.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ), + }; + let evidence_phase_valid = match journal.phase { + CutoverPhase::ComponentPlanned + | CutoverPhase::PrivilegeGranted + | CutoverPhase::ComponentStaged + | CutoverPhase::ServiceSwitched + | CutoverPhase::ComponentVerified + | CutoverPhase::LegacyQuarantined + | CutoverPhase::RolledBack => journal.next_start.is_none() && journal.route_smoke.is_none(), + CutoverPhase::NextStartVerified => { + journal.next_start.is_some() && journal.route_smoke.is_none() + } + CutoverPhase::CleanupConfirmed | CutoverPhase::ComponentComplete => { + journal.next_start.is_some() && journal.route_smoke.is_some() + } + CutoverPhase::RecoveryRequired => { + journal.route_smoke.is_none() || journal.next_start.is_some() + } + }; + let verified_phase_valid = !matches!( + journal.phase, + CutoverPhase::ComponentVerified + | CutoverPhase::LegacyQuarantined + | CutoverPhase::NextStartVerified + | CutoverPhase::CleanupConfirmed + | CutoverPhase::ComponentComplete + ) || journal.verified_current_fingerprint.is_some(); + if !verified_current_valid + || !next_start_valid + || !route_valid + || !recovery_code_valid + || !mode_valid + || !evidence_phase_valid + || !verified_phase_valid + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +fn validate_mutation_history(journal: &CutoverJournal) -> bool { + let mut forward = forward_operations(journal); + forward.push(CutoverOperation::QuarantineLegacyRoot); + let rollback = rollback_operations(journal); + let cleanup = cleanup_operations(journal); + let mut forward_index = 0usize; + let mut rollback_index = 0usize; + let mut cleanup_index = 0usize; + let mut non_forward_direction = None; + + for (record_index, record) in journal.mutations.iter().enumerate() { + if record.effect.is_none() && record_index + 1 != journal.mutations.len() { + return false; + } + let (operations, index) = match record.direction { + MutationDirection::Forward if non_forward_direction.is_none() => { + (&forward, &mut forward_index) + } + MutationDirection::Forward => return false, + MutationDirection::Rollback => { + if non_forward_direction == Some(MutationDirection::Cleanup) { + return false; + } + non_forward_direction = Some(MutationDirection::Rollback); + (&rollback, &mut rollback_index) + } + MutationDirection::Cleanup => { + if non_forward_direction == Some(MutationDirection::Rollback) + || !operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::QuarantineLegacyRoot, + ) + { + return false; + } + non_forward_direction = Some(MutationDirection::Cleanup); + (&cleanup, &mut cleanup_index) + } + }; + if operations.get(*index) != Some(&record.operation) { + return false; + } + if record.effect.as_ref().is_some_and(|effect| { + matches!( + effect.disposition, + EffectDisposition::ExpectedEffect | EffectDisposition::AlreadySatisfied + ) + }) { + *index += 1; + } + } + + let forward_base_complete = forward + .iter() + .take(forward.len().saturating_sub(1)) + .all(|operation| operation_applied(journal, MutationDirection::Forward, operation)); + let quarantine_complete = operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::QuarantineLegacyRoot, + ); + let rollback_complete = rollback + .iter() + .all(|operation| operation_applied(journal, MutationDirection::Rollback, operation)); + let cleanup_complete = cleanup + .iter() + .all(|operation| operation_applied(journal, MutationDirection::Cleanup, operation)); + + match journal.phase { + CutoverPhase::ComponentPlanned => journal.mutations.is_empty(), + CutoverPhase::PrivilegeGranted | CutoverPhase::RecoveryRequired => true, + CutoverPhase::ComponentStaged => operation_applied( + journal, + MutationDirection::Forward, + &CutoverOperation::PromoteCurrentCandidate, + ), + CutoverPhase::ServiceSwitched | CutoverPhase::ComponentVerified => forward_base_complete, + CutoverPhase::LegacyQuarantined + | CutoverPhase::NextStartVerified + | CutoverPhase::CleanupConfirmed => forward_base_complete && quarantine_complete, + CutoverPhase::ComponentComplete => { + forward_base_complete && quarantine_complete && cleanup_complete + } + CutoverPhase::RolledBack => rollback_complete, + } +} + +fn operation_is_safe(operation: &CutoverOperation) -> bool { + match operation { + CutoverOperation::DeleteQuarantineEntry(path) + | CutoverOperation::DeleteCurrentEntry(path) + | CutoverOperation::WriteCurrentCandidatePackageEntry(path) + | CutoverOperation::HardenLegacyEntrySecurity(path) + | CutoverOperation::RestoreLegacyEntrySecurity(path) => safe_relative_path(path), + _ => true, + } +} + +fn operation_is_bound(operation: &CutoverOperation, journal: &CutoverJournal) -> bool { + let legacy_entry = |path: &Path| { + journal + .before_state + .filesystem + .entries + .iter() + .any(|entry| manifest_path_eq(&entry.relative_path, path)) + }; + let candidate_entry = |path: &Path| { + journal + .before_state + .current_candidate + .files + .iter() + .any(|entry| manifest_path_eq(&entry.relative_path, path)) + }; + match operation { + CutoverOperation::HardenLegacyEntrySecurity(path) + | CutoverOperation::RestoreLegacyEntrySecurity(path) + | CutoverOperation::DeleteQuarantineEntry(path) => legacy_entry(path), + CutoverOperation::WriteCurrentCandidatePackageEntry(path) => journal + .before_state + .current_candidate + .files + .iter() + .any(|entry| { + entry.role == CurrentCandidateFileRole::Package + && manifest_path_eq(&entry.relative_path, path) + }), + CutoverOperation::DeleteCurrentEntry(path) => candidate_entry(path), + CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt => { + candidate_file_for_operation(operation, journal).is_some() + } + _ => true, + } +} + +fn safe_relative_path(path: &Path) -> bool { + !path.as_os_str().is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(_))) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn version_is_at_least(actual: &str, minimum: &str) -> bool { + fn parse(value: &str) -> Option<(u64, u64, u64)> { + let mut parts = value.split('.'); + let parsed = ( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + ); + if parts.next().is_some() { + return None; + } + Some(parsed) + } + + matches!((parse(actual), parse(minimum)), (Some(actual), Some(minimum)) if actual >= minimum) +} + +fn windows_path_eq(path: &Path, expected: &str) -> bool { + path.as_os_str() + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .eq_ignore_ascii_case(expected) +} + +fn windows_path_eq_path(left: &Path, right: &Path) -> bool { + left.as_os_str() + .to_string_lossy() + .trim_end_matches(['\\', '/']) + .eq_ignore_ascii_case( + right + .as_os_str() + .to_string_lossy() + .trim_end_matches(['\\', '/']), + ) +} + +fn canonical_uuid_v4(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|parsed| { + parsed.get_version() == Some(Version::Random) && parsed.hyphenated().to_string() == value + }) +} + +pub fn cutover_transaction_fingerprint(journal: &CutoverJournal) -> Result { + let immutable = serde_json::to_vec(&(&journal.plan, &journal.before_state)) + .map_err(|_| CutoverError::InvalidPlan)?; + Ok( + StateFingerprint::digest("proxywarden:component-cutover-transaction:v1", &immutable) + .as_str() + .to_string(), + ) +} + +/// Binds terminal retirement to the complete canonical journal, including its +/// mutation history and user-evidence milestones. It is intentionally stricter +/// than `cutover_transaction_fingerprint`, which identifies only immutable +/// plan/before-state data. +pub fn cutover_terminal_record_fingerprint( + journal: &CutoverJournal, +) -> Result { + if !is_retirable_terminal_journal(journal) { + return Err(CutoverError::StateConflict); + } + Ok(retirement_record_identity(journal)?.fingerprint) +} + +pub fn cutover_evidence_fingerprint(journal: &CutoverJournal) -> Option { + let next_start = journal.next_start.as_ref()?; + let route = journal.route_smoke.as_ref(); + Some( + StateFingerprint::digest( + "proxywarden:component-cutover-evidence:v1", + format!( + "{}|{}|{}|{}", + next_start.startup_session_id, + next_start.current_inventory_fingerprint, + route.is_some_and(|value| value.confirmed_by_user), + route.map_or(0, |value| value.confirmed_at_epoch_seconds), + ) + .as_bytes(), + ) + .as_str() + .to_string(), + ) +} + +pub fn validate_component_cutover_user_evidence( + evidence: &ComponentCutoverUserEvidence, +) -> Result<(), CutoverError> { + let confirmation_is_valid = match ( + evidence.route_smoke_confirmed, + evidence.confirmed_at_epoch_seconds, + ) { + (false, None) => true, + (true, Some(confirmed)) => confirmed >= evidence.observed_at_epoch_seconds, + _ => false, + }; + if evidence.schema_version != CUTOVER_USER_EVIDENCE_SCHEMA_VERSION + || !canonical_uuid_v4(&evidence.cutover_id) + || !canonical_uuid_v4(&evidence.startup_session_id) + || !is_sha256(&evidence.current_inventory_fingerprint) + || !confirmation_is_valid + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +/// Validates the complete redacted ProgramData cache before normal-process UX +/// code consumes it. The cache remains untrusted authorization-wise, but a +/// structurally forged record must not publish an impossible lifecycle state. +pub fn validate_component_cutover_observation( + observation: &ComponentCutoverObservation, +) -> Result<(), CutoverError> { + let state_matches_phase = match observation.phase { + CutoverPhase::ComponentPlanned + | CutoverPhase::PrivilegeGranted + | CutoverPhase::ComponentStaged + | CutoverPhase::ServiceSwitched + | CutoverPhase::ComponentVerified => observation.state == CutoverDisplayState::InProgress, + CutoverPhase::LegacyQuarantined => { + observation.state == CutoverDisplayState::AwaitingNextStart + } + CutoverPhase::NextStartVerified => { + observation.state == CutoverDisplayState::AwaitingRouteSmoke + } + CutoverPhase::CleanupConfirmed => matches!( + observation.state, + CutoverDisplayState::CleanupReady | CutoverDisplayState::CleanupPending + ), + CutoverPhase::ComponentComplete => observation.state == CutoverDisplayState::Complete, + CutoverPhase::RolledBack => observation.state == CutoverDisplayState::RolledBack, + CutoverPhase::RecoveryRequired => { + observation.state == CutoverDisplayState::RecoveryRequired + } + }; + let evidence_matches_phase = match observation.phase { + CutoverPhase::ComponentPlanned + | CutoverPhase::PrivilegeGranted + | CutoverPhase::ComponentStaged + | CutoverPhase::ServiceSwitched + | CutoverPhase::ComponentVerified + | CutoverPhase::LegacyQuarantined + | CutoverPhase::RolledBack => { + !observation.next_start_verified && !observation.route_smoke_confirmed + } + CutoverPhase::NextStartVerified => { + observation.next_start_verified && !observation.route_smoke_confirmed + } + CutoverPhase::CleanupConfirmed | CutoverPhase::ComponentComplete => { + observation.next_start_verified && observation.route_smoke_confirmed + } + CutoverPhase::RecoveryRequired => { + !observation.route_smoke_confirmed || observation.next_start_verified + } + }; + let disabled_code_valid = match (observation.phase, observation.disabled_code.as_deref()) { + (CutoverPhase::RecoveryRequired, Some(code)) => { + !code.is_empty() + && code.len() <= 64 + && code + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + } + (CutoverPhase::RecoveryRequired, None) => false, + (_, None) => true, + (_, Some(_)) => false, + }; + let safe_label = |label: &str| { + !label.is_empty() && label.len() <= 96 && label.chars().all(|value| !value.is_control()) + }; + if observation.schema_version != CUTOVER_OBSERVATION_SCHEMA_VERSION + || observation.component != "proxifyre" + || !canonical_uuid_v4(&observation.cutover_id) + || observation.legacy_version != LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION + || !version_is_at_least( + &observation.bundled_version, + LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION, + ) + || !is_sha256(&observation.operation_fingerprint) + || !is_sha256(&observation.transaction_fingerprint) + || observation + .evidence_fingerprint + .as_deref() + .is_some_and(|value| !is_sha256(value)) + || observation.evidence_fingerprint.is_some() != observation.next_start_verified + || !safe_label(&observation.legacy_path_label) + || !safe_label(&observation.current_path_label) + || observation.can_recover != (observation.phase == CutoverPhase::RecoveryRequired) + || observation.can_cleanup != (observation.phase == CutoverPhase::CleanupConfirmed) + || observation.updated_at_epoch_seconds == 0 + || !state_matches_phase + || !evidence_matches_phase + || !disabled_code_valid + { + return Err(CutoverError::InvalidPlan); + } + Ok(()) +} + +/// Stable identity for the complete untrusted carrier. This is only a binding +/// value; it does not turn user evidence into mutation authority. +pub fn component_cutover_user_evidence_fingerprint( + evidence: &ComponentCutoverUserEvidence, +) -> Result { + validate_component_cutover_user_evidence(evidence)?; + let bytes = serde_json::to_vec(evidence).map_err(|_| CutoverError::InvalidPlan)?; + Ok( + StateFingerprint::digest("proxywarden:component-cutover-user-evidence:v1", &bytes) + .as_str() + .to_string(), + ) +} + +fn observation_from_journal( + journal: &CutoverJournal, +) -> Result { + let state = match journal.phase { + CutoverPhase::ComponentComplete => CutoverDisplayState::Complete, + CutoverPhase::RolledBack => CutoverDisplayState::RolledBack, + CutoverPhase::RecoveryRequired => CutoverDisplayState::RecoveryRequired, + CutoverPhase::LegacyQuarantined => CutoverDisplayState::AwaitingNextStart, + CutoverPhase::NextStartVerified => CutoverDisplayState::AwaitingRouteSmoke, + CutoverPhase::CleanupConfirmed + if journal.execution_mode == CutoverExecutionMode::CleaningUp => + { + CutoverDisplayState::CleanupPending + } + CutoverPhase::CleanupConfirmed => CutoverDisplayState::CleanupReady, + _ => CutoverDisplayState::InProgress, + }; + let observation = ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: journal.plan.cutover_id.clone(), + state, + phase: journal.phase, + original_service_state: journal.plan.original_service_state, + legacy_version: journal.plan.legacy_version.clone(), + bundled_version: journal.plan.bundled_version.clone(), + operation_fingerprint: journal.plan.operation_fingerprint.clone(), + transaction_fingerprint: cutover_transaction_fingerprint(journal)?, + evidence_fingerprint: cutover_evidence_fingerprint(journal), + next_start_verified: journal.next_start.is_some(), + route_smoke_confirmed: journal + .route_smoke + .as_ref() + .is_some_and(|value| value.confirmed_by_user), + legacy_path_label: "legacy ProxiFyre installation".to_string(), + current_path_label: "ProxyWarden managed components".to_string(), + can_recover: journal.phase == CutoverPhase::RecoveryRequired, + can_cleanup: journal.phase == CutoverPhase::CleanupConfirmed, + disabled_code: journal.recovery_code.clone(), + updated_at_epoch_seconds: journal.updated_at_epoch_seconds, + }; + validate_component_cutover_observation(&observation)?; + Ok(observation) +} + +pub fn fixed_quarantine_name(cutover_id: &str) -> Result<&OsStr, CutoverError> { + if !canonical_uuid_v4(cutover_id) { + return Err(CutoverError::InvalidPlan); + } + Ok(OsStr::new(cutover_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct FakeRetirementIo { + snapshot: CutoverRetirementSnapshot, + applied: Vec, + } + + impl CutoverRetirementIo for FakeRetirementIo { + fn snapshot(&mut self) -> Result { + Ok(self.snapshot.clone()) + } + + fn apply( + &mut self, + operation: CutoverRetirementOperation, + retirement_record: Option<(&[u8], &RetirementRecordIdentity)>, + ) -> Result<(), CutoverError> { + self.applied.push(operation); + match operation { + CutoverRetirementOperation::DeleteInvalidRetirementRecord + | CutoverRetirementOperation::DeleteRetirementRecord => { + self.snapshot.retirement_record = RetirementRecordState::Missing; + } + CutoverRetirementOperation::WriteRetirementRecord => { + let (_, identity) = retirement_record.ok_or(CutoverError::StateConflict)?; + self.snapshot.retirement_record = + RetirementRecordState::Valid(identity.clone()); + } + CutoverRetirementOperation::DeleteActiveJournal => { + self.snapshot.active_record = RetirementRecordState::Missing; + self.snapshot.journal_root = RetirementRootShape::Empty; + } + CutoverRetirementOperation::DeleteJournalRoot => { + self.snapshot.journal_root = RetirementRootShape::Missing; + } + CutoverRetirementOperation::DeleteQuarantineRoot => { + self.snapshot.quarantine_root = RetirementRootShape::Missing; + } + } + Ok(()) + } + } + + struct InterruptAfter { + target: CutoverRetirementOperation, + fired: bool, + } + + impl CutoverRetirementFaultInjector for InterruptAfter { + fn after_retirement_mutation( + &mut self, + operation: CutoverRetirementOperation, + ) -> Result<(), CutoverError> { + if operation == self.target && !self.fired { + self.fired = true; + return Err(CutoverError::Interrupted); + } + Ok(()) + } + } + + fn retirement_identity(phase: CutoverPhase) -> RetirementRecordIdentity { + RetirementRecordIdentity { + phase, + execution_mode: CutoverExecutionMode::Finished, + fingerprint: "a".repeat(64), + } + } + + fn completed_retirement_io() -> FakeRetirementIo { + FakeRetirementIo { + snapshot: CutoverRetirementSnapshot { + journal_root: RetirementRootShape::ActiveJournalOnly, + quarantine_root: RetirementRootShape::Empty, + active_record: RetirementRecordState::Valid(retirement_identity( + CutoverPhase::ComponentComplete, + )), + retirement_record: RetirementRecordState::Missing, + }, + applied: Vec::new(), + } + } + + #[test] + fn terminal_retirement_retries_after_every_external_mutation() { + let identity = retirement_identity(CutoverPhase::ComponentComplete); + for target in [ + CutoverRetirementOperation::WriteRetirementRecord, + CutoverRetirementOperation::DeleteActiveJournal, + CutoverRetirementOperation::DeleteJournalRoot, + CutoverRetirementOperation::DeleteQuarantineRoot, + CutoverRetirementOperation::DeleteRetirementRecord, + ] { + let mut io = completed_retirement_io(); + let mut fault = InterruptAfter { + target, + fired: false, + }; + assert!(matches!( + run_terminal_retirement_record(&mut io, &identity, b"sealed", &mut fault), + Err(CutoverError::Interrupted) + )); + assert!(fault.fired); + + run_terminal_retirement_record( + &mut io, + &identity, + b"sealed", + &mut NoCutoverRetirementFault, + ) + .unwrap(); + assert_eq!( + classify_external_mutation_status(&io.snapshot), + CutoverExternalMutationStatus::Absent + ); + } + } + + #[test] + fn rolled_back_retirement_is_explicit_and_retries_after_every_mutation() { + let identity = retirement_identity(CutoverPhase::RolledBack); + for target in [ + CutoverRetirementOperation::WriteRetirementRecord, + CutoverRetirementOperation::DeleteActiveJournal, + CutoverRetirementOperation::DeleteJournalRoot, + CutoverRetirementOperation::DeleteQuarantineRoot, + CutoverRetirementOperation::DeleteRetirementRecord, + ] { + let mut io = completed_retirement_io(); + io.snapshot.active_record = RetirementRecordState::Valid(identity.clone()); + assert_eq!( + classify_external_mutation_status(&io.snapshot), + CutoverExternalMutationStatus::RolledBackRetained + ); + let mut fault = InterruptAfter { + target, + fired: false, + }; + assert!(matches!( + run_terminal_retirement_record(&mut io, &identity, b"sealed", &mut fault), + Err(CutoverError::Interrupted) + )); + assert!(fault.fired); + + run_terminal_retirement_record( + &mut io, + &identity, + b"sealed", + &mut NoCutoverRetirementFault, + ) + .unwrap(); + assert_eq!( + classify_external_mutation_status(&io.snapshot), + CutoverExternalMutationStatus::Absent + ); + } + + let mut partial = completed_retirement_io(); + partial.snapshot.active_record = RetirementRecordState::Valid(identity.clone()); + partial.snapshot.retirement_record = RetirementRecordState::Invalid; + assert_eq!( + classify_external_mutation_status(&partial.snapshot), + CutoverExternalMutationStatus::TerminalRetirementPending + ); + partial.snapshot.retirement_record = RetirementRecordState::Valid(identity); + assert_eq!( + classify_external_mutation_status(&partial.snapshot), + CutoverExternalMutationStatus::TerminalRetirementPending + ); + } + + #[test] + fn partial_retirement_record_is_repaired_only_with_matching_terminal_authority() { + let identity = retirement_identity(CutoverPhase::ComponentComplete); + let mut io = completed_retirement_io(); + io.snapshot.retirement_record = RetirementRecordState::Invalid; + let mut fault = InterruptAfter { + target: CutoverRetirementOperation::DeleteInvalidRetirementRecord, + fired: false, + }; + assert!(matches!( + run_terminal_retirement_record(&mut io, &identity, b"sealed", &mut fault), + Err(CutoverError::Interrupted) + )); + run_terminal_retirement_record( + &mut io, + &identity, + b"sealed", + &mut NoCutoverRetirementFault, + ) + .unwrap(); + assert_eq!( + classify_external_mutation_status(&io.snapshot), + CutoverExternalMutationStatus::Absent + ); + + let mut unowned = completed_retirement_io(); + unowned.snapshot.active_record = RetirementRecordState::Invalid; + unowned.snapshot.retirement_record = RetirementRecordState::Invalid; + let mutations_before = unowned.applied.len(); + assert!(matches!( + run_terminal_retirement_record( + &mut unowned, + &identity, + b"sealed", + &mut NoCutoverRetirementFault, + ), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!(unowned.applied.len(), mutations_before); + } + + #[test] + fn resolved_terminal_record_rejects_history_changed_state_without_mutation() { + let expected = retirement_identity(CutoverPhase::ComponentComplete); + let mut io = completed_retirement_io(); + io.snapshot.active_record = RetirementRecordState::Valid(RetirementRecordIdentity { + fingerprint: "b".repeat(64), + ..expected.clone() + }); + + assert!(matches!( + run_terminal_retirement_record( + &mut io, + &expected, + b"sealed", + &mut NoCutoverRetirementFault, + ), + Err(CutoverError::RecoveryRequired) + )); + assert!(io.applied.is_empty()); + } + + #[test] + fn no_create_terminal_retirement_does_not_bootstrap_missing_state() { + let app_root = std::env::temp_dir().join(format!( + "proxywarden-no-create-terminal-retirement-{}", + Uuid::new_v4().hyphenated() + )); + fs::create_dir(&app_root).unwrap(); + let expected = CutoverTerminalRetirementExpectation::Journal { + cutover_id: Uuid::new_v4().hyphenated().to_string(), + phase: CutoverPhase::ComponentComplete, + terminal_record_fingerprint: "a".repeat(64), + }; + + assert!(retire_existing_proxifyre_cutover_terminal_state(&app_root, &expected).is_err()); + assert!(fs::symlink_metadata(app_root.join(CUTOVER_JOURNAL_DIRECTORY)).is_err()); + assert!(fs::symlink_metadata(app_root.join(CUTOVER_QUARANTINE_DIRECTORY)).is_err()); + assert!(fs::symlink_metadata(app_root.join(PROXIFYRE_RETIREMENT_FILENAME)).is_err()); + + fs::remove_dir(app_root).unwrap(); + } + + #[test] + fn empty_owned_infrastructure_retirement_is_idempotent_and_retryable() { + for target in [ + CutoverRetirementOperation::DeleteJournalRoot, + CutoverRetirementOperation::DeleteQuarantineRoot, + ] { + let mut io = FakeRetirementIo { + snapshot: CutoverRetirementSnapshot { + journal_root: RetirementRootShape::Empty, + quarantine_root: RetirementRootShape::Empty, + active_record: RetirementRecordState::Missing, + retirement_record: RetirementRecordState::Missing, + }, + applied: Vec::new(), + }; + let mut fault = InterruptAfter { + target, + fired: false, + }; + assert!(matches!( + retire_empty_cutover_infrastructure(&mut io, &mut fault), + Err(CutoverError::Interrupted) + )); + retire_empty_cutover_infrastructure(&mut io, &mut NoCutoverRetirementFault).unwrap(); + retire_empty_cutover_infrastructure(&mut io, &mut NoCutoverRetirementFault).unwrap(); + assert_eq!( + classify_external_mutation_status(&io.snapshot), + CutoverExternalMutationStatus::Absent + ); + } + + let mut unexpected = FakeRetirementIo { + snapshot: CutoverRetirementSnapshot { + journal_root: RetirementRootShape::Unexpected, + quarantine_root: RetirementRootShape::Empty, + active_record: RetirementRecordState::Missing, + retirement_record: RetirementRecordState::Missing, + }, + applied: Vec::new(), + }; + assert!(matches!( + retire_empty_cutover_infrastructure(&mut unexpected, &mut NoCutoverRetirementFault,), + Err(CutoverError::RecoveryRequired) + )); + assert!(unexpected.applied.is_empty()); + } + + #[test] + fn external_status_is_fail_closed_and_rolled_back_is_explicitly_retained() { + let absent = CutoverRetirementSnapshot { + journal_root: RetirementRootShape::Missing, + quarantine_root: RetirementRootShape::Missing, + active_record: RetirementRecordState::Missing, + retirement_record: RetirementRecordState::Missing, + }; + assert_eq!( + classify_external_mutation_status(&absent), + CutoverExternalMutationStatus::Absent + ); + + let mut active = absent.clone(); + active.journal_root = RetirementRootShape::Unexpected; + active.quarantine_root = RetirementRootShape::Unexpected; + active.active_record = RetirementRecordState::Valid(RetirementRecordIdentity { + phase: CutoverPhase::ComponentStaged, + execution_mode: CutoverExecutionMode::Forward, + fingerprint: "b".repeat(64), + }); + assert_eq!( + classify_external_mutation_status(&active), + CutoverExternalMutationStatus::Active + ); + + let mut rolled_back = absent.clone(); + rolled_back.journal_root = RetirementRootShape::ActiveJournalOnly; + rolled_back.quarantine_root = RetirementRootShape::Empty; + rolled_back.active_record = + RetirementRecordState::Valid(retirement_identity(CutoverPhase::RolledBack)); + assert_eq!( + classify_external_mutation_status(&rolled_back), + CutoverExternalMutationStatus::RolledBackRetained + ); + + rolled_back.quarantine_root = RetirementRootShape::Unexpected; + assert_eq!( + classify_external_mutation_status(&rolled_back), + CutoverExternalMutationStatus::RecoveryRequired + ); + } + + #[test] + fn active_status_accepts_only_bound_direct_children_and_exact_route_smoke_shape() { + let candidate = OsStr::new("proxifyre-00000000-0000-4000-8000-000000000000.candidate"); + let current_tombstone = + OsStr::new("proxifyre-00000000-0000-4000-8000-000000000000.rollback"); + let quarantine = OsStr::new("00000000-0000-4000-8000-000000000000"); + let quarantine_tombstone = OsStr::new("00000000-0000-4000-8000-000000000000.deleting"); + let artifacts = [candidate, current_tombstone]; + let quarantine_artifacts = [quarantine, quarantine_tombstone]; + let journal_only = [OsString::from(PROXIFYRE_JOURNAL_FILENAME)]; + let quarantined = [OsString::from(quarantine)]; + + assert!(active_cutover_entry_names_match( + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::AwaitingUser, + artifacts, + quarantine_artifacts, + &journal_only, + &quarantined, + )); + + let with_stale_candidate = [ + OsString::from(PROXIFYRE_JOURNAL_FILENAME), + OsString::from(candidate), + ]; + assert!(!active_cutover_entry_names_match( + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::AwaitingUser, + artifacts, + quarantine_artifacts, + &with_stale_candidate, + &quarantined, + )); + + let with_unknown = [ + OsString::from(PROXIFYRE_JOURNAL_FILENAME), + OsString::from("foreign"), + ]; + assert!(!active_cutover_entry_names_match( + CutoverPhase::PrivilegeGranted, + CutoverExecutionMode::Forward, + artifacts, + quarantine_artifacts, + &with_unknown, + &[], + )); + + let mutually_exclusive_roots = [ + OsString::from(PROXIFYRE_JOURNAL_FILENAME), + OsString::from(candidate), + OsString::from(current_tombstone), + ]; + assert!(!active_cutover_entry_names_match( + CutoverPhase::PrivilegeGranted, + CutoverExecutionMode::RollingBack, + artifacts, + quarantine_artifacts, + &mutually_exclusive_roots, + &[], + )); + } + + #[test] + fn strict_existing_reader_never_accepts_terminal_journal_with_invalid_root_shape() { + assert!(!existing_journal_status_is_strictly_accepted( + CutoverExternalMutationStatus::RecoveryRequired, + true, + true, + )); + assert!(existing_journal_status_is_strictly_accepted( + CutoverExternalMutationStatus::TerminalRetirementPending, + true, + false, + )); + assert!(existing_journal_status_is_strictly_accepted( + CutoverExternalMutationStatus::RecoveryRequired, + false, + true, + )); + } + + #[cfg(windows)] + #[test] + fn dangling_reparse_is_present_and_fails_closed() { + use std::os::windows::fs::symlink_dir; + + let root = std::env::temp_dir().join(format!( + "proxywarden-dangling-cutover-link-{}", + Uuid::new_v4().hyphenated() + )); + fs::create_dir(&root).unwrap(); + let link = root.join(".proxywarden-cutover"); + let missing_target = root.join("missing-target"); + if let Err(error) = symlink_dir(&missing_target, &link) { + fs::remove_dir(&root).unwrap(); + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) + { + return; + } + panic!("failed to create dangling directory symlink: {error}"); + } + + assert!(path_exists_for_cutover(&link).unwrap()); + assert!(observe_retirement_root(&link, None).is_err()); + assert!(fs::symlink_metadata(&link).is_ok()); + + fs::remove_dir(&link).unwrap(); + fs::remove_dir(&root).unwrap(); + } + + #[test] + fn filesystem_observation_failure_is_not_a_definite_mismatch() { + let error = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "unobservable"); + assert_eq!( + observed_path_exists(Err(error)), + Err(CutoverHostError::ObservationFailed) + ); + } + + #[test] + fn exact_shape_classifies_only_observed_drift_as_verification_failure() { + let actual = HashSet::from(["expected"]); + assert_eq!(classify_exact_shape(&actual, &actual), Ok(())); + assert_eq!( + classify_exact_shape(&actual, &HashSet::from(["foreign"])), + Err(CutoverHostError::VerificationFailed) + ); + } + + #[test] + fn pending_current_parent_shape_rejects_any_foreign_child() { + let root = std::env::temp_dir().join(format!( + "proxywarden-pending-components-parent-{}", + Uuid::new_v4().hyphenated() + )); + fs::create_dir_all(&root).unwrap(); + ensure_shape_exact(&root, &[]).unwrap(); + + fs::write(root.join("foreign.txt"), b"foreign").unwrap(); + assert!(ensure_shape_exact(&root, &[]).is_err()); + + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/component_detection.rs b/src-tauri/src/component_detection.rs index 1535720..87d12dd 100644 --- a/src-tauri/src/component_detection.rs +++ b/src-tauri/src/component_detection.rs @@ -1,11 +1,18 @@ +use crate::component_inventory::{ + classify_component_candidates, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, + ComponentClassification, ComponentInventory, MarkerEvidence, ServiceEvidence, +}; use crate::models::{ ComponentId, ComponentState, ComponentStatus, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME, }; -use crate::process::command_no_window; +use crate::proxifyre_ownership::{validate_proxifyre_marker_text, PROXIFYRE_MARKER_FILE}; +use quick_xml::{escape::unescape, events::Event, Reader}; use serde::Deserialize; +use sha2::{Digest, Sha256}; use std::{ - env, + env, fs, + io::Read, path::{Path, PathBuf}, }; @@ -14,6 +21,97 @@ pub const PROXIFYRE_COMPONENT_DIR_NAME: &str = "ProxiFyre"; pub const SINGBOX_COMPONENT_DIR_NAME: &str = "sing-box"; #[cfg(debug_assertions)] const PROXYWARDEN_DEV_INSTALL_ROOT_ENV: &str = "PROXYWARDEN_DEV_INSTALL_ROOT"; +pub const LEGACY_PROXIFYRE_2_2_1_EXE_SHA256: &str = + "2a60a76480715fca52185163d7ac6d850d4b0abe4079b7d461d7d0fcb3f02d93"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrozenLegacyPackageFile { + pub relative_path: &'static str, + pub size: u64, + pub sha256: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyPackageFileIdentity { + pub relative_path: PathBuf, + pub size: u64, + pub sha256: String, +} + +/// Exact contents of the historical ProxiFyre 2.2.1 x64 package. Mutable +/// runtime files such as `app-config.json` and logs are deliberately absent: +/// the cutover coordinator inventories and leases those separately. +pub const LEGACY_PROXIFYRE_2_2_1_MANIFEST: [FrozenLegacyPackageFile; 10] = [ + FrozenLegacyPackageFile { + relative_path: "Newtonsoft.Json.dll", + size: 711_952, + sha256: "e1e27af7b07eeedf5ce71a9255f0422816a6fc5849a483c6714e1b472044fa9d", + }, + FrozenLegacyPackageFile { + relative_path: "Newtonsoft.Json.xml", + size: 713_541, + sha256: "79ee87d4ede8783461de05b93379d576f6e8575d4ab49359f15897a854b643c4", + }, + FrozenLegacyPackageFile { + relative_path: "NLog.config", + size: 382, + sha256: "06b8e52be9385e4e6a2f042f0d7ca3dd0b043378b455535299846b02fd19250d", + }, + FrozenLegacyPackageFile { + relative_path: "NLog.dll", + size: 940_032, + sha256: "4b1d3cf9f1f3c4a6ead141243069162172e9ef48ba1a9bf4f7ccd618b8194b5c", + }, + FrozenLegacyPackageFile { + relative_path: "NLog.xml", + size: 1_608_606, + sha256: "6871374d682e75aff17de2a8626a75e9c75409516f5e7527e9d159c1de6831bb", + }, + FrozenLegacyPackageFile { + relative_path: "ProxiFyre.exe", + size: 35_960, + sha256: LEGACY_PROXIFYRE_2_2_1_EXE_SHA256, + }, + FrozenLegacyPackageFile { + relative_path: "ProxiFyre.exe.config", + size: 177, + sha256: "8403846edd2ee98fd53b351dbf8773951c8e30f4b04dd53676a7e7dfbd8930b0", + }, + FrozenLegacyPackageFile { + relative_path: "socksify.dll", + size: 1_309_184, + sha256: "940b22ae8e97ff575317cc4a6c20467ed2ff760d01c7b056d13fcb20e7043cbd", + }, + FrozenLegacyPackageFile { + relative_path: "Topshelf.dll", + size: 190_464, + sha256: "bd70a5832124e36840452ff46e442efa0a09a4ceba842aea72c79b2d322d7fe8", + }, + FrozenLegacyPackageFile { + relative_path: "Topshelf.xml", + size: 80_754, + sha256: "3b2228b3333c4fd86e29020bc2d77a5260dbf03e911829d6226498ade53c2790", + }, +]; + +/// Matches only the ten immutable package files. The caller remains +/// responsible for rejecting unknown root entries and for separately sealing +/// the allowed mutable config/log files before any mutation. +pub fn matches_legacy_proxifyre_2_2_1_manifest(observed: &[LegacyPackageFileIdentity]) -> bool { + observed.len() == LEGACY_PROXIFYRE_2_2_1_MANIFEST.len() + && LEGACY_PROXIFYRE_2_2_1_MANIFEST.iter().all(|expected| { + observed + .iter() + .find(|actual| { + normalized_manifest_path(&actual.relative_path) + == expected.relative_path.to_ascii_lowercase() + }) + .is_some_and(|actual| { + actual.size == expected.size + && actual.sha256.eq_ignore_ascii_case(expected.sha256) + }) + }) +} #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyfierEngine { @@ -30,6 +128,7 @@ pub struct DetectedProxyfier { pub running: bool, pub service_name: Option, pub service_status: Option, + pub version: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -49,6 +148,8 @@ pub struct DetectedSingBox { pub wrapper_exists: bool, pub running: bool, pub service_name: String, + pub version: Option, + pub wrapper_version: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -82,6 +183,22 @@ pub trait ProxyfierDetectionHost { } fn registry_install_entries(&self) -> Vec; + + fn read_text(&self, _path: &Path) -> Option { + None + } + + fn path_has_reparse_point(&self, _path: &Path) -> bool { + false + } + + fn file_version(&self, _path: &Path) -> Option { + None + } + + fn binary_identity(&self, _component_id: &ComponentId, _path: &Path) -> BinaryIdentityEvidence { + BinaryIdentityEvidence::Unknown + } } #[derive(Debug, Clone, Copy, Default)] @@ -97,40 +214,51 @@ impl ProxyfierDetectionHost for SystemProxyfierDetectionHost { } fn process_running(&self, process_name: &str) -> bool { - let process_name = process_name.trim_end_matches(".exe"); - let script = format!( - "if (Get-Process -Name '{}' -ErrorAction SilentlyContinue) {{ 'true' }} else {{ 'false' }}", - escape_powershell_single(process_name) - ); - - powershell_bool(&script) + native_process_running(process_name) } fn service_status(&self, service_name: &str) -> Option { - let script = format!( - "$s = Get-Service -Name '{}' -ErrorAction SilentlyContinue; if ($s) {{ $s.Status.ToString() }}", - escape_powershell_single(service_name) - ); - - powershell_text(&script).map(|status| status.to_ascii_lowercase()) + native_service_info(service_name).map(|service| service.status) } fn service_info(&self, service_name: &str) -> Option { - let script = format!( - "$s = Get-CimInstance Win32_Service -Filter \"Name='{}'\" -ErrorAction SilentlyContinue; if ($s) {{ [ordered]@{{ name = $s.Name; status = $s.State; pathName = $s.PathName }} | ConvertTo-Json -Compress }}", - escape_powershell_single(service_name) - ); - let json = powershell_text(&script)?; - serde_json::from_str(&json).ok() + native_service_info(service_name) } fn registry_install_entries(&self) -> Vec { read_registry_install_entries() } + + fn read_text(&self, path: &Path) -> Option { + fs::read_to_string(path).ok() + } + + fn path_has_reparse_point(&self, path: &Path) -> bool { + path_has_reparse_point(path) + } + + fn file_version(&self, path: &Path) -> Option { + file_version_from_path(path) + } + + fn binary_identity(&self, component_id: &ComponentId, path: &Path) -> BinaryIdentityEvidence { + if component_id == &ComponentId::Proxyfier + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("ProxiFyre.exe")) + && sha256_file(path) + .is_some_and(|hash| hash.eq_ignore_ascii_case(LEGACY_PROXIFYRE_2_2_1_EXE_SHA256)) + { + BinaryIdentityEvidence::KnownPackage + } else { + BinaryIdentityEvidence::Unknown + } + } } pub fn detect_proxyfier_install() -> Option { - detect_proxyfier_install_with_host(&SystemProxyfierDetectionHost) + detected_proxyfier_from_inventory(&inventory_proxyfier()) } pub fn app_install_dir_from_current_exe() -> Option { @@ -201,10 +329,99 @@ pub fn default_singbox_install_dir() -> PathBuf { pub fn detect_proxyfier_install_with_host( host: &impl ProxyfierDetectionHost, ) -> Option { - proxyfier_candidates(host) + detected_proxyfier_from_inventory(&inventory_proxyfier_with_host(host)) +} + +pub fn inventory_proxyfier() -> ComponentInventory { + inventory_proxyfier_with_host(&SystemProxyfierDetectionHost) +} + +pub fn inventory_proxyfier_with_host(host: &impl ProxyfierDetectionHost) -> ComponentInventory { + inventory_proxyfier_with_host_and_current_root(host, &default_proxifyre_install_dir()) +} + +pub fn has_additional_matching_legacy_proxifyre_service(legacy_root: &Path) -> bool { + has_additional_matching_legacy_proxifyre_service_with_host( + &SystemProxyfierDetectionHost, + legacy_root, + ) +} + +/// Strict cutover evidence kept separate from ordinary discovery so the +/// temporary Task 5 Start/Stop behavior is unchanged. Any installed historical +/// alias makes the SCM service set non-exact and automatic migration ambiguous. +pub fn has_additional_matching_legacy_proxifyre_service_with_host( + host: &impl ProxyfierDetectionHost, + _legacy_root: &Path, +) -> bool { + host.service_info("ProxiFyre").is_some() +} + +pub fn inventory_proxyfier_with_host_and_current_root( + host: &impl ProxyfierDetectionHost, + current_root: &Path, +) -> ComponentInventory { + let candidates = proxyfier_candidates(host, current_root); + let services = ["ProxiFyreService", "ProxiFyre"] .into_iter() - .filter_map(|candidate| candidate.into_detected(host)) - .next() + .filter_map(|name| host.service_info(name)) + .collect::>(); + let service_matches_any_candidate = services.iter().any(|service| { + service.path_name.as_deref().is_some_and(|path_name| { + candidates.iter().any(|candidate| { + service_path_matches_executable( + path_name, + &candidate.install_dir.join("ProxiFyre.exe"), + ) + }) + }) + }); + + let probes = candidates + .into_iter() + .map(|candidate| { + let executable_path = candidate.install_dir.join("ProxiFyre.exe"); + let executable_exists = host.path_exists(&executable_path); + let root_exists = host.path_exists(&candidate.install_dir) || executable_exists; + let matched_service = services.iter().find(|service| { + service.path_name.as_deref().is_some_and(|path_name| { + service_path_matches_executable(path_name, &executable_path) + }) + }); + let service = matched_service + .or_else(|| { + (candidate.role == CandidateRole::Current + && (root_exists || !service_matches_any_candidate)) + .then(|| services.first()) + .flatten() + }) + .map(|service| service_evidence(host, service, &executable_path)); + let marker = proxifyre_marker_evidence(host, &candidate.install_dir); + let missing_files = (!executable_exists) + .then(|| executable_path.clone()) + .into_iter() + .collect(); + + ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: candidate.role, + root: candidate.install_dir, + root_exists, + has_reparse_point: host.path_has_reparse_point(&executable_path), + executable_path: executable_exists.then(|| executable_path.clone()), + missing_files, + marker, + marker_required: candidate.role == CandidateRole::Current, + binary_identity: host.binary_identity(&ComponentId::Proxyfier, &executable_path), + binary_version: host.file_version(&executable_path), + service, + service_required: true, + legacy_identity_complete: matched_service.is_some(), + } + }) + .collect(); + + classify_component_candidates(ComponentId::Proxyfier, probes) } pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) -> ComponentStatus { @@ -214,20 +431,110 @@ pub fn proxyfier_component_from_detection(detected: Option<&DetectedProxyfier>) } } +pub fn proxyfier_component_from_inventory(inventory: &ComponentInventory) -> ComponentStatus { + if let Some(detected) = detected_proxyfier_from_inventory(inventory) { + let mut component = detected_proxyfier_component(&detected); + if inventory.classification() == ComponentClassification::ManagedLegacy { + component.actions = vec!["Перенести ProxiFyre".to_string()]; + component.problems.push( + "Старая установка не изменяется напрямую и требует явного переноса.".to_string(), + ); + } + return component; + } + inventory_problem_component(inventory).unwrap_or_else(missing_proxyfier_component) +} + pub fn detect_singbox_install() -> Option { - detect_singbox_install_with_host(&SystemProxyfierDetectionHost) + detected_singbox_from_inventory(&inventory_singbox()) } pub fn detect_singbox_install_with_host( host: &impl ProxyfierDetectionHost, ) -> Option { - let running = host.process_running("sing-box.exe") - || host.service_running(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME); + detected_singbox_from_inventory(&inventory_singbox_with_host(host)) +} - singbox_candidates(host) +pub fn inventory_singbox() -> ComponentInventory { + inventory_singbox_with_host(&SystemProxyfierDetectionHost) +} + +pub fn inventory_singbox_with_host(host: &impl ProxyfierDetectionHost) -> ComponentInventory { + let candidates = singbox_candidates(host); + let service = host.service_info(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME); + let service_matches_any_candidate = service.as_ref().is_some_and(|service| { + service.path_name.as_deref().is_some_and(|path_name| { + candidates.iter().any(|candidate| { + service_path_matches_executable( + path_name, + &candidate.install_dir.join("ProxyWardenSingBox.exe"), + ) + }) + }) + }); + + let probes = candidates .into_iter() - .filter_map(|install_dir| detected_singbox_from_dir(host, install_dir, running)) - .next() + .map(|candidate| { + let executable_path = candidate.install_dir.join("sing-box.exe"); + let wrapper_path = candidate.install_dir.join("ProxyWardenSingBox.exe"); + let xml_path = candidate.install_dir.join("ProxyWardenSingBox.xml"); + let executable_exists = host.path_exists(&executable_path); + let wrapper_exists = host.path_exists(&wrapper_path); + let root_exists = + host.path_exists(&candidate.install_dir) || executable_exists || wrapper_exists; + let xml_text = host.read_text(&xml_path); + let xml_identity = xml_text + .as_deref() + .is_some_and(singbox_xml_identity_matches); + let matched_service = service.as_ref().filter(|service| { + service.path_name.as_deref().is_some_and(|path_name| { + service_path_matches_executable(path_name, &wrapper_path) + }) + }); + let service_evidence = matched_service + .or_else(|| { + (candidate.role == CandidateRole::Current + && (root_exists || !service_matches_any_candidate)) + .then_some(service.as_ref()) + .flatten() + }) + .map(|service| service_evidence(host, service, &wrapper_path)); + let mut missing_files = Vec::new(); + for (exists, path) in [ + (executable_exists, executable_path.clone()), + (wrapper_exists, wrapper_path.clone()), + (xml_text.is_some(), xml_path), + ] { + if !exists { + missing_files.push(path); + } + } + let binary_identity = combine_binary_identities( + host.binary_identity(&ComponentId::Singbox, &executable_path), + host.binary_identity(&ComponentId::Singbox, &wrapper_path), + ); + + ComponentCandidateProbe { + component_id: ComponentId::Singbox, + role: candidate.role, + root_exists, + has_reparse_point: host.path_has_reparse_point(&candidate.install_dir), + root: candidate.install_dir, + executable_path: executable_exists.then(|| executable_path.clone()), + missing_files, + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity, + binary_version: host.file_version(&executable_path), + service: service_evidence, + service_required: true, + legacy_identity_complete: matched_service.is_some() && xml_identity, + } + }) + .collect(); + + classify_component_candidates(ComponentId::Singbox, probes) } pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> ComponentStatus { @@ -237,6 +544,21 @@ pub fn singbox_component_from_detection(detected: Option<&DetectedSingBox>) -> C } } +pub fn singbox_component_from_inventory(inventory: &ComponentInventory) -> ComponentStatus { + if let Some(detected) = detected_singbox_from_inventory(inventory) { + let mut component = detected_singbox_component(&detected); + if inventory.classification() == ComponentClassification::ManagedLegacy { + component.actions.clear(); + component.problems.push( + "Эта старая установка sing-box требует ручного переноса; автоматические действия заблокированы." + .to_string(), + ); + } + return component; + } + inventory_problem_component(inventory).unwrap_or_else(missing_singbox_component) +} + fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatus { let state = if proxyfier.running { ComponentState::Running @@ -276,13 +598,7 @@ fn detected_proxyfier_component(proxyfier: &DetectedProxyfier) -> ComponentStatu state, installed: true, running: proxyfier.running, - version: Some(match proxyfier.engine { - ProxyfierEngine::ProxiFyre => match service_status.as_deref() { - Some(status) if service_status_is_running(status) => "служба запущена".to_string(), - Some(_) => "служба остановлена".to_string(), - None => "служба не установлена".to_string(), - }, - }), + version: proxyfier.version.clone(), path: Some(proxyfier.install_dir.display().to_string()), service_name, service_status, @@ -338,7 +654,7 @@ fn detected_singbox_component(singbox: &DetectedSingBox) -> ComponentStatus { state, installed: true, running: singbox.running, - version: Some("sing-box найден".to_string()), + version: singbox.version.clone(), path: Some(singbox.executable_path.display().to_string()), service_name: Some(singbox.service_name.clone()), service_status: Some( @@ -370,43 +686,105 @@ fn missing_singbox_component() -> ComponentStatus { } } +pub fn detected_proxyfier_from_inventory( + inventory: &ComponentInventory, +) -> Option { + let candidate = inventory.selected_candidate()?; + if !matches!( + candidate.classification, + ComponentClassification::ManagedCurrent | ComponentClassification::ManagedLegacy + ) { + return None; + } + let executable_path = candidate.executable_path.clone()?; + let service = candidate.service.as_ref()?; + + Some(DetectedProxyfier { + engine: ProxyfierEngine::ProxiFyre, + name: "ProxiFyre".to_string(), + install_dir: candidate.root.clone(), + executable_path, + config_path: Some(candidate.root.join("app-config.json")), + running: service_status_is_running(&service.status), + service_name: Some(service.name.clone()), + service_status: Some(normalize_service_status(&service.status)), + version: candidate.binary_version.clone(), + }) +} + +pub fn detected_singbox_from_inventory(inventory: &ComponentInventory) -> Option { + let candidate = inventory.selected_candidate()?; + if !matches!( + candidate.classification, + ComponentClassification::ManagedCurrent | ComponentClassification::ManagedLegacy + ) { + return None; + } + let executable_path = candidate.executable_path.clone()?; + let service = candidate.service.as_ref()?; + let wrapper_path = candidate.root.join("ProxyWardenSingBox.exe"); + + Some(DetectedSingBox { + install_dir: candidate.root.clone(), + executable_path, + wrapper_path, + binary_exists: true, + wrapper_exists: true, + running: service_status_is_running(&service.status), + service_name: service.name.clone(), + version: candidate.binary_version.clone(), + wrapper_version: service.binary_version.clone(), + }) +} + +fn inventory_problem_component(inventory: &ComponentInventory) -> Option { + let candidate = inventory.selected_candidate()?; + if !matches!( + candidate.classification, + ComponentClassification::Foreign | ComponentClassification::Incomplete + ) { + return None; + } + let service = candidate.service.as_ref(); + let name = match inventory.component_id { + ComponentId::Proxyfier => "ProxiFyre", + ComponentId::Singbox => "Local sing-box", + ComponentId::ControlApp => "ProxyWarden", + }; + let problems = candidate + .issues + .iter() + .chain(inventory.issues.iter()) + .map(|issue| issue.message.clone()) + .collect(); + + Some(ComponentStatus { + id: inventory.component_id.clone(), + name: name.to_string(), + state: ComponentState::Error, + installed: true, + running: false, + version: candidate.binary_version.clone(), + path: Some(candidate.root.display().to_string()), + service_name: service.map(|service| service.name.clone()), + service_status: service.map(|service| normalize_service_status(&service.status)), + problems, + actions: Vec::new(), + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ProxyfierCandidate { engine: ProxyfierEngine, name: String, install_dir: PathBuf, + role: CandidateRole, } -impl ProxyfierCandidate { - fn into_detected(self, host: &impl ProxyfierDetectionHost) -> Option { - let executable_path = self.install_dir.join(executable_name(&self.engine)); - let config_path = config_path(&self.engine, &self.install_dir); - if !host.path_exists(&executable_path) { - return None; - } - let detected_service = detect_proxifyre_service(host, &executable_path); - let proxifyre_running = detected_service - .as_ref() - .is_some_and(|service| service_status_is_running(&service.status)); - - Some(DetectedProxyfier { - service_name: detected_service - .as_ref() - .map(|service| service.name.clone()), - service_status: detected_service - .as_ref() - .map(|service| service.status.clone()), - engine: self.engine, - name: self.name, - install_dir: self.install_dir, - executable_path, - config_path, - running: proxifyre_running, - }) - } -} - -fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec { +fn proxyfier_candidates( + host: &impl ProxyfierDetectionHost, + current_root: &Path, +) -> Vec { let mut candidates = Vec::new(); push_env_candidate( @@ -415,13 +793,16 @@ fn proxyfier_candidates(host: &impl ProxyfierDetectionHost) -> Vec Vec Vec, candidate: Proxyfier } } -fn common_install_dirs(host: &impl ProxyfierDetectionHost, folder_name: &str) -> Vec { - let mut dirs = Vec::new(); - +fn legacy_proxifyre_dirs(host: &impl ProxyfierDetectionHost) -> Vec { + let mut dirs = vec![PathBuf::from(r"C:\Tools\ProxiFyre")]; for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { if let Some(root) = host.env_var(env_name) { - let proxywarden_root = PathBuf::from(root).join("ProxyWarden"); - dirs.push( - proxywarden_root - .join(PROXYWARDEN_COMPONENTS_DIR_NAME) - .join(folder_name), - ); - dirs.push(proxywarden_root.join(folder_name)); + let root = PathBuf::from(root); + let proxywarden_root = root.join("ProxyWarden"); + dirs.push(root.join("ProxiFyre")); + dirs.push(proxywarden_root.join("ProxiFyre")); } } - dirs } -fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec { +fn proxifyre_role_for_path( + host: &impl ProxyfierDetectionHost, + path: &Path, + current_root: &Path, +) -> CandidateRole { + if same_path(path, current_root) { + CandidateRole::Current + } else if legacy_proxifyre_dirs(host) + .iter() + .any(|legacy| same_path(path, legacy)) + { + CandidateRole::Legacy + } else { + CandidateRole::Foreign + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SingBoxCandidate { + install_dir: PathBuf, + role: CandidateRole, +} + +fn singbox_candidates(host: &impl ProxyfierDetectionHost) -> Vec { let mut candidates = Vec::new(); + let current_root = default_singbox_install_dir(); if let Some(path) = host.env_var("PROXYWARDEN_SINGBOX_ROOT") { - push_path_candidate(&mut candidates, PathBuf::from(path)); + let path = PathBuf::from(path); + push_singbox_candidate( + &mut candidates, + SingBoxCandidate { + role: singbox_role_for_path(host, &path, ¤t_root), + install_dir: path, + }, + ); } - push_path_candidate(&mut candidates, default_singbox_install_dir()); - push_path_candidate( + push_singbox_candidate( &mut candidates, - PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT), + SingBoxCandidate { + install_dir: current_root.clone(), + role: CandidateRole::Current, + }, + ); + let fallback = PathBuf::from(DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT); + push_singbox_candidate( + &mut candidates, + SingBoxCandidate { + role: singbox_role_for_path(host, &fallback, ¤t_root), + install_dir: fallback, + }, + ); + let tools_legacy = PathBuf::from(r"C:\Tools\ProxyWarden\sing-box"); + push_singbox_candidate( + &mut candidates, + SingBoxCandidate { + install_dir: tools_legacy, + role: CandidateRole::Legacy, + }, ); for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { if let Some(root) = host.env_var(env_name) { - push_path_candidate( + let root = PathBuf::from(root); + push_singbox_candidate( &mut candidates, - PathBuf::from(&root) - .join("ProxyWarden") - .join(PROXYWARDEN_COMPONENTS_DIR_NAME) - .join(SINGBOX_COMPONENT_DIR_NAME), + SingBoxCandidate { + install_dir: root.join("ProxyWarden").join(SINGBOX_COMPONENT_DIR_NAME), + role: CandidateRole::Legacy, + }, ); - push_path_candidate( + push_singbox_candidate( &mut candidates, - PathBuf::from(&root) - .join("ProxyWarden") - .join(SINGBOX_COMPONENT_DIR_NAME), + SingBoxCandidate { + install_dir: root.join(SINGBOX_COMPONENT_DIR_NAME), + role: CandidateRole::ForeignByDefault, + }, ); } } + if let Some(service_root) = host + .service_info(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME) + .and_then(|service| service.path_name) + .and_then(|path_name| service_executable_from_path_name(&path_name)) + .and_then(|path| path.parent().map(Path::to_path_buf)) + { + push_singbox_candidate( + &mut candidates, + SingBoxCandidate { + role: singbox_role_for_path(host, &service_root, ¤t_root), + install_dir: service_root, + }, + ); + } candidates } -fn push_path_candidate(candidates: &mut Vec, candidate: PathBuf) { +fn push_singbox_candidate(candidates: &mut Vec, candidate: SingBoxCandidate) { if !candidates .iter() - .any(|existing| same_path(existing, &candidate)) + .any(|existing| same_path(&existing.install_dir, &candidate.install_dir)) { candidates.push(candidate); } } -fn detected_singbox_from_dir( +fn singbox_role_for_path( host: &impl ProxyfierDetectionHost, - install_dir: PathBuf, - running: bool, -) -> Option { - let executable_path = install_dir.join("sing-box.exe"); - let wrapper_path = install_dir.join("ProxyWardenSingBox.exe"); - let binary_exists = host.path_exists(&executable_path); - let wrapper_exists = host.path_exists(&wrapper_path); - let exists = host.path_exists(&install_dir) || binary_exists || wrapper_exists; - - if !exists { - return None; + path: &Path, + current_root: &Path, +) -> CandidateRole { + if same_path(path, current_root) { + return CandidateRole::Current; } - - Some(DetectedSingBox { - install_dir, - executable_path, - wrapper_path, - binary_exists, - wrapper_exists, - running, - service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), - }) -} - -fn executable_name(engine: &ProxyfierEngine) -> &'static str { - match engine { - ProxyfierEngine::ProxiFyre => "ProxiFyre.exe", + if same_path(path, Path::new(r"C:\Tools\ProxyWarden\sing-box")) { + return CandidateRole::Legacy; } -} - -fn config_path(engine: &ProxyfierEngine, install_dir: &Path) -> Option { - match engine { - ProxyfierEngine::ProxiFyre => Some(install_dir.join("app-config.json")), + for env_name in ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"] { + let Some(root) = host.env_var(env_name).map(PathBuf::from) else { + continue; + }; + if same_path(path, &root.join("ProxyWarden").join("sing-box")) { + return CandidateRole::Legacy; + } + if same_path(path, &root.join("sing-box")) { + return CandidateRole::ForeignByDefault; + } } + CandidateRole::Foreign } fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> { @@ -586,34 +1038,240 @@ fn service_name(engine: &ProxyfierEngine) -> Option<&'static str> { } } -fn detect_proxifyre_service( +fn proxifyre_marker_evidence( host: &impl ProxyfierDetectionHost, - executable_path: &Path, -) -> Option { - for name in ["ProxiFyreService", "ProxiFyre"] { - if let Some(mut service) = host.service_info(name) { - let matches_executable = service.path_name.as_deref().is_some_and(|path_name| { - service_path_matches_executable(path_name, executable_path) - }); - if matches_executable { - service.status = normalize_service_status(&service.status); - return Some(service); + install_dir: &Path, +) -> MarkerEvidence { + let marker_path = install_dir.join(PROXIFYRE_MARKER_FILE); + let Some(marker_text) = host.read_text(&marker_path) else { + return MarkerEvidence::Missing; + }; + if validate_proxifyre_marker_text(&marker_text, install_dir).is_ok() { + MarkerEvidence::Valid + } else { + MarkerEvidence::Invalid + } +} + +fn service_evidence( + host: &impl ProxyfierDetectionHost, + service: &DetectedService, + expected_executable: &Path, +) -> ServiceEvidence { + let executable_path = service + .path_name + .as_deref() + .and_then(service_executable_from_path_name); + ServiceEvidence { + name: service.name.clone(), + status: normalize_service_status(&service.status), + path_name: service.path_name.clone(), + path_matches_candidate: executable_path + .as_deref() + .is_some_and(|path| same_path(path, expected_executable)), + binary_version: executable_path + .as_deref() + .and_then(|path| host.file_version(path)), + executable_path, + } +} + +fn combine_binary_identities( + left: BinaryIdentityEvidence, + right: BinaryIdentityEvidence, +) -> BinaryIdentityEvidence { + if left == BinaryIdentityEvidence::Mismatch || right == BinaryIdentityEvidence::Mismatch { + BinaryIdentityEvidence::Mismatch + } else if left == BinaryIdentityEvidence::KnownPackage + && right == BinaryIdentityEvidence::KnownPackage + { + BinaryIdentityEvidence::KnownPackage + } else { + BinaryIdentityEvidence::Unknown + } +} + +fn singbox_xml_identity_matches(xml: &str) -> bool { + const MAX_WINSW_XML_BYTES: usize = 64 * 1024; + if xml.len() > MAX_WINSW_XML_BYTES { + return false; + } + + #[derive(Clone, Copy, PartialEq, Eq)] + enum IdentityField { + Id, + Executable, + Arguments, + } + + let mut reader = Reader::from_str(xml); + let mut depth = 0_usize; + let mut root_seen = false; + let mut root_closed = false; + let mut declaration_seen = false; + let mut active_field = None; + let mut active_value = String::new(); + let mut id = None; + let mut executable = None; + let mut arguments = None; + + loop { + let event = match reader.read_event() { + Ok(event) => event, + Err(_) => return false, + }; + + match event { + Event::Start(element) => { + if root_closed { + return false; + } + depth += 1; + if depth == 1 { + if root_seen + || element.name().as_ref() != b"service" + || element.attributes().next().is_some() + { + return false; + } + root_seen = true; + continue; + } + + if active_field.is_some() { + return false; + } + if depth != 2 { + continue; + } + + let field = match element.name().as_ref() { + b"id" => Some(IdentityField::Id), + b"executable" => Some(IdentityField::Executable), + b"arguments" => Some(IdentityField::Arguments), + _ => None, + }; + if let Some(field) = field { + let duplicate = match field { + IdentityField::Id => id.is_some(), + IdentityField::Executable => executable.is_some(), + IdentityField::Arguments => arguments.is_some(), + }; + if duplicate || element.attributes().next().is_some() { + return false; + } + active_field = Some(field); + active_value.clear(); + } } + Event::End(element) => { + if depth == 0 { + return false; + } + if depth == 2 { + if let Some(field) = active_field.take() { + let expected_name = match field { + IdentityField::Id => b"id".as_slice(), + IdentityField::Executable => b"executable".as_slice(), + IdentityField::Arguments => b"arguments".as_slice(), + }; + if element.name().as_ref() != expected_name { + return false; + } + let value = active_value.trim().to_string(); + match field { + IdentityField::Id => id = Some(value), + IdentityField::Executable => executable = Some(value), + IdentityField::Arguments => arguments = Some(value), + } + } + } else if depth == 1 { + if element.name().as_ref() != b"service" { + return false; + } + root_closed = true; + } + depth -= 1; + } + Event::Empty(element) => { + if root_closed || depth == 0 { + return false; + } + if active_field.is_some() { + return false; + } + if depth == 1 + && matches!( + element.name().as_ref(), + b"id" | b"executable" | b"arguments" + ) + { + return false; + } + } + Event::Text(text) => { + let decoded = match text.xml_content() { + Ok(decoded) => decoded, + Err(_) => return false, + }; + let decoded = match unescape(&decoded) { + Ok(decoded) => decoded, + Err(_) => return false, + }; + if active_field.is_some() { + active_value.push_str(&decoded); + } else if (depth == 0 || root_closed) && !decoded.trim().is_empty() { + return false; + } + } + Event::Comment(_) => { + if active_field.is_some() { + return false; + } + } + Event::CData(_) => { + if active_field.is_some() || depth == 0 || root_closed { + return false; + } + } + Event::Decl(_) => { + if declaration_seen || root_seen || depth != 0 { + return false; + } + declaration_seen = true; + } + Event::PI(_) | Event::DocType(_) | Event::GeneralRef(_) => return false, + Event::Eof => break, } } - None + root_seen + && root_closed + && depth == 0 + && id.as_deref() == Some(DEFAULT_LOCAL_SINGBOX_SERVICE_NAME) + && executable.as_deref() == Some(r"%BASE%\sing-box.exe") + && arguments.as_deref() == Some(r#"run -c "%BASE%\config.json""#) } pub fn service_path_matches_executable(path_name: &str, executable_path: &Path) -> bool { - let path_name = path_name.trim(); - let candidate = if let Some(rest) = path_name.strip_prefix('"') { - rest.split_once('"').map(|(path, _)| path) - } else { - path_name.split_whitespace().next() - }; + service_executable_from_path_name(path_name) + .is_some_and(|candidate| same_path(&candidate, executable_path)) +} - candidate.is_some_and(|candidate| same_path(Path::new(candidate), executable_path)) +pub fn service_executable_from_path_name(path_name: &str) -> Option { + let path_name = path_name.trim(); + if path_name.is_empty() { + return None; + } + if let Some(rest) = path_name.strip_prefix('"') { + let closing_quote = rest.find('"')?; + if closing_quote == 0 { + return None; + } + return Some(PathBuf::from(&rest[..closing_quote])); + } + + path_name.split_whitespace().next().map(PathBuf::from) } fn normalize_service_status(status: &str) -> String { @@ -642,99 +1300,21 @@ fn same_path(left: &Path, right: &Path) -> bool { .eq_ignore_ascii_case(&right.to_string_lossy()) } -fn powershell_text(script: &str) -> Option { - command_no_window("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", script]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|stdout| stdout.trim().to_string()) - .filter(|stdout| !stdout.is_empty()) -} - -fn powershell_bool(script: &str) -> bool { - command_no_window("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", script]) - .output() - .ok() - .and_then(|output| String::from_utf8(output.stdout).ok()) - .is_some_and(|stdout| stdout.trim().eq_ignore_ascii_case("true")) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct RegistryInstallJson { - display_name: Option, - install_location: Option, - display_icon: Option, +fn normalized_manifest_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() } fn read_registry_install_entries() -> Vec { - let script = r#" -$paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -) -$items = foreach ($path in $paths) { - Get-ItemProperty -Path $path -ErrorAction SilentlyContinue -} -$items | - Where-Object { $_.DisplayName -match 'ProxiFyre' } | - Select-Object DisplayName,InstallLocation,DisplayIcon | - ConvertTo-Json -Compress -"#; - - let Ok(output) = command_no_window("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", script]) - .output() - else { - return Vec::new(); - }; - - if !output.status.success() { - return Vec::new(); + #[cfg(windows)] + { + windows_native_detection::registry_install_entries() } - - let Ok(stdout) = String::from_utf8(output.stdout) else { - return Vec::new(); - }; - let stdout = stdout.trim(); - if stdout.is_empty() { - return Vec::new(); + #[cfg(not(windows))] + { + Vec::new() } - - parse_registry_json(stdout) -} - -fn parse_registry_json(json: &str) -> Vec { - let Ok(value) = serde_json::from_str::(json) else { - return Vec::new(); - }; - - match value { - serde_json::Value::Array(entries) => entries - .into_iter() - .filter_map(registry_entry_from_value) - .collect(), - entry => registry_entry_from_value(entry).into_iter().collect(), - } -} - -fn registry_entry_from_value(value: serde_json::Value) -> Option { - let parsed = serde_json::from_value::(value).ok()?; - let display_name = parsed.display_name?; - Some(RegistryInstallEntry { - display_name, - install_location: parsed - .install_location - .filter(|value| !value.trim().is_empty()) - .map(PathBuf::from), - display_icon: parsed - .display_icon - .and_then(|value| display_icon_path(&value)), - }) } fn display_icon_path(value: &str) -> Option { @@ -753,6 +1333,398 @@ fn display_icon_path(value: &str) -> Option { Some(PathBuf::from(without_icon_index)) } -fn escape_powershell_single(value: &str) -> String { - value.replace('\'', "''") +fn native_process_running(process_name: &str) -> bool { + #[cfg(windows)] + { + windows_native_detection::process_running(process_name) + } + #[cfg(not(windows))] + { + let _ = process_name; + false + } +} + +fn native_service_info(service_name: &str) -> Option { + #[cfg(windows)] + { + windows_native_detection::service_info(service_name) + } + #[cfg(not(windows))] + { + let _ = service_name; + None + } +} + +#[cfg(windows)] +mod windows_native_detection { + use super::{display_icon_path, DetectedService, RegistryInstallEntry}; + use std::{ + ffi::OsStr, + mem::size_of, + os::windows::ffi::OsStrExt, + path::PathBuf, + ptr::{null, null_mut}, + slice, + }; + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }, + Services::{ + CloseServiceHandle, OpenSCManagerW, OpenServiceW, QueryServiceConfigW, + QueryServiceStatusEx, QUERY_SERVICE_CONFIGW, SC_HANDLE, SC_MANAGER_CONNECT, + SC_STATUS_PROCESS_INFO, SERVICE_CONTINUE_PENDING, SERVICE_PAUSED, + SERVICE_PAUSE_PENDING, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, SERVICE_RUNNING, + SERVICE_START_PENDING, SERVICE_STATUS_PROCESS, SERVICE_STOPPED, + SERVICE_STOP_PENDING, + }, + }, + }; + use winreg::{ + enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, + }, + RegKey, + }; + + const MAX_SERVICE_CONFIG_BYTES: u32 = 64 * 1024; + const UNINSTALL_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + + struct OwnedScHandle(SC_HANDLE); + + impl Drop for OwnedScHandle { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the handle was returned by OpenSCManagerW/OpenServiceW and is owned here. + unsafe { CloseServiceHandle(self.0) }; + } + } + } + + struct OwnedSnapshot(HANDLE); + + impl Drop for OwnedSnapshot { + fn drop(&mut self) { + if !self.0.is_null() && self.0 != INVALID_HANDLE_VALUE { + // SAFETY: the handle was returned by CreateToolhelp32Snapshot and is owned here. + unsafe { CloseHandle(self.0) }; + } + } + } + + pub(super) fn process_running(process_name: &str) -> bool { + let target = process_basename(process_name); + if target.is_empty() { + return false; + } + + // SAFETY: no pointer parameters are required; the returned snapshot is checked and owned. + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return false; + } + let snapshot = OwnedSnapshot(snapshot); + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + + // SAFETY: snapshot is valid and entry points to a correctly-sized writable structure. + if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 { + return false; + } + loop { + let executable = wide_array_to_string(&entry.szExeFile); + if process_basename(&executable).eq_ignore_ascii_case(&target) { + return true; + } + // SAFETY: snapshot and entry remain valid for the duration of enumeration. + if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 { + return false; + } + } + } + + pub(super) fn service_info(service_name: &str) -> Option { + let wide_name = wide_nul(service_name)?; + // SAFETY: null machine/database select the local active database; no borrowed handles exist. + let manager = unsafe { OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT) }; + if manager.is_null() { + return None; + } + let manager = OwnedScHandle(manager); + // SAFETY: manager is valid and wide_name is NUL-terminated for the duration of the call. + let service = unsafe { + OpenServiceW( + manager.0, + wide_name.as_ptr(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) + }; + if service.is_null() { + return None; + } + let service = OwnedScHandle(service); + + let status = query_service_status(service.0)?; + let path_name = query_service_binary_path(service.0); + Some(DetectedService { + name: service_name.to_string(), + status: service_state_name(status.dwCurrentState).to_string(), + path_name, + }) + } + + pub(super) fn registry_install_entries() -> Vec { + let roots = [ + (HKEY_LOCAL_MACHINE, KEY_READ | KEY_WOW64_64KEY), + (HKEY_LOCAL_MACHINE, KEY_READ | KEY_WOW64_32KEY), + (HKEY_CURRENT_USER, KEY_READ), + ]; + let mut entries = Vec::new(); + + for (root, access) in roots { + let root = RegKey::predef(root); + let Ok(uninstall) = root.open_subkey_with_flags(UNINSTALL_KEY, access) else { + continue; + }; + for subkey_name in uninstall.enum_keys().flatten() { + let Ok(subkey) = uninstall.open_subkey_with_flags(&subkey_name, access) else { + continue; + }; + let Ok(display_name) = subkey.get_value::("DisplayName") else { + continue; + }; + let display_name = display_name.trim(); + if display_name.is_empty() + || !display_name.to_ascii_lowercase().contains("proxifyre") + { + continue; + } + let install_location = + non_empty_registry_string(&subkey, "InstallLocation").map(PathBuf::from); + let display_icon = non_empty_registry_string(&subkey, "DisplayIcon") + .and_then(|value| display_icon_path(&value)); + entries.push(RegistryInstallEntry { + display_name: display_name.to_string(), + install_location, + display_icon, + }); + } + } + + entries + } + + fn query_service_status(service: SC_HANDLE) -> Option { + let mut status = SERVICE_STATUS_PROCESS::default(); + let mut bytes_needed = 0_u32; + // SAFETY: status is writable and its exact byte size is passed to the API. + let queried = unsafe { + QueryServiceStatusEx( + service, + SC_STATUS_PROCESS_INFO, + (&mut status as *mut SERVICE_STATUS_PROCESS).cast::(), + size_of::() as u32, + &mut bytes_needed, + ) + }; + (queried != 0).then_some(status) + } + + fn query_service_binary_path(service: SC_HANDLE) -> Option { + let mut bytes_needed = 0_u32; + // SAFETY: the initial null-buffer call only requests the required allocation size. + unsafe { QueryServiceConfigW(service, null_mut(), 0, &mut bytes_needed) }; + if bytes_needed < size_of::() as u32 + || bytes_needed > MAX_SERVICE_CONFIG_BYTES + { + return None; + } + + let words = (bytes_needed as usize).div_ceil(size_of::()); + let mut aligned_buffer = vec![0_usize; words]; + let buffer_start = aligned_buffer.as_mut_ptr().cast::(); + let config = buffer_start.cast::(); + let mut actual_size = bytes_needed; + // SAFETY: the usize-backed buffer is aligned for QueryServiceConfigW and has bytes_needed bytes. + let queried = + unsafe { QueryServiceConfigW(service, config, bytes_needed, &mut actual_size) }; + if queried == 0 { + return None; + } + + // SAFETY: QueryServiceConfigW populated the leading structure in the live buffer. + let binary_path = unsafe { (*config).lpBinaryPathName }; + wide_pointer_in_buffer(binary_path, buffer_start, bytes_needed as usize) + } + + fn wide_pointer_in_buffer( + pointer: *const u16, + buffer: *const u8, + length: usize, + ) -> Option { + if pointer.is_null() { + return None; + } + let start = buffer as usize; + let end = start.checked_add(length)?; + let pointer = pointer as usize; + if pointer < start || pointer >= end || !pointer.is_multiple_of(size_of::()) { + return None; + } + let remaining_units = (end - pointer) / size_of::(); + // SAFETY: the pointer was range-checked against the API-owned buffer above. + let units = unsafe { slice::from_raw_parts(pointer as *const u16, remaining_units) }; + let nul = units.iter().position(|unit| *unit == 0)?; + Some(String::from_utf16_lossy(&units[..nul])) + } + + fn service_state_name(state: u32) -> &'static str { + match state { + SERVICE_STOPPED => "stopped", + SERVICE_START_PENDING => "start pending", + SERVICE_STOP_PENDING => "stop pending", + SERVICE_RUNNING => "running", + SERVICE_CONTINUE_PENDING => "continue pending", + SERVICE_PAUSE_PENDING => "pause pending", + SERVICE_PAUSED => "paused", + _ => "unknown", + } + } + + fn wide_nul(value: &str) -> Option> { + let wide = OsStr::new(value).encode_wide().collect::>(); + if wide.is_empty() || wide.contains(&0) { + return None; + } + Some(wide.into_iter().chain(Some(0)).collect()) + } + + fn wide_array_to_string(value: &[u16]) -> String { + let length = value + .iter() + .position(|unit| *unit == 0) + .unwrap_or(value.len()); + String::from_utf16_lossy(&value[..length]) + } + + fn process_basename(value: &str) -> String { + let normalized = value.trim().to_ascii_lowercase(); + normalized + .strip_suffix(".exe") + .unwrap_or(&normalized) + .to_string() + } + + fn non_empty_registry_string(key: &RegKey, name: &str) -> Option { + key.get_value::(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } +} + +fn path_has_reparse_point(path: &Path) -> bool { + path.ancestors().any(path_is_reparse_point) +} + +fn sha256_file(path: &Path) -> Option { + let mut file = fs::File::open(path).ok()?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer).ok()?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Some(format!("{:x}", hasher.finalize())) +} + +#[cfg(windows)] +fn path_is_reparse_point(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + fs::symlink_metadata(path) + .map(|metadata| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn path_is_reparse_point(path: &Path) -> bool { + fs::symlink_metadata(path) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false) +} + +#[cfg(windows)] +fn file_version_from_path(path: &Path) -> Option { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileVersionInfoSizeW, GetFileVersionInfoW, VerQueryValueW, VS_FIXEDFILEINFO, + }; + + let wide_path: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let mut ignored_handle = 0_u32; + // SAFETY: wide_path is NUL-terminated and ignored_handle is a valid out pointer. + let size = unsafe { GetFileVersionInfoSizeW(wide_path.as_ptr(), &mut ignored_handle) }; + if size == 0 { + return None; + } + let mut buffer = vec![0_u8; size as usize]; + // SAFETY: buffer has exactly the size requested by GetFileVersionInfoSizeW. + let loaded = unsafe { + GetFileVersionInfoW( + wide_path.as_ptr(), + 0, + size, + buffer.as_mut_ptr().cast::(), + ) + }; + if loaded == 0 { + return None; + } + + let root_query = [b'\\' as u16, 0]; + let mut version_ptr: *mut c_void = std::ptr::null_mut(); + let mut version_len = 0_u32; + // SAFETY: buffer contains the version resource returned above; output pointers are valid. + let queried = unsafe { + VerQueryValueW( + buffer.as_ptr().cast::(), + root_query.as_ptr(), + &mut version_ptr, + &mut version_len, + ) + }; + if queried == 0 + || version_ptr.is_null() + || (version_len as usize) < std::mem::size_of::() + { + return None; + } + + // SAFETY: VerQueryValueW returned a buffer at least VS_FIXEDFILEINFO bytes long. + let version = unsafe { &*version_ptr.cast::() }; + Some(format!( + "{}.{}.{}.{}", + version.dwFileVersionMS >> 16, + version.dwFileVersionMS & 0xffff, + version.dwFileVersionLS >> 16, + version.dwFileVersionLS & 0xffff + )) +} + +#[cfg(not(windows))] +fn file_version_from_path(_path: &Path) -> Option { + None } diff --git a/src-tauri/src/component_inventory.rs b/src-tauri/src/component_inventory.rs new file mode 100644 index 0000000..c7eafc0 --- /dev/null +++ b/src-tauri/src/component_inventory.rs @@ -0,0 +1,924 @@ +//! Pure component ownership classification and lifecycle preflight. +//! +//! Detection gathers evidence; this module decides whether ProxyWarden may +//! inspect or mutate a candidate. No component binary is executed here. + +use crate::models::ComponentId; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +pub const OWNERSHIP_MISMATCH: &str = "ownership_mismatch"; +pub const COMPONENT_INCOMPLETE: &str = "component_incomplete"; +pub const FOREIGN_COMPONENT: &str = "foreign_component"; +pub const AMBIGUOUS_LEGACY: &str = "ambiguous_legacy"; +pub const COMPONENT_MISSING: &str = "component_missing"; +pub const LEGACY_IDENTITY_CHANGED: &str = "legacy_identity_changed"; +pub const MANUAL_MIGRATION_REQUIRED: &str = "manual_migration_required"; + +pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT: &str = r"C:\Tools\ProxiFyre"; +pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION: &str = "2.2.1"; +const LEGACY_PROXIFYRE_PRIMARY_SERVICE: &str = "ProxiFyreService"; +const LEGACY_PROXIFYRE_FIXED_VERSION: &str = "2.2.1.0"; +const SERVICE_WIN32_OWN_PROCESS: u32 = 0x0000_0010; +const SERVICE_AUTO_START: u32 = 0x0000_0002; +const SERVICE_ERROR_NORMAL: u32 = 0x0000_0001; +const SERVICE_SID_TYPE_NONE: u32 = 0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComponentClassification { + ManagedCurrent, + ManagedLegacy, + Foreign, + Incomplete, + Missing, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CandidateRole { + Current, + Legacy, + ForeignByDefault, + Foreign, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarkerEvidence { + Valid, + Missing, + Invalid, + NotRequired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryIdentityEvidence { + KnownPackage, + Unknown, + Mismatch, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceEvidence { + pub name: String, + pub status: String, + pub path_name: Option, + pub executable_path: Option, + pub path_matches_candidate: bool, + pub binary_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentCandidateProbe { + pub component_id: ComponentId, + pub role: CandidateRole, + pub root: PathBuf, + pub root_exists: bool, + pub has_reparse_point: bool, + pub executable_path: Option, + pub missing_files: Vec, + pub marker: MarkerEvidence, + pub marker_required: bool, + pub binary_identity: BinaryIdentityEvidence, + pub binary_version: Option, + pub service: Option, + pub service_required: bool, + pub legacy_identity_complete: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InventoryIssue { + pub code: String, + pub message: String, +} + +impl InventoryIssue { + pub fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentCandidate { + pub component_id: ComponentId, + pub classification: ComponentClassification, + pub role: CandidateRole, + pub root: PathBuf, + pub executable_path: Option, + pub binary_version: Option, + pub service: Option, + pub marker: MarkerEvidence, + pub issues: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentInventory { + pub component_id: ComponentId, + pub candidates: Vec, + pub selected: Option, + pub issues: Vec, +} + +/// Immutable identity retained only by the disabled legacy compatibility +/// helpers until Task 8 removes their implementation. Normal lifecycle routing +/// no longer grants ManagedLegacy Start/Stop/Apply authority. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyComponentIdentity { + component_id: ComponentId, + fingerprint: String, +} + +/// Read-only evidence used by the durable cutover coordinator. This is +/// intentionally separate from `ComponentClassification`: legacy discovery +/// and compatibility helpers must not grant migration authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyCutoverEvidence { + pub proxifyre_manifest_matches: bool, + pub proxifyre_scm_profile: Option, + /// SHA-256 over the complete SCM restore snapshot (base config, every + /// CONFIG2 value, security descriptor, and original stable state). The + /// cutover coordinator computes it from the leased snapshot so fields + /// outside the frozen safety profile remain bound to the sealed evidence. + pub proxifyre_scm_snapshot_fingerprint: String, + pub additional_matching_service: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyProxifyreScmProfile { + pub service_type: u32, + pub start_type: u32, + pub error_control: u32, + pub account_name: String, + pub display_name: String, + pub description: String, + pub dependencies: Vec, + pub load_order_group: Option, + pub has_failure_actions: bool, + pub failure_actions_on_non_crash: bool, + pub delayed_auto_start: bool, + pub sid_type: u32, + pub required_privileges: Vec, + pub has_triggers: bool, + pub untrusted_mutation_rights: bool, +} + +impl LegacyProxifyreScmProfile { + pub fn matches_frozen_2_2_1_profile(&self) -> bool { + self.service_type == SERVICE_WIN32_OWN_PROCESS + && self.start_type == SERVICE_AUTO_START + && self.error_control == SERVICE_ERROR_NORMAL + && self.account_name.eq_ignore_ascii_case("LocalSystem") + && self.display_name == "ProxiFyre Service" + && self.description == "ProxiFyre - SOCKS5 ProxiFyre Service" + && self.dependencies.is_empty() + && self.load_order_group.as_deref().is_none_or(str::is_empty) + && !self.has_failure_actions + && !self.failure_actions_on_non_crash + && !self.delayed_auto_start + && self.sid_type == SERVICE_SID_TYPE_NONE + && self.required_privileges.is_empty() + && !self.has_triggers + && !self.untrusted_mutation_rights + } +} + +/// Opaque strict-gate result. External callers can only obtain one through the +/// matcher below; private fields prevent constructing an "approved" enum, and +/// mutation entrypoints do not accept caller-supplied proofs. +/// +/// ```compile_fail +/// use proxywarden_lib::component_inventory::LegacyCutoverProof; +/// +/// let _forged = LegacyCutoverProof { +/// identity_fingerprint: "forged".to_string(), +/// }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegacyCutoverProof { + identity_fingerprint: String, +} + +impl LegacyCutoverProof { + pub fn fingerprint(&self) -> &str { + &self.identity_fingerprint + } +} + +impl ComponentInventory { + pub fn missing(component_id: ComponentId) -> Self { + Self { + component_id, + candidates: Vec::new(), + selected: None, + issues: Vec::new(), + } + } + + pub fn selected_candidate(&self) -> Option<&ComponentCandidate> { + self.selected.and_then(|index| self.candidates.get(index)) + } + + pub fn classification(&self) -> ComponentClassification { + self.selected_candidate() + .map(|candidate| candidate.classification) + .unwrap_or(ComponentClassification::Missing) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InventoryAction { + Install, + Apply, + CheckBinary, + Start, + Stop, + ConfigureFirewall, + Update, + Uninstall, + Cutover, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthorizedActionError { + Denied(InventoryIssue), + Runner(E), +} + +pub fn classify_component_candidates( + component_id: ComponentId, + probes: Vec, +) -> ComponentInventory { + let mut candidates: Vec<_> = probes.into_iter().map(classify_candidate).collect(); + candidates.retain(|candidate| candidate.classification != ComponentClassification::Missing); + + let current = candidates + .iter() + .position(|candidate| candidate.role == CandidateRole::Current); + let managed_legacy: Vec<_> = candidates + .iter() + .enumerate() + .filter(|(_, candidate)| candidate.classification == ComponentClassification::ManagedLegacy) + .map(|(index, _)| index) + .collect(); + + let mut issues = Vec::new(); + let selected = if let Some(current) = current { + Some(current) + } else if managed_legacy.len() == 1 { + managed_legacy.first().copied() + } else if managed_legacy.len() > 1 { + issues.push(InventoryIssue::new( + AMBIGUOUS_LEGACY, + "Найдено несколько подтвержденных старых установок; автоматический выбор заблокирован.", + )); + None + } else { + candidates + .iter() + .position(|candidate| { + candidate.classification == ComponentClassification::Foreign + && candidate + .issues + .iter() + .any(|issue| issue.code == OWNERSHIP_MISMATCH) + }) + .or_else(|| { + candidates.iter().position(|candidate| { + candidate.classification == ComponentClassification::Foreign + }) + }) + .or_else(|| { + candidates.iter().position(|candidate| { + candidate.classification == ComponentClassification::Incomplete + }) + }) + }; + + ComponentInventory { + component_id, + candidates, + selected, + issues, + } +} + +pub fn authorize_component_action( + inventory: &ComponentInventory, + action: InventoryAction, +) -> Result, InventoryIssue> { + if let Some(issue) = inventory.issues.first() { + return Err(issue.clone()); + } + + let Some(candidate) = inventory.selected_candidate() else { + return if action == InventoryAction::Install { + Ok(None) + } else { + Err(InventoryIssue::new( + COMPONENT_MISSING, + "Управляемый компонент не найден.", + )) + }; + }; + + match candidate.classification { + ComponentClassification::ManagedCurrent => match action { + InventoryAction::Apply + | InventoryAction::CheckBinary + | InventoryAction::Start + | InventoryAction::Stop + | InventoryAction::ConfigureFirewall + | InventoryAction::Update + | InventoryAction::Uninstall => Ok(Some(candidate)), + InventoryAction::Install | InventoryAction::Cutover => Err(InventoryIssue::new( + "component_already_current", + "Компонент уже находится в текущей управляемой папке.", + )), + }, + ComponentClassification::ManagedLegacy => match action { + InventoryAction::CheckBinary => Ok(Some(candidate)), + InventoryAction::Apply + | InventoryAction::Install + | InventoryAction::Start + | InventoryAction::Stop + | InventoryAction::ConfigureFirewall + | InventoryAction::Update + | InventoryAction::Uninstall + | InventoryAction::Cutover => Err(InventoryIssue::new( + "legacy_cutover_required", + "Старая установка требует отдельного доказанного cutover-потока.", + )), + }, + ComponentClassification::Foreign => { + Err(candidate.issues.first().cloned().unwrap_or_else(|| { + InventoryIssue::new( + FOREIGN_COMPONENT, + "Найдена чужая установка; управление ею заблокировано.", + ) + })) + } + ComponentClassification::Incomplete => { + Err(candidate.issues.first().cloned().unwrap_or_else(|| { + InventoryIssue::new( + COMPONENT_INCOMPLETE, + "Установка компонента неполна; опасные действия заблокированы.", + ) + })) + } + ComponentClassification::Missing => { + if action == InventoryAction::Install { + Ok(None) + } else { + Err(InventoryIssue::new( + COMPONENT_MISSING, + "Управляемый компонент не найден.", + )) + } + } + } +} + +/// Produces read-only proof for the one supported automatic legacy cutover. +/// +/// A `ManagedLegacy` candidate alone is discovery evidence, not mutation +/// authority. The caller must independently match the leased ten-file package +/// manifest and query the complete live SCM profile before calling this gate. +pub fn prove_legacy_cutover( + inventory: &ComponentInventory, + evidence: &LegacyCutoverEvidence, +) -> Result { + let manual = || { + Err(InventoryIssue::new( + MANUAL_MIGRATION_REQUIRED, + "Найдена старая установка, но ее identity недостаточна для автоматического переноса.", + )) + }; + + if inventory.component_id != ComponentId::Proxyfier + || !inventory.issues.is_empty() + || inventory.candidates.len() != 1 + { + return manual(); + } + let Some(candidate) = inventory.selected_candidate() else { + return manual(); + }; + if candidate.component_id != ComponentId::Proxyfier + || candidate.classification != ComponentClassification::ManagedLegacy + || candidate.role != CandidateRole::Legacy + || !candidate.issues.is_empty() + || normalized_identity_path(&candidate.root) + != normalized_identity_text(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT) + || !candidate + .binary_version + .as_deref() + .is_some_and(legacy_proxifyre_version_matches) + || !evidence.proxifyre_manifest_matches + || !is_sha256(&evidence.proxifyre_scm_snapshot_fingerprint) + || evidence.additional_matching_service + || !evidence + .proxifyre_scm_profile + .as_ref() + .is_some_and(LegacyProxifyreScmProfile::matches_frozen_2_2_1_profile) + { + return manual(); + } + + let expected_executable = candidate.root.join("ProxiFyre.exe"); + if candidate.executable_path.as_deref().is_none_or(|path| { + normalized_identity_path(path) != normalized_identity_path(&expected_executable) + }) { + return manual(); + } + let Some(service) = candidate.service.as_ref() else { + return manual(); + }; + if !service + .name + .eq_ignore_ascii_case(LEGACY_PROXIFYRE_PRIMARY_SERVICE) + || !matches!( + service.status.trim().to_ascii_lowercase().as_str(), + "running" | "stopped" + ) + || service + .binary_version + .as_deref() + .is_none_or(|version| !legacy_proxifyre_version_matches(version)) + || service.executable_path.as_deref().is_none_or(|path| { + normalized_identity_path(path) != normalized_identity_path(&expected_executable) + }) + || service.path_name.as_deref().is_none_or(|path_name| { + !legacy_proxifyre_topshelf_path_matches(path_name, &expected_executable) + }) + { + return manual(); + } + + let identity_fingerprint = json!({ + "domain": "proxywarden-legacy-cutover-proof-v1", + "candidate": legacy_candidate_fingerprint(candidate, service), + "evidence": evidence, + }); + Ok(LegacyCutoverProof { + identity_fingerprint: format!( + "{:x}", + Sha256::digest(identity_fingerprint.to_string().as_bytes()) + ), + }) +} + +/// Cross-platform pure matcher for the historical Topshelf service command. +/// It parses Windows quoting rules even when contract tests run on Linux. +pub fn legacy_proxifyre_topshelf_path_matches(path_name: &str, expected_executable: &Path) -> bool { + if normalized_identity_path(expected_executable) + != normalized_identity_text(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + { + return false; + } + let Some(arguments) = split_windows_command_line(path_name) else { + return false; + }; + if arguments.len() != 5 + || normalized_identity_text(&arguments[0]) != normalized_identity_path(expected_executable) + { + return false; + } + let mut display_name = false; + let mut service_name = false; + for pair in arguments[1..].chunks_exact(2) { + match (pair[0].to_ascii_lowercase().as_str(), pair[1].as_str()) { + ("-displayname", "ProxiFyre Service") if !display_name => display_name = true, + ("-servicename", LEGACY_PROXIFYRE_PRIMARY_SERVICE) if !service_name => { + service_name = true; + } + _ => return false, + } + } + display_name && service_name +} + +pub fn run_authorized_component_action( + inventory: &ComponentInventory, + action: InventoryAction, + runner: impl FnOnce(Option<&ComponentCandidate>) -> Result, +) -> Result> { + let candidate = + authorize_component_action(inventory, action).map_err(AuthorizedActionError::Denied)?; + runner(candidate).map_err(AuthorizedActionError::Runner) +} + +pub fn capture_legacy_component_identity( + inventory: &ComponentInventory, +) -> Result { + if !inventory.issues.is_empty() { + return Err(legacy_identity_changed()); + } + let candidate = inventory + .selected_candidate() + .filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy) + .ok_or_else(legacy_identity_changed)?; + let service = candidate + .service + .as_ref() + .filter(|service| { + !service.name.trim().is_empty() + && service + .path_name + .as_deref() + .is_some_and(|path_name| !path_name.trim().is_empty()) + && service.executable_path.is_some() + && service.path_matches_candidate + }) + .ok_or_else(legacy_identity_changed)?; + if candidate.component_id != inventory.component_id + || candidate.executable_path.is_none() + || !candidate.issues.is_empty() + { + return Err(legacy_identity_changed()); + } + + Ok(LegacyComponentIdentity { + component_id: inventory.component_id.clone(), + fingerprint: legacy_candidate_fingerprint(candidate, service), + }) +} + +pub fn revalidate_legacy_component<'a>( + expected: &LegacyComponentIdentity, + inventory: &'a ComponentInventory, + action: InventoryAction, +) -> Result<&'a ComponentCandidate, InventoryIssue> { + if !matches!(action, InventoryAction::Start | InventoryAction::Stop) + || inventory.component_id != expected.component_id + { + return Err(legacy_identity_changed()); + } + let candidate = authorize_component_action(inventory, action) + .ok() + .flatten() + .filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy) + .ok_or_else(legacy_identity_changed)?; + let actual = capture_legacy_component_identity(inventory)?; + if actual != *expected { + return Err(legacy_identity_changed()); + } + Ok(candidate) +} + +pub fn run_revalidated_legacy_action( + expected: &LegacyComponentIdentity, + inventory: &ComponentInventory, + action: InventoryAction, + runner: impl FnOnce(&ComponentCandidate) -> Result, +) -> Result> { + let candidate = revalidate_legacy_component(expected, inventory, action) + .map_err(AuthorizedActionError::Denied)?; + runner(candidate).map_err(AuthorizedActionError::Runner) +} + +fn legacy_candidate_fingerprint( + candidate: &ComponentCandidate, + service: &ServiceEvidence, +) -> String { + let value = json!({ + "component": component_identity_label(&candidate.component_id), + "classification": "managed-legacy", + "role": candidate_role_label(candidate.role), + "root": normalized_identity_path(&candidate.root), + "executable": candidate.executable_path.as_deref().map(normalized_identity_path), + "binaryIdentity": "known-package", + "binaryVersion": candidate.binary_version, + "marker": marker_identity_label(candidate.marker), + "service": { + "name": service.name.to_ascii_lowercase(), + "pathName": service.path_name.as_deref().map(normalized_identity_text), + "executable": service.executable_path.as_deref().map(normalized_identity_path), + "pathMatchesCandidate": service.path_matches_candidate, + "binaryVersion": service.binary_version, + }, + }); + format!("{:x}", Sha256::digest(value.to_string().as_bytes())) +} + +/// Canonical redacted identity used by normal startup, the privileged plan, +/// and elevated next-start verification. Keeping this in the inventory owner +/// prevents subtly different hashes from authorizing cleanup. +pub fn component_inventory_fingerprint_for_cutover(inventory: &ComponentInventory) -> String { + let mut candidates = inventory + .candidates + .iter() + .map(inventory_candidate_fingerprint_value) + .collect::>(); + candidates.sort_by_key(Value::to_string); + let mut issues = inventory + .issues + .iter() + .map(|issue| issue.code.clone()) + .collect::>(); + issues.sort(); + let value = json!({ + "component": component_identity_label(&inventory.component_id), + "selected": inventory.selected_candidate().map(inventory_candidate_fingerprint_value), + "candidates": candidates, + "issues": issues, + }); + format!("{:x}", Sha256::digest(value.to_string().as_bytes())) +} + +fn inventory_candidate_fingerprint_value(candidate: &ComponentCandidate) -> Value { + let mut issues = candidate + .issues + .iter() + .map(|issue| issue.code.clone()) + .collect::>(); + issues.sort(); + json!({ + "component": component_identity_label(&candidate.component_id), + "classification": component_classification_label(candidate.classification), + "role": candidate_role_label(candidate.role), + "root": normalized_inventory_path(&candidate.root), + "executable": candidate.executable_path.as_deref().map(normalized_inventory_path), + "binaryVersion": candidate.binary_version, + "marker": marker_identity_label(candidate.marker), + "service": candidate.service.as_ref().map(|service| json!({ + "name": service.name.to_ascii_lowercase(), + "status": service.status.to_ascii_lowercase(), + "pathName": service.path_name.as_deref().map(normalized_inventory_text), + "executable": service.executable_path.as_deref().map(normalized_inventory_path), + "pathMatches": service.path_matches_candidate, + "binaryVersion": service.binary_version, + })), + "issues": issues, + }) +} + +fn component_classification_label(classification: ComponentClassification) -> &'static str { + match classification { + ComponentClassification::ManagedCurrent => "managed-current", + ComponentClassification::ManagedLegacy => "managed-legacy", + ComponentClassification::Foreign => "foreign", + ComponentClassification::Incomplete => "incomplete", + ComponentClassification::Missing => "missing", + } +} + +fn normalized_inventory_path(path: &Path) -> String { + normalized_inventory_text(&path.to_string_lossy()) +} + +fn normalized_inventory_text(value: &str) -> String { + value.trim().replace('/', "\\").to_ascii_lowercase() +} + +fn legacy_identity_changed() -> InventoryIssue { + InventoryIssue::new( + LEGACY_IDENTITY_CHANGED, + "Старая управляемая установка изменилась после проверки; действие отменено.", + ) +} + +fn legacy_proxifyre_version_matches(version: &str) -> bool { + matches!( + version.trim(), + LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION | LEGACY_PROXIFYRE_FIXED_VERSION + ) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn normalized_identity_path(path: &Path) -> String { + normalized_identity_text(&path.to_string_lossy()) +} + +fn normalized_identity_text(value: &str) -> String { + value + .trim() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} + +fn split_windows_command_line(value: &str) -> Option> { + if value.contains('\0') { + return None; + } + let characters: Vec = value.chars().collect(); + let mut index = 0; + let mut arguments = Vec::new(); + while index < characters.len() { + while index < characters.len() && characters[index].is_whitespace() { + index += 1; + } + if index == characters.len() { + break; + } + let mut argument = String::new(); + let mut quoted = false; + while index < characters.len() { + if characters[index] == '\\' { + let start = index; + while index < characters.len() && characters[index] == '\\' { + index += 1; + } + let count = index - start; + if index < characters.len() && characters[index] == '"' { + argument.extend(std::iter::repeat_n('\\', count / 2)); + if count % 2 == 0 { + quoted = !quoted; + } else { + argument.push('"'); + } + index += 1; + } else { + argument.extend(std::iter::repeat_n('\\', count)); + } + continue; + } + match characters[index] { + '"' => quoted = !quoted, + character if character.is_whitespace() && !quoted => break, + character => argument.push(character), + } + index += 1; + } + if quoted || argument.is_empty() { + return None; + } + arguments.push(argument); + while index < characters.len() && characters[index].is_whitespace() { + index += 1; + } + } + (!arguments.is_empty()).then_some(arguments) +} + +fn component_identity_label(component: &ComponentId) -> &'static str { + match component { + ComponentId::ControlApp => "control-app", + ComponentId::Proxyfier => "proxifyre", + ComponentId::Singbox => "sing-box", + } +} + +fn candidate_role_label(role: CandidateRole) -> &'static str { + match role { + CandidateRole::Current => "current", + CandidateRole::Legacy => "legacy", + CandidateRole::ForeignByDefault => "foreign-by-default", + CandidateRole::Foreign => "foreign", + } +} + +fn marker_identity_label(marker: MarkerEvidence) -> &'static str { + match marker { + MarkerEvidence::Valid => "valid", + MarkerEvidence::Missing => "missing", + MarkerEvidence::Invalid => "invalid", + MarkerEvidence::NotRequired => "not-required", + } +} + +fn classify_candidate(probe: ComponentCandidateProbe) -> ComponentCandidate { + let mut issues = Vec::new(); + let classification = + if !probe.root_exists && probe.executable_path.is_none() && probe.service.is_none() { + ComponentClassification::Missing + } else if probe.has_reparse_point { + issues.push(InventoryIssue::new( + OWNERSHIP_MISMATCH, + format!( + "Путь компонента содержит reparse point и не может считаться управляемым: {}", + probe.root.display() + ), + )); + ComponentClassification::Foreign + } else if probe.binary_identity == BinaryIdentityEvidence::Mismatch { + issues.push(InventoryIssue::new( + OWNERSHIP_MISMATCH, + "Binary не совпадает с известным пакетом ProxyWarden.", + )); + ComponentClassification::Foreign + } else if probe + .service + .as_ref() + .is_some_and(|service| !service.path_matches_candidate) + { + issues.push(InventoryIssue::new( + OWNERSHIP_MISMATCH, + "Имя службы совпало, но ее PathName указывает на другой binary.", + )); + ComponentClassification::Foreign + } else if probe.role == CandidateRole::Foreign { + let (code, message) = if probe.service.is_some() { + ( + OWNERSHIP_MISMATCH, + "Служба с известным именем указывает в путь вне allowlist ProxyWarden.", + ) + } else { + ( + FOREIGN_COMPONENT, + "Путь не входит в allowlist управляемых установок ProxyWarden.", + ) + }; + issues.push(InventoryIssue::new(code, message)); + ComponentClassification::Foreign + } else if !probe.root_exists || !probe.missing_files.is_empty() { + issues.push(InventoryIssue::new( + COMPONENT_INCOMPLETE, + missing_files_message(&probe.root, &probe.missing_files), + )); + incomplete_classification(probe.role) + } else if probe.marker_required && probe.marker != MarkerEvidence::Valid { + let code = if probe.marker == MarkerEvidence::Invalid { + OWNERSHIP_MISMATCH + } else { + COMPONENT_INCOMPLETE + }; + issues.push(InventoryIssue::new( + code, + "Marker установки не подтверждает владение ProxyWarden.", + )); + if probe.marker == MarkerEvidence::Invalid { + ComponentClassification::Foreign + } else { + incomplete_classification(probe.role) + } + } else if probe.service_required && probe.service.is_none() { + issues.push(InventoryIssue::new( + COMPONENT_INCOMPLETE, + "Ожидаемая Windows-служба отсутствует.", + )); + incomplete_classification(probe.role) + } else { + match probe.role { + CandidateRole::Current + if probe.marker == MarkerEvidence::Valid || probe.legacy_identity_complete => + { + ComponentClassification::ManagedCurrent + } + CandidateRole::Legacy | CandidateRole::ForeignByDefault + if probe.legacy_identity_complete + && probe.binary_identity == BinaryIdentityEvidence::KnownPackage => + { + ComponentClassification::ManagedLegacy + } + CandidateRole::ForeignByDefault => { + issues.push(InventoryIssue::new( + FOREIGN_COMPONENT, + "Путь считается чужим без полной legacy identity ProxyWarden.", + )); + ComponentClassification::Foreign + } + CandidateRole::Current | CandidateRole::Legacy => { + issues.push(InventoryIssue::new( + COMPONENT_INCOMPLETE, + "Недостаточно evidence для подтверждения владения компонентом.", + )); + ComponentClassification::Incomplete + } + CandidateRole::Foreign => ComponentClassification::Foreign, + } + }; + + ComponentCandidate { + component_id: probe.component_id, + classification, + role: probe.role, + root: probe.root, + executable_path: probe.executable_path, + binary_version: probe.binary_version, + service: probe.service, + marker: probe.marker, + issues, + } +} + +fn incomplete_classification(role: CandidateRole) -> ComponentClassification { + if role == CandidateRole::ForeignByDefault { + ComponentClassification::Foreign + } else { + ComponentClassification::Incomplete + } +} + +fn missing_files_message(root: &Path, missing_files: &[PathBuf]) -> String { + if missing_files.is_empty() { + return format!("Папка компонента отсутствует: {}", root.display()); + } + + let names = missing_files + .iter() + .filter_map(|path| path.file_name().and_then(|name| name.to_str())) + .collect::>() + .join(", "); + format!("Установка неполна; отсутствуют: {names}") +} diff --git a/src-tauri/src/component_packages.rs b/src-tauri/src/component_packages.rs new file mode 100644 index 0000000..25c6ded --- /dev/null +++ b/src-tauri/src/component_packages.rs @@ -0,0 +1,2940 @@ +use crate::component_catalog::{ + validate_bundle, validate_github_update_asset, ComponentCatalog, ComponentCatalogError, + ComponentId, ComponentPackage, UpdateTrustPolicy, COMPONENT_CATALOG_FILENAME, +}; +use crate::safe_fs::{ + backup_path, ensure_no_reparse_ancestors, protect_path_admin_owned_user_read_only, + protect_path_for_owner_admin_system, verify_path_admin_owned_user_read_only, + verify_path_installed_read_only, verify_path_protected_for_owner_admin_system, + write_restricted_atomic, write_restricted_with_backup, +}; +use crate::storage::StoragePaths; +use reqwest::header::{ACCEPT, LOCATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +pub const PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub const PACKAGE_CACHE_MANIFEST_FILENAME: &str = "package.json"; +pub const COMPONENT_UPDATES_STATE_SCHEMA_VERSION: u32 = 1; +pub const UPDATE_CHECK_STALE_AFTER_SECS: u64 = 7 * 24 * 60 * 60; +pub const COMPONENT_UPDATES_LOCK_FILENAME: &str = "component-updates.lock"; + +const MAX_CACHE_MANIFEST_BYTES: u64 = 64 * 1024; +const MAX_COMPONENT_UPDATES_STATE_BYTES: u64 = 256 * 1024; +const MAX_COMPONENT_PACKAGE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_GITHUB_RELEASE_RESPONSE_BYTES: u64 = 1024 * 1024; +const MAX_REDIRECTS: usize = 1; +const MAX_TRUSTED_RELEASES_PER_COMPONENT: usize = 8; + +static UPDATE_STATE_LOCK: OnceLock> = OnceLock::new(); + +#[derive(Debug, Error)] +pub enum ComponentPackagesError { + #[error("bundled component catalog is invalid: {0}")] + Catalog(#[from] ComponentCatalogError), + #[error("bundled component is missing from the catalog")] + MissingBundledComponent, + #[error("component update timestamp must be a positive Unix epoch value")] + InvalidTimestamp, + #[error("component update transport failed")] + Transport, + #[error("component update response was not accepted")] + InvalidResponse, + #[error("component update redirect was not accepted")] + InvalidRedirect, + #[error("component update release metadata is invalid")] + InvalidReleaseMetadata, + #[error("component update release has no unique matching asset")] + MissingOrAmbiguousAsset, + #[error("component update state is invalid and cannot be recovered")] + InvalidUpdateState, + #[error("component update state could not be read or written")] + UpdateStateIo(#[source] io::Error), + #[error("no independently trusted newer release is available")] + NoTrustedUpdate, + #[error("component package staging failed")] + StagingIo(#[source] io::Error), + #[error("component package size does not match trusted release metadata")] + SizeMismatch, + #[error("component package digest does not match trusted release metadata")] + DigestMismatch, + #[error("component package signature verification failed")] + SignatureVerification, + #[error("component package publisher does not match the pinned policy")] + PublisherMismatch, + #[error("component package archive does not match the pinned layout")] + InvalidArchive, + #[error("component package destination already exists but is not trusted")] + PromotionConflict, + #[error("installed component snapshot is inconsistent")] + InvalidInstalledSnapshot, + #[error("component update state is busy in another process")] + UpdateStateBusy, + #[error("privileged component bundle root is not trusted")] + UntrustedBundleRoot, + #[error("privileged cached update plan is invalid")] + InvalidPrivilegedUpdatePlan, + #[error("privileged package staging root is not trusted")] + UntrustedPrivilegedStaging, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum UpdateTransportError { + #[error("request failed")] + RequestFailed, + #[error("response headers are invalid")] + InvalidHeaders, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateRequestKind { + GithubReleaseMetadata, + PackageAsset, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateTransportRequest { + pub url: String, + pub kind: UpdateRequestKind, +} + +pub struct UpdateTransportResponse { + pub status: u16, + pub location: Option, + pub content_length: Option, + pub body: Box, +} + +pub trait UpdateTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result; +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("signature verification failed")] +pub struct SignatureVerifierError; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SignaturePublisher { + pub common_name: String, + pub organization: String, +} + +pub trait PackageSignatureVerifier { + fn verify(&self, path: &Path) -> Result; +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("privileged bundle root verification failed")] +pub struct PrivilegedBundleVerificationError; + +/// Native production verifier for immutable component assets next to the +/// running Control App executable. +pub struct NativePrivilegedBundleVerifier; + +pub trait PrivilegedBundleVerifier { + fn verify( + &self, + bundled_root: &Path, + catalog_path: &Path, + asset_path: &Path, + ) -> Result<(), PrivilegedBundleVerificationError>; +} + +impl PrivilegedBundleVerifier for NativePrivilegedBundleVerifier { + fn verify( + &self, + bundled_root: &Path, + catalog_path: &Path, + asset_path: &Path, + ) -> Result<(), PrivilegedBundleVerificationError> { + verify_native_privileged_bundle(bundled_root, catalog_path, asset_path) + .map_err(|_| PrivilegedBundleVerificationError) + } +} + +fn verify_native_privileged_bundle( + bundled_root: &Path, + catalog_path: &Path, + asset_path: &Path, +) -> io::Result<()> { + let executable = std::env::current_exe()?; + ensure_no_reparse_ancestors(&executable)?; + let executable_parent = executable.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "Control App executable has no install parent", + ) + })?; + let program_files = program_files_root()?; + let program_files = fs::canonicalize(program_files)?; + let executable_parent = fs::canonicalize(executable_parent)?; + if executable_parent == program_files || !executable_parent.starts_with(&program_files) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Control App is not installed below the canonical Program Files root", + )); + } + + let expected_root = executable_parent.join("bundled").join("components"); + ensure_no_reparse_ancestors(&expected_root)?; + ensure_no_reparse_ancestors(bundled_root)?; + if fs::canonicalize(&expected_root)? != fs::canonicalize(bundled_root)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "component bundle is not anchored beside the running executable", + )); + } + if catalog_path != bundled_root.join(COMPONENT_CATALOG_FILENAME) + || !asset_path.starts_with(bundled_root) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "component bundle asset escaped the anchored root", + )); + } + + verify_installed_path_chain(&program_files, &executable_parent)?; + verify_installed_path_chain(&program_files, &executable)?; + verify_installed_path_chain(&program_files, bundled_root)?; + verify_installed_path_chain(&program_files, catalog_path)?; + verify_installed_path_chain(&program_files, asset_path)?; + Ok(()) +} + +#[cfg(windows)] +fn program_files_root() -> io::Result { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion")? + .get_value::("ProgramFilesDir") + .map(PathBuf::from) +} + +#[cfg(not(windows))] +fn program_files_root() -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "canonical Program Files discovery is unavailable", + )) +} + +fn verify_installed_path_chain(root: &Path, target: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(root)?; + ensure_no_reparse_ancestors(target)?; + let root = fs::canonicalize(root)?; + let target = fs::canonicalize(target)?; + let relative = target.strip_prefix(&root).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "installed package path escaped Program Files", + ) + })?; + verify_path_installed_read_only(&root)?; + let mut current = root; + for component in relative.components() { + current.push(component.as_os_str()); + verify_path_installed_read_only(¤t)?; + } + Ok(()) +} + +pub struct NativePackageSignatureVerifier; + +impl PackageSignatureVerifier for NativePackageSignatureVerifier { + fn verify(&self, path: &Path) -> Result { + let verification = + crate::process::verify_authenticode(path).map_err(|_| SignatureVerifierError)?; + if !verification.is_trusted { + return Err(SignatureVerifierError); + } + let publisher = verification.publisher.ok_or(SignatureVerifierError)?; + Ok(SignaturePublisher { + common_name: publisher.common_name, + organization: publisher.organization, + }) + } +} + +pub struct ReqwestUpdateTransport { + client: reqwest::blocking::Client, +} + +impl ReqwestUpdateTransport { + pub fn new() -> Result { + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .build() + .map_err(|_| UpdateTransportError::RequestFailed)?; + Ok(Self { client }) + } +} + +impl UpdateTransport for ReqwestUpdateTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + let accept = match request.kind { + UpdateRequestKind::GithubReleaseMetadata => "application/vnd.github+json", + UpdateRequestKind::PackageAsset => "application/octet-stream", + }; + let response = self + .client + .get(&request.url) + .header(USER_AGENT, "ProxyWarden/1.2") + .header(ACCEPT, accept) + .send() + .map_err(|_| UpdateTransportError::RequestFailed)?; + let location = response + .headers() + .get(LOCATION) + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| UpdateTransportError::InvalidHeaders)?; + Ok(UpdateTransportResponse { + status: response.status().as_u16(), + location, + content_length: response.content_length(), + body: Box::new(response), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateCheckTrust { + Trusted, + MissingIndependentDigest, + MalformedIndependentDigest, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateCheckResult { + pub component_id: ComponentId, + pub latest_known_version: String, + pub checked_at_unix: u64, + pub trust: UpdateCheckTrust, + pub update_available: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateFreshness { + NeverChecked, + Fresh, + Stale, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComponentUpdateState { + Current, + UpdateAvailable, + CheckStale, + UnknownOffline, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComponentInstallSource { + Bundled, + Cache, + External, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InstalledComponentSnapshot<'a> { + version: Option<&'a str>, + source: ComponentInstallSource, +} + +impl<'a> InstalledComponentSnapshot<'a> { + pub fn new( + version: Option<&'a str>, + source: ComponentInstallSource, + ) -> Result { + if version.is_none() != (source == ComponentInstallSource::None) + || version.is_some_and(|value| NumericVersion::parse(value).is_none()) + { + return Err(ComponentPackagesError::InvalidInstalledSnapshot); + } + Ok(Self { version, source }) + } + + pub const fn not_installed() -> Self { + Self { + version: None, + source: ComponentInstallSource::None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ComponentUpdateStatus { + pub component_id: ComponentId, + pub installed_version: Option, + pub bundled_version: String, + pub available_offline_version: String, + pub latest_known_version: Option, + pub last_checked_at_unix: Option, + pub freshness: UpdateFreshness, + pub update_state: ComponentUpdateState, + pub install_source: ComponentInstallSource, + pub offline_package_source: PackageSource, + pub can_install_offline: bool, + pub offline_unavailable_reason: Option, + pub can_download: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PackageSource { + Bundled, + Cache, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedPackage { + pub component_id: ComponentId, + pub version: String, + pub asset_path: PathBuf, + pub package_root: PathBuf, + pub source: PackageSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivilegedCachedUpdatePlan { + pub component_id: ComponentId, + pub version: String, + pub independent_proof: GithubReleaseDigestProof, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivilegedPackageProof { + pub component_id: ComponentId, + pub version: String, + pub asset_name: String, + pub sha256: String, + pub size: u64, + pub source: PackageSource, + pub independent_proof: Option, +} + +/// Keeps the exact verified asset open without share-write/delete while an +/// elevated lifecycle runner consumes it. Cached leases own a random +/// admin-sealed staging directory which is removed on drop. +pub struct PrivilegedPackageLease { + proof: PrivilegedPackageProof, + asset_path: PathBuf, + locked_asset: Option, + staging: Option, +} + +impl PrivilegedPackageLease { + pub fn proof(&self) -> &PrivilegedPackageProof { + &self.proof + } + + pub fn asset_path(&self) -> &Path { + &self.asset_path + } +} + +impl Drop for PrivilegedPackageLease { + fn drop(&mut self) { + self.locked_asset.take(); + self.staging.take(); + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackageCacheManifest { + pub schema_version: u32, + pub component_id: ComponentId, + pub version: String, + pub asset_name: String, + pub sha256: String, + pub size: u64, + pub independent_proof: GithubReleaseDigestProof, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GithubReleaseDigestProof { + pub repository: String, + pub release_id: u64, + pub asset_id: u64, + pub stable_tag: String, + pub asset_name: String, + pub size: u64, + pub sha256_from_api: String, + pub verified_signatures: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VerifiedSignatureEvidence { + pub target_name: String, + pub common_name: String, + pub organization: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentUpdatesState { + pub schema_version: u32, + pub observations: Vec, +} + +impl Default for ComponentUpdatesState { + fn default() -> Self { + Self { + schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION, + observations: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentUpdateObservation { + pub component_id: ComponentId, + pub checked_at_unix: u64, + pub latest_known_version: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub trusted_releases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct TrustedGithubReleaseObservation { + pub repository: String, + pub release_id: u64, + pub asset_id: u64, + pub stable_tag: String, + pub asset_name: String, + pub size: u64, + pub sha256_from_api: String, +} + +#[derive(Debug, Clone)] +pub struct ComponentPackageService { + bundled_root: PathBuf, + packages_root: PathBuf, + updates_state_path: PathBuf, + catalog: ComponentCatalog, +} + +impl ComponentPackageService { + /// Opens only local package state. This API intentionally has no network dependency. + pub fn open( + bundled_root: impl Into, + storage_paths: &StoragePaths, + ) -> Result { + let bundled_root = bundled_root.into(); + let catalog = validate_bundle(&bundled_root)?; + Ok(Self { + bundled_root, + packages_root: storage_paths.packages_dir.clone(), + updates_state_path: storage_paths.component_updates_file.clone(), + catalog, + }) + } + + pub fn catalog(&self) -> &ComponentCatalog { + &self.catalog + } + + /// Revalidates and locks only the immutable app-bundled asset. Cache state + /// and network transport are deliberately absent from this fresh-install API. + pub fn lease_bundled_for_privileged_install( + &self, + component_id: ComponentId, + bundle_verifier: &dyn PrivilegedBundleVerifier, + ) -> Result { + let bundled = self.bundled_component(component_id)?; + let catalog_path = self.bundled_root.join(COMPONENT_CATALOG_FILENAME); + let asset_path = self + .bundled_root + .join(relative_catalog_path(&bundled.asset_path)); + let _locked_catalog = + open_locked_regular_file(&catalog_path).map_err(ComponentPackagesError::StagingIo)?; + let mut locked_asset = + open_locked_regular_file(&asset_path).map_err(ComponentPackagesError::StagingIo)?; + bundle_verifier + .verify(&self.bundled_root, &catalog_path, &asset_path) + .map_err(|_| ComponentPackagesError::UntrustedBundleRoot)?; + + let live_catalog = validate_bundle(&self.bundled_root)?; + if live_catalog != self.catalog { + return Err(ComponentPackagesError::UntrustedBundleRoot); + } + let live_component = live_catalog + .components + .iter() + .find(|component| component.id == component_id) + .ok_or(ComponentPackagesError::MissingBundledComponent)?; + if live_component != bundled { + return Err(ComponentPackagesError::UntrustedBundleRoot); + } + + let (size, sha256) = + hash_locked_file(&mut locked_asset).map_err(ComponentPackagesError::StagingIo)?; + if size != bundled.size || sha256 != bundled.sha256 { + return Err(ComponentPackagesError::DigestMismatch); + } + let asset_name = asset_path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| safe_asset_name(name)) + .ok_or(ComponentPackagesError::UntrustedBundleRoot)? + .to_string(); + + Ok(PrivilegedPackageLease { + proof: PrivilegedPackageProof { + component_id, + version: bundled.version.clone(), + asset_name, + sha256, + size, + source: PackageSource::Bundled, + independent_proof: None, + }, + asset_path, + locked_asset: Some(locked_asset), + staging: None, + }) + } + + /// Captures the exact previously verified cache proof for a one-shot + /// privileged update job. The elevated helper must still perform the live + /// official-origin recheck below. + pub fn plan_cached_update( + &self, + component_id: ComponentId, + ) -> Result { + let selected = self.select_verified(component_id)?; + if selected.source != PackageSource::Cache { + return Err(ComponentPackagesError::NoTrustedUpdate); + } + let manifest_path = selected.package_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + let manifest = + read_cache_manifest(&manifest_path).ok_or(ComponentPackagesError::NoTrustedUpdate)?; + if manifest.component_id != component_id || manifest.version != selected.version { + return Err(ComponentPackagesError::NoTrustedUpdate); + } + Ok(PrivilegedCachedUpdatePlan { + component_id, + version: manifest.version, + independent_proof: manifest.independent_proof, + }) + } + + /// Rechecks the exact official GitHub `releases/latest` record, then copies + /// only the locked cache bytes into an admin-owned staging directory. + pub fn lease_cached_update_for_privileged_install( + &self, + plan: &PrivilegedCachedUpdatePlan, + admin_staging_parent: &Path, + transport: &dyn UpdateTransport, + signature_verifier: &dyn PackageSignatureVerifier, + ) -> Result { + self.lease_cached_update_with_policy( + plan, + admin_staging_parent, + transport, + signature_verifier, + &NativePrivilegedStagingPolicy, + ) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn lease_cached_update_with_owner_protected_staging_for_tests( + &self, + plan: &PrivilegedCachedUpdatePlan, + staging_parent: &Path, + transport: &dyn UpdateTransport, + signature_verifier: &dyn PackageSignatureVerifier, + ) -> Result { + self.lease_cached_update_with_policy( + plan, + staging_parent, + transport, + signature_verifier, + &OwnerProtectedTestStagingPolicy, + ) + } + + fn lease_cached_update_with_policy( + &self, + plan: &PrivilegedCachedUpdatePlan, + admin_staging_parent: &Path, + transport: &dyn UpdateTransport, + signature_verifier: &dyn PackageSignatureVerifier, + staging_policy: &dyn PrivilegedStagingPolicy, + ) -> Result { + let bundled = self.bundled_component(plan.component_id)?; + let UpdateTrustPolicy::GithubReleaseDigest { repository, .. } = + &bundled.update_trust_policy + else { + return Err(ComponentPackagesError::NoTrustedUpdate); + }; + validate_privileged_update_plan(plan, bundled, repository)?; + + let live = fetch_latest_release_candidate(bundled, repository, transport)?; + require_exact_live_release(plan, &live)?; + + let (manifest, mut source) = self.open_exact_cached_update(plan, bundled)?; + staging_policy + .verify(admin_staging_parent) + .map_err(|_| ComponentPackagesError::UntrustedPrivilegedStaging)?; + let mut staging = PrivilegedStagingDirectory::create( + admin_staging_parent, + plan.component_id, + staging_policy, + )?; + let asset_path = staging.path.join(&manifest.asset_name); + let (size, sha256) = copy_locked_to_admin_staging( + &mut source, + &asset_path, + manifest.size, + &manifest.sha256, + staging_policy, + )?; + let verified_signatures = verify_privileged_cached_package( + bundled, + &asset_path, + &staging.path, + signature_verifier, + staging_policy, + )?; + let locked_asset = + open_locked_regular_file(&asset_path).map_err(ComponentPackagesError::StagingIo)?; + staging.active = true; + + let mut independent_proof = plan.independent_proof.clone(); + independent_proof.verified_signatures = verified_signatures; + Ok(PrivilegedPackageLease { + proof: PrivilegedPackageProof { + component_id: plan.component_id, + version: plan.version.clone(), + asset_name: manifest.asset_name, + sha256, + size, + source: PackageSource::Cache, + independent_proof: Some(independent_proof), + }, + asset_path, + locked_asset: Some(locked_asset), + staging: Some(staging), + }) + } + + /// Performs the only release-metadata network operation in this service. + /// Unsupported policies return without invoking the injected transport. + pub fn check_for_update( + &self, + component_id: ComponentId, + checked_at_unix: u64, + transport: &dyn UpdateTransport, + ) -> Result { + if checked_at_unix == 0 { + return Err(ComponentPackagesError::InvalidTimestamp); + } + let bundled = self.bundled_component(component_id)?; + let UpdateTrustPolicy::GithubReleaseDigest { repository, .. } = + &bundled.update_trust_policy + else { + return Ok(UpdateCheckResult { + component_id, + latest_known_version: bundled.version.clone(), + checked_at_unix, + trust: UpdateCheckTrust::Unsupported, + update_available: false, + }); + }; + + let candidate = fetch_latest_release_candidate(bundled, repository, transport)?; + + let current_version = self + .select_verified(component_id) + .ok() + .and_then(|selected| NumericVersion::parse(&selected.version)) + .ok_or(ComponentPackagesError::InvalidReleaseMetadata)?; + + let (trust, trusted_releases) = match candidate.digest { + DigestField::Trusted(sha256_from_api) => ( + UpdateCheckTrust::Trusted, + vec![TrustedGithubReleaseObservation { + repository: repository.clone(), + release_id: candidate.release_id, + asset_id: candidate.asset_id, + stable_tag: candidate.stable_tag.clone(), + asset_name: candidate.asset_name.clone(), + size: candidate.size, + sha256_from_api, + }], + ), + DigestField::Missing => (UpdateCheckTrust::MissingIndependentDigest, Vec::new()), + DigestField::Malformed => (UpdateCheckTrust::MalformedIndependentDigest, Vec::new()), + }; + let observation = ComponentUpdateObservation { + component_id, + checked_at_unix, + latest_known_version: candidate.version.clone(), + trusted_releases, + }; + persist_update_observation(&self.updates_state_path, observation)?; + + let candidate_version = NumericVersion::parse(&candidate.version) + .ok_or(ComponentPackagesError::InvalidReleaseMetadata)?; + Ok(UpdateCheckResult { + component_id, + latest_known_version: candidate.version, + checked_at_unix, + trust, + update_available: candidate_version > current_version, + }) + } + + pub fn update_status( + &self, + component_id: ComponentId, + now_unix: u64, + installed: InstalledComponentSnapshot<'_>, + ) -> Result { + let bundled = self.bundled_component(component_id)?; + let state = load_component_updates_state(&self.updates_state_path)?; + let selected = self.select_verified(component_id)?; + let observation = state + .observations + .iter() + .find(|observation| observation.component_id == component_id); + let freshness = match observation { + None => UpdateFreshness::NeverChecked, + Some(observation) + if observation.checked_at_unix > now_unix + || now_unix.saturating_sub(observation.checked_at_unix) + >= UPDATE_CHECK_STALE_AFTER_SECS => + { + UpdateFreshness::Stale + } + Some(_) => UpdateFreshness::Fresh, + }; + let can_download = observation + .filter(|observation| { + observation.trusted_releases.iter().any(|trusted| { + trusted.stable_tag == format!("v{}", observation.latest_known_version) + }) + }) + .map(|observation| &observation.latest_known_version) + .and_then(|version| NumericVersion::parse(version)) + .zip(NumericVersion::parse(&selected.version)) + .is_some_and(|(latest, current)| latest > current); + let unsupported = !matches!( + bundled.update_trust_policy, + UpdateTrustPolicy::GithubReleaseDigest { .. } + ); + let offline_version = NumericVersion::parse(&selected.version); + let latest_version = observation + .and_then(|observation| NumericVersion::parse(&observation.latest_known_version)); + let best_available = match (offline_version.as_ref(), latest_version.as_ref()) { + (Some(offline), Some(latest)) => Some(offline.max(latest)), + (Some(offline), None) => Some(offline), + (None, Some(latest)) => Some(latest), + (None, None) => None, + }; + let installed_numeric = installed.version.and_then(NumericVersion::parse); + let update_available = installed_numeric + .as_ref() + .zip(best_available) + .is_some_and(|(installed, available)| available > installed); + let update_state = if unsupported { + ComponentUpdateState::Unsupported + } else if installed_numeric.is_none() { + // Inventory did not prove an installed version. Offline availability is + // exposed separately, but this must not be presented as a current install. + ComponentUpdateState::UnknownOffline + } else { + match freshness { + UpdateFreshness::NeverChecked => ComponentUpdateState::UnknownOffline, + UpdateFreshness::Stale => ComponentUpdateState::CheckStale, + UpdateFreshness::Fresh if update_available => ComponentUpdateState::UpdateAvailable, + UpdateFreshness::Fresh => ComponentUpdateState::Current, + } + }; + + Ok(ComponentUpdateStatus { + component_id, + installed_version: installed.version.map(str::to_owned), + bundled_version: bundled.version.clone(), + available_offline_version: selected.version.clone(), + latest_known_version: observation + .map(|observation| observation.latest_known_version.clone()), + last_checked_at_unix: observation.map(|observation| observation.checked_at_unix), + freshness, + update_state, + install_source: installed.source, + offline_package_source: selected.source, + can_install_offline: true, + offline_unavailable_reason: None, + can_download, + }) + } + + /// Downloads only an independently trusted observation produced by + /// `check_for_update`. Install/select paths never call this method. + pub fn download_checked_update( + &self, + component_id: ComponentId, + transport: &dyn UpdateTransport, + signature_verifier: &dyn PackageSignatureVerifier, + ) -> Result { + let bundled = self.bundled_component(component_id)?; + if !matches!( + bundled.update_trust_policy, + UpdateTrustPolicy::GithubReleaseDigest { .. } + ) { + return Err(ComponentPackagesError::NoTrustedUpdate); + } + let state = load_component_updates_state(&self.updates_state_path)?; + let observation = state + .observations + .iter() + .find(|observation| observation.component_id == component_id) + .ok_or(ComponentPackagesError::NoTrustedUpdate)?; + let expected_tag = format!("v{}", observation.latest_known_version); + let trusted = observation + .trusted_releases + .iter() + .find(|trusted| trusted.stable_tag == expected_tag) + .ok_or(ComponentPackagesError::NoTrustedUpdate)?; + let latest_version = NumericVersion::parse(&observation.latest_known_version) + .ok_or(ComponentPackagesError::NoTrustedUpdate)?; + let current = self.select_verified(component_id)?; + let current_version = NumericVersion::parse(¤t.version) + .ok_or(ComponentPackagesError::NoTrustedUpdate)?; + if latest_version <= current_version { + return Ok(current); + } + + let source_url = format!( + "https://github.com/{}/releases/download/{}/{}", + trusted.repository, trusted.stable_tag, trusted.asset_name + ); + validate_github_update_asset( + bundled, + &observation.latest_known_version, + &trusted.asset_name, + &source_url, + )?; + + let staging = create_staging_directory(&self.packages_root, component_id)?; + let asset_path = staging.path.join(&trusted.asset_name); + let response = fetch_package_asset(transport, &source_url)?; + write_verified_download( + response, + &asset_path, + trusted.size, + &trusted.sha256_from_api, + )?; + let verified_signatures = + verify_downloaded_package(bundled, &asset_path, &staging.path, signature_verifier)?; + let manifest = PackageCacheManifest { + schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + component_id, + version: observation.latest_known_version.clone(), + asset_name: trusted.asset_name.clone(), + sha256: trusted.sha256_from_api.clone(), + size: trusted.size, + independent_proof: GithubReleaseDigestProof { + repository: trusted.repository.clone(), + release_id: trusted.release_id, + asset_id: trusted.asset_id, + stable_tag: trusted.stable_tag.clone(), + asset_name: trusted.asset_name.clone(), + size: trusted.size, + sha256_from_api: trusted.sha256_from_api.clone(), + verified_signatures, + }, + }; + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|_| ComponentPackagesError::InvalidReleaseMetadata)?; + crate::safe_fs::write_new_restricted( + &staging.path.join(PACKAGE_CACHE_MANIFEST_FILENAME), + &manifest_bytes, + ) + .map_err(ComponentPackagesError::StagingIo)?; + + let staged = validate_cache_version( + &staging.path, + &observation.latest_known_version, + bundled, + &self.packages_root, + &state, + ) + .ok_or(ComponentPackagesError::PromotionConflict)?; + let current_state = load_component_updates_state(&self.updates_state_path)?; + if !manifest_matches_trusted_observation(&manifest, ¤t_state) { + return Err(ComponentPackagesError::NoTrustedUpdate); + } + + let final_root = staging.parent.join(&observation.latest_known_version); + promote_staging_directory( + staging, + &final_root, + bundled, + &self.packages_root, + ¤t_state, + ) + .or_else(|error| { + validate_cache_version( + &final_root, + &observation.latest_known_version, + bundled, + &self.packages_root, + ¤t_state, + ) + .ok_or(error) + }) + .inspect(|selection| { + debug_assert_eq!(selection.version, staged.version); + }) + } + + /// Selects verified cache first and always falls back to the immutable bundled package. + /// Cache corruption is deliberately non-fatal for offline installation. + pub fn select_verified( + &self, + component_id: ComponentId, + ) -> Result { + let bundled = self + .catalog + .components + .iter() + .find(|component| component.id == component_id) + .ok_or(ComponentPackagesError::MissingBundledComponent)?; + let bundled_selection = self.bundled_selection(bundled); + let Some(bundled_version) = NumericVersion::parse(&bundled.version) else { + return Ok(bundled_selection); + }; + + Ok(self + .best_verified_cache( + bundled, + &bundled_version, + &read_component_updates_state(&self.updates_state_path).unwrap_or_default(), + ) + .unwrap_or(bundled_selection)) + } + + fn bundled_component( + &self, + component_id: ComponentId, + ) -> Result<&ComponentPackage, ComponentPackagesError> { + self.catalog + .components + .iter() + .find(|component| component.id == component_id) + .ok_or(ComponentPackagesError::MissingBundledComponent) + } + + fn bundled_selection(&self, bundled: &ComponentPackage) -> SelectedPackage { + let asset_path = self + .bundled_root + .join(relative_catalog_path(&bundled.asset_path)); + let package_root = asset_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| self.bundled_root.clone()); + SelectedPackage { + component_id: bundled.id, + version: bundled.version.clone(), + asset_path, + package_root, + source: PackageSource::Bundled, + } + } + + fn open_exact_cached_update( + &self, + plan: &PrivilegedCachedUpdatePlan, + bundled: &ComponentPackage, + ) -> Result<(PackageCacheManifest, File), ComponentPackagesError> { + let component_root = self.packages_root.join(plan.component_id.as_str()); + let version_root = component_root.join(&plan.version); + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + for path in [ + self.packages_root.as_path(), + component_root.as_path(), + version_root.as_path(), + manifest_path.as_path(), + ] { + ensure_no_reparse_ancestors(path).map_err(ComponentPackagesError::StagingIo)?; + verify_path_protected_for_owner_admin_system(path) + .map_err(|_| ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + } + + let manifest = read_cache_manifest(&manifest_path) + .ok_or(ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + if manifest.component_id != plan.component_id + || manifest.version != plan.version + || manifest.independent_proof != plan.independent_proof + || !validate_manifest_identity(&manifest, &plan.version, bundled) + { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + + let expected_files = HashSet::from([ + PACKAGE_CACHE_MANIFEST_FILENAME.to_string(), + manifest.asset_name.clone(), + ]); + let actual_files = fs::read_dir(&version_root) + .map_err(ComponentPackagesError::StagingIo)? + .map(|entry| { + let entry = entry.map_err(ComponentPackagesError::StagingIo)?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(ComponentPackagesError::StagingIo)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + entry + .file_name() + .into_string() + .map_err(|_| ComponentPackagesError::InvalidPrivilegedUpdatePlan) + }) + .collect::, _>>()?; + if actual_files != expected_files { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + + let asset_path = version_root.join(&manifest.asset_name); + verify_path_protected_for_owner_admin_system(&asset_path) + .map_err(|_| ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + let mut source = + open_locked_regular_file(&asset_path).map_err(ComponentPackagesError::StagingIo)?; + let (size, sha256) = + hash_locked_file(&mut source).map_err(ComponentPackagesError::StagingIo)?; + if size != manifest.size || sha256 != manifest.sha256 { + return Err(ComponentPackagesError::DigestMismatch); + } + Ok((manifest, source)) + } + + fn best_verified_cache( + &self, + bundled: &ComponentPackage, + bundled_version: &NumericVersion, + updates_state: &ComponentUpdatesState, + ) -> Option { + if ensure_no_reparse_ancestors(&self.packages_root).is_err() { + return None; + } + let root_metadata = match fs::symlink_metadata(&self.packages_root) { + Ok(metadata) if metadata.is_dir() => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + _ => return None, + }; + if root_metadata.file_type().is_symlink() { + return None; + } + if verify_path_protected_for_owner_admin_system(&self.packages_root).is_err() { + return None; + } + + let component_root = self.packages_root.join(bundled.id.as_str()); + if ensure_no_reparse_ancestors(&component_root).is_err() { + return None; + } + let component_metadata = match fs::symlink_metadata(&component_root) { + Ok(metadata) if metadata.is_dir() => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return None, + _ => return None, + }; + if component_metadata.file_type().is_symlink() { + return None; + } + if verify_path_protected_for_owner_admin_system(&component_root).is_err() { + return None; + } + + let entries = fs::read_dir(&component_root).ok()?; + let mut best: Option<(NumericVersion, SelectedPackage)> = None; + for entry in entries.flatten() { + let version_name = match entry.file_name().into_string() { + Ok(name) => name, + Err(_) => continue, + }; + if version_name.starts_with(".staging-") { + continue; + } + let Some(version) = NumericVersion::parse(&version_name) else { + continue; + }; + if version <= *bundled_version + || best + .as_ref() + .is_some_and(|(best_version, _)| version <= *best_version) + { + continue; + } + + let version_root = entry.path(); + let Some(selection) = validate_cache_version( + &version_root, + &version_name, + bundled, + &self.packages_root, + updates_state, + ) else { + continue; + }; + best = Some((version, selection)); + } + + best.map(|(_, selection)| selection) + } +} + +#[derive(Debug, Deserialize)] +struct GithubLatestRelease { + id: u64, + tag_name: String, + draft: bool, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubReleaseAsset { + id: u64, + name: String, + size: u64, + #[serde(default)] + digest: Option, + browser_download_url: String, +} + +struct TrustedReleaseCandidate { + version: String, + release_id: u64, + asset_id: u64, + stable_tag: String, + asset_name: String, + size: u64, + digest: DigestField, +} + +enum DigestField { + Trusted(String), + Missing, + Malformed, +} + +fn fetch_latest_release_candidate( + bundled: &ComponentPackage, + repository: &str, + transport: &dyn UpdateTransport, +) -> Result { + let request = UpdateTransportRequest { + url: format!("https://api.github.com/repos/{repository}/releases/latest"), + kind: UpdateRequestKind::GithubReleaseMetadata, + }; + let response = transport + .get(&request) + .map_err(|_| ComponentPackagesError::Transport)?; + if is_redirect(response.status) { + return Err(ComponentPackagesError::InvalidRedirect); + } + if response.status != 200 + || response + .content_length + .is_some_and(|length| length > MAX_GITHUB_RELEASE_RESPONSE_BYTES) + { + return Err(ComponentPackagesError::InvalidResponse); + } + let body = read_bounded(response.body, MAX_GITHUB_RELEASE_RESPONSE_BYTES) + .map_err(|_| ComponentPackagesError::InvalidResponse)?; + let release: GithubLatestRelease = serde_json::from_slice(&body) + .map_err(|_| ComponentPackagesError::InvalidReleaseMetadata)?; + trusted_release_candidate(bundled, repository, release) +} + +fn trusted_release_candidate( + bundled: &ComponentPackage, + repository: &str, + release: GithubLatestRelease, +) -> Result { + let UpdateTrustPolicy::GithubReleaseDigest { asset_pattern, .. } = &bundled.update_trust_policy + else { + return Err(ComponentPackagesError::InvalidReleaseMetadata); + }; + if release.id == 0 + || release.draft + || release.prerelease + || release.assets.len() > 100 + || !release.tag_name.starts_with('v') + { + return Err(ComponentPackagesError::InvalidReleaseMetadata); + } + let version = release.tag_name[1..].to_string(); + if NumericVersion::parse(&version).is_none() { + return Err(ComponentPackagesError::InvalidReleaseMetadata); + } + + let mut matching = release.assets.into_iter().filter(|asset| { + asset.id > 0 + && asset.size > 0 + && asset.size <= MAX_COMPONENT_PACKAGE_BYTES + && asset_matches_version(bundled.id, asset_pattern, &asset.name, &version) + && validate_github_update_asset( + bundled, + &version, + &asset.name, + &asset.browser_download_url, + ) + .is_ok() + }); + let Some(asset) = matching.next() else { + return Err(ComponentPackagesError::MissingOrAmbiguousAsset); + }; + if matching.next().is_some() { + return Err(ComponentPackagesError::MissingOrAmbiguousAsset); + } + + let expected_prefix = format!("https://github.com/{repository}/releases/download/"); + if !asset.browser_download_url.starts_with(&expected_prefix) { + return Err(ComponentPackagesError::InvalidReleaseMetadata); + } + let digest = match asset.digest { + None => DigestField::Missing, + Some(value) => match value.strip_prefix("sha256:") { + Some(sha256) if valid_sha256(sha256) => DigestField::Trusted(sha256.to_string()), + _ => DigestField::Malformed, + }, + }; + + Ok(TrustedReleaseCandidate { + version, + release_id: release.id, + asset_id: asset.id, + stable_tag: release.tag_name, + asset_name: asset.name, + size: asset.size, + digest, + }) +} + +fn validate_privileged_update_plan( + plan: &PrivilegedCachedUpdatePlan, + bundled: &ComponentPackage, + repository: &str, +) -> Result<(), ComponentPackagesError> { + let proof = &plan.independent_proof; + let planned_version = NumericVersion::parse(&plan.version) + .ok_or(ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + let bundled_version = NumericVersion::parse(&bundled.version) + .ok_or(ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + if plan.component_id != bundled.id + || planned_version <= bundled_version + || proof.repository != repository + || proof.release_id == 0 + || proof.asset_id == 0 + || proof.stable_tag != format!("v{}", plan.version) + || !safe_asset_name(&proof.asset_name) + || proof.size == 0 + || proof.size > MAX_COMPONENT_PACKAGE_BYTES + || !valid_sha256(&proof.sha256_from_api) + { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + let source_url = format!( + "https://github.com/{}/releases/download/{}/{}", + proof.repository, proof.stable_tag, proof.asset_name + ); + validate_github_update_asset(bundled, &plan.version, &proof.asset_name, &source_url) + .map_err(|_| ComponentPackagesError::InvalidPrivilegedUpdatePlan)?; + let UpdateTrustPolicy::GithubReleaseDigest { + authenticode_publishers, + .. + } = &bundled.update_trust_policy + else { + return Err(ComponentPackagesError::NoTrustedUpdate); + }; + if !validate_signature_evidence( + plan.component_id, + &proof.asset_name, + authenticode_publishers.as_deref(), + &proof.verified_signatures, + ) { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + Ok(()) +} + +fn require_exact_live_release( + plan: &PrivilegedCachedUpdatePlan, + live: &TrustedReleaseCandidate, +) -> Result<(), ComponentPackagesError> { + let DigestField::Trusted(live_digest) = &live.digest else { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + }; + let proof = &plan.independent_proof; + if live.version != plan.version + || live.release_id != proof.release_id + || live.asset_id != proof.asset_id + || live.stable_tag != proof.stable_tag + || live.asset_name != proof.asset_name + || live.size != proof.size + || live_digest != &proof.sha256_from_api + { + return Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan); + } + Ok(()) +} + +fn read_bounded(mut reader: Box, limit: u64) -> io::Result> { + let mut bytes = Vec::new(); + reader.by_ref().take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "response exceeds the configured limit", + )); + } + Ok(bytes) +} + +fn is_redirect(status: u16) -> bool { + matches!(status, 301 | 302 | 303 | 307 | 308) +} + +trait PrivilegedStagingPolicy { + fn protect(&self, path: &Path) -> io::Result<()>; + fn verify(&self, path: &Path) -> io::Result<()>; +} + +struct NativePrivilegedStagingPolicy; + +impl PrivilegedStagingPolicy for NativePrivilegedStagingPolicy { + fn protect(&self, path: &Path) -> io::Result<()> { + protect_path_admin_owned_user_read_only(path) + } + + fn verify(&self, path: &Path) -> io::Result<()> { + verify_path_admin_owned_user_read_only(path) + } +} + +#[cfg(debug_assertions)] +struct OwnerProtectedTestStagingPolicy; + +#[cfg(debug_assertions)] +impl PrivilegedStagingPolicy for OwnerProtectedTestStagingPolicy { + fn protect(&self, path: &Path) -> io::Result<()> { + protect_path_for_owner_admin_system(path) + } + + fn verify(&self, path: &Path) -> io::Result<()> { + verify_path_protected_for_owner_admin_system(path) + } +} + +struct PrivilegedStagingDirectory { + path: PathBuf, + parent: PathBuf, + active: bool, +} + +impl PrivilegedStagingDirectory { + fn create( + parent: &Path, + component_id: ComponentId, + policy: &dyn PrivilegedStagingPolicy, + ) -> Result { + ensure_no_reparse_ancestors(parent).map_err(ComponentPackagesError::StagingIo)?; + let metadata = fs::symlink_metadata(parent).map_err(ComponentPackagesError::StagingIo)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(ComponentPackagesError::UntrustedPrivilegedStaging); + } + policy + .verify(parent) + .map_err(|_| ComponentPackagesError::UntrustedPrivilegedStaging)?; + + let path = parent.join(format!( + ".package-{}-{}", + component_id.as_str(), + Uuid::new_v4().hyphenated() + )); + fs::create_dir(&path).map_err(ComponentPackagesError::StagingIo)?; + if let Err(error) = policy.protect(&path).and_then(|()| policy.verify(&path)) { + let _ = fs::remove_dir(&path); + return Err(ComponentPackagesError::StagingIo(error)); + } + Ok(Self { + path, + parent: parent.to_path_buf(), + active: true, + }) + } +} + +impl Drop for PrivilegedStagingDirectory { + fn drop(&mut self) { + let valid_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".package-") && name.len() < 128); + if self.active + && self.path.parent() == Some(self.parent.as_path()) + && valid_name + && ensure_no_reparse_ancestors(&self.path).is_ok() + { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn open_locked_regular_file(path: &Path) -> io::Result { + ensure_no_reparse_ancestors(path)?; + let metadata = fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "component package is not a regular file", + )); + } + + #[cfg(windows)] + let file = { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + }; + + OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)? + }; + #[cfg(not(windows))] + let file = File::open(path)?; + + if !file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "component package handle is not a regular file", + )); + } + ensure_no_reparse_ancestors(path)?; + Ok(file) +} + +fn hash_locked_file(file: &mut File) -> io::Result<(u64, String)> { + file.seek(SeekFrom::Start(0))?; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + size = size.checked_add(count as u64).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "component package is too large") + })?; + if size > MAX_COMPONENT_PACKAGE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "component package is too large", + )); + } + hasher.update(&buffer[..count]); + } + file.seek(SeekFrom::Start(0))?; + Ok((size, format!("{:x}", hasher.finalize()))) +} + +fn copy_locked_to_admin_staging( + source: &mut File, + destination: &Path, + expected_size: u64, + expected_sha256: &str, + policy: &dyn PrivilegedStagingPolicy, +) -> Result<(u64, String), ComponentPackagesError> { + ensure_no_reparse_ancestors(destination).map_err(ComponentPackagesError::StagingIo)?; + source + .seek(SeekFrom::Start(0)) + .map_err(ComponentPackagesError::StagingIo)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(ComponentPackagesError::StagingIo)?; + if let Err(error) = policy + .protect(destination) + .and_then(|()| policy.verify(destination)) + { + drop(output); + let _ = fs::remove_file(destination); + return Err(ComponentPackagesError::StagingIo(error)); + } + + let result = (|| { + let mut hasher = Sha256::new(); + let mut written = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = source + .read(&mut buffer) + .map_err(ComponentPackagesError::StagingIo)?; + if count == 0 { + break; + } + written = written + .checked_add(count as u64) + .ok_or(ComponentPackagesError::SizeMismatch)?; + if written > expected_size || written > MAX_COMPONENT_PACKAGE_BYTES { + return Err(ComponentPackagesError::SizeMismatch); + } + output + .write_all(&buffer[..count]) + .map_err(ComponentPackagesError::StagingIo)?; + hasher.update(&buffer[..count]); + } + output + .sync_all() + .map_err(ComponentPackagesError::StagingIo)?; + let sha256 = format!("{:x}", hasher.finalize()); + if written != expected_size { + return Err(ComponentPackagesError::SizeMismatch); + } + if sha256 != expected_sha256 { + return Err(ComponentPackagesError::DigestMismatch); + } + policy + .verify(destination) + .map_err(|_| ComponentPackagesError::UntrustedPrivilegedStaging)?; + Ok((written, sha256)) + })(); + drop(output); + if result.is_err() { + let _ = fs::remove_file(destination); + } + result +} + +struct StagingDirectory { + path: PathBuf, + parent: PathBuf, + active: bool, +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + if !self.active + || self.path.parent() != Some(self.parent.as_path()) + || !self + .path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with(".staging-")) + || ensure_no_reparse_ancestors(&self.path).is_err() + { + return; + } + let _ = fs::remove_dir_all(&self.path); + } +} + +fn create_staging_directory( + packages_root: &Path, + component_id: ComponentId, +) -> Result { + ensure_no_reparse_ancestors(packages_root).map_err(ComponentPackagesError::StagingIo)?; + fs::create_dir_all(packages_root).map_err(ComponentPackagesError::StagingIo)?; + protect_path_for_owner_admin_system(packages_root) + .map_err(ComponentPackagesError::StagingIo)?; + let parent = packages_root.join(component_id.as_str()); + ensure_no_reparse_ancestors(&parent).map_err(ComponentPackagesError::StagingIo)?; + fs::create_dir_all(&parent).map_err(ComponentPackagesError::StagingIo)?; + protect_path_for_owner_admin_system(&parent).map_err(ComponentPackagesError::StagingIo)?; + + let path = parent.join(format!(".staging-{}", Uuid::new_v4().hyphenated())); + fs::create_dir(&path).map_err(ComponentPackagesError::StagingIo)?; + if let Err(error) = protect_path_for_owner_admin_system(&path) { + let _ = fs::remove_dir(&path); + return Err(ComponentPackagesError::StagingIo(error)); + } + Ok(StagingDirectory { + path, + parent, + active: true, + }) +} + +fn fetch_package_asset( + transport: &dyn UpdateTransport, + source_url: &str, +) -> Result { + let mut current_url = source_url.to_string(); + for redirect_count in 0..=MAX_REDIRECTS { + let response = transport + .get(&UpdateTransportRequest { + url: current_url.clone(), + kind: UpdateRequestKind::PackageAsset, + }) + .map_err(|_| ComponentPackagesError::Transport)?; + if response.status == 200 { + return Ok(response); + } + if is_redirect(response.status) && redirect_count == MAX_REDIRECTS { + return Err(ComponentPackagesError::InvalidRedirect); + } + if !is_redirect(response.status) { + return Err(ComponentPackagesError::InvalidResponse); + } + let location = response + .location + .as_deref() + .ok_or(ComponentPackagesError::InvalidRedirect)?; + validate_asset_redirect(source_url, ¤t_url, location)?; + current_url = location.to_string(); + } + Err(ComponentPackagesError::InvalidRedirect) +} + +fn validate_asset_redirect( + original_url: &str, + current_url: &str, + location: &str, +) -> Result<(), ComponentPackagesError> { + if current_url != original_url { + return Err(ComponentPackagesError::InvalidRedirect); + } + let source = Url::parse(original_url).map_err(|_| ComponentPackagesError::InvalidRedirect)?; + let target = Url::parse(location).map_err(|_| ComponentPackagesError::InvalidRedirect)?; + if source.scheme() != "https" + || source.host_str() != Some("github.com") + || target.scheme() != "https" + || target.host_str() != Some("release-assets.githubusercontent.com") + || !target.username().is_empty() + || target.password().is_some() + || target.port().is_some() + || target.fragment().is_some() + || target.path().len() > 2048 + || target.path() == "/" + || target + .query() + .is_none_or(|query| query.is_empty() || query.len() > 4096) + { + return Err(ComponentPackagesError::InvalidRedirect); + } + Ok(()) +} + +fn write_verified_download( + mut response: UpdateTransportResponse, + path: &Path, + expected_size: u64, + expected_sha256: &str, +) -> Result<(), ComponentPackagesError> { + if expected_size == 0 + || expected_size > MAX_COMPONENT_PACKAGE_BYTES + || !valid_sha256(expected_sha256) + || response + .content_length + .is_some_and(|length| length != expected_size) + { + return Err(ComponentPackagesError::SizeMismatch); + } + ensure_no_reparse_ancestors(path).map_err(ComponentPackagesError::StagingIo)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(ComponentPackagesError::StagingIo)?; + if let Err(error) = protect_path_for_owner_admin_system(path) { + drop(output); + let _ = fs::remove_file(path); + return Err(ComponentPackagesError::StagingIo(error)); + } + + let result = (|| { + let mut hasher = Sha256::new(); + let mut written = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = response + .body + .read(&mut buffer) + .map_err(ComponentPackagesError::StagingIo)?; + if count == 0 { + break; + } + written = written + .checked_add(count as u64) + .ok_or(ComponentPackagesError::SizeMismatch)?; + if written > expected_size { + return Err(ComponentPackagesError::SizeMismatch); + } + output + .write_all(&buffer[..count]) + .map_err(ComponentPackagesError::StagingIo)?; + hasher.update(&buffer[..count]); + } + output + .sync_all() + .map_err(ComponentPackagesError::StagingIo)?; + if written != expected_size { + return Err(ComponentPackagesError::SizeMismatch); + } + if format!("{:x}", hasher.finalize()) != expected_sha256 { + return Err(ComponentPackagesError::DigestMismatch); + } + Ok(()) + })(); + drop(output); + if result.is_err() { + let _ = fs::remove_file(path); + } + result +} + +fn verify_downloaded_package( + bundled: &ComponentPackage, + asset_path: &Path, + staging_root: &Path, + signature_verifier: &dyn PackageSignatureVerifier, +) -> Result, ComponentPackagesError> { + let UpdateTrustPolicy::GithubReleaseDigest { + authenticode_publishers, + .. + } = &bundled.update_trust_policy + else { + return Err(ComponentPackagesError::NoTrustedUpdate); + }; + match bundled.id { + ComponentId::Proxifyre => { + let publisher = exact_single_publisher(authenticode_publishers.as_deref())?; + verify_proxifyre_archive(asset_path, staging_root, publisher, signature_verifier) + } + ComponentId::WindowsPacketFilter => { + let publisher = exact_single_publisher(authenticode_publishers.as_deref())?; + let identity = verify_exact_publisher(asset_path, publisher, signature_verifier)?; + Ok(vec![VerifiedSignatureEvidence { + target_name: asset_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(ComponentPackagesError::SignatureVerification)? + .to_string(), + common_name: identity.common_name, + organization: identity.organization, + }]) + } + ComponentId::SingBox if authenticode_publishers.is_none() => Ok(Vec::new()), + ComponentId::VcRuntime | ComponentId::Winsw | ComponentId::SingBox => { + Err(ComponentPackagesError::NoTrustedUpdate) + } + } +} + +fn verify_privileged_cached_package( + bundled: &ComponentPackage, + asset_path: &Path, + staging_root: &Path, + signature_verifier: &dyn PackageSignatureVerifier, + staging_policy: &dyn PrivilegedStagingPolicy, +) -> Result, ComponentPackagesError> { + let UpdateTrustPolicy::GithubReleaseDigest { + authenticode_publishers, + .. + } = &bundled.update_trust_policy + else { + return Err(ComponentPackagesError::NoTrustedUpdate); + }; + match bundled.id { + ComponentId::Proxifyre => { + let publisher = exact_single_publisher(authenticode_publishers.as_deref())?; + verify_proxifyre_archive_with_policy( + asset_path, + staging_root, + publisher, + signature_verifier, + Some(staging_policy), + ) + } + ComponentId::WindowsPacketFilter => { + let publisher = exact_single_publisher(authenticode_publishers.as_deref())?; + let identity = verify_exact_publisher(asset_path, publisher, signature_verifier)?; + Ok(vec![VerifiedSignatureEvidence { + target_name: asset_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or(ComponentPackagesError::SignatureVerification)? + .to_string(), + common_name: identity.common_name, + organization: identity.organization, + }]) + } + ComponentId::SingBox if authenticode_publishers.is_none() => Ok(Vec::new()), + ComponentId::VcRuntime | ComponentId::Winsw | ComponentId::SingBox => { + Err(ComponentPackagesError::NoTrustedUpdate) + } + } +} + +fn exact_single_publisher(publishers: Option<&[String]>) -> Result<&str, ComponentPackagesError> { + let [publisher] = publishers.unwrap_or_default() else { + return Err(ComponentPackagesError::PublisherMismatch); + }; + Ok(publisher) +} + +fn verify_exact_publisher( + path: &Path, + expected: &str, + verifier: &dyn PackageSignatureVerifier, +) -> Result { + let publisher = verifier + .verify(path) + .map_err(|_| ComponentPackagesError::SignatureVerification)?; + if publisher.common_name != expected || publisher.organization != expected { + return Err(ComponentPackagesError::PublisherMismatch); + } + Ok(publisher) +} + +fn verify_proxifyre_archive( + asset_path: &Path, + staging_root: &Path, + expected_publisher: &str, + verifier: &dyn PackageSignatureVerifier, +) -> Result, ComponentPackagesError> { + verify_proxifyre_archive_with_policy( + asset_path, + staging_root, + expected_publisher, + verifier, + None, + ) +} + +fn verify_proxifyre_archive_with_policy( + asset_path: &Path, + staging_root: &Path, + expected_publisher: &str, + verifier: &dyn PackageSignatureVerifier, + staging_policy: Option<&dyn PrivilegedStagingPolicy>, +) -> Result, ComponentPackagesError> { + const TARGETS: [&str; 2] = ["ProxiFyre.exe", "socksify.dll"]; + const MAX_ARCHIVE_ENTRIES: usize = 512; + const MAX_TARGET_BYTES: u64 = 64 * 1024 * 1024; + + let file = File::open(asset_path).map_err(ComponentPackagesError::StagingIo)?; + let mut archive = + zip::ZipArchive::new(file).map_err(|_| ComponentPackagesError::InvalidArchive)?; + if archive.len() > MAX_ARCHIVE_ENTRIES { + return Err(ComponentPackagesError::InvalidArchive); + } + let mut target_indices = HashMap::new(); + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|_| ComponentPackagesError::InvalidArchive)?; + let name = entry.name(); + for target in TARGETS { + let first_component = name.split(['/', '\\']).next().unwrap_or_default(); + let target_basename = name.rsplit(['/', '\\']).next().unwrap_or_default(); + if first_component.eq_ignore_ascii_case(target) + || target_basename.eq_ignore_ascii_case(target) + { + if name != target { + return Err(ComponentPackagesError::InvalidArchive); + } + if entry.is_dir() + || entry.size() == 0 + || entry.size() > MAX_TARGET_BYTES + || target_indices.insert(target, index).is_some() + { + return Err(ComponentPackagesError::InvalidArchive); + } + } + } + } + if target_indices.len() != TARGETS.len() { + return Err(ComponentPackagesError::InvalidArchive); + } + + let mut evidence = Vec::with_capacity(TARGETS.len()); + for target in TARGETS { + let index = *target_indices + .get(target) + .ok_or(ComponentPackagesError::InvalidArchive)?; + let mut entry = archive + .by_index(index) + .map_err(|_| ComponentPackagesError::InvalidArchive)?; + let verify_path = + staging_root.join(format!(".verify-{}-{target}", Uuid::new_v4().hyphenated())); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(&verify_path) + .map_err(ComponentPackagesError::StagingIo)?; + match staging_policy { + Some(policy) => policy + .protect(&verify_path) + .and_then(|()| policy.verify(&verify_path)) + .map_err(ComponentPackagesError::StagingIo)?, + None => protect_path_for_owner_admin_system(&verify_path) + .map_err(ComponentPackagesError::StagingIo)?, + } + let copied = io::copy(&mut entry.by_ref().take(MAX_TARGET_BYTES + 1), &mut output) + .map_err(ComponentPackagesError::StagingIo)?; + output + .sync_all() + .map_err(ComponentPackagesError::StagingIo)?; + drop(output); + if copied != entry.size() || copied > MAX_TARGET_BYTES { + return Err(ComponentPackagesError::InvalidArchive); + } + if let Some(policy) = staging_policy { + policy + .verify(&verify_path) + .map_err(|_| ComponentPackagesError::UntrustedPrivilegedStaging)?; + } + let identity = verify_exact_publisher(&verify_path, expected_publisher, verifier)?; + fs::remove_file(&verify_path).map_err(ComponentPackagesError::StagingIo)?; + evidence.push(VerifiedSignatureEvidence { + target_name: target.to_string(), + common_name: identity.common_name, + organization: identity.organization, + }); + } + Ok(evidence) +} + +fn promote_staging_directory( + mut staging: StagingDirectory, + final_root: &Path, + bundled: &ComponentPackage, + packages_root: &Path, + state: &ComponentUpdatesState, +) -> Result { + ensure_no_reparse_ancestors(final_root).map_err(ComponentPackagesError::StagingIo)?; + match fs::rename(&staging.path, final_root) { + Ok(()) => staging.active = false, + Err(_error) if final_root.try_exists().unwrap_or(false) => { + return validate_cache_version( + final_root, + final_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(), + bundled, + packages_root, + state, + ) + .ok_or(ComponentPackagesError::PromotionConflict); + } + Err(error) => return Err(ComponentPackagesError::StagingIo(error)), + } + validate_cache_version( + final_root, + final_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(), + bundled, + packages_root, + state, + ) + .ok_or(ComponentPackagesError::PromotionConflict) +} + +fn read_cache_manifest(path: &Path) -> Option { + ensure_no_reparse_ancestors(path).ok()?; + let metadata = fs::symlink_metadata(path).ok()?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() == 0 + || metadata.len() > MAX_CACHE_MANIFEST_BYTES + { + return None; + } + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn validate_cache_version( + version_root: &Path, + version_name: &str, + bundled: &ComponentPackage, + packages_root: &Path, + updates_state: &ComponentUpdatesState, +) -> Option { + if ensure_no_reparse_ancestors(version_root).is_err() { + return None; + } + let metadata = fs::symlink_metadata(version_root).ok()?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return None; + } + if verify_path_protected_for_owner_admin_system(version_root).is_err() { + return None; + } + + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + if ensure_no_reparse_ancestors(&manifest_path).is_err() { + return None; + } + if verify_path_protected_for_owner_admin_system(&manifest_path).is_err() { + return None; + } + let manifest = read_cache_manifest(&manifest_path)?; + if !validate_manifest_identity(&manifest, version_name, bundled) { + return None; + } + if !manifest_matches_trusted_observation(&manifest, updates_state) { + return None; + } + + let asset_path = version_root.join(&manifest.asset_name); + if ensure_no_reparse_ancestors(&asset_path).is_err() { + return None; + } + let asset_metadata = fs::symlink_metadata(&asset_path).ok()?; + if !asset_metadata.is_file() + || asset_metadata.file_type().is_symlink() + || asset_metadata.len() != manifest.size + || manifest.size == 0 + || manifest.size > MAX_COMPONENT_PACKAGE_BYTES + { + return None; + } + if verify_path_protected_for_owner_admin_system(&asset_path).is_err() { + return None; + } + + let expected_files = HashSet::from([ + PACKAGE_CACHE_MANIFEST_FILENAME.to_string(), + manifest.asset_name.clone(), + ]); + let mut actual_files = HashSet::new(); + for entry_result in fs::read_dir(version_root).ok()? { + let entry = entry_result.ok()?; + let name = entry.file_name().into_string().ok()?; + let entry_metadata = fs::symlink_metadata(entry.path()).ok()?; + if !entry_metadata.is_file() || entry_metadata.file_type().is_symlink() { + return None; + } + actual_files.insert(name); + } + if actual_files != expected_files { + return None; + } + + if sha256_file(&asset_path).ok()?.as_str() != manifest.sha256 { + return None; + } + if !asset_path.starts_with(packages_root) { + return None; + } + + Some(SelectedPackage { + component_id: bundled.id, + version: manifest.version, + asset_path, + package_root: version_root.to_path_buf(), + source: PackageSource::Cache, + }) +} + +fn validate_manifest_identity( + manifest: &PackageCacheManifest, + version_name: &str, + bundled: &ComponentPackage, +) -> bool { + if manifest.schema_version != PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION + || manifest.component_id != bundled.id + || manifest.version != version_name + || NumericVersion::parse(&manifest.version).is_none() + || !safe_asset_name(&manifest.asset_name) + || !valid_sha256(&manifest.sha256) + || manifest.size == 0 + || manifest.size > MAX_COMPONENT_PACKAGE_BYTES + { + return false; + } + + let proof = &manifest.independent_proof; + if proof.release_id == 0 + || proof.asset_id == 0 + || proof.asset_name != manifest.asset_name + || proof.size != manifest.size + || proof.sha256_from_api != manifest.sha256 + || !valid_sha256(&proof.sha256_from_api) + || proof.stable_tag != format!("v{}", manifest.version) + { + return false; + } + + match &bundled.update_trust_policy { + UpdateTrustPolicy::GithubReleaseDigest { + repository, + asset_pattern, + require_stable, + authenticode_publishers, + .. + } => { + *require_stable + && proof.repository == *repository + && asset_matches_version( + bundled.id, + asset_pattern, + &manifest.asset_name, + &manifest.version, + ) + && validate_signature_evidence( + bundled.id, + &manifest.asset_name, + authenticode_publishers.as_deref(), + &proof.verified_signatures, + ) + } + UpdateTrustPolicy::BuildTimeOnlyAuthenticode { .. } + | UpdateTrustPolicy::BundledOnlyNoIndependentProof { .. } => false, + } +} + +fn manifest_matches_trusted_observation( + manifest: &PackageCacheManifest, + state: &ComponentUpdatesState, +) -> bool { + if !valid_component_updates_state(state) { + return false; + } + let mut matches = state + .observations + .iter() + .filter(|observation| observation.component_id == manifest.component_id); + let Some(observation) = matches.next() else { + return false; + }; + if matches.next().is_some() { + return false; + } + let proof = &manifest.independent_proof; + observation.trusted_releases.iter().any(|trusted| { + trusted.repository == proof.repository + && trusted.release_id == proof.release_id + && trusted.asset_id == proof.asset_id + && trusted.stable_tag == proof.stable_tag + && trusted.stable_tag == format!("v{}", manifest.version) + && trusted.asset_name == proof.asset_name + && trusted.size == proof.size + && trusted.sha256_from_api == proof.sha256_from_api + }) +} + +fn validate_signature_evidence( + component_id: ComponentId, + asset_name: &str, + expected_publishers: Option<&[String]>, + evidence: &[VerifiedSignatureEvidence], +) -> bool { + let Some(expected_publishers) = expected_publishers else { + return evidence.is_empty(); + }; + if expected_publishers.len() != 1 { + return false; + } + let publisher = &expected_publishers[0]; + let expected_targets: HashSet<&str> = match component_id { + ComponentId::Proxifyre => HashSet::from(["ProxiFyre.exe", "socksify.dll"]), + ComponentId::WindowsPacketFilter => HashSet::from([asset_name]), + ComponentId::VcRuntime | ComponentId::SingBox | ComponentId::Winsw => return false, + }; + let mut actual_targets = HashSet::new(); + for signature in evidence { + if signature.common_name != *publisher + || signature.organization != *publisher + || !actual_targets.insert(signature.target_name.as_str()) + { + return false; + } + } + actual_targets == expected_targets +} + +fn read_component_updates_state(path: &Path) -> Option { + load_component_updates_state(path).ok() +} + +fn load_component_updates_state( + path: &Path, +) -> Result { + match read_component_updates_state_file(path) { + Ok(state) => Ok(state), + Err(StateReadError::NotFound) => { + let backup = backup_path(path); + match read_component_updates_state_file(&backup) { + Ok(state) => { + restore_component_updates_state(path, &state)?; + Ok(state) + } + Err(StateReadError::NotFound) => Ok(ComponentUpdatesState::default()), + Err(StateReadError::Invalid | StateReadError::Io) => { + Err(ComponentPackagesError::InvalidUpdateState) + } + } + } + Err(StateReadError::Invalid | StateReadError::Io) => { + let backup = backup_path(path); + let state = read_component_updates_state_file(&backup) + .map_err(|_| ComponentPackagesError::InvalidUpdateState)?; + restore_component_updates_state(path, &state)?; + Ok(state) + } + } +} + +#[derive(Debug)] +enum StateReadError { + NotFound, + Invalid, + Io, +} + +fn read_component_updates_state_file(path: &Path) -> Result { + ensure_no_reparse_ancestors(path).map_err(|_| StateReadError::Invalid)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(StateReadError::NotFound) + } + Err(_) => return Err(StateReadError::Io), + }; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() == 0 + || metadata.len() > MAX_COMPONENT_UPDATES_STATE_BYTES + { + return Err(StateReadError::Invalid); + } + let parent = path.parent().ok_or(StateReadError::Invalid)?; + verify_path_protected_for_owner_admin_system(parent).map_err(|_| StateReadError::Invalid)?; + verify_path_protected_for_owner_admin_system(path).map_err(|_| StateReadError::Invalid)?; + let bytes = fs::read(path).map_err(|_| StateReadError::Io)?; + let state: ComponentUpdatesState = + serde_json::from_slice(&bytes).map_err(|_| StateReadError::Invalid)?; + valid_component_updates_state(&state) + .then_some(state) + .ok_or(StateReadError::Invalid) +} + +fn restore_component_updates_state( + path: &Path, + state: &ComponentUpdatesState, +) -> Result<(), ComponentPackagesError> { + let bytes = + serde_json::to_vec_pretty(state).map_err(|_| ComponentPackagesError::InvalidUpdateState)?; + prepare_restricted_parent(path)?; + write_restricted_atomic(path, &bytes).map_err(ComponentPackagesError::UpdateStateIo) +} + +fn persist_update_observation( + path: &Path, + mut observation: ComponentUpdateObservation, +) -> Result<(), ComponentPackagesError> { + let lock = UPDATE_STATE_LOCK.get_or_init(|| Mutex::new(())); + let _guard = lock + .lock() + .map_err(|_| ComponentPackagesError::InvalidUpdateState)?; + let _file_lock = acquire_component_updates_lock(path)?; + let mut state = load_component_updates_state(path)?; + if let Some(index) = state + .observations + .iter() + .position(|existing| existing.component_id == observation.component_id) + { + let mut previous = state.observations.remove(index); + let incoming_is_older = observation.checked_at_unix < previous.checked_at_unix; + let mut history = std::mem::take(&mut previous.trusted_releases); + for trusted in observation.trusted_releases.drain(..) { + if let Some(existing) = history + .iter() + .find(|existing| existing.stable_tag == trusted.stable_tag) + { + if existing != &trusted { + return Err(ComponentPackagesError::InvalidUpdateState); + } + continue; + } + if incoming_is_older { + if history.len() < MAX_TRUSTED_RELEASES_PER_COMPONENT { + history.insert(0, trusted); + } + } else { + history.push(trusted); + } + } + if history.len() > MAX_TRUSTED_RELEASES_PER_COMPONENT { + history.drain(..history.len() - MAX_TRUSTED_RELEASES_PER_COMPONENT); + } + match observation.checked_at_unix.cmp(&previous.checked_at_unix) { + Ordering::Less => { + observation = previous; + observation.trusted_releases = history; + } + Ordering::Equal + if observation.latest_known_version != previous.latest_known_version => + { + return Err(ComponentPackagesError::InvalidUpdateState); + } + Ordering::Equal | Ordering::Greater => observation.trusted_releases = history, + } + } + state.observations.push(observation); + state + .observations + .sort_by_key(|observation| observation.component_id.as_str()); + if !valid_component_updates_state(&state) { + return Err(ComponentPackagesError::InvalidUpdateState); + } + let bytes = serde_json::to_vec_pretty(&state) + .map_err(|_| ComponentPackagesError::InvalidUpdateState)?; + prepare_restricted_parent(path)?; + write_restricted_with_backup(path, &bytes).map_err(ComponentPackagesError::UpdateStateIo) +} + +struct ComponentUpdatesFileLock { + #[cfg(windows)] + _file: File, +} + +#[cfg(windows)] +fn acquire_component_updates_lock( + state_path: &Path, +) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + prepare_restricted_parent(state_path)?; + let parent = state_path + .parent() + .ok_or(ComponentPackagesError::InvalidUpdateState)?; + let lock_path = parent.join(COMPONENT_UPDATES_LOCK_FILENAME); + ensure_no_reparse_ancestors(&lock_path).map_err(ComponentPackagesError::UpdateStateIo)?; + + let create = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .share_mode(0) + .open(&lock_path); + let file = match create { + Ok(file) => { + protect_path_for_owner_admin_system(&lock_path) + .map_err(ComponentPackagesError::UpdateStateIo)?; + verify_path_protected_for_owner_admin_system(&lock_path) + .map_err(ComponentPackagesError::UpdateStateIo)?; + file + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = + fs::symlink_metadata(&lock_path).map_err(ComponentPackagesError::UpdateStateIo)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(ComponentPackagesError::InvalidUpdateState); + } + verify_path_protected_for_owner_admin_system(&lock_path) + .map_err(ComponentPackagesError::UpdateStateIo)?; + OpenOptions::new() + .read(true) + .write(true) + .share_mode(0) + .open(&lock_path) + .map_err(component_updates_lock_open_error)? + } + Err(error) => return Err(component_updates_lock_open_error(error)), + }; + ensure_no_reparse_ancestors(&lock_path).map_err(ComponentPackagesError::UpdateStateIo)?; + let metadata = file + .metadata() + .map_err(ComponentPackagesError::UpdateStateIo)?; + if !metadata.is_file() { + return Err(ComponentPackagesError::InvalidUpdateState); + } + verify_path_protected_for_owner_admin_system(&lock_path) + .map_err(ComponentPackagesError::UpdateStateIo)?; + Ok(ComponentUpdatesFileLock { _file: file }) +} + +#[cfg(windows)] +fn component_updates_lock_open_error(error: io::Error) -> ComponentPackagesError { + if matches!(error.raw_os_error(), Some(32) | Some(33)) { + ComponentPackagesError::UpdateStateBusy + } else { + ComponentPackagesError::UpdateStateIo(error) + } +} + +#[cfg(not(windows))] +fn acquire_component_updates_lock( + state_path: &Path, +) -> Result { + prepare_restricted_parent(state_path)?; + Ok(ComponentUpdatesFileLock {}) +} + +fn prepare_restricted_parent(path: &Path) -> Result<(), ComponentPackagesError> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or(ComponentPackagesError::InvalidUpdateState)?; + ensure_no_reparse_ancestors(parent).map_err(ComponentPackagesError::UpdateStateIo)?; + fs::create_dir_all(parent).map_err(ComponentPackagesError::UpdateStateIo)?; + protect_path_for_owner_admin_system(parent).map_err(ComponentPackagesError::UpdateStateIo) +} + +fn valid_component_updates_state(state: &ComponentUpdatesState) -> bool { + if state.schema_version != COMPONENT_UPDATES_STATE_SCHEMA_VERSION + || state.observations.len() > ComponentId::ALL.len() + { + return false; + } + let mut seen = HashMap::new(); + state.observations.iter().all(|observation| { + if observation.checked_at_unix == 0 + || NumericVersion::parse(&observation.latest_known_version).is_none() + || seen.insert(observation.component_id, ()).is_some() + { + return false; + } + if observation.trusted_releases.len() > MAX_TRUSTED_RELEASES_PER_COMPONENT { + return false; + } + let mut trusted_ids = HashSet::new(); + let mut trusted_tags = HashSet::new(); + observation.trusted_releases.iter().all(|trusted| { + trusted.release_id > 0 + && trusted.asset_id > 0 + && !trusted.repository.is_empty() + && trusted + .stable_tag + .strip_prefix('v') + .and_then(NumericVersion::parse) + .is_some() + && safe_asset_name(&trusted.asset_name) + && trusted.size > 0 + && trusted.size <= MAX_COMPONENT_PACKAGE_BYTES + && valid_sha256(&trusted.sha256_from_api) + && trusted_ids.insert(( + trusted.repository.as_str(), + trusted.release_id, + trusted.asset_id, + trusted.stable_tag.as_str(), + trusted.asset_name.as_str(), + )) + && trusted_tags.insert(trusted.stable_tag.as_str()) + }) + }) +} + +fn asset_matches_version( + component_id: ComponentId, + pattern: &str, + asset_name: &str, + version: &str, +) -> bool { + let Some((prefix, suffix)) = pattern.split_once('*') else { + return false; + }; + if suffix.contains('*') + || !asset_name.starts_with(prefix) + || !asset_name.ends_with(suffix) + || asset_name.len() < prefix.len() + suffix.len() + { + return false; + } + let wildcard_end = asset_name.len() - suffix.len(); + let wildcard = &asset_name[prefix.len()..wildcard_end]; + wildcard == version + || (component_id == ComponentId::WindowsPacketFilter + && wildcard.strip_prefix(version).is_some_and(|remainder| { + remainder.starts_with('.') + && remainder[1..].split('.').all(|part| { + !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()) + }) + })) +} + +fn safe_asset_name(value: &str) -> bool { + if value.is_empty() + || value.len() > 180 + || value.starts_with('.') + || value.ends_with(['.', ' ']) + || value.contains("..") + || value + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || b"._+-".contains(&byte))) + { + return false; + } + let stem = value + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + !matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + && !(stem.len() == 4 + && (stem.starts_with("COM") || stem.starts_with("LPT")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn sha256_file(path: &Path) -> io::Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + break; + } + hasher.update(&buffer[..count]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn relative_catalog_path(value: &str) -> PathBuf { + value.split('/').collect() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NumericVersion(Vec); + +impl NumericVersion { + fn parse(value: &str) -> Option { + let parts = value.split('.').collect::>(); + if parts.is_empty() || parts.len() > 4 { + return None; + } + let mut parsed = Vec::with_capacity(parts.len()); + for part in parts { + if part.is_empty() + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || (part.len() > 1 && part.starts_with('0')) + { + return None; + } + parsed.push(part.parse().ok()?); + } + Some(Self(parsed)) + } +} + +impl Ord for NumericVersion { + fn cmp(&self, other: &Self) -> Ordering { + let len = self.0.len().max(other.0.len()); + (0..len) + .map(|index| { + self.0 + .get(index) + .copied() + .unwrap_or(0) + .cmp(&other.0.get(index).copied().unwrap_or(0)) + }) + .find(|ordering| *ordering != Ordering::Equal) + .unwrap_or(Ordering::Equal) + } +} + +impl PartialOrd for NumericVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use crate::storage::StoragePaths; + use serde_json::json; + use std::io::Cursor; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + + #[test] + fn bundled_proxifyre_has_both_exact_native_signature_evidences() { + let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target_root).expect("create Cargo target directory"); + let staging_root = target_root.join(format!( + "component-package-native-signature-{}", + Uuid::new_v4().hyphenated() + )); + fs::create_dir(&staging_root).expect("create signature staging directory"); + protect_path_for_owner_admin_system(&staging_root) + .expect("protect signature staging directory"); + let asset_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled/components/proxifyre/ProxiFyre-v2.4.0-x64-signed.zip"); + + let mut evidence = verify_proxifyre_archive( + &asset_path, + &staging_root, + "The Anti-Cloud Corporation", + &NativePackageSignatureVerifier, + ) + .expect("verify bundled ProxiFyre signatures"); + evidence.sort_by(|left, right| left.target_name.cmp(&right.target_name)); + assert_eq!( + evidence + .iter() + .map(|item| ( + item.target_name.as_str(), + item.common_name.as_str(), + item.organization.as_str() + )) + .collect::>(), + vec![ + ( + "ProxiFyre.exe", + "The Anti-Cloud Corporation", + "The Anti-Cloud Corporation" + ), + ( + "socksify.dll", + "The Anti-Cloud Corporation", + "The Anti-Cloud Corporation" + ) + ] + ); + fs::remove_dir(&staging_root).expect("remove empty signature staging directory"); + } + + #[test] + fn component_update_lock_is_process_exclusive_without_mutating_state() { + let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target_root).expect("create Cargo target directory"); + let root = target_root.join(format!( + "component-update-lock-{}", + Uuid::new_v4().hyphenated() + )); + let state_path = root.join("component-updates.json"); + + let first = acquire_component_updates_lock(&state_path).expect("acquire first lock"); + assert!(matches!( + acquire_component_updates_lock(&state_path), + Err(ComponentPackagesError::UpdateStateBusy) + )); + assert!(!state_path.exists(), "lock contention must not write state"); + drop(first); + let second = acquire_component_updates_lock(&state_path).expect("reacquire released lock"); + drop(second); + + fs::remove_file(root.join(COMPONENT_UPDATES_LOCK_FILENAME)).expect("remove test lock"); + fs::remove_dir(root).expect("remove test lock directory"); + } + + #[test] + fn cached_update_copies_exact_locked_bytes_into_random_lease_and_cleans_up() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join(format!( + "privileged-package-unit-{}", + Uuid::new_v4().hyphenated() + )); + let staging_parent = root.join("admin-staging"); + fs::create_dir_all(&staging_parent).expect("create unit staging parent"); + let paths = StoragePaths::new(root.join("storage")); + let service = ComponentPackageService::open( + Path::new(env!("CARGO_MANIFEST_DIR")).join("bundled/components"), + &paths, + ) + .expect("open package service"); + let bytes = b"privileged cached sing-box bytes"; + let plan = unit_sing_box_plan(bytes); + let asset_path = write_unit_cache(&paths, &plan, bytes); + + let locked_source = open_locked_regular_file(&asset_path).expect("lock cache source"); + let source_write = OpenOptions::new().write(true).open(&asset_path); + assert!( + source_write.is_err(), + "cache source cannot be opened for write while locked" + ); + drop(locked_source); + + let transport = UnitMetadataTransport { + body: unit_metadata(&plan), + calls: AtomicUsize::new(0), + }; + let lease = service + .lease_cached_update_with_policy( + &plan, + &staging_parent, + &transport, + &UnitSignatureVerifier, + &UnitStagingPolicy, + ) + .expect("lease exact cached update"); + assert_eq!(lease.proof.source, PackageSource::Cache); + assert_eq!(lease.proof.sha256, plan.independent_proof.sha256_from_api); + assert_eq!(fs::read(lease.asset_path()).unwrap(), bytes); + assert!(OpenOptions::new() + .write(true) + .open(lease.asset_path()) + .is_err()); + let leased_root = lease + .asset_path() + .parent() + .expect("leased asset parent") + .to_path_buf(); + assert_eq!(transport.calls.load(AtomicOrdering::SeqCst), 1); + drop(lease); + assert!(!leased_root.exists(), "lease drop removes random staging"); + + let _ = fs::remove_dir_all(root); + } + + struct UnitStagingPolicy; + + impl PrivilegedStagingPolicy for UnitStagingPolicy { + fn protect(&self, _path: &Path) -> io::Result<()> { + Ok(()) + } + + fn verify(&self, _path: &Path) -> io::Result<()> { + Ok(()) + } + } + + struct UnitSignatureVerifier; + + impl PackageSignatureVerifier for UnitSignatureVerifier { + fn verify(&self, _path: &Path) -> Result { + Err(SignatureVerifierError) + } + } + + struct UnitMetadataTransport { + body: Vec, + calls: AtomicUsize, + } + + impl UpdateTransport for UnitMetadataTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + assert_eq!(request.kind, UpdateRequestKind::GithubReleaseMetadata); + self.calls.fetch_add(1, AtomicOrdering::SeqCst); + Ok(UpdateTransportResponse { + status: 200, + location: None, + content_length: Some(self.body.len() as u64), + body: Box::new(Cursor::new(self.body.clone())), + }) + } + } + + fn unit_sing_box_plan(bytes: &[u8]) -> PrivilegedCachedUpdatePlan { + let version = "1.14.0"; + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let sha256 = format!("{:x}", Sha256::digest(bytes)); + PrivilegedCachedUpdatePlan { + component_id: ComponentId::SingBox, + version: version.to_string(), + independent_proof: GithubReleaseDigestProof { + repository: "SagerNet/sing-box".to_string(), + release_id: 901, + asset_id: 902, + stable_tag: format!("v{version}"), + asset_name, + size: bytes.len() as u64, + sha256_from_api: sha256, + verified_signatures: Vec::new(), + }, + } + } + + fn unit_metadata(plan: &PrivilegedCachedUpdatePlan) -> Vec { + let proof = &plan.independent_proof; + serde_json::to_vec(&json!({ + "id": proof.release_id, + "tag_name": proof.stable_tag, + "draft": false, + "prerelease": false, + "assets": [{ + "id": proof.asset_id, + "name": proof.asset_name, + "size": proof.size, + "digest": format!("sha256:{}", proof.sha256_from_api), + "browser_download_url": format!( + "https://github.com/{}/releases/download/{}/{}", + proof.repository, proof.stable_tag, proof.asset_name + ) + }] + })) + .expect("serialize unit metadata") + } + + fn write_unit_cache( + paths: &StoragePaths, + plan: &PrivilegedCachedUpdatePlan, + bytes: &[u8], + ) -> PathBuf { + let component_root = paths.packages_dir.join(plan.component_id.as_str()); + let version_root = component_root.join(&plan.version); + fs::create_dir_all(&version_root).expect("create unit cache root"); + let asset_path = version_root.join(&plan.independent_proof.asset_name); + fs::write(&asset_path, bytes).expect("write unit cache asset"); + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + let manifest = PackageCacheManifest { + schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + component_id: plan.component_id, + version: plan.version.clone(), + asset_name: plan.independent_proof.asset_name.clone(), + sha256: plan.independent_proof.sha256_from_api.clone(), + size: plan.independent_proof.size, + independent_proof: plan.independent_proof.clone(), + }; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).unwrap(), + ) + .expect("write unit cache manifest"); + for path in [ + paths.packages_dir.as_path(), + component_root.as_path(), + version_root.as_path(), + manifest_path.as_path(), + asset_path.as_path(), + ] { + protect_path_for_owner_admin_system(path).expect("protect unit cache path"); + } + asset_path + } +} diff --git a/src-tauri/src/component_status.rs b/src-tauri/src/component_status.rs index 9e16ba7..9bbc9ed 100644 --- a/src-tauri/src/component_status.rs +++ b/src-tauri/src/component_status.rs @@ -1,49 +1,32 @@ //! Live component status resolution and read-only route/profile presentation. -use crate::command_dto::{CommandError, ResolvedAppDto}; +use crate::command_dto::ResolvedAppDto; use crate::component_detection::{ - detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection, - singbox_component_from_detection, DetectedProxyfier, DetectedSingBox, + inventory_proxyfier, inventory_singbox, proxyfier_component_from_detection, + proxyfier_component_from_inventory, singbox_component_from_detection, + singbox_component_from_inventory, DetectedProxyfier, DetectedSingBox, }; +use crate::component_inventory::ComponentInventory; use crate::models::{ ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target, }; -use crate::storage::JsonStorage; - -pub(crate) fn components_or_defaults( - storage: &JsonStorage, -) -> Result, CommandError> { - components_or_defaults_with_detection( - storage, - detect_proxyfier_install(), - detect_singbox_install(), - ) +pub(crate) fn live_components() -> Vec { + resolve_component_statuses_with_inventories(&inventory_proxyfier(), &inventory_singbox()) } -pub(crate) fn components_or_defaults_with_detection( - storage: &JsonStorage, +pub(crate) fn components_with_detection( detected_proxyfier: Option, detected_singbox: Option, -) -> Result, CommandError> { - let components = storage.read_components().map_err(storage_error)?; - Ok(resolve_component_statuses( - components, - detected_proxyfier, - detected_singbox, - )) +) -> Vec { + resolve_component_statuses(detected_proxyfier, detected_singbox) } pub fn resolve_component_statuses( - stored_components: Vec, detected_proxyfier: Option, detected_singbox: Option, ) -> Vec { let mut components = default_components(); - for component in stored_components { - upsert_component(&mut components, component); - } - upsert_component( &mut components, proxyfier_component_from_detection(detected_proxyfier.as_ref()), @@ -56,6 +39,24 @@ pub fn resolve_component_statuses( components } +pub fn resolve_component_statuses_with_inventories( + proxyfier_inventory: &ComponentInventory, + singbox_inventory: &ComponentInventory, +) -> Vec { + let mut components = default_components(); + + upsert_component( + &mut components, + proxyfier_component_from_inventory(proxyfier_inventory), + ); + upsert_component( + &mut components, + singbox_component_from_inventory(singbox_inventory), + ); + + components +} + fn default_components() -> Vec { vec![ ComponentStatus { @@ -150,7 +151,3 @@ pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec) -> Re notes, } } - -fn storage_error(error: std::io::Error) -> CommandError { - CommandError::new("storage_error", error.to_string()) -} diff --git a/src-tauri/src/configuration_transaction.rs b/src-tauri/src/configuration_transaction.rs new file mode 100644 index 0000000..8ee133e --- /dev/null +++ b/src-tauri/src/configuration_transaction.rs @@ -0,0 +1,295 @@ +//! One process-independent configuration lock and a fixed, recoverable commit. +//! Only ProgramData source/generated files are included; this is never privileged authority. +use crate::{safe_fs, storage::JsonStorage}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::{self, File, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +pub struct RootGuard { + _file: File, +} + +pub fn acquire_root(storage: &JsonStorage) -> io::Result { + let dir = &storage.paths().migrations_dir; + safe_fs::ensure_no_reparse_ancestors(dir)?; + fs::create_dir_all(dir)?; + safe_fs::protect_path_for_owner_admin_system(dir)?; + let path = dir.join("storage-migration.lock"); + safe_fs::ensure_no_reparse_ancestors(&path)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0); + } + let file = options.open(&path)?; + safe_fs::protect_path_for_owner_admin_system(&path)?; + #[cfg(not(windows))] + file.try_lock().map_err(io::Error::other)?; + Ok(RootGuard { _file: file }) +} + +pub fn read_guard(storage: &JsonStorage) -> io::Result { + let guard = acquire_root(storage)?; + if migration_active(storage).try_exists()? { + return Err(io::Error::other( + "storage recovery required before reading configuration", + )); + } + recover_locked(storage)?; + Ok(guard) +} + +fn migration_active(storage: &JsonStorage) -> PathBuf { + storage + .paths() + .migrations_dir + .join("active-storage-migration.json") +} +fn journal_path(storage: &JsonStorage) -> PathBuf { + storage + .paths() + .migrations_dir + .join("configuration-commit.json") +} +fn snapshot_path(storage: &JsonStorage, index: usize) -> PathBuf { + storage + .paths() + .migrations_dir + .join(format!("configuration-before-{index}.json")) +} +fn revision_path(storage: &JsonStorage) -> PathBuf { + storage + .paths() + .state_dir + .join("configuration-revision.json") +} + +fn tracked_paths(storage: &JsonStorage) -> Vec { + let paths = storage.paths(); + [ + paths.profiles_file.clone(), + paths.targets_file.clone(), + paths.local_singbox_file.clone(), + paths.singbox_subscription_cache_file.clone(), + paths.generated_dir.join("proxifyre-app-config.json"), + paths.generated_dir.join("sing-box-config.json"), + revision_path(storage), + crate::route_state::prepared_path(storage), + ] + .into_iter() + .flat_map(|path| [path.clone(), safe_fs::backup_path(&path)]) + .collect() +} + +fn optional_bytes(path: &Path) -> io::Result>> { + safe_fs::ensure_no_reparse_ancestors(path)?; + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// Read only while holding this module's root guard. Content protects against uncoordinated old writers too. +pub fn revision_locked(storage: &JsonStorage) -> io::Result { + let mut hash = Sha256::new(); + for path in tracked_paths(storage).into_iter().step_by(2) { + match optional_bytes(&path)? { + Some(bytes) => { + hash.update([1]); + hash.update((bytes.len() as u64).to_le_bytes()); + hash.update(bytes); + } + None => hash.update([0]), + } + } + Ok(format!("{:x}", hash.finalize())) +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Intent { + version: u8, + committed: bool, + before: Vec>, +} + +/// Caller must hold the common root lock; migration calls this before inspecting source. +pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> { + let Some(bytes) = optional_bytes(&journal_path(storage))? else { + return Ok(()); + }; + if migration_active(storage).try_exists()? { + return Err(io::Error::other( + "conflicting storage intents require recovery", + )); + } + let intent: Intent = serde_json::from_slice(&bytes) + .map_err(|_| io::Error::other("invalid configuration intent"))?; + let paths = tracked_paths(storage); + if intent.version != 1 || intent.before.len() != paths.len() { + return Err(io::Error::other("unsupported configuration intent")); + } + if !intent.committed { + // Verify every snapshot before the first restoration, including absent destinations. + let mut snapshots = Vec::new(); + for (index, expected) in intent.before.iter().enumerate() { + safe_fs::ensure_no_reparse_ancestors(&paths[index])?; + snapshots.push(match expected { + Some(hash) => { + let bytes = optional_bytes(&snapshot_path(storage, index))? + .ok_or_else(|| io::Error::other("missing configuration snapshot"))?; + if digest(&bytes) != *hash { + return Err(io::Error::other("damaged configuration snapshot")); + } + Some(bytes) + } + None => None, + }); + } + for (path, bytes) in paths.iter().zip(snapshots) { + match bytes { + Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?, + None => remove_optional(path)?, + } + } + } + cleanup(storage, paths.len()) +} + +fn remove_optional(path: &Path) -> io::Result<()> { + safe_fs::ensure_no_reparse_ancestors(path)?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} +fn cleanup(storage: &JsonStorage, count: usize) -> io::Result<()> { + // Mark rollback complete before deleting any snapshot, so interrupted cleanup is retryable. + let marker = Intent { + version: 1, + committed: true, + before: vec![None; count], + }; + safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&marker)?)?; + for index in 0..count { + remove_optional(&safe_fs::backup_path(&snapshot_path(storage, index)))?; + remove_optional(&snapshot_path(storage, index))?; + } + remove_optional(&safe_fs::backup_path(&journal_path(storage)))?; + remove_optional(&journal_path(storage)) +} + +pub struct ConfigurationTransaction<'a> { + storage: &'a JsonStorage, + guard: Option, + intent: Intent, + committed: bool, +} +impl<'a> ConfigurationTransaction<'a> { + pub fn begin(storage: &'a JsonStorage, expected: Option<&str>) -> io::Result { + let guard = read_guard(storage)?; + if let Some(expected) = expected { + if revision_locked(storage)? != expected { + return Err(io::Error::other( + "configuration changed; retry using current settings", + )); + } + } + let mut before = Vec::new(); + for (index, path) in tracked_paths(storage).iter().enumerate() { + before.push(match optional_bytes(path)? { + Some(bytes) => { + safe_fs::write_restricted_atomic(&snapshot_path(storage, index), &bytes)?; + Some(digest(&bytes)) + } + None => None, + }); + } + let intent = Intent { + version: 1, + committed: false, + before, + }; + safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&intent)?)?; + Ok(Self { + storage, + guard: Some(guard), + intent, + committed: false, + }) + } + pub fn commit(self) -> io::Result<()> { + self.commit_with_revision().map(|_| ()) + } + + pub fn commit_with_revision(mut self) -> io::Result { + // A fresh nonce records intent even if a later edit returns source to identical bytes. + let prepared = safe_fs::write_restricted_atomic( + &revision_path(self.storage), + &serde_json::to_vec(&uuid::Uuid::new_v4().to_string())?, + ) + .and_then(|()| revision_locked(self.storage)); + let revision = match prepared { + Ok(revision) => revision, + Err(error) => { + self.committed = true; + return match recover_locked(self.storage) { + Ok(()) => Err(error), + Err(_) => Err(io::Error::other( + "configuration_recovery_required: восстановление сохранения не завершено", + )), + }; + } + }; + self.intent.committed = true; + let marker = serde_json::to_vec(&self.intent)?; + if let Err(error) = safe_fs::write_restricted_atomic(&journal_path(self.storage), &marker) { + // The atomic writer may fail its final ACL step after promotion. + // Read back the exact marker under the same lock before deciding the outcome. + match optional_bytes(&journal_path(self.storage)) { + Ok(Some(bytes)) if bytes == marker => {} + Ok(Some(_)) => { + self.committed = true; + return match recover_locked(self.storage) { + Ok(()) => Err(error), + Err(_) => Err(io::Error::other("configuration_recovery_required: восстановление сохранения не завершено")), + }; + } + _ => { + self.committed = true; + return Err(io::Error::other("configuration_outcome_unknown: итог сохранения не подтверждён; обновите состояние перед повтором")); + } + } + } + self.committed = true; + let _ = cleanup(self.storage, self.intent.before.len()); + self.guard.take(); + Ok(revision) + } + + pub fn abort(mut self) -> io::Result<()> { + let result = recover_locked(self.storage); + // Do not silently retry and hide a failed explicit recovery in Drop. + self.committed = true; + result + } +} +impl Drop for ConfigurationTransaction<'_> { + fn drop(&mut self) { + if !self.committed { + let _ = recover_locked(self.storage); + } + // Failed recovery leaves the durable intent for the next guarded read, never fresh defaults. + } +} diff --git a/src-tauri/src/configuration_use_case.rs b/src-tauri/src/configuration_use_case.rs index 3d44d78..e41f7e1 100644 --- a/src-tauri/src/configuration_use_case.rs +++ b/src-tauri/src/configuration_use_case.rs @@ -1,33 +1,35 @@ -//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases. +use crate::configuration_transaction::{read_guard, ConfigurationTransaction}; +// Persisted profiles/targets, startup preparation, and preview use cases. -use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy}; +use crate::adapters::proxifyre::{ProxiFyreAdapter, PROXIFYRE_OUTPUT_FILE}; +use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest}; use crate::admin::admin_status; use crate::command_dto::*; use crate::component_detection::{ - default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install, - detect_singbox_install, + default_proxifyre_install_dir, default_singbox_install_dir, detected_proxyfier_from_inventory, + detected_singbox_from_inventory, inventory_proxyfier, inventory_singbox, }; +use crate::component_inventory::ComponentClassification; use crate::component_status::{ - components_or_defaults, resolve_component_statuses, resolved_app, route_line, + live_components, resolve_component_statuses_with_inventories, resolved_app, route_line, }; -use crate::models::{ - Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind, +use crate::migration::{ + prepare_storage, reconcile_component_layout, record_component_cutover_startup_evidence, + recover_incomplete_migration, with_component_layout, }; use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection; +use crate::safe_fs; use crate::singbox_service::build_singbox_setup_status_with_install_root; use crate::singbox_subscription::read_singbox_status_with_detection; use crate::storage::JsonStorage; use crate::validation::{normalize_profile, normalize_target, ValidationError}; use std::fs; -use std::path::Path; - -const MAIN_PROFILE_ID: &str = "main-profile"; -const MAIN_TARGET_ID: &str = "main-proxy"; pub fn build_status(storage: &JsonStorage) -> Result { + let _guard = read_guard(storage).map_err(storage_error)?; let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; - let components = components_or_defaults(storage)?; + let components = live_components(); let activity = storage.read_activity().map_err(storage_error)?; let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count(); let routed_app_count = profiles @@ -62,6 +64,7 @@ pub fn build_status(storage: &JsonStorage) -> Result Result, CommandError> { + let _guard = read_guard(storage).map_err(storage_error)?; storage .read_profiles() .map_err(storage_error) @@ -72,6 +75,7 @@ pub fn save_profile_to_storage( storage: &JsonStorage, input: ProfileInputDto, ) -> Result { + let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?; let profile = normalize_profile(input.into()).map_err(validation_error)?; let mut profiles = storage.read_profiles().map_err(storage_error)?; @@ -84,10 +88,12 @@ pub fn save_profile_to_storage( } storage.write_profiles(&profiles).map_err(storage_error)?; + transaction.commit().map_err(storage_error)?; Ok(ProfileDto::from(&profile)) } pub fn read_targets(storage: &JsonStorage) -> Result, CommandError> { + let _guard = read_guard(storage).map_err(storage_error)?; storage .read_targets() .map_err(storage_error) @@ -98,6 +104,7 @@ pub fn save_target_to_storage( storage: &JsonStorage, input: TargetInputDto, ) -> Result { + let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?; let target = normalize_target(input.into()).map_err(validation_error)?; let mut targets = storage.read_targets().map_err(storage_error)?; @@ -107,46 +114,155 @@ pub fn save_target_to_storage( } storage.write_targets(&targets).map_err(storage_error)?; + transaction.commit().map_err(storage_error)?; Ok(TargetDto::from(&target)) } -pub fn read_components(storage: &JsonStorage) -> Result, CommandError> { - components_or_defaults(storage).map(|components| { - components - .iter() - .map(ComponentStatusDto::from) - .collect::>() - }) +pub fn read_live_components() -> Vec { + live_components() + .iter() + .map(ComponentStatusDto::from) + .collect() +} + +/// Rebuilds only an existing, untrusted derived config from authoritative +/// profiles/targets. A missing config still requires an explicit Apply action. +pub fn ensure_proxifyre_generated_config_ready(storage: &JsonStorage) -> Result<(), CommandError> { + let guard = read_guard(storage).map_err(storage_error)?; + let path = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE); + if !path.try_exists().map_err(storage_error)? { + return Err(CommandError::new( + "generated_config_missing", + "Сначала нажмите «Применить», чтобы создать конфигурацию ProxiFyre.", + )); + } + let profiles = storage.read_profiles().map_err(storage_error)?; + if !profiles + .iter() + .any(|profile| profile.enabled && !profile.items.is_empty()) + { + return Err(CommandError::new( + "route_has_no_apps", + "Нет включённых правил. Добавьте приложения и примените конфигурацию перед запуском ProxiFyre.", + )); + } + if safe_fs::open_restricted_file_read_lease(&path).is_ok() { + return Ok(()); + } + + let targets = storage.read_targets().map_err(storage_error)?; + let components = live_components(); + let generated = ProxiFyreAdapter::default() + .generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) + .map_err(|_| { + CommandError::new( + "generated_config_rebuild_failed", + "Старую конфигурацию ProxiFyre нельзя использовать. Нажмите «Применить», чтобы пересоздать её.", + ) + })?; + + let revision = + crate::configuration_transaction::revision_locked(storage).map_err(storage_error)?; + drop(guard); + let transaction = + ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?; + remove_untrusted_generated_file(&path, true)?; + remove_untrusted_generated_file(&safe_fs::backup_path(&path), false)?; + safe_fs::write_restricted_atomic(&path, generated.contents.as_bytes()) + .map_err(|_| generated_config_rebuild_error())?; + transaction.commit().map_err(storage_error) +} + +fn remove_untrusted_generated_file( + path: &std::path::Path, + required: bool, +) -> Result<(), CommandError> { + safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| generated_config_rebuild_error())?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => { + fs::remove_file(path).map_err(|_| generated_config_rebuild_error()) + } + Ok(_) => Err(generated_config_rebuild_error()), + Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(generated_config_rebuild_error()), + } +} + +fn generated_config_rebuild_error() -> CommandError { + CommandError::new( + "generated_config_rebuild_failed", + "Не удалось безопасно пересоздать старую конфигурацию ProxiFyre. Нажмите «Применить» и повторите запуск.", + ) } pub fn read_startup_snapshot( storage: &JsonStorage, + startup_session_id: &str, ) -> Result { + // Resolve an interrupted storage transaction before any normal read or + // component-dependent startup work. + recover_incomplete_migration(storage)?; // Both detectors query Windows independently. Run them together so the // startup snapshot is bounded by the slower check instead of their sum. - let proxyfier_detection = std::thread::spawn(detect_proxyfier_install); - let detected_singbox = detect_singbox_install(); - let detected_proxyfier = proxyfier_detection.join().ok().flatten(); - let saved_state = read_saved_state_with_proxifyre_config( + let proxyfier_inventory_task = std::thread::spawn(inventory_proxyfier); + let singbox_inventory = inventory_singbox(); + let proxyfier_inventory = proxyfier_inventory_task.join().map_err(|_| { + CommandError::new( + "component_inventory_failed", + "Не удалось проверить установку ProxiFyre.", + ) + })?; + let detected_proxyfier = detected_proxyfier_from_inventory(&proxyfier_inventory); + let detected_singbox = detected_singbox_from_inventory(&singbox_inventory); + let mut legacy_candidates = vec![storage + .paths() + .generated_dir + .join("proxifyre-app-config.json")]; + legacy_candidates.extend( + proxyfier_inventory + .candidates + .iter() + .filter(|candidate| { + matches!( + candidate.classification, + ComponentClassification::ManagedCurrent + | ComponentClassification::ManagedLegacy + ) + }) + .map(|candidate| candidate.root.join("app-config.json")), + ); + let migration_status = prepare_storage(storage, &legacy_candidates)?; + if migration_status.blocking { + return Err(CommandError::new( + migration_status + .notice_code + .clone() + .unwrap_or_else(|| "storage_migration_blocked".to_string()), + migration_status.message, + )); + } + let component_layout_version = + reconcile_component_layout(storage, &proxyfier_inventory, &singbox_inventory)?; + // This is an untrusted UX carrier. A failed write must not block normal + // startup; cleanup remains unavailable until an exact later observation. + let _ = record_component_cutover_startup_evidence( storage, - detected_proxyfier - .as_ref() - .and_then(|detected| detected.config_path.as_deref()), - )?; - let stored_components = storage.read_components().map_err(storage_error)?; - let components = resolve_component_statuses( - stored_components, - detected_proxyfier.clone(), - detected_singbox.clone(), - ) - .iter() - .map(ComponentStatusDto::from) - .collect(); + startup_session_id, + &proxyfier_inventory, + ); + let migration_status = with_component_layout(migration_status, component_layout_version); + + let components = + resolve_component_statuses_with_inventories(&proxyfier_inventory, &singbox_inventory) + .iter() + .map(ComponentStatusDto::from) + .collect(); let proxifyre_setup_status = build_proxifyre_setup_status_with_detection( detected_proxyfier.as_ref(), &default_proxifyre_install_dir(), ); let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?; + let saved_state = singbox_status.saved_state.clone(); let singbox_setup_status = build_singbox_setup_status_with_install_root( detected_singbox.as_ref(), &default_singbox_install_dir(), @@ -154,6 +270,7 @@ pub fn read_startup_snapshot( Ok(StartupSnapshotResponse { admin_status: admin_status(), + migration_status, saved_state, components, proxifyre_setup_status, @@ -170,29 +287,20 @@ pub fn read_activity(storage: &JsonStorage) -> Result, Com } pub fn read_saved_state(storage: &JsonStorage) -> Result { - let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path); - read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref()) + let _guard = read_guard(storage).map_err(storage_error)?; + read_saved_state_locked(storage) } -pub fn read_saved_state_with_proxifyre_config( +pub(crate) fn read_saved_state_locked( storage: &JsonStorage, - proxifyre_config_path: Option<&Path>, ) -> Result { - let mut profiles = storage.read_profiles().map_err(storage_error)?; - let mut targets = storage.read_targets().map_err(storage_error)?; - - if should_bootstrap_profiles(&profiles) { - if let Some(imported) = - proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config) - { - profiles = imported.profiles; - upsert_targets(&mut targets, imported.targets); - storage.write_targets(&targets).map_err(storage_error)?; - storage.write_profiles(&profiles).map_err(storage_error)?; - } - } + let profiles = storage.read_profiles().map_err(storage_error)?; + let targets = storage.read_targets().map_err(storage_error)?; Ok(SavedStateResponse { + artifacts: crate::route_state::read_status_locked(storage).map_err(storage_error)?, + revision: crate::configuration_transaction::revision_locked(storage) + .map_err(storage_error)?, profiles: profiles.iter().map(ProfileDto::from).collect(), targets: targets.iter().map(TargetDto::from).collect(), generated_config_path: storage @@ -204,198 +312,6 @@ pub fn read_saved_state_with_proxifyre_config( }) } -struct ImportedSavedState { - profiles: Vec, - targets: Vec, -} - -fn should_bootstrap_profiles(profiles: &[Profile]) -> bool { - !profiles - .iter() - .any(|profile| profile.enabled && !profile.items.is_empty()) -} - -fn import_saved_state_from_proxifyre_config(path: &Path) -> Option { - let contents = fs::read_to_string(path).ok()?; - let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?; - - let proxy_entries = config - .proxies - .iter() - .filter_map(import_proxy_entry) - .collect::>(); - if proxy_entries.is_empty() { - return None; - } - - let single_entry = proxy_entries.len() == 1; - let mut profiles = Vec::with_capacity(proxy_entries.len()); - let mut targets = Vec::with_capacity(proxy_entries.len()); - - for (index, entry) in proxy_entries.into_iter().enumerate() { - let ordinal = index + 1; - let target_id = if single_entry { - MAIN_TARGET_ID.to_string() - } else { - format!("proxifyre-import-target-{ordinal}") - }; - let profile_id = if single_entry { - MAIN_PROFILE_ID.to_string() - } else { - format!("proxifyre-import-profile-{ordinal}") - }; - let profile_name = if single_entry { - "Приложения через прокси".to_string() - } else { - format!("Импорт ProxiFyre {ordinal}") - }; - - targets.push(Target { - id: target_id.clone(), - name: if single_entry { - "Основной прокси".to_string() - } else { - format!("Прокси ProxiFyre {ordinal}") - }, - kind: TargetKind::External, - protocol: ProxyProtocol::Socks5, - host: entry.host, - port: entry.port, - requires_component: None, - }); - profiles.push(Profile { - id: profile_id, - name: profile_name, - enabled: true, - target_id, - protocols: entry.protocols, - items: entry.items, - }); - } - - Some(ImportedSavedState { profiles, targets }) -} - -struct ImportedProxyEntry { - items: Vec, - protocols: Vec, - host: String, - port: u16, -} - -fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option { - let items = proxy - .app_names - .iter() - .filter_map(|name| imported_profile_item(name)) - .collect::>(); - if items.is_empty() { - return None; - } - - let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?; - - Some(ImportedProxyEntry { - items, - protocols: imported_protocols(&proxy.supported_protocols), - host, - port, - }) -} - -fn imported_profile_item(raw_value: &str) -> Option { - let value = raw_value.trim().trim_matches('"'); - if value.is_empty() { - return None; - } - - let looks_like_path = value.contains('\\') || value.contains('/'); - let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") { - ProfileItemType::Exe - } else if looks_like_path { - ProfileItemType::Folder - } else { - ProfileItemType::Process - }; - let value = match item_type { - ProfileItemType::Process => { - let base = value.rsplit(['\\', '/']).next().unwrap_or(value); - if base.to_ascii_lowercase().ends_with(".exe") { - base[..base.len() - 4].to_string() - } else { - base.to_string() - } - } - ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(), - }; - - if value.is_empty() { - return None; - } - - Some(ProfileItem { - recursive: matches!(item_type, ProfileItemType::Folder), - item_type, - value, - }) -} - -fn imported_protocols(values: &[String]) -> Vec { - let mut protocols = Vec::new(); - for value in values { - let protocol = match value.trim().to_ascii_uppercase().as_str() { - "TCP" => Protocol::Tcp, - "UDP" => Protocol::Udp, - _ => continue, - }; - if !protocols.contains(&protocol) { - protocols.push(protocol); - } - } - - if protocols.is_empty() { - vec![Protocol::Tcp, Protocol::Udp] - } else { - protocols - } -} - -fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> { - let endpoint = endpoint.trim(); - let endpoint = if endpoint - .get(.."socks5://".len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://")) - { - &endpoint["socks5://".len()..] - } else { - endpoint - }; - if endpoint.is_empty() { - return None; - } - - if let Some(rest) = endpoint.strip_prefix('[') { - let (host, rest) = rest.split_once(']')?; - let port = rest.strip_prefix(':')?.parse::().ok()?; - let host = host.trim(); - return (!host.is_empty()).then(|| (host.to_string(), port)); - } - - let (host, port) = endpoint.rsplit_once(':')?; - let host = host.trim(); - let port = port.trim().parse::().ok()?; - (!host.is_empty()).then(|| (host.to_string(), port)) -} - -fn upsert_targets(targets: &mut Vec, imported_targets: Vec) { - for target in imported_targets { - match targets.iter().position(|existing| existing.id == target.id) { - Some(index) => targets[index] = target, - None => targets.push(target), - } - } -} - pub fn resolve_preview( input: ProfileInputDto, ) -> Result { @@ -431,3 +347,89 @@ fn validation_error(errors: Vec) -> CommandError { .collect(), ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ + Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind, + }; + use std::path::Path; + + #[test] + fn rebuilds_existing_untrusted_generated_config_from_source_of_truth() { + let root = test_root("rebuild-generated"); + let storage = JsonStorage::new(&root); + storage + .write_profiles(&[test_profile()]) + .expect("write profiles"); + storage + .write_targets(&[test_target()]) + .expect("write targets"); + let generated = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE); + fs::create_dir_all(generated.parent().expect("generated parent")) + .expect("create generated parent"); + fs::write(&generated, b"untrusted legacy bytes").expect("write weak legacy config"); + fs::write(safe_fs::backup_path(&generated), b"untrusted backup") + .expect("write weak legacy backup"); + + ensure_proxifyre_generated_config_ready(&storage).expect("rebuild generated config"); + + let contents = fs::read_to_string(&generated).expect("read rebuilt config"); + assert!(contents.contains("Discord.exe")); + assert!(contents.contains("127.0.0.1:1080")); + assert!(!contents.contains("untrusted legacy bytes")); + assert!(!safe_fs::backup_path(&generated).exists()); + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(&generated) + .expect("rebuilt config keeps the restricted ACL"); + cleanup(&root); + } + + #[test] + fn missing_generated_config_still_requires_explicit_apply() { + let root = test_root("missing-generated"); + let storage = JsonStorage::new(&root); + + let error = ensure_proxifyre_generated_config_ready(&storage) + .expect_err("missing config must not be created implicitly"); + + assert_eq!(error.code, "generated_config_missing"); + cleanup(&root); + } + + fn test_profile() -> Profile { + Profile { + id: "test".to_string(), + name: "Test".to_string(), + enabled: true, + target_id: "external".to_string(), + protocols: vec![Protocol::Tcp, Protocol::Udp], + items: vec![ProfileItem { + item_type: ProfileItemType::Process, + value: "Discord.exe".to_string(), + recursive: false, + }], + } + } + + fn test_target() -> Target { + Target { + id: "external".to_string(), + name: "External".to_string(), + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host: "127.0.0.1".to_string(), + port: 1080, + requires_component: None, + } + } + + fn test_root(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("proxywarden-{label}-{}", uuid::Uuid::new_v4())) + } + + fn cleanup(root: &Path) { + let _ = fs::remove_dir_all(root); + } +} diff --git a/src-tauri/src/elevated_scripts.rs b/src-tauri/src/elevated_scripts.rs deleted file mode 100644 index 9bcae39..0000000 --- a/src-tauri/src/elevated_scripts.rs +++ /dev/null @@ -1,19 +0,0 @@ -use std::env; -use std::path::{Path, PathBuf}; - -pub fn temp_script_path(prefix: &str) -> PathBuf { - env::temp_dir().join(unique_file_name(prefix, "ps1")) -} - -pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf { - artifact_dir.join(unique_file_name(prefix, extension)) -} - -fn unique_file_name(prefix: &str, extension: &str) -> String { - let extension = extension.trim_start_matches('.'); - format!( - "{prefix}-{}.{}", - uuid::Uuid::new_v4().hyphenated(), - extension - ) -} diff --git a/src-tauri/src/helper.rs b/src-tauri/src/helper.rs deleted file mode 100644 index 1334c29..0000000 --- a/src-tauri/src/helper.rs +++ /dev/null @@ -1,184 +0,0 @@ -use crate::models::ComponentId; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum HelperAction { - #[serde(rename = "install-control-app")] - InstallControlApp, - #[serde(rename = "install-proxyfier")] - InstallProxyfier, - #[serde(rename = "install-singbox")] - InstallSingbox, - #[serde(rename = "proxyfier.apply")] - ProxyfierApply, - #[serde(rename = "service.status")] - ServiceStatus, - #[serde(rename = "service.start")] - ServiceStart, - #[serde(rename = "service.stop")] - ServiceStop, - #[serde(rename = "service.restart")] - ServiceRestart, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HelperRequest { - pub action: HelperAction, - #[serde(skip_serializing_if = "Option::is_none")] - pub component: Option, - #[serde(default)] - pub payload: Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HelperResponse { - pub success: bool, - pub action: HelperAction, - pub changed: bool, - pub message: String, - #[serde(default)] - pub details: Value, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelperCommandSpec { - pub program: PathBuf, - pub args: Vec, - pub stdin: String, - pub requires_elevation: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HelperCommandOutput { - pub status_code: i32, - pub stdout: String, - pub stderr: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HelperError { - pub code: String, - pub message: String, -} - -impl HelperError { - pub fn new(code: impl Into, message: impl Into) -> Self { - Self { - code: code.into(), - message: message.into(), - } - } -} - -pub trait HelperCommandRunner { - fn run(&self, spec: &HelperCommandSpec) -> Result; -} - -#[derive(Debug, Clone)] -pub struct StructuredHelper { - helper_program: PathBuf, - runner: R, -} - -impl StructuredHelper -where - R: HelperCommandRunner, -{ - pub fn new(helper_program: impl Into, runner: R) -> Self { - Self { - helper_program: helper_program.into(), - runner, - } - } - - pub fn runner(&self) -> &R { - &self.runner - } - - pub fn execute(&self, request: &HelperRequest) -> Result { - let stdin = serde_json::to_string(request) - .map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?; - let spec = HelperCommandSpec { - program: self.helper_program.clone(), - args: vec!["--json".to_string()], - stdin, - requires_elevation: helper_action_requires_elevation(&request.action), - }; - let output = self.runner.run(&spec)?; - - if output.status_code != 0 { - return Err(HelperError::new( - "helper_exit", - format!( - "Помощник завершился с кодом {}: {}", - output.status_code, output.stderr - ), - )); - } - - parse_helper_response(&output.stdout) - } -} - -pub fn parse_helper_response(stdout: &str) -> Result { - serde_json::from_str(stdout).map_err(|error| { - HelperError::new( - "helper_response_decode", - format!("Помощник вернул не JSON или некорректный JSON: {error}"), - ) - }) -} - -pub fn install_request(component: ComponentId) -> HelperRequest { - let action = match component { - ComponentId::ControlApp => HelperAction::InstallControlApp, - ComponentId::Proxyfier => HelperAction::InstallProxyfier, - ComponentId::Singbox => HelperAction::InstallSingbox, - }; - - HelperRequest { - action, - component: Some(component), - payload: json!({}), - } -} - -pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest { - HelperRequest { - action, - component: Some(component), - payload: json!({}), - } -} - -pub fn proxifyre_apply_request( - config_path: impl AsRef, - service_name: impl Into, -) -> HelperRequest { - HelperRequest { - action: HelperAction::ProxyfierApply, - component: Some(ComponentId::Proxyfier), - payload: json!({ - "configPath": config_path.as_ref().display().to_string(), - "serviceName": service_name.into(), - }), - } -} - -pub fn helper_action_requires_elevation(action: &HelperAction) -> bool { - matches!( - action, - HelperAction::InstallControlApp - | HelperAction::InstallProxyfier - | HelperAction::InstallSingbox - | HelperAction::ProxyfierApply - | HelperAction::ServiceStart - | HelperAction::ServiceStop - | HelperAction::ServiceRestart - ) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f37d1d0..7da2426 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,19 +4,25 @@ pub mod apply_flow; pub mod clock; pub mod command_dto; pub mod commands; +pub mod component_catalog; +pub mod component_cutover; pub mod component_detection; +pub mod component_inventory; +pub mod component_packages; pub mod component_status; +pub mod configuration_transaction; pub mod configuration_use_case; -pub mod elevated_scripts; -pub mod helper; +pub mod migration; pub mod models; -mod powershell; +pub mod nsis_runtime; +pub mod privileged_jobs; +pub mod privileged_runtime; pub mod process; pub mod proxifyre_ownership; pub mod proxifyre_runtime; -pub mod proxifyre_scripts; pub mod proxy_apply; pub mod proxy_probe; +pub mod route_state; pub mod safe_fs; pub mod singbox_config; pub mod singbox_runtime; @@ -26,6 +32,113 @@ pub mod storage; pub mod subscription; pub mod validation; +pub enum EarlyProcessMode { + NotHandled, + Exit(i32), +} + +/// Handles the fixed elevated-helper mode before Tauri or a webview is initialized. +/// Ordinary startup returns before constructing any component/network runtime. +pub fn run_early_process_mode(arguments: I) -> EarlyProcessMode +where + I: IntoIterator, +{ + let arguments = arguments.into_iter().collect::>(); + match nsis_runtime::parse_nsis_early_arguments(arguments.clone()) { + Ok(Some(mode)) => { + return EarlyProcessMode::Exit(nsis_runtime::nsis_process_exit_code( + nsis_runtime::run_system_nsis_mode(mode), + )); + } + Ok(None) => {} + Err(_) => return EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE), + } + let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) { + Ok(Some(job_id)) => job_id, + Ok(None) => return EarlyProcessMode::NotHandled, + Err(_) => return EarlyProcessMode::Exit(64), + }; + let runtime = match privileged_runtime::SystemPrivilegedRuntime::production() { + Ok(runtime) => runtime, + Err(_) => return EarlyProcessMode::Exit(2), + }; + run_recognized_early_job(&job_id, &runtime, &runtime) +} + +pub fn run_early_process_mode_with_runtime( + arguments: I, + resolver: &dyn privileged_jobs::PrivilegedPlanResolver, + runner: &dyn privileged_jobs::PrivilegedActionRunner, +) -> EarlyProcessMode +where + I: IntoIterator, +{ + let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) { + Ok(Some(job_id)) => job_id, + Ok(None) => return EarlyProcessMode::NotHandled, + Err(_) => return EarlyProcessMode::Exit(64), + }; + run_recognized_early_job(&job_id, resolver, runner) +} + +fn run_recognized_early_job( + job_id: &privileged_jobs::PrivilegedJobId, + resolver: &dyn privileged_jobs::PrivilegedPlanResolver, + runner: &dyn privileged_jobs::PrivilegedActionRunner, +) -> EarlyProcessMode { + let store = match privileged_jobs::PrivilegedJobStore::production() { + Ok(store) => store, + Err(_) => return EarlyProcessMode::Exit(2), + }; + let result = privileged_jobs::execute_privileged_job( + &store, + job_id, + &privileged_jobs::SystemEpochClock, + &privileged_jobs::NativeElevationProbe, + resolver, + runner, + ); + match result { + Ok(result) if result.status == privileged_jobs::PrivilegedJobStatus::Succeeded => { + EarlyProcessMode::Exit(0) + } + Ok(_) => EarlyProcessMode::Exit(1), + Err(_) => EarlyProcessMode::Exit(2), + } +} + +#[cfg(test)] +mod early_process_mode_tests { + use super::*; + + #[test] + fn ordinary_startup_returns_before_constructing_privileged_runtime() { + assert!(matches!( + run_early_process_mode(Vec::::new()), + EarlyProcessMode::NotHandled + )); + } + + #[test] + fn malformed_helper_arguments_fail_before_runtime_construction() { + assert!(matches!( + run_early_process_mode([std::ffi::OsString::from("--elevated-helper")]), + EarlyProcessMode::Exit(64) + )); + } + + #[test] + fn malformed_nsis_arguments_fail_before_runtime_construction() { + assert!(matches!( + run_early_process_mode([ + std::ffi::OsString::from(nsis_runtime::NSIS_VERIFY_UPGRADE_ARGUMENT), + std::ffi::OsString::from("unexpected"), + ]), + EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE) + )); + } +} + pub mod adapters { pub mod proxifyre; pub mod proxy_router; @@ -42,10 +155,16 @@ pub fn run() { commands::get_saved_state, commands::get_components, commands::get_proxifyre_setup_status, - commands::get_proxifyre_setup_progress, commands::get_singbox_status, commands::get_singbox_setup_status, - commands::save_singbox_subscription, + commands::get_component_package_statuses, + commands::get_component_cutover_statuses, + commands::check_component_update, + commands::download_component_update, + commands::update_component, + commands::cutover_component, + commands::confirm_component_route_smoke, + commands::cleanup_component_quarantine, commands::fetch_singbox_subscription, commands::forget_singbox_subscription, commands::select_singbox_server, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 737f2ce..ad7345e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,5 +1,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + match proxywarden_lib::run_early_process_mode(std::env::args_os().skip(1)) { + proxywarden_lib::EarlyProcessMode::NotHandled => {} + proxywarden_lib::EarlyProcessMode::Exit(code) => std::process::exit(code), + } proxywarden_lib::run(); } diff --git a/src-tauri/src/migration.rs b/src-tauri/src/migration.rs new file mode 100644 index 0000000..428c3f4 --- /dev/null +++ b/src-tauri/src/migration.rs @@ -0,0 +1,1630 @@ +//! Versioned, fail-closed migration of pre-1.2 user configuration. +//! +//! Storage migration and component cutover are deliberately separate. This +//! module only adopts or converts user JSON; it never controls services or +//! moves component directories. + +use crate::adapters::proxifyre::{ProxiFyreAdapter, ProxiFyreConfig, ProxiFyreProxy}; +use crate::adapters::proxy_router::ProxyRouterRequest; +use crate::command_dto::{CommandError, StorageMigrationStatusDto}; +use crate::component_cutover::{ + validate_component_cutover_user_evidence, ComponentCutoverUserEvidence, CutoverDisplayState, + LegacyServiceState, CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, +}; +use crate::component_inventory::{ + component_inventory_fingerprint_for_cutover, ComponentClassification, ComponentInventory, +}; +use crate::models::{ + ComponentId, ComponentLayoutMeta, Profile, ProfileInput, ProfileItem, ProfileItemInput, + ProfileItemType, Protocol, ProxyProtocol, StorageMeta, StorageMigrationOutcome, Target, + TargetInput, TargetKind, +}; +use crate::safe_fs; +use crate::storage::{backup_path, JsonStorage}; +use crate::validation::{normalize_profile, normalize_target}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::io::{self, ErrorKind}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const CURRENT_STORAGE_SCHEMA: u32 = 1; +pub const CURRENT_COMPONENT_LAYOUT: u32 = 1; +const JOURNAL_SCHEMA: u32 = 1; +const MAX_LEGACY_CONFIG_BYTES: u64 = 4 * 1024 * 1024; +const ACTIVE_JOURNAL_FILE: &str = "active-storage-migration.json"; + +/// Publishes the normal-process evidence used by the later explicit cleanup +/// action. The ProgramData record is intentionally only a carrier: the +/// elevated cutover owner rechecks the sealed journal and live inventory. +/// Invalid or stale UX observations never block ordinary startup. +pub fn record_component_cutover_startup_evidence( + storage: &JsonStorage, + startup_session_id: &str, + proxyfier_inventory: &ComponentInventory, +) -> io::Result { + let observation = match storage.read_component_cutover_observation() { + Ok(Some(observation)) => observation, + Ok(None) | Err(_) => return Ok(false), + }; + if observation.component != "proxifyre" + || observation.state != CutoverDisplayState::AwaitingNextStart + || proxyfier_inventory.classification() != ComponentClassification::ManagedCurrent + { + return Ok(false); + } + let expected_status = match observation.original_service_state { + LegacyServiceState::Running => "running", + LegacyServiceState::Stopped => "stopped", + }; + let live_status_matches = proxyfier_inventory + .selected_candidate() + .and_then(|candidate| candidate.service.as_ref()) + .is_some_and(|service| service.status.trim().eq_ignore_ascii_case(expected_status)); + if !live_status_matches { + return Ok(false); + } + + let current_inventory_fingerprint = + component_inventory_fingerprint_for_cutover(proxyfier_inventory); + if storage + .read_component_cutover_user_evidence() + .ok() + .flatten() + .is_some_and(|existing| { + existing.cutover_id == observation.cutover_id + && existing.startup_session_id == startup_session_id + && existing.current_inventory_fingerprint == current_inventory_fingerprint + }) + { + return Ok(true); + } + + let evidence = ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: observation.cutover_id, + startup_session_id: startup_session_id.to_string(), + current_inventory_fingerprint, + route_smoke_confirmed: false, + observed_at_epoch_seconds: now_epoch_seconds(), + confirmed_at_epoch_seconds: None, + }; + validate_component_cutover_user_evidence(&evidence) + .map_err(|_| io::Error::new(ErrorKind::InvalidInput, "invalid startup evidence"))?; + storage.write_component_cutover_user_evidence(&evidence)?; + Ok(true) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MigrationPhase { + BackedUp, + SourceConverted, + GeneratedValidated, + StorageVerified, + StorageComplete, +} + +pub trait MigrationHook { + fn after_phase(&self, phase: MigrationPhase) -> io::Result<()>; +} + +struct NoopMigrationHook; + +impl MigrationHook for NoopMigrationHook { + fn after_phase(&self, _phase: MigrationPhase) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct MigrationJournal { + journal_schema_version: u32, + migration_id: String, + phase: MigrationPhase, + snapshot_dir: PathBuf, + legacy_source_path: Option, + legacy_source_sha256: Option, + tracked_files: Vec, + started_at_epoch_seconds: u64, + last_error_code: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum MigrationJournalErrorCode { + #[serde(rename = "migration_interrupted")] + Interrupted, + #[serde(rename = "migration_io_failed")] + IoFailed, + #[serde(rename = "migration_validation_failed")] + ValidationFailed, + #[serde(rename = "migration_unclean_shutdown")] + UncleanShutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct TrackedFile { + target_path: PathBuf, + existed: bool, + snapshot_name: Option, + sha256: Option, +} + +#[derive(Debug, Clone, Copy)] +enum RunError { + Io, + Interrupted, + Invalid, +} + +impl From for RunError { + fn from(_error: io::Error) -> Self { + Self::Io + } +} + +type StorageLock = crate::configuration_transaction::RootGuard; + +#[derive(Debug)] +struct StrictSplitState { + any_source_exists: bool, + existing_source_count: usize, + recoveries: Vec<(PathBuf, Vec)>, + invalid: bool, + ignored_invalid_components: bool, +} + +#[derive(Debug)] +struct ImportedState { + profiles: Vec, + targets: Vec, + generated: ProxiFyreConfig, + source_bytes: Vec, + source_path: PathBuf, +} + +enum MetaInspection { + Absent, + Current(StorageMeta), + Unsupported, + Recoverable(StorageMeta, Vec), + Invalid, +} + +pub fn prepare_storage( + storage: &JsonStorage, + legacy_candidates: &[PathBuf], +) -> Result { + prepare_storage_with_hook(storage, legacy_candidates, &NoopMigrationHook) +} + +#[doc(hidden)] +pub fn prepare_storage_with_hook( + storage: &JsonStorage, + legacy_candidates: &[PathBuf], + hook: &dyn MigrationHook, +) -> Result { + ensure_storage_paths_safe(storage) + .map_err(|_| migration_command_error("storage_path_unsafe"))?; + let _lock = + acquire_storage_lock(storage).map_err(|_| migration_command_error("migration_locked"))?; + crate::configuration_transaction::recover_locked(storage) + .map_err(|_| migration_command_error("configuration_recovery_failed"))?; + let active_journal = active_journal_path(storage); + let active_exists = path_exists_strict(&active_journal) + .map_err(|_| migration_command_error("migration_journal_read_failed"))?; + + if !active_exists { + match inspect_meta(storage) { + MetaInspection::Current(meta) => { + let split = inspect_split_storage(storage); + if split.invalid { + return Ok(blocked_status("split_storage_invalid")); + } + if split.recoveries.is_empty() { + return Ok(status_from_meta(&meta, false)); + } + } + MetaInspection::Unsupported => { + return Ok(blocked_status("storage_schema_unsupported")); + } + MetaInspection::Invalid => return Ok(blocked_status("storage_meta_invalid")), + MetaInspection::Absent => { + if let Some(blocked) = preflight_without_writes(storage, legacy_candidates)? { + return Ok(blocked); + } + } + MetaInspection::Recoverable(_, _) => {} + } + } + + if path_exists_strict(&active_journal) + .map_err(|_| migration_command_error("migration_journal_read_failed"))? + { + recover_active_journal_locked(storage) + .map_err(|_| migration_command_error("migration_recovery_failed"))?; + } + + match inspect_meta(storage) { + MetaInspection::Current(meta) => { + let split = inspect_split_storage(storage); + if split.invalid { + return Ok(blocked_status("split_storage_invalid")); + } + if split.recoveries.is_empty() { + return Ok(status_from_meta(&meta, false)); + } + return run_meta_only_transaction( + storage, + meta.outcome, + split.recoveries, + Some("split_recovered_from_backup"), + hook, + ); + } + MetaInspection::Recoverable(meta, bytes) => { + return run_meta_only_transaction( + storage, + meta.outcome, + vec![(storage.paths().storage_meta_file.clone(), bytes)], + Some("storage_meta_recovered"), + hook, + ); + } + MetaInspection::Unsupported => { + return Ok(blocked_status("storage_schema_unsupported")); + } + MetaInspection::Invalid => return Ok(blocked_status("storage_meta_invalid")), + MetaInspection::Absent => {} + } + + let split = inspect_split_storage(storage); + if split.invalid { + return Ok(blocked_status("split_storage_invalid")); + } + + let existing_legacy = existing_legacy_candidates(legacy_candidates) + .map_err(|_| migration_command_error("legacy_discovery_failed"))?; + + if split.any_source_exists { + let notice = if !existing_legacy.is_empty() { + Some("legacy_preserved_current_wins") + } else if split.ignored_invalid_components { + Some("legacy_components_ignored_invalid") + } else if split.existing_source_count < split_source_paths(storage).len() { + Some("partial_split_adopted") + } else if !split.recoveries.is_empty() { + Some("split_recovered_from_backup") + } else { + None + }; + return run_adoption(storage, split.recoveries, notice, hook); + } + + match existing_legacy.as_slice() { + [] => run_empty_initialization(storage, hook), + [source] => match strict_import_legacy(source) { + Ok(imported) => run_import(storage, imported, hook), + Err(_) => Ok(blocked_status("legacy_config_unsupported")), + }, + _ => Ok(blocked_status("legacy_config_ambiguous")), + } +} + +fn preflight_without_writes( + storage: &JsonStorage, + legacy_candidates: &[PathBuf], +) -> Result, CommandError> { + let split = inspect_split_storage(storage); + if split.invalid { + return Ok(Some(blocked_status("split_storage_invalid"))); + } + if split.any_source_exists { + return Ok(None); + } + + let existing = existing_legacy_candidates(legacy_candidates) + .map_err(|_| migration_command_error("legacy_discovery_failed"))?; + match existing.as_slice() { + [] => Ok(None), + [source] if strict_import_legacy(source).is_ok() => Ok(None), + [_] => Ok(Some(blocked_status("legacy_config_unsupported"))), + _ => Ok(Some(blocked_status("legacy_config_ambiguous"))), + } +} + +pub fn recover_incomplete_migration(storage: &JsonStorage) -> Result { + ensure_storage_paths_safe(storage) + .map_err(|_| migration_command_error("storage_path_unsafe"))?; + let _lock = + acquire_storage_lock(storage).map_err(|_| migration_command_error("migration_locked"))?; + crate::configuration_transaction::recover_locked(storage) + .map_err(|_| migration_command_error("configuration_recovery_failed"))?; + if !path_exists_strict(&active_journal_path(storage)) + .map_err(|_| migration_command_error("migration_journal_read_failed"))? + { + return Ok(false); + } + + if !path_exists_strict(&active_journal_path(storage)) + .map_err(|_| migration_command_error("migration_journal_read_failed"))? + { + return Ok(false); + } + recover_active_journal_locked(storage) + .map_err(|_| migration_command_error("migration_recovery_failed"))?; + Ok(true) +} + +pub fn reconcile_component_layout( + storage: &JsonStorage, + proxyfier_inventory: &ComponentInventory, + singbox_inventory: &ComponentInventory, +) -> Result, CommandError> { + let has_managed_legacy = [proxyfier_inventory, singbox_inventory] + .into_iter() + .flat_map(|inventory| inventory.candidates.iter()) + .any(|candidate| candidate.classification == ComponentClassification::ManagedLegacy); + if has_managed_legacy { + return Ok(None); + } + + let path = &storage.paths().component_layout_file; + if path_exists_strict(path).map_err(|_| migration_command_error("layout_meta_invalid"))? { + safe_fs::ensure_no_reparse_ancestors(path) + .map_err(|_| migration_command_error("layout_path_unsafe"))?; + let bytes = fs::read(path).map_err(|_| migration_command_error("layout_meta_invalid"))?; + let meta: ComponentLayoutMeta = serde_json::from_slice(&bytes) + .map_err(|_| migration_command_error("layout_meta_invalid"))?; + return if meta.component_layout_version == CURRENT_COMPONENT_LAYOUT { + Ok(Some(CURRENT_COMPONENT_LAYOUT)) + } else { + Err(migration_command_error("layout_schema_unsupported")) + }; + } + + safe_fs::ensure_no_reparse_ancestors(path) + .map_err(|_| migration_command_error("layout_path_unsafe"))?; + storage + .write_component_layout(&ComponentLayoutMeta { + component_layout_version: CURRENT_COMPONENT_LAYOUT, + verified_at_epoch_seconds: now_epoch_seconds(), + }) + .map_err(|_| migration_command_error("layout_write_failed"))?; + Ok(Some(CURRENT_COMPONENT_LAYOUT)) +} + +pub fn with_component_layout( + mut status: StorageMigrationStatusDto, + component_layout_version: Option, +) -> StorageMigrationStatusDto { + status.component_layout_version = component_layout_version; + status +} + +fn run_empty_initialization( + storage: &JsonStorage, + hook: &dyn MigrationHook, +) -> Result { + run_meta_only_transaction( + storage, + StorageMigrationOutcome::InitializedEmpty, + Vec::new(), + None, + hook, + ) +} + +fn run_adoption( + storage: &JsonStorage, + recoveries: Vec<(PathBuf, Vec)>, + notice: Option<&str>, + hook: &dyn MigrationHook, +) -> Result { + run_meta_only_transaction( + storage, + StorageMigrationOutcome::AdoptedWithoutLegacyImport, + recoveries, + notice, + hook, + ) +} + +fn run_meta_only_transaction( + storage: &JsonStorage, + outcome: StorageMigrationOutcome, + recoveries: Vec<(PathBuf, Vec)>, + notice: Option<&str>, + hook: &dyn MigrationHook, +) -> Result { + let migration_id = uuid::Uuid::new_v4().hyphenated().to_string(); + let mut targets = vec![storage.paths().storage_meta_file.clone()]; + targets.extend(recoveries.iter().map(|(path, _)| path.clone())); + let targets = targets_with_backups(targets); + let mut journal = create_journal(storage, &migration_id, &targets, None) + .map_err(|_| migration_command_error("migration_snapshot_failed"))?; + + let run = (|| -> Result<(), RunError> { + persist_phase(storage, &mut journal, MigrationPhase::BackedUp, hook)?; + for (path, bytes) in &recoveries { + safe_fs::write_atomic_without_backup(path, bytes)?; + } + persist_phase(storage, &mut journal, MigrationPhase::SourceConverted, hook)?; + persist_phase( + storage, + &mut journal, + MigrationPhase::GeneratedValidated, + hook, + )?; + validate_split_after_recovery(storage)?; + persist_phase(storage, &mut journal, MigrationPhase::StorageVerified, hook)?; + let meta = storage_meta(&migration_id, outcome.clone()); + storage.write_storage_meta(&meta)?; + let read_back = read_meta_without_recovery(storage) + .map_err(|_| RunError::Io)? + .ok_or(RunError::Invalid)?; + if read_back != meta { + return Err(RunError::Invalid); + } + persist_phase(storage, &mut journal, MigrationPhase::StorageComplete, hook)?; + archive_active_journal(storage, &journal, "complete")?; + Ok(()) + })(); + + finish_transaction(storage, &mut journal, run)?; + let meta = storage_meta(&migration_id, outcome); + let mut status = status_from_meta(&meta, true); + if let Some(code) = notice { + status.notice_code = Some(code.to_string()); + status.message = migration_message(code).to_string(); + } + Ok(status) +} + +fn run_import( + storage: &JsonStorage, + imported: ImportedState, + hook: &dyn MigrationHook, +) -> Result { + let migration_id = uuid::Uuid::new_v4().hyphenated().to_string(); + let generated_path = storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + let targets = targets_with_backups(vec![ + storage.paths().profiles_file.clone(), + storage.paths().targets_file.clone(), + storage.paths().storage_meta_file.clone(), + generated_path.clone(), + ]); + let legacy = ( + imported.source_path.clone(), + imported.source_bytes.as_slice(), + ); + let mut journal = create_journal(storage, &migration_id, &targets, Some(legacy)) + .map_err(|_| migration_command_error("migration_snapshot_failed"))?; + + let generated_bytes = serde_json::to_vec_pretty(&imported.generated) + .map_err(|_| migration_command_error("legacy_config_unsupported"))?; + let run = (|| -> Result<(), RunError> { + persist_phase(storage, &mut journal, MigrationPhase::BackedUp, hook)?; + storage.write_profiles(&imported.profiles)?; + storage.write_targets(&imported.targets)?; + persist_phase(storage, &mut journal, MigrationPhase::SourceConverted, hook)?; + + safe_fs::write_restricted_with_backup(&generated_path, &generated_bytes)?; + let generated_read_back: ProxiFyreConfig = + serde_json::from_slice(&fs::read(&generated_path)?).map_err(|_| RunError::Invalid)?; + if generated_read_back != imported.generated { + return Err(RunError::Invalid); + } + persist_phase( + storage, + &mut journal, + MigrationPhase::GeneratedValidated, + hook, + )?; + + if storage.read_profiles()? != imported.profiles + || storage.read_targets()? != imported.targets + { + return Err(RunError::Invalid); + } + persist_phase(storage, &mut journal, MigrationPhase::StorageVerified, hook)?; + + let meta = storage_meta(&migration_id, StorageMigrationOutcome::ImportedLegacyConfig); + storage.write_storage_meta(&meta)?; + if read_meta_without_recovery(storage) + .map_err(|_| RunError::Io)? + .as_ref() + != Some(&meta) + { + return Err(RunError::Invalid); + } + persist_phase(storage, &mut journal, MigrationPhase::StorageComplete, hook)?; + archive_active_journal(storage, &journal, "complete")?; + Ok(()) + })(); + + finish_transaction(storage, &mut journal, run)?; + Ok(status_from_meta( + &storage_meta(&migration_id, StorageMigrationOutcome::ImportedLegacyConfig), + true, + )) +} + +fn finish_transaction( + storage: &JsonStorage, + journal: &mut MigrationJournal, + run: Result<(), RunError>, +) -> Result<(), CommandError> { + let Err(error) = run else { + return Ok(()); + }; + let (journal_code, public_code) = match error { + RunError::Interrupted => ( + MigrationJournalErrorCode::Interrupted, + "migration_interrupted", + ), + RunError::Io => (MigrationJournalErrorCode::IoFailed, "migration_failed"), + RunError::Invalid => ( + MigrationJournalErrorCode::ValidationFailed, + "migration_failed", + ), + }; + journal.last_error_code = Some(journal_code); + // Diagnostics must never become a prerequisite for restoring user data. + let _ = write_active_journal(storage, journal); + recover_active_journal_locked(storage) + .map_err(|_| migration_command_error("migration_rollback_failed"))?; + Err(migration_command_error(public_code)) +} + +fn create_journal( + storage: &JsonStorage, + migration_id: &str, + targets: &[PathBuf], + legacy_source: Option<(PathBuf, &[u8])>, +) -> io::Result { + let migrations_dir = &storage.paths().migrations_dir; + safe_fs::ensure_no_reparse_ancestors(migrations_dir)?; + fs::create_dir_all(migrations_dir)?; + safe_fs::protect_path_for_owner_admin_system(migrations_dir)?; + + let migration_dir = migrations_dir.join(migration_id); + let snapshot_dir = migration_dir.join("snapshot"); + fs::create_dir_all(&snapshot_dir)?; + safe_fs::protect_path_for_owner_admin_system(&migration_dir)?; + safe_fs::protect_path_for_owner_admin_system(&snapshot_dir)?; + + let mut tracked_files = Vec::with_capacity(targets.len()); + for (index, target) in targets.iter().enumerate() { + safe_fs::ensure_no_reparse_ancestors(target)?; + if let Some(bytes) = read_optional_file(target)? { + let snapshot_name = format!("tracked-{index}.bin"); + safe_fs::write_new_restricted(&snapshot_dir.join(&snapshot_name), &bytes)?; + tracked_files.push(TrackedFile { + target_path: absolute_path(target)?, + existed: true, + snapshot_name: Some(snapshot_name), + sha256: Some(sha256_hex(&bytes)), + }); + } else { + tracked_files.push(TrackedFile { + target_path: absolute_path(target)?, + existed: false, + snapshot_name: None, + sha256: None, + }); + } + } + + let (legacy_source_path, legacy_source_sha256) = match legacy_source { + Some((path, bytes)) => { + safe_fs::ensure_no_reparse_ancestors(&path)?; + safe_fs::write_new_restricted(&snapshot_dir.join("legacy-source.json"), bytes)?; + (Some(absolute_path(&path)?), Some(sha256_hex(bytes))) + } + None => (None, None), + }; + + let journal = MigrationJournal { + journal_schema_version: JOURNAL_SCHEMA, + migration_id: migration_id.to_string(), + phase: MigrationPhase::BackedUp, + snapshot_dir: absolute_path(&snapshot_dir)?, + legacy_source_path, + legacy_source_sha256, + tracked_files, + started_at_epoch_seconds: now_epoch_seconds(), + last_error_code: None, + }; + write_active_journal(storage, &journal)?; + Ok(journal) +} + +fn persist_phase( + storage: &JsonStorage, + journal: &mut MigrationJournal, + phase: MigrationPhase, + hook: &dyn MigrationHook, +) -> Result<(), RunError> { + journal.phase = phase; + write_active_journal(storage, journal)?; + hook.after_phase(phase).map_err(|_| RunError::Interrupted) +} + +fn write_active_journal(storage: &JsonStorage, journal: &MigrationJournal) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(journal) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?; + safe_fs::write_restricted_atomic(&active_journal_path(storage), &bytes) +} + +fn recover_active_journal_locked(storage: &JsonStorage) -> io::Result<()> { + let active_path = active_journal_path(storage); + safe_fs::ensure_no_reparse_ancestors(&active_path)?; + let bytes = fs::read(&active_path)?; + let mut journal: MigrationJournal = serde_json::from_slice(&bytes) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?; + validate_journal(storage, &journal)?; + + if journal.last_error_code.is_none() { + journal.last_error_code = Some(MigrationJournalErrorCode::UncleanShutdown); + // A diagnostic update is useful but cannot block snapshot restoration. + let _ = write_active_journal(storage, &journal); + } + + let mut restore_plan = Vec::with_capacity(journal.tracked_files.len()); + for tracked in &journal.tracked_files { + safe_fs::ensure_no_reparse_ancestors(&tracked.target_path)?; + if tracked.existed { + let snapshot_name = tracked.snapshot_name.as_deref().ok_or_else(|| { + io::Error::new(ErrorKind::InvalidData, "missing snapshot identity") + })?; + let snapshot = journal.snapshot_dir.join(snapshot_name); + safe_fs::ensure_no_reparse_ancestors(&snapshot)?; + let snapshot_bytes = fs::read(&snapshot)?; + if tracked.sha256.as_deref() != Some(sha256_hex(&snapshot_bytes).as_str()) { + return Err(io::Error::new( + ErrorKind::InvalidData, + "snapshot integrity mismatch", + )); + } + restore_plan.push((tracked.target_path.clone(), Some(snapshot_bytes))); + } else { + restore_plan.push((tracked.target_path.clone(), None)); + } + } + + for (target, snapshot_bytes) in restore_plan { + if let Some(snapshot_bytes) = snapshot_bytes { + if is_canonical_generated_target(storage, &target)? { + safe_fs::write_restricted_atomic(&target, &snapshot_bytes)?; + } else { + safe_fs::write_atomic_without_backup(&target, &snapshot_bytes)?; + } + } else { + remove_file_if_exists(&target)?; + } + } + + archive_active_journal(storage, &journal, "rolled-back") +} + +fn validate_journal(storage: &JsonStorage, journal: &MigrationJournal) -> io::Result<()> { + let parsed_id = uuid::Uuid::parse_str(&journal.migration_id) + .map_err(|_| io::Error::new(ErrorKind::InvalidData, "invalid migration identity"))?; + if journal.journal_schema_version != JOURNAL_SCHEMA + || parsed_id.hyphenated().to_string() != journal.migration_id + || journal.tracked_files.is_empty() + { + return Err(io::Error::new( + ErrorKind::InvalidData, + "invalid migration journal", + )); + } + + let migrations_dir = fs::canonicalize(&storage.paths().migrations_dir)?; + let snapshot_dir = fs::canonicalize(&journal.snapshot_dir)?; + let expected_snapshot_dir = fs::canonicalize( + storage + .paths() + .migrations_dir + .join(&journal.migration_id) + .join("snapshot"), + )?; + if !snapshot_dir.starts_with(&migrations_dir) || snapshot_dir != expected_snapshot_dir { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "snapshot path is outside migration storage", + )); + } + safe_fs::ensure_no_reparse_ancestors(&snapshot_dir)?; + + let allowed_paths = allowed_recovery_targets(storage); + for path in &allowed_paths { + safe_fs::ensure_no_reparse_ancestors(path)?; + } + let allowed: HashSet<_> = allowed_paths + .into_iter() + .map(|path| absolute_path(&path)) + .collect::>()?; + let mut seen = HashSet::new(); + for tracked in &journal.tracked_files { + safe_fs::ensure_no_reparse_ancestors(&tracked.target_path)?; + let target = absolute_path(&tracked.target_path)?; + if !allowed.contains(&target) || !seen.insert(target) { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "journal target is not allowlisted", + )); + } + match (&tracked.snapshot_name, &tracked.sha256, tracked.existed) { + (Some(name), Some(hash), true) + if safe_snapshot_name(name) + && hash.len() == 64 + && hash.chars().all(|ch| ch.is_ascii_hexdigit()) => {} + (None, None, false) => {} + _ => { + return Err(io::Error::new( + ErrorKind::InvalidData, + "invalid tracked snapshot", + )); + } + } + } + match ( + journal.legacy_source_path.as_ref(), + journal.legacy_source_sha256.as_deref(), + ) { + (Some(source), Some(expected_hash)) + if source.is_absolute() + && expected_hash.len() == 64 + && expected_hash.chars().all(|ch| ch.is_ascii_hexdigit()) => + { + let legacy_snapshot = snapshot_dir.join("legacy-source.json"); + let bytes = fs::read(legacy_snapshot)?; + if sha256_hex(&bytes) != expected_hash { + return Err(io::Error::new( + ErrorKind::InvalidData, + "legacy snapshot integrity mismatch", + )); + } + } + (None, None) => {} + _ => { + return Err(io::Error::new( + ErrorKind::InvalidData, + "invalid legacy snapshot identity", + )); + } + } + Ok(()) +} + +fn archive_active_journal( + storage: &JsonStorage, + journal: &MigrationJournal, + suffix: &str, +) -> io::Result<()> { + let active = active_journal_path(storage); + let destination = storage + .paths() + .migrations_dir + .join(format!("{}.{}.json", journal.migration_id, suffix)); + safe_fs::ensure_no_reparse_ancestors(&active)?; + safe_fs::ensure_no_reparse_ancestors(&destination)?; + if path_exists_strict(&destination)? { + return Err(io::Error::new( + ErrorKind::AlreadyExists, + "migration journal archive already exists", + )); + } + fs::rename(&active, &destination)?; + safe_fs::protect_path_for_owner_admin_system(&destination)?; + let _ = remove_file_if_exists(&safe_fs::backup_path(&active)); + Ok(()) +} + +fn acquire_storage_lock(storage: &JsonStorage) -> io::Result { + crate::configuration_transaction::acquire_root(storage) +} + +fn inspect_split_storage(storage: &JsonStorage) -> StrictSplitState { + let paths = split_source_paths(storage); + let mut live_files = BTreeMap::new(); + let mut existing_source_count = 0; + let mut recoveries = Vec::new(); + let mut invalid = false; + + for path in &paths { + match read_optional_file(path) { + Ok(Some(bytes)) => { + existing_source_count += 1; + live_files.insert(path.clone(), Some(bytes)); + } + Ok(None) => { + live_files.insert(path.clone(), None); + } + Err(_) => { + invalid = true; + live_files.insert(path.clone(), None); + } + } + } + + for path in [ + &storage.paths().profiles_file, + &storage.paths().targets_file, + &storage.paths().local_singbox_file, + ] { + let live = live_files.get(path).and_then(Option::as_ref); + if live.is_none_or(|bytes| !validate_split_file(path, storage, bytes)) { + match read_optional_file(&backup_path(path)) { + Ok(Some(bytes)) if validate_split_file(path, storage, &bytes) => { + if live.is_none() { + existing_source_count += 1; + } + recoveries.push((path.clone(), bytes)); + } + Ok(None) if live.is_none() => {} + Ok(_) | Err(_) => invalid = true, + } + } + } + + let ignored_invalid_components = live_files + .get(&storage.paths().components_file) + .and_then(Option::as_ref) + .is_some_and(|bytes| serde_json::from_slice::>(bytes).is_err()); + + let effective_bytes = |path: &Path| { + recoveries + .iter() + .find(|(target, _)| target == path) + .map(|(_, bytes)| bytes.clone()) + .or_else(|| live_files.get(path).and_then(Clone::clone)) + }; + let profiles = effective_bytes(&storage.paths().profiles_file) + .and_then(|bytes| strict_profiles(&bytes).ok()) + .unwrap_or_default(); + let targets = effective_bytes(&storage.paths().targets_file) + .and_then(|bytes| strict_targets(&bytes).ok()) + .unwrap_or_default(); + if profiles + .iter() + .any(|profile| !targets.iter().any(|target| target.id == profile.target_id)) + { + invalid = true; + } + + StrictSplitState { + any_source_exists: existing_source_count > 0, + existing_source_count, + recoveries, + invalid, + ignored_invalid_components, + } +} + +fn validate_split_file(path: &Path, storage: &JsonStorage, bytes: &[u8]) -> bool { + if path == storage.paths().profiles_file { + strict_profiles(bytes).is_ok() + } else if path == storage.paths().targets_file { + strict_targets(bytes).is_ok() + } else if path == storage.paths().local_singbox_file { + strict_local_singbox(bytes).is_ok() + } else { + false + } +} + +fn validate_split_after_recovery(storage: &JsonStorage) -> Result<(), RunError> { + let split = inspect_split_storage(storage); + if split.invalid || !split.recoveries.is_empty() { + Err(RunError::Invalid) + } else { + let profiles = read_optional_file(&storage.paths().profiles_file)? + .map(|bytes| strict_profiles(&bytes)) + .transpose()? + .unwrap_or_default(); + let targets = read_optional_file(&storage.paths().targets_file)? + .map(|bytes| strict_targets(&bytes)) + .transpose()? + .unwrap_or_default(); + if profiles + .iter() + .any(|profile| !targets.iter().any(|target| target.id == profile.target_id)) + { + return Err(RunError::Invalid); + } + Ok(()) + } +} + +fn strict_import_legacy(path: &Path) -> Result { + let metadata = fs::metadata(path).map_err(|_| ())?; + if !metadata.is_file() || metadata.len() > MAX_LEGACY_CONFIG_BYTES { + return Err(()); + } + safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| ())?; + let source_bytes = fs::read(path).map_err(|_| ())?; + let legacy: LegacyProxiFyreConfig = serde_json::from_slice(&source_bytes).map_err(|_| ())?; + if !legacy.extras.is_empty() + || legacy.log_level != "Info" + || !legacy.bypass_lan + || legacy.proxies.is_empty() + { + return Err(()); + } + + let proxy_count = legacy.proxies.len(); + let mut profiles = Vec::with_capacity(proxy_count); + let mut targets = Vec::with_capacity(proxy_count); + let mut canonical_proxies = Vec::with_capacity(proxy_count); + for (index, proxy) in legacy.proxies.into_iter().enumerate() { + if !proxy.extras.is_empty() + || proxy.app_names.is_empty() + || proxy.supported_protocols.is_empty() + { + return Err(()); + } + let mut protocols = Vec::with_capacity(proxy.supported_protocols.len()); + for value in &proxy.supported_protocols { + let protocol = match value.as_str() { + "TCP" => Protocol::Tcp, + "UDP" => Protocol::Udp, + _ => return Err(()), + }; + if protocols.contains(&protocol) { + return Err(()); + } + protocols.push(protocol); + } + let (host, port) = strict_plain_endpoint(&proxy.socks5_proxy_endpoint)?; + let mut items = Vec::with_capacity(proxy.app_names.len()); + for value in &proxy.app_names { + if value.trim().is_empty() + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(()); + } + let is_path = value.contains(['\\', '/']); + let (item_type, recursive) = if is_path && value.to_ascii_lowercase().ends_with(".exe") + { + (ProfileItemType::Exe, false) + } else if is_path { + (ProfileItemType::Folder, true) + } else { + (ProfileItemType::Process, false) + }; + items.push(ProfileItem { + item_type, + value: value.clone(), + recursive, + }); + } + + let ordinal = index + 1; + let single = proxy_count == 1; + let target_id = if single { + "main-proxy".to_string() + } else { + format!("legacy-proxifyre-target-{ordinal}") + }; + profiles.push(Profile { + id: if single { + "main-profile".to_string() + } else { + format!("legacy-proxifyre-profile-{ordinal}") + }, + name: if single { + "Приложения через прокси".to_string() + } else { + format!("Импорт ProxiFyre {ordinal}") + }, + enabled: true, + target_id: target_id.clone(), + protocols, + items, + }); + targets.push(Target { + id: target_id, + name: if single { + "Основной прокси".to_string() + } else { + format!("Прокси ProxiFyre {ordinal}") + }, + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host, + port, + requires_component: None, + }); + canonical_proxies.push(ProxiFyreProxy { + app_names: proxy.app_names, + socks5_proxy_endpoint: proxy.socks5_proxy_endpoint, + supported_protocols: proxy.supported_protocols, + }); + } + + let source_config = ProxiFyreConfig { + log_level: "Info".to_string(), + bypass_lan: true, + proxies: canonical_proxies, + }; + let generated = ProxiFyreAdapter::default() + .generate_proxifyre_config(ProxyRouterRequest::new(&profiles, &targets, &[])) + .map_err(|_| ())?; + if generated != source_config { + return Err(()); + } + validate_canonical_models(&profiles, &targets)?; + + Ok(ImportedState { + profiles, + targets, + generated, + source_bytes, + source_path: path.to_path_buf(), + }) +} + +fn strict_plain_endpoint(endpoint: &str) -> Result<(String, u16), ()> { + if endpoint.trim() != endpoint + || endpoint.contains(['/', '@', '\\']) + || endpoint.matches(':').count() != 1 + { + return Err(()); + } + let (host, port) = endpoint.rsplit_once(':').ok_or(())?; + if host.is_empty() || host.chars().any(|ch| ch.is_whitespace() || ch.is_control()) { + return Err(()); + } + let port = port.parse::().map_err(|_| ())?; + if port == 0 || url::Host::parse(host).is_err() { + return Err(()); + } + Ok((host.to_string(), port)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyProxiFyreConfig { + #[serde(rename = "logLevel")] + log_level: String, + #[serde(rename = "bypassLan")] + bypass_lan: bool, + proxies: Vec, + #[serde(flatten)] + extras: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct LegacyProxiFyreProxy { + #[serde(rename = "appNames")] + app_names: Vec, + #[serde(rename = "socks5ProxyEndpoint")] + socks5_proxy_endpoint: String, + #[serde(rename = "supportedProtocols")] + supported_protocols: Vec, + #[serde(flatten)] + extras: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct StrictProfile { + id: String, + name: String, + enabled: bool, + target_id: String, + protocols: Vec, + items: Vec, + #[serde(flatten)] + extras: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct StrictProfileItem { + #[serde(rename = "type")] + item_type: ProfileItemType, + value: String, + recursive: bool, + #[serde(flatten)] + extras: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct StrictTarget { + id: String, + name: String, + kind: TargetKind, + protocol: ProxyProtocol, + host: String, + port: u16, + requires_component: Option, + #[serde(flatten)] + extras: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct StrictLocalSingBox { + #[serde(default)] + subscription_url: Option, + #[serde(default)] + device_hwid: Option, + #[serde(default)] + selected_server_tag: Option, + #[serde(default)] + selected_server_id: Option, + #[serde(default)] + listen_host: Option, + #[serde(default)] + listen_port: Option, + #[serde(default)] + service_name: Option, + #[serde(default)] + install_root: Option, + #[serde(default)] + updated_at: Option, + #[serde(flatten)] + extras: BTreeMap, +} + +fn strict_profiles(bytes: &[u8]) -> Result, RunError> { + let values: Vec = + serde_json::from_slice(bytes).map_err(|_| RunError::Invalid)?; + let mut profiles = Vec::with_capacity(values.len()); + for value in values { + if !value.extras.is_empty() || value.items.iter().any(|item| !item.extras.is_empty()) { + return Err(RunError::Invalid); + } + let profile = Profile { + id: value.id, + name: value.name, + enabled: value.enabled, + target_id: value.target_id, + protocols: value.protocols, + items: value + .items + .into_iter() + .map(|item| ProfileItem { + item_type: item.item_type, + value: item.value, + recursive: item.recursive, + }) + .collect(), + }; + if normalize_profile(profile_input(&profile)).ok().as_ref() != Some(&profile) { + return Err(RunError::Invalid); + } + profiles.push(profile); + } + Ok(profiles) +} + +fn strict_targets(bytes: &[u8]) -> Result, RunError> { + let values: Vec = serde_json::from_slice(bytes).map_err(|_| RunError::Invalid)?; + let mut targets = Vec::with_capacity(values.len()); + for value in values { + if !value.extras.is_empty() { + return Err(RunError::Invalid); + } + let target = Target { + id: value.id, + name: value.name, + kind: value.kind, + protocol: value.protocol, + host: value.host, + port: value.port, + requires_component: value.requires_component, + }; + if normalize_target(target_input(&target)).ok().as_ref() != Some(&target) { + return Err(RunError::Invalid); + } + targets.push(target); + } + Ok(targets) +} + +fn strict_local_singbox(bytes: &[u8]) -> Result<(), RunError> { + let value: StrictLocalSingBox = serde_json::from_slice(bytes).map_err(|_| RunError::Invalid)?; + if !value.extras.is_empty() { + return Err(RunError::Invalid); + } + // Reading all known fields is intentional: it ensures credential-bearing + // values remain representable without ever copying them into outward text. + let _known = ( + value.subscription_url, + value.device_hwid, + value.selected_server_tag, + value.selected_server_id, + value.listen_host, + value.listen_port, + value.service_name, + value.install_root, + value.updated_at, + ); + Ok(()) +} + +fn validate_canonical_models(profiles: &[Profile], targets: &[Target]) -> Result<(), ()> { + let mut profile_ids = HashSet::new(); + let mut target_ids = HashSet::new(); + if profiles.iter().any(|profile| { + !profile_ids.insert(profile.id.clone()) + || normalize_profile(profile_input(profile)).ok().as_ref() != Some(profile) + || !targets.iter().any(|target| target.id == profile.target_id) + }) || targets.iter().any(|target| { + !target_ids.insert(target.id.clone()) + || normalize_target(target_input(target)).ok().as_ref() != Some(target) + }) { + return Err(()); + } + Ok(()) +} + +fn profile_input(profile: &Profile) -> ProfileInput { + ProfileInput { + id: Some(profile.id.clone()), + name: profile.name.clone(), + enabled: profile.enabled, + target_id: profile.target_id.clone(), + protocols: profile + .protocols + .iter() + .map(|protocol| match protocol { + Protocol::Tcp => "TCP".to_string(), + Protocol::Udp => "UDP".to_string(), + }) + .collect(), + items: profile + .items + .iter() + .map(|item| ProfileItemInput { + item_type: match item.item_type { + ProfileItemType::Process => "process", + ProfileItemType::Folder => "folder", + ProfileItemType::Exe => "exe", + } + .to_string(), + value: item.value.clone(), + recursive: Some(item.recursive), + }) + .collect(), + } +} + +fn target_input(target: &Target) -> TargetInput { + TargetInput { + id: Some(target.id.clone()), + name: target.name.clone(), + kind: match target.kind { + TargetKind::Local => "local", + TargetKind::External => "external", + } + .to_string(), + protocol: match target.protocol { + ProxyProtocol::Socks5 => "socks5", + ProxyProtocol::Http => "http", + } + .to_string(), + host: target.host.clone(), + port: u32::from(target.port), + requires_component: target.requires_component.as_ref().map(|component| { + match component { + ComponentId::ControlApp => "control-app", + ComponentId::Proxyfier => "proxyfier", + ComponentId::Singbox => "singbox", + } + .to_string() + }), + } +} + +fn existing_legacy_candidates(candidates: &[PathBuf]) -> io::Result> { + let mut output = Vec::new(); + let mut seen = HashSet::new(); + for candidate in candidates { + safe_fs::ensure_no_reparse_ancestors(candidate)?; + match fs::metadata(candidate) { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => { + return Err(io::Error::new( + ErrorKind::InvalidData, + "legacy config candidate is not a regular file", + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => return Err(error), + } + let canonical = fs::canonicalize(candidate)?; + if seen.insert(canonical.clone()) { + output.push(canonical); + } + } + Ok(output) +} + +fn read_meta_without_recovery(storage: &JsonStorage) -> io::Result> { + match fs::read(&storage.paths().storage_meta_file) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn inspect_meta(storage: &JsonStorage) -> MetaInspection { + let path = &storage.paths().storage_meta_file; + match fs::read(path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(meta) + if meta.storage_schema_version == CURRENT_STORAGE_SCHEMA + && !meta.migration_id.is_empty() => + { + MetaInspection::Current(meta) + } + Ok(_) => MetaInspection::Unsupported, + Err(_) => recoverable_meta_from_backup(path), + }, + Err(error) if error.kind() == ErrorKind::NotFound => { + match read_optional_file(&backup_path(path)) { + Ok(Some(bytes)) => recoverable_meta_from_bytes(bytes), + Ok(None) => MetaInspection::Absent, + Err(_) => MetaInspection::Invalid, + } + } + Err(_) => MetaInspection::Invalid, + } +} + +fn recoverable_meta_from_backup(path: &Path) -> MetaInspection { + let Ok(Some(bytes)) = read_optional_file(&backup_path(path)) else { + return MetaInspection::Invalid; + }; + recoverable_meta_from_bytes(bytes) +} + +fn recoverable_meta_from_bytes(bytes: Vec) -> MetaInspection { + match serde_json::from_slice::(&bytes) { + Ok(meta) + if meta.storage_schema_version == CURRENT_STORAGE_SCHEMA + && !meta.migration_id.is_empty() => + { + MetaInspection::Recoverable(meta, bytes) + } + Ok(_) => MetaInspection::Unsupported, + Err(_) => MetaInspection::Invalid, + } +} + +fn storage_meta(id: &str, outcome: StorageMigrationOutcome) -> StorageMeta { + StorageMeta { + storage_schema_version: CURRENT_STORAGE_SCHEMA, + outcome, + migration_id: id.to_string(), + completed_at_epoch_seconds: now_epoch_seconds(), + } +} + +fn status_from_meta(meta: &StorageMeta, changed: bool) -> StorageMigrationStatusDto { + let outcome = match meta.outcome { + StorageMigrationOutcome::InitializedEmpty => "initialized_empty", + StorageMigrationOutcome::AdoptedWithoutLegacyImport => "adopted_without_legacy_import", + StorageMigrationOutcome::ImportedLegacyConfig => "imported_legacy_config", + }; + StorageMigrationStatusDto { + storage_schema_version: meta.storage_schema_version, + component_layout_version: None, + outcome: outcome.to_string(), + changed, + blocking: false, + notice_code: None, + message: migration_message(outcome).to_string(), + } +} + +fn blocked_status(code: &str) -> StorageMigrationStatusDto { + StorageMigrationStatusDto { + storage_schema_version: 0, + component_layout_version: None, + outcome: "blocked".to_string(), + changed: false, + blocking: true, + notice_code: Some(code.to_string()), + message: migration_message(code).to_string(), + } +} + +fn migration_message(code: &str) -> &'static str { + match code { + "initialized_empty" => "Хранилище настроек готово.", + "adopted_without_legacy_import" => { + "Существующие настройки приняты без смешивания со старым конфигом." + } + "imported_legacy_config" => "Старый конфиг безопасно перенесён в новое хранилище.", + "legacy_preserved_current_wins" => { + "Новые настройки сохранены; старый конфиг оставлен без изменений." + } + "partial_split_adopted" => { + "Неполный набор новых настроек принят без импорта старого конфига." + } + "split_recovered_from_backup" => { + "Повреждённый файл настроек восстановлен из резервной копии." + } + "legacy_components_ignored_invalid" => { + "Старый файл состояния компонентов повреждён, сохранён без изменений и больше не используется." + } + "storage_meta_recovered" => "Метаданные хранилища восстановлены из резервной копии.", + "legacy_config_unsupported" => { + "Старый конфиг содержит неподдерживаемые поля и оставлен без изменений." + } + "legacy_config_ambiguous" => { + "Найдено несколько старых конфигов; автоматический выбор заблокирован." + } + "split_storage_invalid" => { + "Новые настройки повреждены; смешивание со старым конфигом заблокировано." + } + "storage_meta_invalid" => { + "Метаданные хранилища повреждены; автоматическое изменение заблокировано." + } + "storage_schema_unsupported" => { + "Версия хранилища новее поддерживаемой; автоматическое изменение заблокировано." + } + _ => "Требуется проверить состояние хранилища настроек.", + } +} + +fn migration_command_error(code: &str) -> CommandError { + CommandError::new(code, migration_message(code)) +} + +fn split_source_paths(storage: &JsonStorage) -> Vec { + vec![ + storage.paths().profiles_file.clone(), + storage.paths().targets_file.clone(), + storage.paths().components_file.clone(), + storage.paths().local_singbox_file.clone(), + ] +} + +fn targets_with_backups(targets: Vec) -> Vec { + let mut output = Vec::with_capacity(targets.len() * 2); + let mut seen = HashSet::new(); + for target in targets { + for path in [target.clone(), backup_path(&target)] { + if seen.insert(path.clone()) { + output.push(path); + } + } + } + output +} + +fn allowed_recovery_targets(storage: &JsonStorage) -> Vec { + targets_with_backups(vec![ + storage.paths().profiles_file.clone(), + storage.paths().targets_file.clone(), + storage.paths().local_singbox_file.clone(), + storage.paths().storage_meta_file.clone(), + storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"), + ]) +} + +fn is_canonical_generated_target(storage: &JsonStorage, target: &Path) -> io::Result { + let generated = storage + .paths() + .generated_dir + .join("proxifyre-app-config.json"); + Ok(target == absolute_path(&generated)? || target == absolute_path(&backup_path(&generated))?) +} + +fn active_journal_path(storage: &JsonStorage) -> PathBuf { + storage.paths().migrations_dir.join(ACTIVE_JOURNAL_FILE) +} + +fn ensure_storage_paths_safe(storage: &JsonStorage) -> io::Result<()> { + for path in [ + &storage.paths().root, + &storage.paths().config_dir, + &storage.paths().state_dir, + &storage.paths().generated_dir, + &storage.paths().profiles_file, + &storage.paths().targets_file, + &storage.paths().components_file, + &storage.paths().local_singbox_file, + &storage.paths().storage_meta_file, + &storage.paths().component_layout_file, + &storage.paths().migrations_dir, + ] { + safe_fs::ensure_no_reparse_ancestors(path)?; + } + Ok(()) +} + +fn absolute_path(path: &Path) -> io::Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + if absolute.try_exists()? { + return fs::canonicalize(absolute); + } + + let mut existing = absolute.as_path(); + let mut missing = Vec::new(); + while !existing.try_exists()? { + let name = existing.file_name().ok_or_else(|| { + io::Error::new( + ErrorKind::InvalidInput, + "filesystem path has no existing ancestor", + ) + })?; + missing.push(name.to_os_string()); + existing = existing.parent().ok_or_else(|| { + io::Error::new( + ErrorKind::InvalidInput, + "filesystem path has no existing parent", + ) + })?; + } + let mut canonical = fs::canonicalize(existing)?; + for name in missing.into_iter().rev() { + canonical.push(name); + } + Ok(canonical) +} + +fn safe_snapshot_name(name: &str) -> bool { + !name.is_empty() + && Path::new(name).file_name().and_then(|value| value.to_str()) == Some(name) + && !name.contains(['/', '\\']) +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn read_optional_file(path: &Path) -> io::Result>> { + match fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn path_exists_strict(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +fn remove_file_if_exists(path: &Path) -> io::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn now_epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2253837..9a6a72c 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,6 +1,5 @@ use percent_encoding::percent_decode_str; use serde::{Deserialize, Serialize}; -use serde_json::Value; use url::Url; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; @@ -160,12 +159,36 @@ pub struct LocalSingBoxConfig { pub listen_port: u16, #[serde(default = "default_local_singbox_service_name")] pub service_name: String, - #[serde(default = "default_local_singbox_install_root")] + #[serde(default = "default_local_singbox_install_root", skip_serializing)] pub install_root: String, #[serde(default)] pub updated_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageMigrationOutcome { + InitializedEmpty, + AdoptedWithoutLegacyImport, + ImportedLegacyConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StorageMeta { + pub storage_schema_version: u32, + pub outcome: StorageMigrationOutcome, + pub migration_id: String, + pub completed_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ComponentLayoutMeta { + pub component_layout_version: u32, + pub verified_at_epoch_seconds: u64, +} + impl LocalSingBoxConfig { pub fn subscription_display_url(&self) -> Option { self.subscription_url @@ -213,27 +236,7 @@ impl SubscriptionCache { server.ensure_id(); } - let Some(outbounds) = self - .config - .get_mut("outbounds") - .and_then(Value::as_array_mut) - else { - return; - }; - - for outbound in outbounds { - let Some(decoded_tag) = outbound - .get("tag") - .and_then(Value::as_str) - .map(decode_percent_encoded_utf8) - else { - continue; - }; - - if let Some(object) = outbound.as_object_mut() { - object.insert("tag".to_string(), Value::String(decoded_tag)); - } - } + // Outbound bytes define stable identity. Decode display labels only. } } diff --git a/src-tauri/src/nsis_runtime.rs b/src-tauri/src/nsis_runtime.rs new file mode 100644 index 0000000..fb79cb7 --- /dev/null +++ b/src-tauri/src/nsis_runtime.rs @@ -0,0 +1,1080 @@ +//! Fixed early-process boundary used by the generated NSIS uninstaller. +//! +//! The bridge accepts no paths, job identifiers, service names, or command +//! arguments. Upgrade verification is strictly read-only. Full uninstall +//! obtains the same global lifecycle lock as ordinary privileged jobs, proves +//! ownership of both native components before the first component mutation, +//! then performs the fixed stop/uninstall/terminal-cleanup sequence. + +use crate::component_cutover::{ + cutover_terminal_record_fingerprint, read_existing_sealed_cutover_journal, + retire_existing_proxifyre_cutover_terminal_state, verify_cutover_external_mutation_status, + CutoverExternalMutationStatus, CutoverTerminalRetirementExpectation, +}; +use crate::privileged_jobs::{ + verified_nsis_current_exe_app_root, verify_nsis_privileged_lifecycle_idle, + NsisPrivilegedLifecycleGuard, NsisPrivilegedLifecycleState, INSTALL_RECEIPT_FILENAME, +}; +use crate::process::is_process_elevated; +use crate::proxifyre_runtime::{ + stop_proxifyre_native, uninstall_proxifyre_native, ProxifyreNativeHost, + ProxifyreNativeOwnership, ProxifyreNativeServiceStatus, ProxifyreNativeSnapshot, + SystemProxifyreNativeHost, +}; +use crate::safe_fs::{self, StableObjectKind}; +use crate::singbox_runtime::{ + stop_singbox_native, uninstall_singbox_native, SingBoxNativeHost, SingBoxNativeOwnership, + SingBoxNativeSnapshot, SystemSingBoxNativeHost, +}; +use crate::singbox_service::{ + SingBoxNativeServiceState, SingBoxServiceInstallSpec, SINGBOX_CRONET_FILE, + SINGBOX_LICENSE_FILE, SINGBOX_OWNERSHIP_MARKER_FILE, SINGBOX_RUNTIME_CONFIG_FILE, + SINGBOX_RUNTIME_FILE, WINSW_SERVICE_XML_FILE, WINSW_WRAPPER_FILE, +}; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use uuid::{Uuid, Version}; + +pub const NSIS_VERIFY_UPGRADE_ARGUMENT: &str = "--nsis-verify-upgrade"; +pub const NSIS_UNINSTALL_MANAGED_ARGUMENT: &str = "--nsis-uninstall-managed"; + +const COMPONENTS_DIRECTORY_NAME: &str = "components"; +const PROXIFYRE_DIRECTORY_NAME: &str = "ProxiFyre"; +const SINGBOX_DIRECTORY_NAME: &str = "sing-box"; +const SERVICE_LOG_ROOT_NAME: &str = ".proxywarden-service-logs"; +const SERVICE_LOG_COMPONENT_NAME: &str = "sing-box"; +const PACKAGE_STAGING_DIRECTORY_NAME: &str = ".proxywarden-package-staging"; +const SINGBOX_CLEANUP_TOMBSTONE_NAME: &str = ".proxywarden-sing-box-cleanup"; + +pub const NSIS_EXIT_SUCCESS: i32 = 0; +pub const NSIS_EXIT_BLOCKED: i32 = 1; +pub const NSIS_EXIT_RUNTIME_FAILURE: i32 = 2; +pub const NSIS_EXIT_USAGE: i32 = 64; +pub const NSIS_EXIT_REBOOT_REQUIRED: i32 = 3010; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NsisEarlyMode { + VerifyUpgrade, + UninstallManaged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NsisRunOutcome { + Success, + RebootRequired, +} + +impl NsisRunOutcome { + pub const fn exit_code(self) -> i32 { + match self { + Self::Success => NSIS_EXIT_SUCCESS, + Self::RebootRequired => NSIS_EXIT_REBOOT_REQUIRED, + } + } +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum NsisRuntimeError { + #[error("NSIS early-mode arguments are invalid")] + InvalidArguments, + #[error("NSIS early mode requires an elevated token")] + NotElevated, + #[error("the running executable is not the canonical installed Control App")] + InvalidExecutable, + #[error("another lifecycle operation is active")] + LifecycleBusy, + #[error("legacy cutover state blocks installer mutation")] + CutoverBlocked, + #[error("a component is foreign, incomplete, or unstable")] + ComponentUnsafe, + #[error("installer-owned transient state is incomplete or contains unknown entries")] + TransientUnsafe, + #[error("live state changed after the lifecycle lock was acquired")] + StateChanged, + #[error("a fixed native lifecycle operation failed")] + OperationFailed, +} + +impl NsisRuntimeError { + pub const fn exit_code(self) -> i32 { + match self { + Self::InvalidArguments => NSIS_EXIT_USAGE, + Self::NotElevated + | Self::LifecycleBusy + | Self::CutoverBlocked + | Self::ComponentUnsafe + | Self::TransientUnsafe + | Self::StateChanged => NSIS_EXIT_BLOCKED, + Self::InvalidExecutable | Self::OperationFailed => NSIS_EXIT_RUNTIME_FAILURE, + } + } +} + +pub fn nsis_process_exit_code(result: Result) -> i32 { + match result { + Ok(outcome) => outcome.exit_code(), + Err(error) => error.exit_code(), + } +} + +/// Recognizes only the two fixed one-flag NSIS modes. Unknown ordinary +/// application arguments are left to Tauri, but every reserved `--nsis-*` +/// spelling or any extra argument around a recognized flag is a usage error. +pub fn parse_nsis_early_arguments( + arguments: I, +) -> Result, NsisRuntimeError> +where + I: IntoIterator, +{ + let arguments = arguments.into_iter().collect::>(); + if arguments.len() == 1 { + return match arguments[0].to_str() { + Some(NSIS_VERIFY_UPGRADE_ARGUMENT) => Ok(Some(NsisEarlyMode::VerifyUpgrade)), + Some(NSIS_UNINSTALL_MANAGED_ARGUMENT) => Ok(Some(NsisEarlyMode::UninstallManaged)), + Some(value) if value.starts_with("--nsis-") => Err(NsisRuntimeError::InvalidArguments), + _ => Ok(None), + }; + } + let contains_reserved = arguments.iter().any(|argument| { + argument + .to_str() + .is_some_and(|value| value.starts_with("--nsis-")) + }); + if contains_reserved { + Err(NsisRuntimeError::InvalidArguments) + } else { + Ok(None) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NsisComponentState { + Missing, + ManagedStopped, + ManagedRunning, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NsisCutoverState { + Absent, + Retirable(CutoverTerminalRetirementExpectation), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NsisTransientState { + pub singbox_cleanup_pending: bool, + pub package_staging_pending: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NsisLifecycleState { + pub retirement_pending: bool, + pub reboot_required: bool, +} + +/// Injectable orchestration seam. Production implementations keep every +/// path/service identity fixed; tests use this trait to prove call ordering and +/// the zero-mutation rejection cases without emulating Windows SCM. +pub trait NsisRuntimeHost { + fn is_elevated(&self) -> bool; + fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError>; + fn verify_lifecycle_state(&mut self) -> Result; + fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError>; + fn inspect_cutover(&mut self) -> Result; + fn preflight_proxifyre(&mut self) -> Result; + fn preflight_singbox(&mut self) -> Result; + fn verify_transient_layout(&mut self) -> Result; + fn retire_cutover( + &mut self, + expected: &CutoverTerminalRetirementExpectation, + ) -> Result<(), NsisRuntimeError>; + fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError>; + fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError>; + fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError>; + fn uninstall_proxifyre(&mut self) -> Result; + fn uninstall_singbox(&mut self) -> Result; + fn reboot_required_under_lock(&mut self) -> Result; + /// Returns `true` only when this lifecycle owner created the intent. + fn mark_reboot_required(&mut self) -> Result; + fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError>; + fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NsisPreflight { + cutover: NsisCutoverState, + proxifyre: NsisComponentState, + singbox: NsisComponentState, + transients: NsisTransientState, +} + +pub fn run_nsis_mode( + host: &mut dyn NsisRuntimeHost, + mode: NsisEarlyMode, +) -> Result { + if !host.is_elevated() { + return Err(NsisRuntimeError::NotElevated); + } + host.verify_current_executable()?; + let (initial, lifecycle) = collect_preflight(host, true)?; + let lifecycle = lifecycle.ok_or(NsisRuntimeError::LifecycleBusy)?; + + if mode == NsisEarlyMode::VerifyUpgrade { + if initial.cutover != NsisCutoverState::Absent { + return Err(NsisRuntimeError::CutoverBlocked); + } + if lifecycle.retirement_pending + || lifecycle.reboot_required + || initial.transients.singbox_cleanup_pending + || initial.transients.package_staging_pending + { + return Err(NsisRuntimeError::TransientUnsafe); + } + return Ok(NsisRunOutcome::Success); + } + + host.acquire_lifecycle_lock()?; + host.verify_current_executable()?; + let (committed, _) = collect_preflight(host, false)?; + if committed != initial { + return Err(NsisRuntimeError::StateChanged); + } + let mut reboot_required = host.reboot_required_under_lock()?; + + if let NsisCutoverState::Retirable(expected) = &committed.cutover { + host.retire_cutover(expected)?; + if host.inspect_cutover()? != NsisCutoverState::Absent { + return Err(NsisRuntimeError::CutoverBlocked); + } + } + + if committed.transients.singbox_cleanup_pending { + host.retry_singbox_cleanup()?; + if host.verify_transient_layout()?.singbox_cleanup_pending { + return Err(NsisRuntimeError::TransientUnsafe); + } + } + + if committed.proxifyre == NsisComponentState::ManagedRunning { + host.stop_proxifyre()?; + } + if committed.singbox == NsisComponentState::ManagedRunning { + host.stop_singbox()?; + } + + if committed.proxifyre != NsisComponentState::Missing { + let created_reboot_intent = if reboot_required { + false + } else { + let created = host.mark_reboot_required()?; + reboot_required = !created; + created + }; + let component_reboot = host.uninstall_proxifyre()?; + if created_reboot_intent && !component_reboot { + host.clear_reboot_required()?; + } + reboot_required |= component_reboot; + } + if committed.singbox != NsisComponentState::Missing { + let created_reboot_intent = if reboot_required { + false + } else { + let created = host.mark_reboot_required()?; + reboot_required = !created; + created + }; + let component_reboot = host.uninstall_singbox()?; + if created_reboot_intent && !component_reboot { + host.clear_reboot_required()?; + } + reboot_required |= component_reboot; + } + host.cleanup_transients()?; + + Ok(if reboot_required { + NsisRunOutcome::RebootRequired + } else { + NsisRunOutcome::Success + }) +} + +fn collect_preflight( + host: &mut dyn NsisRuntimeHost, + include_lifecycle: bool, +) -> Result<(NsisPreflight, Option), NsisRuntimeError> { + // Evaluate every read-only owner before propagating an error. In + // particular, a foreign ProxiFyre install never prevents the independent + // sing-box preflight from running, and no component mutation can occur + // until both have succeeded. + let lifecycle = include_lifecycle.then(|| host.verify_lifecycle_state()); + let cutover = host.inspect_cutover(); + let proxifyre = host.preflight_proxifyre(); + let singbox = host.preflight_singbox(); + let transients = host.verify_transient_layout(); + + let lifecycle = lifecycle.transpose()?; + Ok(( + NsisPreflight { + cutover: cutover?, + proxifyre: proxifyre?, + singbox: singbox?, + transients: transients?, + }, + lifecycle, + )) +} + +pub fn run_system_nsis_mode(mode: NsisEarlyMode) -> Result { + let mut host = SystemNsisRuntimeHost::production()?; + run_nsis_mode(&mut host, mode) +} + +struct SystemNsisRuntimeHost { + current_exe: PathBuf, + app_root: PathBuf, + proxifyre: SystemProxifyreNativeHost, + singbox: SystemSingBoxNativeHost, + lifecycle: Option, +} + +impl SystemNsisRuntimeHost { + fn production() -> Result { + let current_exe = + std::env::current_exe().map_err(|_| NsisRuntimeError::InvalidExecutable)?; + let app_root = verified_nsis_current_exe_app_root(¤t_exe) + .map_err(|_| NsisRuntimeError::InvalidExecutable)?; + let proxifyre = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| NsisRuntimeError::InvalidExecutable)?; + let singbox = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| NsisRuntimeError::InvalidExecutable)?; + Ok(Self { + current_exe, + app_root, + proxifyre, + singbox, + lifecycle: None, + }) + } +} + +impl NsisRuntimeHost for SystemNsisRuntimeHost { + fn is_elevated(&self) -> bool { + is_process_elevated() + } + + fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError> { + let live = std::env::current_exe().map_err(|_| NsisRuntimeError::InvalidExecutable)?; + let live_root = verified_nsis_current_exe_app_root(&live) + .map_err(|_| NsisRuntimeError::InvalidExecutable)?; + if live != self.current_exe || live_root != self.app_root { + return Err(NsisRuntimeError::InvalidExecutable); + } + Ok(()) + } + + fn verify_lifecycle_state(&mut self) -> Result { + verify_nsis_privileged_lifecycle_idle(&self.app_root) + .map(|state| { + let NsisPrivilegedLifecycleState { + retirement_pending, + reboot_required, + } = state; + NsisLifecycleState { + retirement_pending, + reboot_required, + } + }) + .map_err(map_lifecycle_error) + } + + fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError> { + if self.lifecycle.is_some() { + return Err(NsisRuntimeError::LifecycleBusy); + } + self.lifecycle = Some( + NsisPrivilegedLifecycleGuard::acquire(&self.app_root).map_err(map_lifecycle_error)?, + ); + Ok(()) + } + + fn inspect_cutover(&mut self) -> Result { + inspect_system_cutover(&self.app_root) + } + + fn preflight_proxifyre(&mut self) -> Result { + let snapshot = self + .proxifyre + .inspect() + .map_err(|_| NsisRuntimeError::ComponentUnsafe)?; + classify_proxifyre(&snapshot) + } + + fn preflight_singbox(&mut self) -> Result { + let snapshot = self + .singbox + .inspect() + .map_err(|_| NsisRuntimeError::ComponentUnsafe)?; + classify_singbox(&snapshot, self.singbox.install_root()) + } + + fn verify_transient_layout(&mut self) -> Result { + verify_system_transient_layout(&self.app_root) + } + + fn retire_cutover( + &mut self, + expected: &CutoverTerminalRetirementExpectation, + ) -> Result<(), NsisRuntimeError> { + retire_existing_proxifyre_cutover_terminal_state(&self.app_root, expected) + .map_err(|_| NsisRuntimeError::CutoverBlocked) + } + + fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError> { + stop_proxifyre_native(&mut self.proxifyre) + .map(|_| ()) + .map_err(|_| NsisRuntimeError::OperationFailed) + } + + fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError> { + stop_singbox_native(&mut self.singbox) + .map(|_| ()) + .map_err(|_| NsisRuntimeError::OperationFailed) + } + + fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError> { + self.singbox + .retry_cleanup() + .map_err(|_| NsisRuntimeError::TransientUnsafe) + } + + fn uninstall_proxifyre(&mut self) -> Result { + uninstall_proxifyre_native(&mut self.proxifyre) + .map(|outcome| outcome.reboot_required) + .map_err(|_| NsisRuntimeError::OperationFailed) + } + + fn uninstall_singbox(&mut self) -> Result { + uninstall_singbox_native(&mut self.singbox) + .map(|_| false) + .map_err(|_| NsisRuntimeError::OperationFailed) + } + + fn reboot_required_under_lock(&mut self) -> Result { + self.lifecycle + .as_ref() + .ok_or(NsisRuntimeError::LifecycleBusy)? + .reboot_required() + .map_err(map_lifecycle_error) + } + + fn mark_reboot_required(&mut self) -> Result { + self.lifecycle + .as_ref() + .ok_or(NsisRuntimeError::LifecycleBusy)? + .mark_reboot_required() + .map_err(map_lifecycle_error) + } + + fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError> { + self.lifecycle + .as_ref() + .ok_or(NsisRuntimeError::LifecycleBusy)? + .clear_reboot_required() + .map_err(map_lifecycle_error) + } + + fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError> { + cleanup_package_staging(&self.app_root)?; + cleanup_service_log_directories(&self.app_root)?; + cleanup_components_directory(&self.app_root)?; + self.lifecycle + .take() + .ok_or(NsisRuntimeError::LifecycleBusy)? + .retire_terminal_store() + .map_err(|_| NsisRuntimeError::TransientUnsafe) + } +} + +fn map_lifecycle_error(error: crate::privileged_jobs::PrivilegedJobsError) -> NsisRuntimeError { + if matches!( + error, + crate::privileged_jobs::PrivilegedJobsError::LifecycleBusy + ) { + NsisRuntimeError::LifecycleBusy + } else { + NsisRuntimeError::TransientUnsafe + } +} + +fn inspect_system_cutover(app_root: &Path) -> Result { + let status = verify_cutover_external_mutation_status(app_root); + match status { + CutoverExternalMutationStatus::Absent => Ok(NsisCutoverState::Absent), + CutoverExternalMutationStatus::Active | CutoverExternalMutationStatus::RecoveryRequired => { + Err(NsisRuntimeError::CutoverBlocked) + } + CutoverExternalMutationStatus::TerminalRetirementPending + | CutoverExternalMutationStatus::RolledBackRetained => { + let journal = read_existing_sealed_cutover_journal(app_root) + .map_err(|_| NsisRuntimeError::CutoverBlocked)?; + let expected = match journal { + Some(journal) => CutoverTerminalRetirementExpectation::Journal { + cutover_id: journal.plan.cutover_id.clone(), + phase: journal.phase, + terminal_record_fingerprint: cutover_terminal_record_fingerprint(&journal) + .map_err(|_| NsisRuntimeError::CutoverBlocked)?, + }, + None if status == CutoverExternalMutationStatus::TerminalRetirementPending => { + CutoverTerminalRetirementExpectation::EmptyInfrastructure + } + None => return Err(NsisRuntimeError::CutoverBlocked), + }; + Ok(NsisCutoverState::Retirable(expected)) + } + } +} + +fn classify_proxifyre( + snapshot: &ProxifyreNativeSnapshot, +) -> Result { + if snapshot.ownership == ProxifyreNativeOwnership::Missing + && snapshot.service_status == ProxifyreNativeServiceStatus::Missing + { + return Ok(NsisComponentState::Missing); + } + if snapshot.ownership != ProxifyreNativeOwnership::Managed + || !snapshot.install_root_trusted + || !snapshot.install_root_reparse_free + || !snapshot.receipt_valid + || !snapshot.receipt_files_match + || !snapshot.service_path_matches + || !snapshot.demand_start + || !snapshot.failure_recovery_disabled + || !snapshot.builtin_users_start_denied + || !snapshot.vc_runtime_present + || !snapshot.packet_filter_present + || !snapshot + .installed_version + .as_deref() + .is_some_and(valid_numeric_version) + { + return Err(NsisRuntimeError::ComponentUnsafe); + } + match snapshot.service_status { + ProxifyreNativeServiceStatus::Stopped => Ok(NsisComponentState::ManagedStopped), + ProxifyreNativeServiceStatus::Running => Ok(NsisComponentState::ManagedRunning), + ProxifyreNativeServiceStatus::Missing | ProxifyreNativeServiceStatus::Other => { + Err(NsisRuntimeError::ComponentUnsafe) + } + } +} + +fn classify_singbox( + snapshot: &SingBoxNativeSnapshot, + install_root: &Path, +) -> Result { + if snapshot.ownership == SingBoxNativeOwnership::Missing + && snapshot.service.state == SingBoxNativeServiceState::Missing + { + return Ok(NsisComponentState::Missing); + } + let spec = SingBoxServiceInstallSpec::for_install_root(install_root) + .ok_or(NsisRuntimeError::ComponentUnsafe)?; + let marker = snapshot + .marker + .as_ref() + .ok_or(NsisRuntimeError::ComponentUnsafe)?; + if snapshot.ownership != SingBoxNativeOwnership::Managed + || !snapshot.install_root_trusted + || !snapshot.install_root_reparse_free + || !snapshot.promoted_files_match + || !snapshot.receipt_valid + || !snapshot.receipt_files_match + || !snapshot.marker_valid + || !snapshot.marker_files_match + || !snapshot.service_xml_matches + || snapshot.installed_version.as_deref() != Some(marker.runtime.version.as_str()) + || snapshot.wrapper_version.as_deref() != Some(marker.wrapper.version.as_str()) + || !valid_numeric_version(&marker.runtime.version) + || !valid_numeric_version(&marker.wrapper.version) + || !snapshot.service.matches_managed_policy(&spec) + { + return Err(NsisRuntimeError::ComponentUnsafe); + } + match snapshot.service.state { + SingBoxNativeServiceState::Stopped => Ok(NsisComponentState::ManagedStopped), + SingBoxNativeServiceState::Running => Ok(NsisComponentState::ManagedRunning), + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + Err(NsisRuntimeError::ComponentUnsafe) + } + } +} + +fn valid_numeric_version(value: &str) -> bool { + let parts = value.split('.').collect::>(); + !parts.is_empty() + && parts.len() <= 4 + && parts.iter().all(|part| { + !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && part.parse::().is_ok() + }) +} + +fn verify_system_transient_layout(app_root: &Path) -> Result { + let singbox_cleanup_pending = verify_components_layout(app_root)?; + verify_allowed_directory( + &app_root.join(SERVICE_LOG_ROOT_NAME), + &[SERVICE_LOG_COMPONENT_NAME], + true, + )?; + let package_staging_pending = verify_package_staging_layout(app_root)?; + Ok(NsisTransientState { + singbox_cleanup_pending, + package_staging_pending, + }) +} + +fn verify_package_staging_layout(app_root: &Path) -> Result { + let root = app_root.join(PACKAGE_STAGING_DIRECTORY_NAME); + let metadata = match fs::symlink_metadata(&root) { + Ok(metadata) if metadata.file_type().is_dir() => metadata, + Ok(_) => return Err(NsisRuntimeError::TransientUnsafe), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&root) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + return Ok(false); + } + Err(_) => return Err(NsisRuntimeError::TransientUnsafe), + }; + if !metadata.file_type().is_dir() + || safe_fs::verify_path_admin_owned_user_read_only(&root).is_err() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + + let mut found = Vec::new(); + for entry in fs::read_dir(&root).map_err(|_| NsisRuntimeError::TransientUnsafe)? { + let entry = entry.map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if found + .iter() + .any(|seen: &String| seen.eq_ignore_ascii_case(&name)) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let component = parse_package_staging_directory_name(&name)?; + verify_package_staging_child(&entry.path(), component)?; + found.push(name); + } + Ok(!found.is_empty()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackageStagingComponent { + Proxifyre, + WindowsPacketFilter, + SingBox, +} + +fn parse_package_staging_directory_name( + name: &str, +) -> Result { + let suffix = name + .strip_prefix(".package-") + .ok_or(NsisRuntimeError::TransientUnsafe)?; + let (component, uuid) = [ + ("proxifyre-", PackageStagingComponent::Proxifyre), + ( + "windows-packet-filter-", + PackageStagingComponent::WindowsPacketFilter, + ), + ("sing-box-", PackageStagingComponent::SingBox), + ] + .into_iter() + .find_map(|(prefix, component)| suffix.strip_prefix(prefix).map(|uuid| (component, uuid))) + .ok_or(NsisRuntimeError::TransientUnsafe)?; + let parsed = Uuid::parse_str(uuid).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if parsed.get_version() != Some(Version::Random) || parsed.hyphenated().to_string() != uuid { + return Err(NsisRuntimeError::TransientUnsafe); + } + Ok(component) +} + +fn verify_package_staging_child( + path: &Path, + component: PackageStagingComponent, +) -> Result, NsisRuntimeError> { + let metadata = fs::symlink_metadata(path).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !metadata.file_type().is_dir() + || safe_fs::verify_path_admin_owned_user_read_only(path).is_err() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let max_entries = if component == PackageStagingComponent::Proxifyre { + 3 + } else { + 1 + }; + let mut found = Vec::::new(); + let mut roles = Vec::::new(); + let mut paths = Vec::new(); + for entry in fs::read_dir(path).map_err(|_| NsisRuntimeError::TransientUnsafe)? { + let entry = entry.map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let role = package_staging_entry_role(component, &name) + .ok_or(NsisRuntimeError::TransientUnsafe)?; + if found.len() == max_entries + || found.iter().any(|seen| seen.eq_ignore_ascii_case(&name)) + || roles.contains(&role) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let child = entry.path(); + let metadata = + fs::symlink_metadata(&child).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !metadata.file_type().is_file() + || metadata.len() > 256 * 1024 * 1024 + || safe_fs::verify_path_admin_owned_user_read_only(&child).is_err() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + found.push(name); + roles.push(role); + paths.push(child); + } + paths.sort(); + Ok(paths) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PackageStagingEntryRole { + Asset, + VerifyProxifyreExe, + VerifySocksifyDll, +} + +fn package_staging_entry_role( + component: PackageStagingComponent, + name: &str, +) -> Option { + match component { + PackageStagingComponent::Proxifyre => { + if valid_versioned_asset(name, "ProxiFyre-v", "-x64-signed.zip") { + return Some(PackageStagingEntryRole::Asset); + } + let (uuid, target) = name.strip_prefix(".verify-")?.rsplit_once('-')?; + let parsed = Uuid::parse_str(uuid).ok()?; + if parsed.get_version() != Some(Version::Random) + || parsed.hyphenated().to_string() != uuid + { + return None; + } + match target { + "ProxiFyre.exe" => Some(PackageStagingEntryRole::VerifyProxifyreExe), + "socksify.dll" => Some(PackageStagingEntryRole::VerifySocksifyDll), + _ => None, + } + } + PackageStagingComponent::WindowsPacketFilter => { + valid_versioned_asset(name, "Windows.Packet.Filter.", ".x64.msi") + .then_some(PackageStagingEntryRole::Asset) + } + PackageStagingComponent::SingBox => { + valid_versioned_asset(name, "sing-box-", "-windows-amd64.zip") + .then_some(PackageStagingEntryRole::Asset) + } + } +} + +fn valid_versioned_asset(name: &str, prefix: &str, suffix: &str) -> bool { + name.strip_prefix(prefix) + .and_then(|value| value.strip_suffix(suffix)) + .is_some_and(valid_numeric_version) +} + +fn verify_components_layout(app_root: &Path) -> Result { + let path = app_root.join(COMPONENTS_DIRECTORY_NAME); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_dir() => metadata, + Ok(_) => return Err(NsisRuntimeError::TransientUnsafe), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&path) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + return Ok(false); + } + Err(_) => return Err(NsisRuntimeError::TransientUnsafe), + }; + if !metadata.file_type().is_dir() + || safe_fs::verify_path_admin_owned_user_read_only(&path).is_err() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + + let mut found = Vec::new(); + let mut cleanup_pending = false; + for entry in fs::read_dir(&path).map_err(|_| NsisRuntimeError::TransientUnsafe)? { + let entry = entry.map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if found + .iter() + .any(|seen: &String| seen.eq_ignore_ascii_case(&name)) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let child = entry.path(); + let child_metadata = + fs::symlink_metadata(&child).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !child_metadata.file_type().is_dir() { + return Err(NsisRuntimeError::TransientUnsafe); + } + if name.eq_ignore_ascii_case(PROXIFYRE_DIRECTORY_NAME) + || name.eq_ignore_ascii_case(SINGBOX_DIRECTORY_NAME) + { + safe_fs::verify_path_admin_owned_user_read_only(&child) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } else if name.eq_ignore_ascii_case(SINGBOX_CLEANUP_TOMBSTONE_NAME) { + verify_singbox_cleanup_tombstone(&child)?; + cleanup_pending = true; + } else { + return Err(NsisRuntimeError::TransientUnsafe); + } + found.push(name); + } + Ok(cleanup_pending) +} + +fn verify_singbox_cleanup_tombstone(path: &Path) -> Result<(), NsisRuntimeError> { + safe_fs::verify_path_admin_owned_user_read_only(path) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let allowed = [ + SINGBOX_LICENSE_FILE, + WINSW_WRAPPER_FILE, + WINSW_SERVICE_XML_FILE, + SINGBOX_CRONET_FILE, + SINGBOX_RUNTIME_FILE, + SINGBOX_OWNERSHIP_MARKER_FILE, + INSTALL_RECEIPT_FILENAME, + SINGBOX_RUNTIME_CONFIG_FILE, + ]; + let mut found = Vec::new(); + for entry in fs::read_dir(path).map_err(|_| NsisRuntimeError::TransientUnsafe)? { + let entry = entry.map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !allowed + .iter() + .any(|allowed| name.eq_ignore_ascii_case(allowed)) + || found + .iter() + .any(|seen: &String| seen.eq_ignore_ascii_case(&name)) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let child = entry.path(); + let metadata = + fs::symlink_metadata(&child).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !metadata.file_type().is_file() { + return Err(NsisRuntimeError::TransientUnsafe); + } + if name.eq_ignore_ascii_case(SINGBOX_RUNTIME_CONFIG_FILE) { + safe_fs::verify_admin_system_only_file(&child) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } else { + safe_fs::verify_path_admin_owned_user_read_only(&child) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } + found.push(name); + } + Ok(()) +} + +fn verify_allowed_directory( + path: &Path, + allowed_children: &[&str], + children_must_be_empty: bool, +) -> Result<(), NsisRuntimeError> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_dir() => metadata, + Ok(_) => return Err(NsisRuntimeError::TransientUnsafe), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(path) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + return Ok(()); + } + Err(_) => return Err(NsisRuntimeError::TransientUnsafe), + }; + if !metadata.file_type().is_dir() + || safe_fs::verify_path_admin_owned_user_read_only(path).is_err() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let mut found = Vec::new(); + for entry in fs::read_dir(path).map_err(|_| NsisRuntimeError::TransientUnsafe)? { + let entry = entry.map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !allowed_children + .iter() + .any(|allowed| name.eq_ignore_ascii_case(allowed)) + || found + .iter() + .any(|seen: &String| seen.eq_ignore_ascii_case(&name)) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let child = entry.path(); + let child_metadata = + fs::symlink_metadata(&child).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if !child_metadata.file_type().is_dir() { + return Err(NsisRuntimeError::TransientUnsafe); + } + safe_fs::verify_path_admin_owned_user_read_only(&child) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if children_must_be_empty + && fs::read_dir(&child) + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .next() + .transpose() + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .is_some() + { + return Err(NsisRuntimeError::TransientUnsafe); + } + found.push(name); + } + Ok(()) +} + +fn cleanup_service_log_directories(app_root: &Path) -> Result<(), NsisRuntimeError> { + let root = app_root.join(SERVICE_LOG_ROOT_NAME); + if path_is_proven_missing(&root)? { + return Ok(()); + } + let root_lease = open_admin_directory_lease(&root)?; + let entries = fs::read_dir(&root) + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .collect::, _>>() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + if entries.len() > 1 { + return Err(NsisRuntimeError::TransientUnsafe); + } + if let Some(entry) = entries.first() { + if !entry + .file_name() + .to_str() + .is_some_and(|name| name.eq_ignore_ascii_case(SERVICE_LOG_COMPONENT_NAME)) + { + return Err(NsisRuntimeError::TransientUnsafe); + } + let child = open_admin_directory_lease(&entry.path())?; + ensure_empty_directory(child.path())?; + safe_fs::delete_identity_lease(child).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } + ensure_empty_directory(&root)?; + safe_fs::delete_identity_lease(root_lease).map_err(|_| NsisRuntimeError::TransientUnsafe) +} + +fn cleanup_components_directory(app_root: &Path) -> Result<(), NsisRuntimeError> { + let root = app_root.join(COMPONENTS_DIRECTORY_NAME); + if path_is_proven_missing(&root)? { + return Ok(()); + } + let root_lease = open_admin_directory_lease(&root)?; + ensure_empty_directory(&root)?; + safe_fs::delete_identity_lease(root_lease).map_err(|_| NsisRuntimeError::TransientUnsafe) +} + +fn cleanup_package_staging(app_root: &Path) -> Result<(), NsisRuntimeError> { + let root = app_root.join(PACKAGE_STAGING_DIRECTORY_NAME); + if path_is_proven_missing(&root)? { + return Ok(()); + } + verify_package_staging_layout(app_root)?; + let root_lease = open_admin_directory_lease(&root)?; + let mut children = fs::read_dir(&root) + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .collect::, _>>() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + children.sort_by_key(|entry| entry.file_name()); + for child in children { + let name = child + .file_name() + .into_string() + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + let component = parse_package_staging_directory_name(&name)?; + let child_lease = open_admin_directory_lease(&child.path())?; + let files = verify_package_staging_child(&child.path(), component)?; + let mut file_leases = Vec::with_capacity(files.len()); + for path in files { + let lease = safe_fs::open_path_identity_lease(&path, StableObjectKind::RegularFile) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + file_leases.push(lease); + } + for lease in file_leases { + safe_fs::delete_identity_lease(lease).map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } + ensure_empty_directory(&child.path())?; + safe_fs::delete_identity_lease(child_lease) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + } + ensure_empty_directory(&root)?; + safe_fs::delete_identity_lease(root_lease).map_err(|_| NsisRuntimeError::TransientUnsafe) +} + +fn open_admin_directory_lease(path: &Path) -> Result { + let lease = safe_fs::open_path_identity_lease(path, StableObjectKind::Directory) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + Ok(lease) +} + +fn ensure_empty_directory(path: &Path) -> Result<(), NsisRuntimeError> { + if fs::read_dir(path) + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .next() + .transpose() + .map_err(|_| NsisRuntimeError::TransientUnsafe)? + .is_none() + { + Ok(()) + } else { + Err(NsisRuntimeError::TransientUnsafe) + } +} + +fn path_is_proven_missing(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(path) + .map_err(|_| NsisRuntimeError::TransientUnsafe)?; + Ok(true) + } + Err(_) => Err(NsisRuntimeError::TransientUnsafe), + Ok(_) => Ok(false), + } +} + +#[cfg(test)] +#[path = "nsis_runtime_tests.rs"] +mod tests; diff --git a/src-tauri/src/nsis_runtime_tests.rs b/src-tauri/src/nsis_runtime_tests.rs new file mode 100644 index 0000000..5bca821 --- /dev/null +++ b/src-tauri/src/nsis_runtime_tests.rs @@ -0,0 +1,993 @@ +use super::*; +use crate::privileged_jobs::{ + verify_nsis_privileged_lifecycle_idle_for_tests, write_nsis_interrupted_retirement_for_tests, + write_nsis_partial_reboot_staging_for_tests, write_nsis_partial_retirement_staging_for_tests, + write_nsis_terminal_pair_for_tests, NsisPrivilegedLifecycleGuard, NsisPrivilegedLifecycleState, + PrivilegedJobsError, +}; +use std::cell::RefCell; +use std::collections::VecDeque; + +#[derive(Default)] +struct FakeHost { + elevated: bool, + calls: RefCell>, + verify_executable: VecDeque>, + lifecycle_state: VecDeque>, + cutover: VecDeque>, + proxifyre: VecDeque>, + singbox: VecDeque>, + transients: VecDeque>, + acquire: VecDeque>, + retire: VecDeque>, + stop_proxifyre: VecDeque>, + stop_singbox: VecDeque>, + retry_singbox_cleanup: VecDeque>, + uninstall_proxifyre: VecDeque>, + uninstall_singbox: VecDeque>, + reboot_under_lock: VecDeque>, + mark_reboot: VecDeque>, + clear_reboot: VecDeque>, + cleanup: VecDeque>, +} + +impl FakeHost { + fn ready(proxifyre: NsisComponentState, singbox: NsisComponentState) -> Self { + Self { + elevated: true, + verify_executable: VecDeque::from([Ok(()), Ok(())]), + lifecycle_state: VecDeque::from([Ok(NsisLifecycleState { + retirement_pending: false, + reboot_required: false, + })]), + cutover: VecDeque::from([Ok(NsisCutoverState::Absent), Ok(NsisCutoverState::Absent)]), + proxifyre: VecDeque::from([Ok(proxifyre), Ok(proxifyre)]), + singbox: VecDeque::from([Ok(singbox), Ok(singbox)]), + transients: VecDeque::from([ + Ok(NsisTransientState { + singbox_cleanup_pending: false, + package_staging_pending: false, + }), + Ok(NsisTransientState { + singbox_cleanup_pending: false, + package_staging_pending: false, + }), + ]), + acquire: VecDeque::from([Ok(())]), + retire: VecDeque::from([Ok(())]), + stop_proxifyre: VecDeque::from([Ok(())]), + stop_singbox: VecDeque::from([Ok(())]), + retry_singbox_cleanup: VecDeque::from([Ok(())]), + uninstall_proxifyre: VecDeque::from([Ok(false)]), + uninstall_singbox: VecDeque::from([Ok(false)]), + reboot_under_lock: VecDeque::from([Ok(false)]), + mark_reboot: VecDeque::from([Ok(true), Ok(true)]), + clear_reboot: VecDeque::from([Ok(()), Ok(())]), + cleanup: VecDeque::from([Ok(())]), + ..Self::default() + } + } + + fn call(&self, name: &'static str) { + self.calls.borrow_mut().push(name); + } + + fn calls(&self) -> Vec<&'static str> { + self.calls.borrow().clone() + } +} + +fn next(queue: &mut VecDeque>) -> Result { + queue.pop_front().expect("fake call was not planned") +} + +impl NsisRuntimeHost for FakeHost { + fn is_elevated(&self) -> bool { + self.call("elevated"); + self.elevated + } + + fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError> { + self.call("verify-exe"); + next(&mut self.verify_executable) + } + + fn verify_lifecycle_state(&mut self) -> Result { + self.call("lifecycle-idle"); + next(&mut self.lifecycle_state) + } + + fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError> { + self.call("acquire"); + next(&mut self.acquire) + } + + fn inspect_cutover(&mut self) -> Result { + self.call("cutover"); + next(&mut self.cutover) + } + + fn preflight_proxifyre(&mut self) -> Result { + self.call("proxifyre"); + next(&mut self.proxifyre) + } + + fn preflight_singbox(&mut self) -> Result { + self.call("singbox"); + next(&mut self.singbox) + } + + fn verify_transient_layout(&mut self) -> Result { + self.call("transients"); + next(&mut self.transients) + } + + fn retire_cutover( + &mut self, + _expected: &CutoverTerminalRetirementExpectation, + ) -> Result<(), NsisRuntimeError> { + self.call("retire-cutover"); + next(&mut self.retire) + } + + fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError> { + self.call("stop-proxifyre"); + next(&mut self.stop_proxifyre) + } + + fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError> { + self.call("stop-singbox"); + next(&mut self.stop_singbox) + } + + fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError> { + self.call("retry-singbox-cleanup"); + next(&mut self.retry_singbox_cleanup) + } + + fn uninstall_proxifyre(&mut self) -> Result { + self.call("uninstall-proxifyre"); + next(&mut self.uninstall_proxifyre) + } + + fn uninstall_singbox(&mut self) -> Result { + self.call("uninstall-singbox"); + next(&mut self.uninstall_singbox) + } + + fn reboot_required_under_lock(&mut self) -> Result { + self.call("reboot-under-lock"); + next(&mut self.reboot_under_lock) + } + + fn mark_reboot_required(&mut self) -> Result { + self.call("mark-reboot"); + next(&mut self.mark_reboot) + } + + fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError> { + self.call("clear-reboot"); + next(&mut self.clear_reboot) + } + + fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError> { + self.call("cleanup"); + next(&mut self.cleanup) + } +} + +#[test] +fn parser_accepts_only_exact_single_nsis_flags() { + assert_eq!( + parse_nsis_early_arguments([OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT)]) + .expect("verify flag"), + Some(NsisEarlyMode::VerifyUpgrade) + ); + assert_eq!( + parse_nsis_early_arguments([OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT)]) + .expect("uninstall flag"), + Some(NsisEarlyMode::UninstallManaged) + ); + assert_eq!( + parse_nsis_early_arguments(Vec::::new()).expect("ordinary launch"), + None + ); + assert_eq!( + parse_nsis_early_arguments([OsString::from("--elevated-helper")]) + .expect("other early mode"), + None + ); + + for invalid in [ + vec![OsString::from(format!("{}{}", "--nsis-", "unknown"))], + vec![ + OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT), + OsString::from("extra"), + ], + vec![ + OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT), + OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT), + ], + vec![ + OsString::from("ordinary"), + OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT), + ], + ] { + assert_eq!( + parse_nsis_early_arguments(invalid), + Err(NsisRuntimeError::InvalidArguments) + ); + } +} + +#[test] +fn package_staging_recovery_accepts_only_fixed_component_uuid_and_entry_shapes() { + let uuid = "6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d"; + assert_eq!( + parse_package_staging_directory_name(&format!(".package-proxifyre-{uuid}")), + Ok(PackageStagingComponent::Proxifyre) + ); + assert_eq!( + parse_package_staging_directory_name(&format!(".package-windows-packet-filter-{uuid}")), + Ok(PackageStagingComponent::WindowsPacketFilter) + ); + assert_eq!( + parse_package_staging_directory_name(&format!(".package-sing-box-{uuid}")), + Ok(PackageStagingComponent::SingBox) + ); + for invalid in [ + ".package-proxifyre-not-a-uuid", + ".package-vc-runtime-6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d", + ".package-proxifyre-6F21E8C7-B63F-4C4C-9AA7-DF96A7D0049D", + ] { + assert_eq!( + parse_package_staging_directory_name(invalid), + Err(NsisRuntimeError::TransientUnsafe) + ); + } + assert!(package_staging_entry_role( + PackageStagingComponent::Proxifyre, + "ProxiFyre-v2.5.1-x64-signed.zip" + ) + .is_some()); + assert!(package_staging_entry_role( + PackageStagingComponent::WindowsPacketFilter, + "Windows.Packet.Filter.3.7.0.1.x64.msi" + ) + .is_some()); + assert!(package_staging_entry_role( + PackageStagingComponent::SingBox, + "sing-box-1.14.0-windows-amd64.zip" + ) + .is_some()); + assert!(package_staging_entry_role(PackageStagingComponent::SingBox, "foreign.zip").is_none()); +} + +#[test] +fn elevation_failure_returns_before_runtime_or_filesystem_checks() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.elevated = false; + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::NotElevated) + ); + assert_eq!(host.calls(), ["elevated"]); +} + +#[test] +fn upgrade_is_strictly_read_only() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::ManagedStopped, + ); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade), + Ok(NsisRunOutcome::Success) + ); + assert_eq!( + host.calls(), + [ + "elevated", + "verify-exe", + "lifecycle-idle", + "cutover", + "proxifyre", + "singbox", + "transients", + ] + ); +} + +#[test] +fn upgrade_blocks_terminal_cutover_without_retiring_it() { + let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure; + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.cutover = VecDeque::from([Ok(NsisCutoverState::Retirable(expected))]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade), + Err(NsisRuntimeError::CutoverBlocked) + ); + assert!(!host.calls().contains(&"retire-cutover")); + assert!(!host.calls().contains(&"acquire")); +} + +#[test] +fn upgrade_blocks_pending_tombstone_without_retrying_it() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.transients = VecDeque::from([Ok(NsisTransientState { + singbox_cleanup_pending: true, + package_staging_pending: false, + })]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade), + Err(NsisRuntimeError::TransientUnsafe) + ); + assert!(!host.calls().contains(&"retry-singbox-cleanup")); + assert!(!host.calls().contains(&"acquire")); +} + +#[test] +fn upgrade_blocks_interrupted_job_store_retirement_without_mutating_it() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState { + retirement_pending: true, + reboot_required: false, + })]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade), + Err(NsisRuntimeError::TransientUnsafe) + ); + assert!(!host.calls().contains(&"acquire")); + assert!(!host.calls().contains(&"cleanup")); +} + +#[test] +fn full_uninstall_resumes_interrupted_job_store_retirement() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState { + retirement_pending: true, + reboot_required: false, + })]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + assert!(host.calls().contains(&"acquire")); + assert!(host.calls().contains(&"cleanup")); +} + +#[test] +fn update_blocks_stale_package_staging_but_uninstall_retires_it() { + let pending = NsisTransientState { + singbox_cleanup_pending: false, + package_staging_pending: true, + }; + let mut update = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + update.transients = VecDeque::from([Ok(pending)]); + assert_eq!( + run_nsis_mode(&mut update, NsisEarlyMode::VerifyUpgrade), + Err(NsisRuntimeError::TransientUnsafe) + ); + assert!(!update.calls().contains(&"cleanup")); + + let mut uninstall = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + uninstall.transients = VecDeque::from([Ok(pending), Ok(pending)]); + assert_eq!( + run_nsis_mode(&mut uninstall, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + assert!(uninstall.calls().contains(&"cleanup")); +} + +#[test] +fn unsafe_first_component_still_preflights_second_and_causes_zero_mutation() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.proxifyre = VecDeque::from([Err(NsisRuntimeError::ComponentUnsafe)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::ComponentUnsafe) + ); + assert!(host.calls().contains(&"singbox")); + assert!(host.calls().contains(&"transients")); + assert!(!host.calls().contains(&"acquire")); + assert!(!host.calls().contains(&"stop-proxifyre")); + assert!(!host.calls().contains(&"uninstall-singbox")); +} + +#[test] +fn busy_lifecycle_still_runs_full_read_only_preflight_and_never_mutates() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::ManagedStopped, + ); + host.lifecycle_state = VecDeque::from([Err(NsisRuntimeError::LifecycleBusy)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::LifecycleBusy) + ); + assert!(host.calls().contains(&"proxifyre")); + assert!(host.calls().contains(&"singbox")); + assert!(host.calls().contains(&"transients")); + assert!(!host.calls().contains(&"acquire")); + assert!(!host.calls().contains(&"stop-proxifyre")); +} + +#[test] +fn uninstall_stops_both_running_services_before_uninstalling_either() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::ManagedRunning, + ); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + let calls = host.calls(); + let stop_prox = calls + .iter() + .position(|call| *call == "stop-proxifyre") + .unwrap(); + let stop_sing = calls + .iter() + .position(|call| *call == "stop-singbox") + .unwrap(); + let uninstall_prox = calls + .iter() + .position(|call| *call == "uninstall-proxifyre") + .unwrap(); + let uninstall_sing = calls + .iter() + .position(|call| *call == "uninstall-singbox") + .unwrap(); + let cleanup = calls.iter().position(|call| *call == "cleanup").unwrap(); + assert!(stop_prox < uninstall_prox); + assert!(stop_sing < uninstall_prox); + assert!(uninstall_prox < uninstall_sing); + assert!(uninstall_sing < cleanup); +} + +#[test] +fn missing_components_are_noops_but_owned_transients_are_retired() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + let calls = host.calls(); + assert!(!calls.contains(&"stop-proxifyre")); + assert!(!calls.contains(&"stop-singbox")); + assert!(!calls.contains(&"uninstall-proxifyre")); + assert!(!calls.contains(&"uninstall-singbox")); + assert!(calls.contains(&"cleanup")); +} + +#[test] +fn state_drift_after_lock_causes_zero_component_mutation() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::Missing, + ); + host.proxifyre = VecDeque::from([ + Ok(NsisComponentState::ManagedRunning), + Ok(NsisComponentState::ManagedStopped), + ]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::StateChanged) + ); + assert!(host.calls().contains(&"acquire")); + assert!(!host.calls().contains(&"stop-proxifyre")); + assert!(!host.calls().contains(&"uninstall-proxifyre")); +} + +#[test] +fn terminal_cutover_is_exactly_retired_before_component_mutation() { + let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure; + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::Missing, + ); + host.cutover = VecDeque::from([ + Ok(NsisCutoverState::Retirable(expected.clone())), + Ok(NsisCutoverState::Retirable(expected)), + Ok(NsisCutoverState::Absent), + ]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + let calls = host.calls(); + let retire = calls + .iter() + .position(|call| *call == "retire-cutover") + .unwrap(); + let stop = calls + .iter() + .position(|call| *call == "stop-proxifyre") + .unwrap(); + assert!(retire < stop); +} + +#[test] +fn stop_failure_prevents_all_uninstall_and_terminal_cleanup() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::ManagedRunning, + ); + host.stop_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::OperationFailed) + ); + let calls = host.calls(); + assert!(!calls.contains(&"stop-singbox")); + assert!(!calls.contains(&"uninstall-proxifyre")); + assert!(!calls.contains(&"cleanup")); +} + +#[test] +fn uninstall_reboot_requirement_maps_to_msi_3010() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedStopped, + NsisComponentState::ManagedStopped, + ); + host.uninstall_proxifyre = VecDeque::from([Ok(true)]); + assert_eq!( + nsis_process_exit_code(run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged)), + NSIS_EXIT_REBOOT_REQUIRED + ); + assert_eq!( + nsis_process_exit_code(Err(NsisRuntimeError::InvalidArguments)), + NSIS_EXIT_USAGE + ); + let calls = host.calls(); + assert!( + calls.iter().position(|call| *call == "mark-reboot") + < calls.iter().position(|call| *call == "uninstall-proxifyre") + ); + assert!(!calls.contains(&"clear-reboot")); +} + +#[test] +fn reboot_intent_is_write_ahead_and_cleared_only_after_proven_no_reboot() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedStopped, + NsisComponentState::Missing, + ); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + let calls = host.calls(); + let mark = calls + .iter() + .position(|call| *call == "mark-reboot") + .unwrap(); + let uninstall = calls + .iter() + .position(|call| *call == "uninstall-proxifyre") + .unwrap(); + let clear = calls + .iter() + .position(|call| *call == "clear-reboot") + .unwrap(); + assert!(mark < uninstall); + assert!(uninstall < clear); +} + +#[test] +fn failed_uninstall_keeps_write_ahead_reboot_intent_for_retry() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedStopped, + NsisComponentState::Missing, + ); + host.uninstall_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::OperationFailed) + ); + let calls = host.calls(); + assert!( + calls.iter().position(|call| *call == "mark-reboot") + < calls.iter().position(|call| *call == "uninstall-proxifyre") + ); + assert!(!calls.contains(&"clear-reboot")); + assert!(!calls.contains(&"cleanup")); +} + +#[test] +fn intent_published_while_waiting_for_lock_is_never_adopted_or_cleared() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedStopped, + NsisComponentState::Missing, + ); + // The read-only pre-lock probe saw no marker, but authoritative observation + // under the acquired lock sees the earlier owner's durable fact. + host.reboot_under_lock = VecDeque::from([Ok(true)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::RebootRequired) + ); + let calls = host.calls(); + assert!(calls.contains(&"uninstall-proxifyre")); + assert!(!calls.contains(&"mark-reboot")); + assert!(!calls.contains(&"clear-reboot")); +} + +#[test] +fn marker_published_after_probe_is_delivered_even_when_components_are_missing() { + let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + host.reboot_under_lock = VecDeque::from([Ok(true)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::RebootRequired) + ); + let calls = host.calls(); + assert!(calls.contains(&"reboot-under-lock")); + assert!(!calls.contains(&"uninstall-proxifyre")); + assert!(!calls.contains(&"uninstall-singbox")); + assert!(!calls.contains(&"clear-reboot")); +} + +#[test] +fn reboot_requirement_survives_a_later_failure_and_retry() { + let mut first = FakeHost::ready( + NsisComponentState::ManagedStopped, + NsisComponentState::ManagedStopped, + ); + first.uninstall_proxifyre = VecDeque::from([Ok(true)]); + first.uninstall_singbox = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]); + assert_eq!( + run_nsis_mode(&mut first, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::OperationFailed) + ); + let calls = first.calls(); + assert!( + calls.iter().position(|call| *call == "mark-reboot") + < calls.iter().position(|call| *call == "uninstall-singbox") + ); + assert!(!calls.contains(&"cleanup")); + + let mut retry = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing); + retry.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState { + retirement_pending: false, + reboot_required: true, + })]); + retry.reboot_under_lock = VecDeque::from([Ok(true)]); + assert_eq!( + run_nsis_mode(&mut retry, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::RebootRequired) + ); + assert!(!retry.calls().contains(&"mark-reboot")); + assert!(retry.calls().contains(&"cleanup")); +} + +#[test] +fn transient_shape_failure_is_observed_before_lock_and_component_mutation() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::ManagedStopped, + ); + host.transients = VecDeque::from([Err(NsisRuntimeError::TransientUnsafe)]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Err(NsisRuntimeError::TransientUnsafe) + ); + assert!(!host.calls().contains(&"acquire")); + assert!(!host.calls().contains(&"stop-proxifyre")); +} + +#[test] +fn pending_singbox_tombstone_is_retried_before_services_are_stopped() { + let mut host = FakeHost::ready( + NsisComponentState::ManagedRunning, + NsisComponentState::Missing, + ); + host.transients = VecDeque::from([ + Ok(NsisTransientState { + singbox_cleanup_pending: true, + package_staging_pending: false, + }), + Ok(NsisTransientState { + singbox_cleanup_pending: true, + package_staging_pending: false, + }), + Ok(NsisTransientState { + singbox_cleanup_pending: false, + package_staging_pending: false, + }), + ]); + assert_eq!( + run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged), + Ok(NsisRunOutcome::Success) + ); + let calls = host.calls(); + let retry = calls + .iter() + .position(|call| *call == "retry-singbox-cleanup") + .unwrap(); + let stop = calls + .iter() + .position(|call| *call == "stop-proxifyre") + .unwrap(); + assert!(retry < stop); +} + +#[test] +fn hook_separates_update_from_full_uninstall_without_powershell() { + let hook = include_str!("../bundled/installer-hooks/proxywarden-hooks.nsh"); + assert!(hook.contains("$UpdateMode")); + assert!(hook.contains(NSIS_VERIFY_UPGRADE_ARGUMENT)); + assert!(hook.contains(NSIS_UNINSTALL_MANAGED_ARGUMENT)); + assert!(hook.contains("CheckIfAppIsRunning")); + assert!(hook.contains("3010")); + assert!(hook.contains("SetRebootFlag true")); + assert_eq!(hook.matches("ClearErrors").count(), 3); + let launch_error_gate = hook.find("IfErrors").expect("launch-error gate"); + let last_exec = hook.rfind("ExecWait").expect("native helper launch"); + assert!(last_exec < launch_error_gate); + for branch in hook.split("ExecWait").take(2) { + assert!(branch.rfind("ClearErrors").is_some()); + } + assert!(!hook.to_ascii_lowercase().contains("powershell")); + let guard = hook.find("CheckIfAppIsRunning").expect("app guard"); + let destructive = hook + .find(NSIS_UNINSTALL_MANAGED_ARGUMENT) + .expect("destructive mode"); + assert!(guard < destructive); + let reboot_observed = hook.find("SetRebootFlag true").expect("reboot flag"); + let marker_ack = hook + .find("Delete \"$INSTDIR\\.proxywarden-nsis-reboot-required.json\"") + .expect("exact reboot marker acknowledgement"); + let marker_error = hook[marker_ack..] + .find("IfErrors") + .map(|offset| marker_ack + offset) + .expect("marker delete error gate"); + assert!(reboot_observed < marker_ack); + assert!(marker_ack < marker_error); + assert!(hook[marker_error..].contains("Abort")); +} + +#[cfg(all(windows, debug_assertions))] +mod windows_store { + use super::*; + use std::fs; + + struct TestRoot(PathBuf); + + impl TestRoot { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "proxywarden-nsis-store-{}", + uuid::Uuid::new_v4().hyphenated() + )); + fs::create_dir(&path).expect("create temp app root"); + Self(path) + } + } + + impl Drop for TestRoot { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn read_only_idle_probe_does_not_create_store() { + let root = TestRoot::new(); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("idle missing store"), + NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required: false, + } + ); + assert!(!root.0.join(".proxywarden-privileged-jobs").exists()); + } + + #[test] + fn exact_terminal_pairs_and_lock_are_retired_nonrecursively() { + let root = TestRoot::new(); + write_nsis_terminal_pair_for_tests(&root.0, true).expect("terminal pair"); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("terminal idle store"), + NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required: false, + } + ); + let held = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("exclusive lifecycle guard"); + assert!(matches!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0), + Err(PrivilegedJobsError::LifecycleBusy) + )); + drop(held); + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("persisted idle lock is read-only verifiable"); + let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("reacquire lifecycle guard"); + match guard.retire_terminal_store() { + Ok(()) => {} + Err(PrivilegedJobsError::Io(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied => + { + // Stable identity leases capture SACL bytes. A normal + // developer token cannot enable SeSecurityPrivilege; the + // elevated NSIS path and elevated Windows gate exercise the + // actual same-handle deletion. + return; + } + Err(error) => panic!("exact retirement: {error}"), + } + assert!(!root.0.join(".proxywarden-privileged-jobs").exists()); + } + + #[test] + fn interrupted_terminal_retirement_is_detected_and_resumed() { + let root = TestRoot::new(); + write_nsis_terminal_pair_for_tests(&root.0, true).expect("independent terminal pair"); + write_nsis_interrupted_retirement_for_tests(&root.0) + .expect("interrupted retirement fixture"); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("durable retirement marker"), + NsisPrivilegedLifecycleState { + retirement_pending: true, + reboot_required: false, + } + ); + let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("resume lifecycle guard"); + match guard.retire_terminal_store() { + Ok(()) => { + assert!(!root.0.join(".proxywarden-privileged-jobs").exists()); + } + Err(PrivilegedJobsError::Io(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(error) => panic!("resumed retirement: {error}"), + } + } + + #[test] + fn reboot_marker_survives_store_retirement_until_nsis_observes_3010() { + let root = TestRoot::new(); + let guard = + NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard"); + assert!(guard.mark_reboot_required().expect("durable reboot marker")); + drop(guard); + assert!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("reboot state") + .reboot_required + ); + + fs::remove_dir_all(root.0.join(".proxywarden-privileged-jobs")) + .expect("simulate completed store cleanup before process exit"); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("reboot survives store loss"), + NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required: true, + } + ); + + let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("recreate exact lifecycle store"); + match guard.retire_terminal_store() { + Ok(()) => assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("reboot marker retained for outward 3010"), + NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required: true, + } + ), + Err(PrivilegedJobsError::Io(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(error) => panic!("reboot marker retirement: {error}"), + } + } + + #[test] + fn proven_no_reboot_clears_only_the_exact_write_ahead_marker() { + let root = TestRoot::new(); + let guard = + NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard"); + assert!(guard.mark_reboot_required().expect("write-ahead intent")); + guard + .clear_reboot_required() + .expect("exact no-reboot acknowledgement"); + drop(guard); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("marker cleared after proven no-reboot"), + NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required: false, + } + ); + } + + #[test] + fn partial_retirement_staging_is_durable_and_resumed_pair_at_a_time() { + let root = TestRoot::new(); + write_nsis_partial_retirement_staging_for_tests(&root.0) + .expect("partial retirement staging"); + assert_eq!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("staging is a durable retirement intent"), + NsisPrivilegedLifecycleState { + retirement_pending: true, + reboot_required: false, + } + ); + + let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("resume staged retirement"); + match guard.retire_terminal_store() { + Ok(()) => assert!(!root.0.join(".proxywarden-privileged-jobs").exists()), + Err(PrivilegedJobsError::Io(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(error) => panic!("staged retirement recovery: {error}"), + } + } + + #[test] + fn partial_reboot_staging_is_published_and_never_acknowledged_by_helper() { + let root = TestRoot::new(); + write_nsis_partial_reboot_staging_for_tests(&root.0).expect("partial reboot staging"); + assert!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("partial reboot intent") + .reboot_required + ); + + let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0) + .expect("recover reboot marker under lifecycle lock"); + assert!(!root + .0 + .join(".proxywarden-nsis-reboot-required.pending") + .exists()); + assert!(root + .0 + .join(".proxywarden-nsis-reboot-required.json") + .is_file()); + match guard.retire_terminal_store() { + Ok(()) => assert!( + verify_nsis_privileged_lifecycle_idle_for_tests(&root.0) + .expect("reboot fact remains after store cleanup") + .reboot_required + ), + Err(PrivilegedJobsError::Io(error)) + if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Err(error) => panic!("reboot staging recovery: {error}"), + } + } + + #[test] + fn running_or_unknown_records_block_without_deletion() { + let running = TestRoot::new(); + write_nsis_terminal_pair_for_tests(&running.0, false).expect("running pair"); + assert!(matches!( + verify_nsis_privileged_lifecycle_idle_for_tests(&running.0), + Err(PrivilegedJobsError::InvalidRecord) + )); + assert!(running.0.join(".proxywarden-privileged-jobs").exists()); + + let unknown = TestRoot::new(); + write_nsis_terminal_pair_for_tests(&unknown.0, true).expect("terminal pair"); + let path = unknown + .0 + .join(".proxywarden-privileged-jobs") + .join("foreign.bin"); + fs::write(&path, b"foreign").expect("foreign entry"); + safe_fs::protect_path_for_owner_admin_system(&path).expect("seal fixture"); + assert!(matches!( + verify_nsis_privileged_lifecycle_idle_for_tests(&unknown.0), + Err(PrivilegedJobsError::InvalidRecord) + )); + assert!(path.exists()); + } +} diff --git a/src-tauri/src/powershell.rs b/src-tauri/src/powershell.rs deleted file mode 100644 index c410a0f..0000000 --- a/src-tauri/src/powershell.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Shared PowerShell execution boundary for fixed ProxyWarden scripts. -//! -//! Callers remain responsible for generating static script templates and for -//! validating every path or service identifier before invoking this module. - -use crate::process::command_no_window; -use std::{fs, path::Path, process::Output}; - -pub(crate) fn write_script(path: &Path, script: &str) -> std::io::Result<()> { - let mut bytes = Vec::with_capacity(script.len() + 3); - bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]); - bytes.extend_from_slice(script.as_bytes()); - fs::write(path, bytes) -} - -pub(crate) fn run_command(script: &str) -> std::io::Result { - command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script, - ]) - .output() -} - -pub(crate) fn run_file(script_path: &Path) -> std::io::Result { - command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-File", - ]) - .arg(script_path) - .output() -} - -pub(crate) fn is_elevated() -> bool { - if !cfg!(windows) { - return false; - } - - let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#; - let Ok(output) = run_command(script) else { - return false; - }; - - output.status.success() - && String::from_utf8_lossy(&output.stdout) - .trim() - .eq_ignore_ascii_case("true") -} - -pub(crate) fn output_message(output: &Output, fallback: &str) -> String { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if !stderr.is_empty() { - return stderr; - } - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !stdout.is_empty() { - return stdout; - } - - fallback.to_string() -} - -pub(crate) fn package_failure_details(result_path: &Path, output: &Output) -> String { - let mut parts = Vec::new(); - - if let Ok(contents) = fs::read_to_string(result_path) { - let details = compact_error_text(&contents); - if !details.is_empty() && !details.eq_ignore_ascii_case("ok") { - parts.push(details); - } - } - - let stdout = compact_error_text(&String::from_utf8_lossy(&output.stdout)); - if !stdout.is_empty() { - parts.push(format!("stdout: {stdout}")); - } - - let stderr = compact_error_text(&String::from_utf8_lossy(&output.stderr)); - if !stderr.is_empty() { - parts.push(format!("stderr: {stderr}")); - } - - if parts.is_empty() { - parts.push( - "Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell." - .to_string(), - ); - } - - parts.join(" ") -} - -fn compact_error_text(value: &str) -> String { - let text = value - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .collect::>() - .join(" "); - - const MAX_CHARS: usize = 1400; - if text.chars().count() <= MAX_CHARS { - return text; - } - - format!("{}...", text.chars().take(MAX_CHARS).collect::()) -} - -pub(crate) fn escape_single(value: &str) -> String { - value.replace('\'', "''") -} diff --git a/src-tauri/src/privileged_jobs.rs b/src-tauri/src/privileged_jobs.rs new file mode 100644 index 0000000..77b7b5d --- /dev/null +++ b/src-tauri/src/privileged_jobs.rs @@ -0,0 +1,3489 @@ +//! One-shot, typed boundary between the normal UI process and the elevated helper mode. +//! +//! The public request deliberately contains no paths, service names, programs, or arguments. +//! Those values are derived again by the elevated runtime from fixed component identities. + +use crate::component_catalog::ComponentId as CatalogComponentId; +use crate::component_cutover::{ + validate_component_cutover_user_evidence, ComponentCutoverUserEvidence, +}; +use crate::process::{is_process_elevated, launch_elevated_helper, ElevatedHelperLaunchOutcome}; +use crate::safe_fs; +use crate::storage::StoragePaths; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use thiserror::Error; +use uuid::{Uuid, Version}; + +pub const PRIVILEGED_JOB_SCHEMA_VERSION: u32 = 2; +pub const INSTALL_RECEIPT_SCHEMA_VERSION: u32 = 1; +pub const PRIVILEGED_JOB_TTL_SECONDS: u64 = 300; +pub const PRIVILEGED_JOB_FUTURE_SKEW_SECONDS: u64 = 30; +pub const MAX_PRIVILEGED_RECORD_BYTES: u64 = 64 * 1024; +pub const ELEVATED_HELPER_ARGUMENT: &str = "--elevated-helper"; +pub const INSTALL_RECEIPT_FILENAME: &str = "proxywarden-install-receipt.json"; +pub const WINDOWS_PACKET_FILTER_PRODUCT_CODE: &str = "{4EC4289E-7F4B-424C-8EA9-B7AC9850FFBE}"; + +const LIFECYCLE_LOCK_FILENAME: &str = "lifecycle.lock"; +const SYSTEM_DIRECTORY_NAME: &str = ".proxywarden-privileged-jobs"; +const NSIS_RETIREMENT_FILENAME: &str = "nsis-retirement.json"; +const NSIS_RETIREMENT_STAGING_FILENAME: &str = "nsis-retirement.pending"; +const NSIS_RETIREMENT_SCHEMA_VERSION: u32 = 1; +const NSIS_REBOOT_REQUIRED_FILENAME: &str = ".proxywarden-nsis-reboot-required.json"; +const NSIS_REBOOT_REQUIRED_STAGING_FILENAME: &str = ".proxywarden-nsis-reboot-required.pending"; +const NSIS_REBOOT_REQUIRED_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum PrivilegedAction { + #[serde(rename = "proxifyre.install")] + InstallProxifyre, + #[serde(rename = "proxifyre.update")] + UpdateProxifyre, + #[serde(rename = "proxifyre.start")] + StartProxifyre, + #[serde(rename = "proxifyre.stop")] + StopProxifyre, + #[serde(rename = "proxifyre.configure-firewall")] + ConfigureProxifyreFirewall, + #[serde(rename = "proxifyre.uninstall")] + UninstallProxifyre, + #[serde(rename = "proxifyre.cutover")] + CutoverProxifyre, + #[serde(rename = "proxifyre.cleanup-quarantine")] + CleanupProxifyreQuarantine, + #[serde(rename = "sing-box.install")] + InstallSingBox, + #[serde(rename = "sing-box.update")] + UpdateSingBox, + #[serde(rename = "sing-box.start")] + StartSingBox, + #[serde(rename = "sing-box.stop")] + StopSingBox, + #[serde(rename = "sing-box.uninstall")] + UninstallSingBox, +} + +impl PrivilegedAction { + pub fn component(self) -> ManagedComponent { + match self { + Self::InstallProxifyre + | Self::UpdateProxifyre + | Self::StartProxifyre + | Self::StopProxifyre + | Self::ConfigureProxifyreFirewall + | Self::UninstallProxifyre + | Self::CutoverProxifyre + | Self::CleanupProxifyreQuarantine => ManagedComponent::Proxifyre, + Self::InstallSingBox + | Self::UpdateSingBox + | Self::StartSingBox + | Self::StopSingBox + | Self::UninstallSingBox => ManagedComponent::SingBox, + } + } + + fn fingerprint_label(self) -> &'static str { + match self { + Self::InstallProxifyre => "proxifyre.install", + Self::UpdateProxifyre => "proxifyre.update", + Self::StartProxifyre => "proxifyre.start", + Self::StopProxifyre => "proxifyre.stop", + Self::ConfigureProxifyreFirewall => "proxifyre.configure-firewall", + Self::UninstallProxifyre => "proxifyre.uninstall", + Self::CutoverProxifyre => "proxifyre.cutover", + Self::CleanupProxifyreQuarantine => "proxifyre.cleanup-quarantine", + Self::InstallSingBox => "sing-box.install", + Self::UpdateSingBox => "sing-box.update", + Self::StartSingBox => "sing-box.start", + Self::StopSingBox => "sing-box.stop", + Self::UninstallSingBox => "sing-box.uninstall", + } + } + + fn requires_package(self) -> Option { + match self { + Self::InstallProxifyre | Self::InstallSingBox => Some(InstalledPackageSource::Bundled), + Self::UpdateProxifyre | Self::UpdateSingBox => Some(InstalledPackageSource::Cache), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivilegedCutoverMode { + New, + Recovery, + Cleanup, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivilegedCutoverContext { + pub mode: PrivilegedCutoverMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cutover_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initiating_startup_session_id: Option, + pub evidence_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PlannedAssetFingerprint { + pub component_id: CatalogComponentId, + pub version: String, + pub name: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PlannedPackageFingerprint { + pub source: InstalledPackageSource, + pub version: String, + pub assets: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin_proof: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PlannedGithubOriginProof { + pub repository: String, + pub stable_tag: String, + pub release_id: u64, + pub asset_id: u64, + pub asset_name: String, + pub size: u64, + pub sha256_from_api: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ResolvedActionPlan { + pub component: ManagedComponent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub package: Option, + pub inventory_fingerprint: String, + pub configuration_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cutover_context: Option, + pub operation_fingerprint: String, +} + +impl ResolvedActionPlan { + pub fn new( + action: PrivilegedAction, + package: Option, + inventory_fingerprint: String, + configuration_fingerprint: String, + cutover_context: Option, + ) -> Self { + let mut plan = Self { + component: action.component(), + package, + inventory_fingerprint, + configuration_fingerprint, + cutover_context, + operation_fingerprint: String::new(), + }; + plan.operation_fingerprint = operation_fingerprint(action, &plan); + plan + } +} + +pub fn cutover_recovery_probe_fingerprint(inventory_fingerprint: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"proxywarden:privileged-cutover-recovery-probe:v1"); + digest.update([0]); + fingerprint_field( + &mut digest, + "inventory-fingerprint", + inventory_fingerprint.as_bytes(), + ); + format!("{:x}", digest.finalize()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PrivilegedJobId(String); + +impl PrivilegedJobId { + pub fn new() -> Self { + Self(Uuid::new_v4().hyphenated().to_string()) + } + + pub fn parse(value: &str) -> Result { + parse_canonical_v4(value) + .map(Self) + .ok_or(PrivilegedJobsError::InvalidJobId) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for PrivilegedJobId { + fn default() -> Self { + Self::new() + } +} + +impl<'de> Deserialize<'de> for PrivilegedJobId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct PrivilegedJobNonce(String); + +impl PrivilegedJobNonce { + fn new() -> Self { + Self(Uuid::new_v4().hyphenated().to_string()) + } + + fn parse(value: &str) -> Result { + parse_canonical_v4(value) + .map(Self) + .ok_or(PrivilegedJobsError::InvalidNonce) + } +} + +impl<'de> Deserialize<'de> for PrivilegedJobNonce { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivilegedJobRequest { + schema_version: u32, + job_id: PrivilegedJobId, + nonce: PrivilegedJobNonce, + action: PrivilegedAction, + plan: ResolvedActionPlan, + created_at_epoch_seconds: u64, +} + +impl PrivilegedJobRequest { + pub fn job_id(&self) -> &PrivilegedJobId { + &self.job_id + } + + pub fn action(&self) -> PrivilegedAction { + self.action + } + + pub fn plan(&self) -> &ResolvedActionPlan { + &self.plan + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PrivilegedJobClaim { + schema_version: u32, + job_id: PrivilegedJobId, + nonce: PrivilegedJobNonce, + action: PrivilegedAction, + claimed_at_epoch_seconds: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PrivilegedJobStatus { + Running, + Succeeded, + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivilegedResultCode { + Running, + Completed, + NoChange, + RunnerUnavailable, + PreconditionFailed, + OwnershipMismatch, + PackageVerificationFailed, + ServiceCollision, + CleanupPending, + CutoverAwaitingNextStart, + CutoverQuarantinePending, + CutoverRolledBack, + CutoverCleanupPending, + CutoverComplete, + CutoverRecoveryRequired, + CutoverStateConflict, + CutoverIdentityRejected, + OperationFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PrivilegedJobResult { + #[serde(default)] + pub activation: Option, + schema_version: u32, + job_id: PrivilegedJobId, + nonce: PrivilegedJobNonce, + action: PrivilegedAction, + pub status: PrivilegedJobStatus, + pub changed: bool, + pub reboot_required: bool, + pub code: PrivilegedResultCode, + pub message: String, + completed_at_epoch_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivationAcknowledgement { + pub component: ManagedComponent, + pub config_sha256: String, + pub instance: crate::process::ServiceInstance, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivilegedMutationResult { + pub activation: Option, + pub changed: bool, + pub reboot_required: bool, + pub result_code: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivilegedRunnerFailure { + RunnerUnavailable, + PreconditionFailed, + OwnershipMismatch, + PackageVerificationFailed, + ServiceCollision, + CleanupPending, + CutoverRecoveryRequired, + CutoverStateConflict, + CutoverIdentityRejected, + OperationFailed, +} + +pub trait PrivilegedActionRunner { + fn run( + &self, + action: PrivilegedAction, + plan: &ResolvedActionPlan, + ) -> Result; +} + +pub trait PrivilegedPlanResolver { + fn resolve( + &self, + action: PrivilegedAction, + ) -> Result; + + /// Elevated runtimes may use the untrusted plan only as a mode hint. The + /// default preserves stateless resolvers; production cutover overrides it + /// and derives all authority from the sealed journal before opening assets. + fn resolve_elevated( + &self, + action: PrivilegedAction, + _requested_plan: &ResolvedActionPlan, + ) -> Result { + self.resolve(action) + } +} + +pub trait EpochClock { + fn now_epoch_seconds(&self) -> u64; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemEpochClock; + +impl EpochClock for SystemEpochClock { + fn now_epoch_seconds(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) + } +} + +pub trait ElevationProbe { + fn is_elevated(&self) -> bool; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct NativeElevationProbe; + +impl ElevationProbe for NativeElevationProbe { + fn is_elevated(&self) -> bool { + is_process_elevated() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElevatedLaunchOutcome { + Completed { exit_code: u32 }, + Canceled, + Indeterminate, +} + +pub trait ElevatedJobLauncher { + fn launch( + &self, + executable: &Path, + job_id: &PrivilegedJobId, + ) -> io::Result; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ShellExecuteElevatedJobLauncher; + +impl ElevatedJobLauncher for ShellExecuteElevatedJobLauncher { + fn launch( + &self, + executable: &Path, + job_id: &PrivilegedJobId, + ) -> io::Result { + if executable != std::env::current_exe()? { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "elevated helper must launch the current executable", + )); + } + Ok(match launch_elevated_helper(executable, job_id.as_str())? { + ElevatedHelperLaunchOutcome::Completed { exit_code } => { + ElevatedLaunchOutcome::Completed { exit_code } + } + ElevatedHelperLaunchOutcome::Canceled => ElevatedLaunchOutcome::Canceled, + ElevatedHelperLaunchOutcome::Indeterminate => ElevatedLaunchOutcome::Indeterminate, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrivilegedJobLaunchState { + Completed { exit_code: u32 }, + Canceled, + Failed, + Indeterminate, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchedPrivilegedJob { + pub request: PrivilegedJobRequest, + pub state: PrivilegedJobLaunchState, +} + +#[derive(Debug, Error)] +pub enum PrivilegedJobsError { + #[error("elevated helper arguments are invalid")] + InvalidArguments, + #[error("privileged job id must be a canonical lowercase UUID v4")] + InvalidJobId, + #[error("privileged job nonce must be a canonical lowercase UUID v4")] + InvalidNonce, + #[error("privileged job schema is unsupported")] + UnsupportedSchema, + #[error("privileged job request does not match the selected job")] + RequestMismatch, + #[error("privileged action plan changed before execution")] + PlanChanged, + #[error("privileged job was created too far in the future")] + FutureDated, + #[error("privileged job has expired")] + Expired, + #[error("elevated helper is not running with an elevated token")] + NotElevated, + #[error("privileged job was already claimed")] + Replay, + #[error("privileged job result already exists")] + ResultAlreadyExists, + #[error("another privileged lifecycle operation is running")] + LifecycleBusy, + #[error("privileged record is too large")] + RecordTooLarge, + #[error("privileged record is invalid")] + InvalidRecord, + #[error("install receipt is invalid")] + InvalidReceipt, + #[error("privileged job filesystem operation failed")] + Io(#[source] io::Error), +} + +impl From for PrivilegedJobsError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +#[derive(Debug, Clone)] +pub struct PrivilegedJobStore { + request_directory: PathBuf, + system_directory: PathBuf, + helper_executable: PathBuf, + seal_policy: SystemSealPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SystemSealPolicy { + AdminOwned, + #[cfg(debug_assertions)] + PortableTests, +} + +impl PrivilegedJobStore { + pub fn read_activation( + &self, + component: ManagedComponent, + ) -> Result, PrivilegedJobsError> { + self.verify_system_directory()?; + let service = match component { + ManagedComponent::Proxifyre => crate::process::KnownWindowsService::Proxifyre, + ManagedComponent::SingBox => crate::process::KnownWindowsService::SingBox, + }; + let instance = match crate::process::running_service_instance(service) { + Ok(instance) => instance, + Err(_) => return Ok(None), + }; + // Reuse sealed successful job results. No new record type or retirement owner. + let mut found = None; + for (count, entry) in fs::read_dir(&self.system_directory)?.enumerate() { + if count >= 4096 { + return Err(PrivilegedJobsError::RecordTooLarge); + } + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let Ok((job_id, SystemRecordKind::Result)) = parse_system_record_filename(&name) else { + continue; + }; + let (result, _lease): (PrivilegedJobResult, File) = + read_system_record_with_open_lease(&entry.path(), self.seal_policy)?; + if result.schema_version != PRIVILEGED_JOB_SCHEMA_VERSION || result.job_id != job_id { + return Err(PrivilegedJobsError::InvalidRecord); + } + if result.action.component() == component && valid_terminal_result_semantics(&result) { + if let Some(ack) = result.activation.filter(|ack| ack.instance == instance) { + if found.as_ref().is_some_and(|previous| previous != &ack) { + return Err(PrivilegedJobsError::InvalidRecord); + } + found = Some(ack); + } + } + } + Ok(found) + } + + pub fn from_storage_paths(paths: &StoragePaths) -> Result { + if paths.privileged_jobs_dir != StoragePaths::default().privileged_jobs_dir { + return Err(PrivilegedJobsError::InvalidRecord); + } + let current_exe = std::env::current_exe()?; + let app_root = verified_current_exe_parent(¤t_exe)?; + Ok(Self::with_app_root( + paths, + &app_root, + ¤t_exe, + SystemSealPolicy::AdminOwned, + )) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_storage_paths_for_tests(paths: &StoragePaths, helper_executable: &Path) -> Self { + Self { + request_directory: paths.privileged_jobs_dir.clone(), + system_directory: paths.privileged_jobs_dir.join("system"), + helper_executable: helper_executable.to_path_buf(), + seal_policy: SystemSealPolicy::PortableTests, + } + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_storage_paths_with_verified_app_root_for_tests( + paths: &StoragePaths, + app_root: &Path, + helper_executable: &Path, + ) -> Self { + Self::with_app_root( + paths, + app_root, + helper_executable, + SystemSealPolicy::PortableTests, + ) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn system_directory_for_tests(&self) -> &Path { + &self.system_directory + } + + pub fn production() -> Result { + Self::from_storage_paths(&StoragePaths::default()) + } + + fn with_app_root( + paths: &StoragePaths, + app_root: &Path, + helper_executable: &Path, + seal_policy: SystemSealPolicy, + ) -> Self { + Self { + request_directory: paths.privileged_jobs_dir.clone(), + system_directory: app_root.join(SYSTEM_DIRECTORY_NAME), + helper_executable: helper_executable.to_path_buf(), + seal_policy, + } + } + + fn create_request( + &self, + action: PrivilegedAction, + plan: ResolvedActionPlan, + clock: &dyn EpochClock, + ) -> Result { + validate_resolved_plan(action, &plan)?; + self.prepare_request_directory()?; + let request = PrivilegedJobRequest { + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: PrivilegedJobId::new(), + nonce: PrivilegedJobNonce::new(), + action, + plan, + created_at_epoch_seconds: clock.now_epoch_seconds(), + }; + let bytes = encode_record(&request)?; + let files = self.files(&request.job_id); + let lease = RequestLease::create(&files.request, &bytes)?; + Ok(PreparedPrivilegedJob { request, lease }) + } + + pub fn read_result( + &self, + request: &PrivilegedJobRequest, + ) -> Result { + self.read_result_with_open_lease(request) + .map(|(result, _lease)| result) + } + + fn read_result_with_open_lease( + &self, + request: &PrivilegedJobRequest, + ) -> Result<(PrivilegedJobResult, File), PrivilegedJobsError> { + self.verify_system_directory()?; + let (result, lease): (PrivilegedJobResult, File) = read_system_record_with_open_lease( + &self.files(&request.job_id).result, + self.seal_policy, + )?; + if result.schema_version != PRIVILEGED_JOB_SCHEMA_VERSION + || result.job_id != request.job_id + || result.nonce != request.nonce + || result.action != request.action + { + return Err(PrivilegedJobsError::RequestMismatch); + } + Ok((result, lease)) + } + + #[cfg(all(debug_assertions, windows))] + #[doc(hidden)] + pub fn read_result_with_open_lease_for_tests( + &self, + request: &PrivilegedJobRequest, + ) -> Result<(PrivilegedJobResult, File), PrivilegedJobsError> { + self.read_result_with_open_lease(request) + } + + fn prepare_request_directory(&self) -> Result<(), PrivilegedJobsError> { + safe_fs::ensure_no_reparse_ancestors(&self.request_directory)?; + fs::create_dir_all(&self.request_directory)?; + safe_fs::protect_path_for_owner_admin_system(&self.request_directory)?; + self.verify_request_directory() + } + + fn verify_request_directory(&self) -> Result<(), PrivilegedJobsError> { + let metadata = fs::symlink_metadata(&self.request_directory)?; + if !metadata.file_type().is_dir() { + return Err(PrivilegedJobsError::InvalidRecord); + } + verify_request_path(&self.request_directory) + } + + fn prepare_system_directory(&self) -> Result<(), PrivilegedJobsError> { + self.verify_request_directory()?; + safe_fs::ensure_no_reparse_ancestors(&self.system_directory)?; + if self.seal_policy == SystemSealPolicy::AdminOwned { + safe_fs::create_directory_admin_owned_user_read_only(&self.system_directory)?; + return self.verify_system_directory(); + } + #[cfg(debug_assertions)] + return match fs::symlink_metadata(&self.system_directory) { + Ok(metadata) => { + if !metadata.file_type().is_dir() { + return Err(PrivilegedJobsError::InvalidRecord); + } + self.verify_system_directory() + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(&self.system_directory)?; + if let Err(error) = seal_system_path(&self.system_directory, self.seal_policy) { + let _ = fs::remove_dir(&self.system_directory); + return Err(error); + } + self.verify_system_directory() + } + Err(error) => Err(error.into()), + }; + #[cfg(not(debug_assertions))] + Err(PrivilegedJobsError::InvalidRecord) + } + + fn verify_system_directory(&self) -> Result<(), PrivilegedJobsError> { + if self.seal_policy == SystemSealPolicy::AdminOwned { + let app_root = self + .system_directory + .parent() + .ok_or(PrivilegedJobsError::InvalidRecord)?; + safe_fs::verify_path_under_trusted_program_files(app_root)?; + } + verify_system_path(&self.system_directory, self.seal_policy) + } + + fn files(&self, job_id: &PrivilegedJobId) -> PrivilegedJobFiles { + let prefix = job_id.as_str(); + PrivilegedJobFiles { + request: self + .request_directory + .join(format!("{prefix}.request.json")), + claim: self.system_directory.join(format!("{prefix}.claim.json")), + result: self.system_directory.join(format!("{prefix}.result.json")), + } + } +} + +pub struct PreparedPrivilegedJob { + pub request: PrivilegedJobRequest, + lease: RequestLease, +} + +pub fn parse_early_helper_arguments( + arguments: I, +) -> Result, PrivilegedJobsError> +where + I: IntoIterator, +{ + let arguments = arguments.into_iter().collect::>(); + if arguments.is_empty() { + return Ok(None); + } + if arguments.first().and_then(|value| value.to_str()) != Some(ELEVATED_HELPER_ARGUMENT) { + return Ok(None); + } + if arguments.len() != 2 { + return Err(PrivilegedJobsError::InvalidArguments); + } + let value = arguments[1] + .to_str() + .ok_or(PrivilegedJobsError::InvalidJobId)?; + PrivilegedJobId::parse(value).map(Some) +} + +pub fn launch_privileged_job( + store: &PrivilegedJobStore, + action: PrivilegedAction, + executable: &Path, + clock: &dyn EpochClock, + resolver: &dyn PrivilegedPlanResolver, + launcher: &dyn ElevatedJobLauncher, +) -> Result { + if !executable.is_absolute() || executable != store.helper_executable { + return Err(PrivilegedJobsError::InvalidArguments); + } + let initial_plan = resolver.resolve(action).map_err(runner_failure_error)?; + validate_resolved_plan(action, &initial_plan)?; + let mut prepared = store.create_request(action, initial_plan.clone(), clock)?; + let committed_plan = match resolver.resolve(action) { + Ok(plan) => plan, + Err(failure) => { + prepared.lease.remove_exact()?; + return Err(runner_failure_error(failure)); + } + }; + if committed_plan != initial_plan { + prepared.lease.remove_exact()?; + return Err(PrivilegedJobsError::PlanChanged); + } + let state = match launcher.launch(executable, &prepared.request.job_id) { + Ok(ElevatedLaunchOutcome::Completed { exit_code }) => { + PrivilegedJobLaunchState::Completed { exit_code } + } + Ok(ElevatedLaunchOutcome::Canceled) => { + prepared.lease.remove_exact()?; + PrivilegedJobLaunchState::Canceled + } + Ok(ElevatedLaunchOutcome::Indeterminate) => { + keep_indeterminate_request_lease( + store.clone(), + prepared.request.clone(), + prepared.lease, + clock.now_epoch_seconds(), + ); + return Ok(LaunchedPrivilegedJob { + request: prepared.request, + state: PrivilegedJobLaunchState::Indeterminate, + }); + } + Err(_) => { + prepared.lease.remove_exact()?; + PrivilegedJobLaunchState::Failed + } + }; + Ok(LaunchedPrivilegedJob { + request: prepared.request, + state, + }) +} + +struct IndeterminateRequestKeeper { + store: PrivilegedJobStore, + request: PrivilegedJobRequest, + lease: RequestLease, + expires_at: Instant, +} + +static INDETERMINATE_REQUEST_KEEPERS: OnceLock< + Mutex>, +> = OnceLock::new(); + +fn indeterminate_request_keepers( +) -> &'static Mutex> { + INDETERMINATE_REQUEST_KEEPERS.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn keep_indeterminate_request_lease( + store: PrivilegedJobStore, + request: PrivilegedJobRequest, + lease: RequestLease, + now_epoch_seconds: u64, +) { + let expires_at_epoch_seconds = request + .created_at_epoch_seconds + .saturating_add(PRIVILEGED_JOB_TTL_SECONDS); + let remaining = expires_at_epoch_seconds.saturating_sub(now_epoch_seconds); + let expires_at = Instant::now() + .checked_add(Duration::from_secs(remaining)) + .unwrap_or_else(Instant::now); + let job_id = request.job_id.clone(); + let keeper = IndeterminateRequestKeeper { + store, + request, + lease, + expires_at, + }; + indeterminate_request_keepers() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(job_id.clone(), keeper); + + let spawn_result = thread::Builder::new() + .name("proxywarden-job-lease".to_string()) + .spawn(move || loop { + let terminal_or_expired = { + let keepers = indeterminate_request_keepers() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(keeper) = keepers.get(&job_id) else { + return; + }; + Instant::now() >= keeper.expires_at + || matches!( + keeper.store.read_result(&keeper.request), + Ok(PrivilegedJobResult { + status: PrivilegedJobStatus::Succeeded | PrivilegedJobStatus::Failed, + .. + }) + ) + }; + if terminal_or_expired { + let keeper = indeterminate_request_keepers() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&job_id); + if let Some(mut keeper) = keeper { + let _ = keeper.lease.remove_exact(); + } + return; + } + thread::park_timeout(Duration::from_millis(25)); + }); + + // If the small watcher cannot be created, the stable registry deliberately keeps the + // deny-write/delete handle until process exit instead of reopening a tamper window. + let _ = spawn_result; +} + +pub fn execute_privileged_job( + store: &PrivilegedJobStore, + job_id: &PrivilegedJobId, + clock: &dyn EpochClock, + elevation: &dyn ElevationProbe, + resolver: &dyn PrivilegedPlanResolver, + runner: &dyn PrivilegedActionRunner, +) -> Result { + if !elevation.is_elevated() { + return Err(PrivilegedJobsError::NotElevated); + } + + store.verify_request_directory()?; + let mut request_lease = RequestLease::open(&store.files(job_id).request)?; + let request: PrivilegedJobRequest = request_lease.read_record()?; + validate_request(&request, job_id, clock.now_epoch_seconds())?; + let app_root = store + .system_directory + .parent() + .ok_or(PrivilegedJobsError::InvalidRecord)?; + ensure_no_nsis_lifecycle_markers(app_root, &store.system_directory)?; + store.prepare_system_directory()?; + let _lock = LifecycleLock::acquire(&store.system_directory, store.seal_policy)?; + ensure_no_nsis_lifecycle_markers(app_root, &store.system_directory)?; + let files = store.files(job_id); + + if files.claim.try_exists()? { + return Err(PrivilegedJobsError::Replay); + } + if files.result.try_exists()? { + return Err(PrivilegedJobsError::ResultAlreadyExists); + } + + let claim = PrivilegedJobClaim { + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: request.job_id.clone(), + nonce: request.nonce.clone(), + action: request.action, + claimed_at_epoch_seconds: clock.now_epoch_seconds(), + }; + write_claim(&files.claim, &claim, store.seal_policy)?; + + let running = job_result( + &request, + PrivilegedJobStatus::Running, + PrivilegedResultCode::Running, + false, + false, + None, + ); + let reservation = ResultReservation::create(&files.result, store.seal_policy, &running)?; + + let resolved_plan = resolver.resolve_elevated(request.action, &request.plan); + let result = match resolved_plan { + Ok(plan) + if validate_resolved_plan(request.action, &plan).is_ok() && plan == request.plan => + { + if validate_request(&request, job_id, clock.now_epoch_seconds()).is_ok() { + runner.run(request.action, &plan) + } else { + Err(PrivilegedRunnerFailure::PreconditionFailed) + } + } + Ok(_) + if matches!( + request.action, + PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine + ) => + { + Err(PrivilegedRunnerFailure::CutoverStateConflict) + } + Ok(_) => Err(PrivilegedRunnerFailure::PreconditionFailed), + Err(failure) => Err(failure), + }; + let completed_at = clock.now_epoch_seconds(); + let result = match result { + Ok(outcome) => { + let code = outcome.result_code.unwrap_or(if outcome.changed { + PrivilegedResultCode::Completed + } else { + PrivilegedResultCode::NoChange + }); + let mut terminal = job_result( + &request, + PrivilegedJobStatus::Succeeded, + code, + outcome.changed, + outcome.reboot_required, + Some(completed_at), + ); + terminal.activation = outcome.activation.filter(|ack| { + ack.component == request.action.component() + && ack.config_sha256 == request.plan.configuration_fingerprint + }); + terminal + } + Err(failure) => job_result( + &request, + PrivilegedJobStatus::Failed, + failure.into(), + false, + false, + Some(completed_at), + ), + }; + reservation.write(&result)?; + + Ok(result) +} + +fn validate_request( + request: &PrivilegedJobRequest, + selected_job_id: &PrivilegedJobId, + now: u64, +) -> Result<(), PrivilegedJobsError> { + if request.schema_version != PRIVILEGED_JOB_SCHEMA_VERSION { + return Err(PrivilegedJobsError::UnsupportedSchema); + } + if &request.job_id != selected_job_id { + return Err(PrivilegedJobsError::RequestMismatch); + } + validate_resolved_plan(request.action, &request.plan)?; + if request.created_at_epoch_seconds > now.saturating_add(PRIVILEGED_JOB_FUTURE_SKEW_SECONDS) { + return Err(PrivilegedJobsError::FutureDated); + } + if now.saturating_sub(request.created_at_epoch_seconds) > PRIVILEGED_JOB_TTL_SECONDS { + return Err(PrivilegedJobsError::Expired); + } + Ok(()) +} + +fn validate_resolved_plan( + action: PrivilegedAction, + plan: &ResolvedActionPlan, +) -> Result<(), PrivilegedJobsError> { + if plan.component != action.component() + || !valid_sha256(&plan.inventory_fingerprint) + || !valid_sha256(&plan.configuration_fingerprint) + || !valid_sha256(&plan.operation_fingerprint) + || plan.operation_fingerprint != operation_fingerprint(action, plan) + || !valid_cutover_context( + action, + plan.cutover_context.as_ref(), + &plan.inventory_fingerprint, + ) + { + return Err(PrivilegedJobsError::InvalidRecord); + } + + match ( + expected_package_source(action, plan.cutover_context.as_ref()), + &plan.package, + ) { + (Err(()), _) => Err(PrivilegedJobsError::InvalidRecord), + (Ok(None), None) => Ok(()), + (Ok(Some(expected_source)), Some(package)) + if package.source == expected_source + && valid_numeric_version(&package.version) + && package + .assets + .first() + .is_some_and(|asset| asset.version == package.version) + && package.assets.iter().all(|asset| { + valid_numeric_version(&asset.version) + && valid_asset_name(&asset.name) + && valid_sha256(&asset.sha256) + && asset.size > 0 + }) => + { + let unique = package + .assets + .iter() + .map(|asset| asset.name.to_lowercase()) + .collect::>(); + if unique.len() == package.assets.len() + && valid_asset_set(action, &package.assets) + && valid_package_origin(plan.component, package) + { + Ok(()) + } else { + Err(PrivilegedJobsError::InvalidRecord) + } + } + _ => Err(PrivilegedJobsError::InvalidRecord), + } +} + +fn valid_cutover_context( + action: PrivilegedAction, + context: Option<&PrivilegedCutoverContext>, + inventory_fingerprint: &str, +) -> bool { + match (action, context) { + ( + PrivilegedAction::CutoverProxifyre, + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::New, + cutover_id: None, + initiating_startup_session_id: Some(initiating_startup_session_id), + evidence_fingerprint, + user_evidence: None, + }), + ) => { + parse_canonical_v4(initiating_startup_session_id).is_some() + && valid_sha256(evidence_fingerprint) + } + ( + PrivilegedAction::CutoverProxifyre, + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: Some(cutover_id), + initiating_startup_session_id: None, + evidence_fingerprint, + user_evidence: None, + }), + ) => parse_canonical_v4(cutover_id).is_some() && valid_sha256(evidence_fingerprint), + ( + PrivilegedAction::CutoverProxifyre, + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: None, + initiating_startup_session_id: None, + evidence_fingerprint, + user_evidence: None, + }), + ) => *evidence_fingerprint == cutover_recovery_probe_fingerprint(inventory_fingerprint), + ( + PrivilegedAction::CleanupProxifyreQuarantine, + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Cleanup, + cutover_id: Some(cutover_id), + initiating_startup_session_id: None, + evidence_fingerprint, + user_evidence: Some(user_evidence), + }), + ) => { + parse_canonical_v4(cutover_id).is_some() + && valid_sha256(evidence_fingerprint) + && user_evidence.cutover_id == *cutover_id + && user_evidence.route_smoke_confirmed + && user_evidence.confirmed_at_epoch_seconds.is_some() + && validate_component_cutover_user_evidence(user_evidence).is_ok() + } + (PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine, _) => { + false + } + (_, None) => true, + (_, Some(_)) => false, + } +} + +fn expected_package_source( + action: PrivilegedAction, + context: Option<&PrivilegedCutoverContext>, +) -> Result, ()> { + match (action, context.map(|value| value.mode)) { + (PrivilegedAction::CutoverProxifyre, Some(PrivilegedCutoverMode::New)) => { + Ok(Some(InstalledPackageSource::Bundled)) + } + (PrivilegedAction::CutoverProxifyre, Some(PrivilegedCutoverMode::Recovery)) + | (PrivilegedAction::CleanupProxifyreQuarantine, Some(PrivilegedCutoverMode::Cleanup)) => { + Ok(None) + } + (PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine, _) => { + Err(()) + } + (_, None) => Ok(action.requires_package()), + (_, Some(_)) => Err(()), + } +} + +fn valid_asset_set(action: PrivilegedAction, assets: &[PlannedAssetFingerprint]) -> bool { + let expected: &[CatalogComponentId] = match action { + PrivilegedAction::InstallProxifyre | PrivilegedAction::CutoverProxifyre => &[ + CatalogComponentId::Proxifyre, + CatalogComponentId::WindowsPacketFilter, + CatalogComponentId::VcRuntime, + ], + PrivilegedAction::UpdateProxifyre => &[CatalogComponentId::Proxifyre], + PrivilegedAction::InstallSingBox => { + &[CatalogComponentId::SingBox, CatalogComponentId::Winsw] + } + PrivilegedAction::UpdateSingBox => &[CatalogComponentId::SingBox], + PrivilegedAction::StartProxifyre + | PrivilegedAction::StopProxifyre + | PrivilegedAction::ConfigureProxifyreFirewall + | PrivilegedAction::UninstallProxifyre + | PrivilegedAction::CleanupProxifyreQuarantine + | PrivilegedAction::StartSingBox + | PrivilegedAction::StopSingBox + | PrivilegedAction::UninstallSingBox => return false, + }; + assets.len() == expected.len() + && assets + .iter() + .zip(expected) + .all(|(asset, component_id)| asset.component_id == *component_id) +} + +fn operation_fingerprint(action: PrivilegedAction, plan: &ResolvedActionPlan) -> String { + let mut digest = Sha256::new(); + digest.update(b"proxywarden:privileged-action-plan:v2"); + digest.update([0]); + fingerprint_field(&mut digest, "action", action.fingerprint_label().as_bytes()); + fingerprint_field( + &mut digest, + "component", + match plan.component { + ManagedComponent::Proxifyre => b"proxifyre", + ManagedComponent::SingBox => b"sing-box", + }, + ); + fingerprint_field( + &mut digest, + "inventory", + plan.inventory_fingerprint.as_bytes(), + ); + fingerprint_field( + &mut digest, + "configuration", + plan.configuration_fingerprint.as_bytes(), + ); + + match &plan.package { + None => fingerprint_field(&mut digest, "package", b"none"), + Some(package) => { + fingerprint_field(&mut digest, "package", b"present"); + fingerprint_field( + &mut digest, + "package-source", + match package.source { + InstalledPackageSource::Bundled => b"bundled", + InstalledPackageSource::Cache => b"cache", + }, + ); + fingerprint_field(&mut digest, "package-version", package.version.as_bytes()); + fingerprint_field( + &mut digest, + "asset-count", + &(package.assets.len() as u64).to_le_bytes(), + ); + for asset in &package.assets { + fingerprint_field( + &mut digest, + "asset-component", + asset.component_id.as_str().as_bytes(), + ); + fingerprint_field(&mut digest, "asset-version", asset.version.as_bytes()); + fingerprint_field(&mut digest, "asset-name", asset.name.as_bytes()); + fingerprint_field(&mut digest, "asset-sha256", asset.sha256.as_bytes()); + fingerprint_field(&mut digest, "asset-size", &asset.size.to_le_bytes()); + } + match &package.origin_proof { + None => fingerprint_field(&mut digest, "origin", b"none"), + Some(origin) => { + fingerprint_field(&mut digest, "origin", b"github"); + fingerprint_field( + &mut digest, + "origin-repository", + origin.repository.as_bytes(), + ); + fingerprint_field(&mut digest, "origin-tag", origin.stable_tag.as_bytes()); + fingerprint_field( + &mut digest, + "origin-release-id", + &origin.release_id.to_le_bytes(), + ); + fingerprint_field( + &mut digest, + "origin-asset-id", + &origin.asset_id.to_le_bytes(), + ); + fingerprint_field( + &mut digest, + "origin-asset-name", + origin.asset_name.as_bytes(), + ); + fingerprint_field(&mut digest, "origin-size", &origin.size.to_le_bytes()); + fingerprint_field( + &mut digest, + "origin-sha256", + origin.sha256_from_api.as_bytes(), + ); + } + } + } + } + + match &plan.cutover_context { + None => fingerprint_field(&mut digest, "cutover-context", b"none"), + Some(context) => { + fingerprint_field(&mut digest, "cutover-context", b"present"); + fingerprint_field( + &mut digest, + "cutover-mode", + match context.mode { + PrivilegedCutoverMode::New => b"new", + PrivilegedCutoverMode::Recovery => b"recovery", + PrivilegedCutoverMode::Cleanup => b"cleanup", + }, + ); + fingerprint_field( + &mut digest, + "cutover-id", + context.cutover_id.as_deref().unwrap_or("none").as_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-initiating-session", + context + .initiating_startup_session_id + .as_deref() + .unwrap_or("none") + .as_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-evidence", + context.evidence_fingerprint.as_bytes(), + ); + match &context.user_evidence { + None => fingerprint_field(&mut digest, "cutover-user-evidence", b"none"), + Some(evidence) => { + fingerprint_field(&mut digest, "cutover-user-evidence", b"present"); + fingerprint_field( + &mut digest, + "cutover-user-schema", + &evidence.schema_version.to_le_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-user-id", + evidence.cutover_id.as_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-user-session", + evidence.startup_session_id.as_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-user-inventory", + evidence.current_inventory_fingerprint.as_bytes(), + ); + fingerprint_field( + &mut digest, + "cutover-user-route", + &[u8::from(evidence.route_smoke_confirmed)], + ); + fingerprint_field( + &mut digest, + "cutover-user-observed-at", + &evidence.observed_at_epoch_seconds.to_le_bytes(), + ); + match evidence.confirmed_at_epoch_seconds { + None => { + fingerprint_field(&mut digest, "cutover-user-confirmed-at", b"none") + } + Some(value) => fingerprint_field( + &mut digest, + "cutover-user-confirmed-at", + &value.to_le_bytes(), + ), + } + } + } + } + } + format!("{:x}", digest.finalize()) +} + +fn fingerprint_field(digest: &mut Sha256, label: &str, value: &[u8]) { + digest.update((label.len() as u64).to_le_bytes()); + digest.update(label.as_bytes()); + digest.update((value.len() as u64).to_le_bytes()); + digest.update(value); +} + +fn valid_package_origin(component: ManagedComponent, package: &PlannedPackageFingerprint) -> bool { + match (package.source, &package.origin_proof) { + (InstalledPackageSource::Bundled, None) => true, + (InstalledPackageSource::Cache, Some(proof)) => { + package.assets.len() == 1 + && proof.repository == component.update_repository() + && proof.stable_tag == format!("v{}", package.version) + && proof.release_id > 0 + && proof.asset_id > 0 + && package.assets.first().is_some_and(|asset| { + asset.name == proof.asset_name + && asset.size == proof.size + && asset.sha256 == proof.sha256_from_api + }) + } + _ => false, + } +} + +fn runner_failure_error(failure: PrivilegedRunnerFailure) -> PrivilegedJobsError { + match failure { + PrivilegedRunnerFailure::PreconditionFailed + | PrivilegedRunnerFailure::CutoverStateConflict => PrivilegedJobsError::PlanChanged, + _ => PrivilegedJobsError::InvalidRecord, + } +} + +fn write_claim( + path: &Path, + claim: &PrivilegedJobClaim, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + let bytes = encode_record(claim)?; + match write_new_system_record(path, &bytes, seal_policy) { + Ok(()) => { + verify_system_path(path, seal_policy)?; + Ok(()) + } + Err(PrivilegedJobsError::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(PrivilegedJobsError::Replay) + } + Err(error) => Err(error), + } +} + +fn job_result( + request: &PrivilegedJobRequest, + status: PrivilegedJobStatus, + code: PrivilegedResultCode, + changed: bool, + reboot_required: bool, + completed_at_epoch_seconds: Option, +) -> PrivilegedJobResult { + PrivilegedJobResult { + activation: None, + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: request.job_id.clone(), + nonce: request.nonce.clone(), + action: request.action, + status, + changed, + reboot_required, + code, + message: result_message(code).to_string(), + completed_at_epoch_seconds, + } +} + +fn result_message(code: PrivilegedResultCode) -> &'static str { + match code { + PrivilegedResultCode::Running => "Privileged action is running.", + PrivilegedResultCode::Completed => "Privileged action completed.", + PrivilegedResultCode::NoChange => "Component state already matched the request.", + PrivilegedResultCode::RunnerUnavailable => "The component action is unavailable.", + PrivilegedResultCode::PreconditionFailed => "The component precondition failed.", + PrivilegedResultCode::OwnershipMismatch => "Component ownership could not be verified.", + PrivilegedResultCode::PackageVerificationFailed => { + "The component package could not be verified." + } + PrivilegedResultCode::ServiceCollision => { + "A service with the managed name is owned by another installation." + } + PrivilegedResultCode::CleanupPending => { + "Managed cleanup is incomplete and must be retried." + } + PrivilegedResultCode::CutoverAwaitingNextStart => { + "ProxiFyre cutover is awaiting the next application start." + } + PrivilegedResultCode::CutoverQuarantinePending => { + "ProxiFyre cutover completed with quarantine still pending." + } + PrivilegedResultCode::CutoverRolledBack => "ProxiFyre cutover was rolled back.", + PrivilegedResultCode::CutoverCleanupPending => { + "ProxiFyre quarantine cleanup is incomplete and must be retried." + } + PrivilegedResultCode::CutoverComplete => "ProxiFyre cutover cleanup completed.", + PrivilegedResultCode::CutoverRecoveryRequired => { + "ProxiFyre cutover requires explicit recovery." + } + PrivilegedResultCode::CutoverStateConflict => { + "ProxiFyre cutover state does not permit this action." + } + PrivilegedResultCode::CutoverIdentityRejected => { + "Legacy ProxiFyre identity could not be proven." + } + PrivilegedResultCode::OperationFailed => "The privileged action failed.", + } +} + +impl From for PrivilegedResultCode { + fn from(value: PrivilegedRunnerFailure) -> Self { + match value { + PrivilegedRunnerFailure::RunnerUnavailable => Self::RunnerUnavailable, + PrivilegedRunnerFailure::PreconditionFailed => Self::PreconditionFailed, + PrivilegedRunnerFailure::OwnershipMismatch => Self::OwnershipMismatch, + PrivilegedRunnerFailure::PackageVerificationFailed => Self::PackageVerificationFailed, + PrivilegedRunnerFailure::ServiceCollision => Self::ServiceCollision, + PrivilegedRunnerFailure::CleanupPending => Self::CleanupPending, + PrivilegedRunnerFailure::CutoverRecoveryRequired => Self::CutoverRecoveryRequired, + PrivilegedRunnerFailure::CutoverStateConflict => Self::CutoverStateConflict, + PrivilegedRunnerFailure::CutoverIdentityRejected => Self::CutoverIdentityRejected, + PrivilegedRunnerFailure::OperationFailed => Self::OperationFailed, + } + } +} + +fn encode_record(value: &impl Serialize) -> Result, PrivilegedJobsError> { + let bytes = serde_json::to_vec_pretty(value).map_err(|_| PrivilegedJobsError::InvalidRecord)?; + if bytes.len() as u64 > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + Ok(bytes) +} + +fn read_system_record_with_open_lease( + path: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(T, File), PrivilegedJobsError> +where + T: for<'de> Deserialize<'de>, +{ + verify_system_path(path, seal_policy)?; + let mut file = open_bounded_deny_write(path)?; + let bytes = read_bounded_file(&mut file)?; + let record = serde_json::from_slice(&bytes).map_err(|_| PrivilegedJobsError::InvalidRecord)?; + Ok((record, file)) +} + +fn read_bounded_deny_write(path: &Path) -> Result, PrivilegedJobsError> { + let mut file = open_bounded_deny_write(path)?; + read_bounded_file(&mut file) +} + +fn open_bounded_deny_write(path: &Path) -> Result { + safe_fs::ensure_no_reparse_ancestors(path)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_DELETE, FILE_SHARE_READ}; + options.share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); + } + let file = options.open(path)?; + if !file.metadata()?.is_file() { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(file) +} + +fn read_bounded_file(file: &mut File) -> Result, PrivilegedJobsError> { + if file.metadata()?.len() > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut *file) + .take(MAX_PRIVILEGED_RECORD_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + Ok(bytes) +} + +fn verify_request_path(path: &Path) -> Result<(), PrivilegedJobsError> { + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(path)?; + #[cfg(not(windows))] + safe_fs::ensure_no_reparse_ancestors(path)?; + Ok(()) +} + +fn seal_system_path(path: &Path, seal_policy: SystemSealPolicy) -> Result<(), PrivilegedJobsError> { + match seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::protect_path_admin_owned_user_read_only(path)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => { + safe_fs::protect_path_for_owner_admin_system(path)?; + } + } + verify_system_path(path, seal_policy) +} + +fn verify_system_path( + path: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + match seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::verify_path_admin_owned_user_read_only(path)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => { + verify_request_path(path)?; + } + } + Ok(()) +} + +fn write_new_system_record( + path: &Path, + contents: &[u8], + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + safe_fs::ensure_no_reparse_ancestors(path)?; + if seal_policy == SystemSealPolicy::AdminOwned { + let mut file = safe_fs::create_new_admin_owned_user_read_only_file(path)?; + if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(path); + return Err(error.into()); + } + drop(file); + return verify_system_path(path, seal_policy); + } + #[cfg(debug_assertions)] + { + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + if let Err(error) = seal_system_path(path, seal_policy) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(path); + return Err(error.into()); + } + Ok(()) + } + #[cfg(not(debug_assertions))] + Err(PrivilegedJobsError::InvalidRecord) +} + +fn publish_recoverable_system_record( + staging: &Path, + destination: &Path, + contents: &[u8], + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + if staging.parent().is_none() || staging.parent() != destination.parent() { + return Err(PrivilegedJobsError::InvalidRecord); + } + match fs::symlink_metadata(destination) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(destination)?; + } + Err(error) => return Err(error.into()), + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + } + write_recoverable_system_staging(staging, contents, seal_policy)?; + fs::rename(staging, destination)?; + verify_system_path(destination, seal_policy)?; + if read_bounded_deny_write(destination)? != contents { + return Err(PrivilegedJobsError::InvalidRecord); + } + match fs::symlink_metadata(staging) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + Ok(_) => Err(PrivilegedJobsError::InvalidRecord), + } +} + +fn write_recoverable_system_staging( + path: &Path, + contents: &[u8], + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + if contents.len() as u64 > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + safe_fs::ensure_no_reparse_ancestors(path)?; + let mut file = match fs::symlink_metadata(path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + if seal_policy == SystemSealPolicy::AdminOwned { + safe_fs::create_new_admin_owned_user_read_only_file(path)? + } else { + #[cfg(debug_assertions)] + { + let file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path)?; + seal_system_path(path, seal_policy)?; + file + } + #[cfg(not(debug_assertions))] + return Err(PrivilegedJobsError::InvalidRecord); + } + } + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.file_type().is_file() => { + verify_system_path(path, seal_policy)?; + let mut options = OpenOptions::new(); + options.read(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0); + } + options.open(path)? + } + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + }; + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(contents)?; + file.sync_all()?; + drop(file); + verify_system_path(path, seal_policy)?; + if read_bounded_deny_write(path)? != contents { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(()) +} + +fn parse_canonical_v4(value: &str) -> Option { + let parsed = Uuid::parse_str(value).ok()?; + let canonical = parsed.hyphenated().to_string(); + (value == canonical && parsed.get_version() == Some(Version::Random)).then_some(canonical) +} + +#[derive(Debug)] +struct PrivilegedJobFiles { + request: PathBuf, + claim: PathBuf, + result: PathBuf, +} + +struct RequestLease { + file: File, + path: PathBuf, +} + +impl RequestLease { + fn create(path: &Path, contents: &[u8]) -> Result { + safe_fs::ensure_no_reparse_ancestors(path)?; + let mut file = request_lease_options(true).open(path)?; + if let Err(error) = safe_fs::protect_path_for_owner_admin_system(path) { + drop(file); + let _ = fs::remove_file(path); + return Err(error.into()); + } + verify_request_path(path)?; + if let Err(error) = file.write_all(contents).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(path); + return Err(error.into()); + } + file.seek(SeekFrom::Start(0))?; + Ok(Self { + file, + path: path.to_path_buf(), + }) + } + + fn open(path: &Path) -> Result { + verify_request_path(path)?; + let file = request_lease_options(false).open(path)?; + if !file.metadata()?.is_file() { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(Self { + file, + path: path.to_path_buf(), + }) + } + + fn read_record(&mut self) -> Result + where + T: for<'de> Deserialize<'de>, + { + if self.file.metadata()?.len() > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + self.file.seek(SeekFrom::Start(0))?; + let mut bytes = Vec::new(); + Read::by_ref(&mut self.file) + .take(MAX_PRIVILEGED_RECORD_BYTES + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + serde_json::from_slice(&bytes).map_err(|_| PrivilegedJobsError::InvalidRecord) + } + + fn remove_exact(&mut self) -> Result<(), PrivilegedJobsError> { + let request: PrivilegedJobRequest = self.read_record()?; + if self.path.file_name().and_then(|name| name.to_str()) + != Some(&format!("{}.request.json", request.job_id.as_str())) + { + return Err(PrivilegedJobsError::RequestMismatch); + } + remove_open_file_exact(&self.file, &self.path)?; + Ok(()) + } +} + +fn request_lease_options(create_new: bool) -> OpenOptions { + let mut options = OpenOptions::new(); + options.read(true).write(create_new).create_new(create_new); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + let access = if create_new { + FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE + } else { + FILE_GENERIC_READ + }; + let share = if create_new { + FILE_SHARE_READ + } else { + // The creator still holds read/write/delete access while denying every new writer. + // This reader must share those already-open rights, but requests read access only. + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + }; + options.access_mode(access).share_mode(share); + } + options +} + +#[cfg(windows)] +fn remove_open_file_exact(file: &File, _path: &Path) -> io::Result<()> { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: the handle is live and disposition points to initialized storage of the stated size. + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileDispositionInfo, + (&disposition as *const FILE_DISPOSITION_INFO).cast(), + size_of::() as u32, + ) + } == 0 + { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn remove_open_file_exact(_file: &File, path: &Path) -> io::Result<()> { + fs::remove_file(path) +} + +struct ResultReservation { + path: PathBuf, + seal_policy: SystemSealPolicy, +} + +impl ResultReservation { + fn create( + path: &Path, + seal_policy: SystemSealPolicy, + initial: &PrivilegedJobResult, + ) -> Result { + safe_fs::ensure_no_reparse_ancestors(path)?; + let bytes = encode_record(initial)?; + match write_new_system_record(path, &bytes, seal_policy) { + Ok(()) => Ok(Self { + path: path.to_path_buf(), + seal_policy, + }), + Err(PrivilegedJobsError::Io(error)) if error.kind() == io::ErrorKind::AlreadyExists => { + Err(PrivilegedJobsError::ResultAlreadyExists) + } + Err(error) => Err(error), + } + } + + fn write(&self, result: &PrivilegedJobResult) -> Result<(), PrivilegedJobsError> { + let bytes = encode_record(result)?; + match self.seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::write_admin_owned_user_read_only_atomic(&self.path, &bytes)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => { + safe_fs::write_restricted_atomic(&self.path, &bytes)?; + } + } + verify_system_path(&self.path, self.seal_policy)?; + Ok(()) + } +} + +struct LifecycleLock { + _file: File, + #[cfg(not(windows))] + path: PathBuf, +} + +impl LifecycleLock { + fn acquire( + directory: &Path, + seal_policy: SystemSealPolicy, + ) -> Result { + let path = directory.join(LIFECYCLE_LOCK_FILENAME); + safe_fs::ensure_no_reparse_ancestors(&path)?; + + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + let pid = std::process::id().to_string(); + if seal_policy == SystemSealPolicy::AdminOwned { + match safe_fs::create_new_admin_system_only_file(&path) { + Ok(mut file) => { + if let Err(error) = file + .write_all(pid.as_bytes()) + .and_then(|()| file.sync_all()) + { + drop(file); + let _ = fs::remove_file(&path); + return Err(error.into()); + } + return Ok(Self { _file: file }); + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + verify_lifecycle_lock_path(&path, seal_policy)?; + } + Err(error) => return Err(error.into()), + } + } + + let existed = if seal_policy == SystemSealPolicy::AdminOwned { + true + } else { + path.try_exists()? + }; + if existed { + verify_lifecycle_lock_path(&path, seal_policy)?; + } + let mut options = OpenOptions::new(); + options.read(true).write(true).share_mode(0); + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, + }; + options.access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE); + if !existed { + options.create_new(true); + } + let mut file = options.open(&path).map_err(|error| { + if matches!(error.raw_os_error(), Some(32 | 33)) + || error.kind() == io::ErrorKind::AlreadyExists + { + PrivilegedJobsError::LifecycleBusy + } else { + PrivilegedJobsError::Io(error) + } + })?; + if !existed { + if let Err(error) = seal_system_path(&path, seal_policy) { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } + } + verify_lifecycle_lock_path(&path, seal_policy)?; + file.set_len(0)?; + file.write_all(pid.as_bytes())?; + file.sync_all()?; + Ok(Self { _file: file }) + } + + #[cfg(not(windows))] + { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| { + if error.kind() == io::ErrorKind::AlreadyExists { + PrivilegedJobsError::LifecycleBusy + } else { + PrivilegedJobsError::Io(error) + } + })?; + file.write_all(std::process::id().to_string().as_bytes())?; + file.sync_all()?; + seal_system_path(&path, seal_policy)?; + Ok(Self { _file: file, path }) + } + } +} + +fn verify_lifecycle_lock_path( + path: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + if seal_policy == SystemSealPolicy::AdminOwned { + safe_fs::verify_admin_system_only_file(path)?; + Ok(()) + } else { + verify_system_path(path, seal_policy) + } +} + +#[cfg(not(windows))] +impl Drop for LifecycleLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +/// Exclusive lifecycle ownership used only by the fixed NSIS uninstall mode. +/// The guard exposes no paths and can retire the Program Files job store only +/// after every persisted claim/result pair is proven terminal and exact. +pub(crate) struct NsisPrivilegedLifecycleGuard { + app_root: PathBuf, + system_directory: PathBuf, + seal_policy: SystemSealPolicy, + lock: LifecycleLock, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct NsisPrivilegedLifecycleState { + pub(crate) retirement_pending: bool, + pub(crate) reboot_required: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NsisStoreRetirementRecord { + schema_version: u32, + records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NsisStoreRetirementEntry { + name: String, + sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NsisRebootRequiredRecord { + schema_version: u32, + reboot_required: bool, +} + +impl NsisPrivilegedLifecycleGuard { + pub(crate) fn acquire(app_root: &Path) -> Result { + verify_nsis_app_root(app_root, SystemSealPolicy::AdminOwned)?; + Self::acquire_with_policy(app_root, SystemSealPolicy::AdminOwned) + } + + #[cfg(all(test, debug_assertions))] + pub(crate) fn acquire_for_tests(app_root: &Path) -> Result { + Self::acquire_with_policy(app_root, SystemSealPolicy::PortableTests) + } + + fn acquire_with_policy( + app_root: &Path, + seal_policy: SystemSealPolicy, + ) -> Result { + let system_directory = app_root.join(SYSTEM_DIRECTORY_NAME); + prepare_nsis_system_directory(app_root, &system_directory, seal_policy)?; + let lock = LifecycleLock::acquire(&system_directory, seal_policy)?; + recover_nsis_reboot_marker(app_root, seal_policy)?; + validate_terminal_system_store(&system_directory, seal_policy)?; + Ok(Self { + app_root: app_root.to_path_buf(), + system_directory, + seal_policy, + lock, + }) + } + + /// Returns `true` only when this held lifecycle owner created the durable + /// intent from a proven-absent state. An already published or staged fact + /// belongs to an earlier owner and must never be cleared by this run. + pub(crate) fn mark_reboot_required(&self) -> Result { + let path = self.app_root.join(NSIS_REBOOT_REQUIRED_FILENAME); + let staging = self.app_root.join(NSIS_REBOOT_REQUIRED_STAGING_FILENAME); + let record = NsisRebootRequiredRecord { + schema_version: NSIS_REBOOT_REQUIRED_SCHEMA_VERSION, + reboot_required: true, + }; + let expected = encode_record(&record)?; + match observe_nsis_reboot_marker(&self.app_root, self.seal_policy)? { + NsisRebootMarkerState::Published => return Ok(false), + NsisRebootMarkerState::Staging => { + publish_recoverable_system_record(&staging, &path, &expected, self.seal_policy)?; + return Ok(false); + } + NsisRebootMarkerState::Absent => {} + } + publish_recoverable_system_record(&staging, &path, &expected, self.seal_policy)?; + Ok(true) + } + + pub(crate) fn reboot_required(&self) -> Result { + Ok( + observe_nsis_reboot_marker(&self.app_root, self.seal_policy)? + != NsisRebootMarkerState::Absent, + ) + } + + /// Clears only the exact canonical marker created by this held lifecycle + /// owner after a potentially rebooting mutation has definitively reported + /// that no reboot is required. Published reboot evidence is otherwise + /// retained for the NSIS hook to acknowledge after observing exit 3010. + pub(crate) fn clear_reboot_required(&self) -> Result<(), PrivilegedJobsError> { + recover_nsis_reboot_marker(&self.app_root, self.seal_policy)?; + if observe_nsis_reboot_marker(&self.app_root, self.seal_policy)? + != NsisRebootMarkerState::Published + { + return Err(PrivilegedJobsError::InvalidRecord); + } + let path = self.app_root.join(NSIS_REBOOT_REQUIRED_FILENAME); + let expected = encode_record(&NsisRebootRequiredRecord { + schema_version: NSIS_REBOOT_REQUIRED_SCHEMA_VERSION, + reboot_required: true, + })?; + SystemRecordDeleteLease::open(&path, self.seal_policy, &expected)?.remove_exact()?; + if observe_nsis_reboot_marker(&self.app_root, self.seal_policy)? + != NsisRebootMarkerState::Absent + { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(()) + } + + /// Deletes only exact terminal records, the currently-held lifecycle lock, + /// and the then-empty store directory. Any unknown, partial, running, or + /// replaced record stops retirement before that record is deleted. + pub(crate) fn retire_terminal_store(self) -> Result<(), PrivilegedJobsError> { + let root_lease = safe_fs::open_path_identity_lease( + &self.system_directory, + safe_fs::StableObjectKind::Directory, + )?; + match self.seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::verify_identity_lease_admin_owned_user_read_only(&root_lease)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => { + verify_request_path(&self.system_directory)?; + } + } + loop { + let snapshot = + validate_terminal_system_store(&self.system_directory, self.seal_policy)?; + if let Some(marker) = snapshot.retirement.as_ref() { + let marked_job_id = nsis_retirement_job_id(&marker.record)?; + let mut leases = Vec::new(); + for record in snapshot + .records + .iter() + .filter(|record| record.job_id == marked_job_id) + { + leases.push(SystemRecordDeleteLease::open( + &record.path, + self.seal_policy, + &record.bytes, + )?); + } + for lease in leases { + lease.remove_exact()?; + } + + let completed = + validate_terminal_system_store(&self.system_directory, self.seal_policy)?; + if completed + .records + .iter() + .any(|record| record.job_id == marked_job_id) + { + return Err(PrivilegedJobsError::InvalidRecord); + } + let completed_marker = completed + .retirement + .ok_or(PrivilegedJobsError::InvalidRecord)?; + if completed_marker.record != marker.record + || completed_marker.bytes != marker.bytes + { + return Err(PrivilegedJobsError::InvalidRecord); + } + SystemRecordDeleteLease::open( + &completed_marker.path, + self.seal_policy, + &completed_marker.bytes, + )? + .remove_exact()?; + continue; + } + + if snapshot.records.is_empty() { + if snapshot.retirement_staging { + return Err(PrivilegedJobsError::InvalidRecord); + } + break; + } + + let retirement = build_next_nsis_retirement_record(&snapshot.records)? + .ok_or(PrivilegedJobsError::InvalidRecord)?; + let bytes = encode_record(&retirement)?; + publish_recoverable_system_record( + &self.system_directory.join(NSIS_RETIREMENT_STAGING_FILENAME), + &self.system_directory.join(NSIS_RETIREMENT_FILENAME), + &bytes, + self.seal_policy, + )?; + } + + verify_only_lifecycle_lock_remains(&self.system_directory)?; + + let lock_path = self.system_directory.join(LIFECYCLE_LOCK_FILENAME); + remove_open_file_exact(&self.lock._file, &lock_path)?; + drop(self.lock); + verify_directory_empty(&self.system_directory)?; + safe_fs::delete_identity_lease(root_lease)?; + Ok(()) + } +} + +/// Read-only NSIS gate. Missing infrastructure is idle; existing +/// infrastructure must be sealed, contain only exact terminal records, and +/// have an unlockable lifecycle lock. This function never creates or repairs +/// a directory or record. +pub(crate) fn verify_nsis_privileged_lifecycle_idle( + app_root: &Path, +) -> Result { + verify_nsis_app_root(app_root, SystemSealPolicy::AdminOwned)?; + verify_nsis_privileged_lifecycle_idle_with_policy(app_root, SystemSealPolicy::AdminOwned) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn verify_nsis_privileged_lifecycle_idle_for_tests( + app_root: &Path, +) -> Result { + verify_nsis_privileged_lifecycle_idle_with_policy(app_root, SystemSealPolicy::PortableTests) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn write_nsis_terminal_pair_for_tests( + app_root: &Path, + terminal: bool, +) -> Result<(), PrivilegedJobsError> { + let system_directory = app_root.join(SYSTEM_DIRECTORY_NAME); + prepare_nsis_system_directory(app_root, &system_directory, SystemSealPolicy::PortableTests)?; + let job_id = PrivilegedJobId::new(); + let nonce = PrivilegedJobNonce::new(); + let claim = PrivilegedJobClaim { + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: job_id.clone(), + nonce: nonce.clone(), + action: PrivilegedAction::StopProxifyre, + claimed_at_epoch_seconds: 1, + }; + let result_code = if terminal { + PrivilegedResultCode::NoChange + } else { + PrivilegedResultCode::Running + }; + let result = PrivilegedJobResult { + activation: None, + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: job_id.clone(), + nonce, + action: PrivilegedAction::StopProxifyre, + status: if terminal { + PrivilegedJobStatus::Succeeded + } else { + PrivilegedJobStatus::Running + }, + changed: false, + reboot_required: false, + code: result_code, + message: result_message(result_code).to_string(), + completed_at_epoch_seconds: terminal.then_some(2), + }; + write_new_system_record( + &system_directory.join(format!("{}.claim.json", job_id.as_str())), + &encode_record(&claim)?, + SystemSealPolicy::PortableTests, + )?; + write_new_system_record( + &system_directory.join(format!("{}.result.json", job_id.as_str())), + &encode_record(&result)?, + SystemSealPolicy::PortableTests, + ) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn write_nsis_interrupted_retirement_for_tests( + app_root: &Path, +) -> Result<(), PrivilegedJobsError> { + write_nsis_terminal_pair_for_tests(app_root, true)?; + let system_directory = app_root.join(SYSTEM_DIRECTORY_NAME); + let snapshot = + validate_terminal_system_store(&system_directory, SystemSealPolicy::PortableTests)?; + let retirement = build_next_nsis_retirement_record(&snapshot.records)? + .ok_or(PrivilegedJobsError::InvalidRecord)?; + publish_recoverable_system_record( + &system_directory.join(NSIS_RETIREMENT_STAGING_FILENAME), + &system_directory.join(NSIS_RETIREMENT_FILENAME), + &encode_record(&retirement)?, + SystemSealPolicy::PortableTests, + )?; + let first = snapshot + .records + .first() + .ok_or(PrivilegedJobsError::InvalidRecord)?; + fs::remove_file(&first.path)?; + Ok(()) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn write_nsis_partial_retirement_staging_for_tests( + app_root: &Path, +) -> Result<(), PrivilegedJobsError> { + write_nsis_terminal_pair_for_tests(app_root, true)?; + let path = app_root + .join(SYSTEM_DIRECTORY_NAME) + .join(NSIS_RETIREMENT_STAGING_FILENAME); + write_recoverable_system_staging(&path, b"{", SystemSealPolicy::PortableTests) +} + +#[cfg(all(test, debug_assertions))] +pub(crate) fn write_nsis_partial_reboot_staging_for_tests( + app_root: &Path, +) -> Result<(), PrivilegedJobsError> { + let path = app_root.join(NSIS_REBOOT_REQUIRED_STAGING_FILENAME); + write_recoverable_system_staging(&path, b"{", SystemSealPolicy::PortableTests) +} + +fn verify_nsis_privileged_lifecycle_idle_with_policy( + app_root: &Path, + seal_policy: SystemSealPolicy, +) -> Result { + verify_nsis_app_root(app_root, seal_policy)?; + let reboot_required = + observe_nsis_reboot_marker(app_root, seal_policy)? != NsisRebootMarkerState::Absent; + let system_directory = app_root.join(SYSTEM_DIRECTORY_NAME); + match fs::symlink_metadata(&system_directory) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&system_directory)?; + return Ok(NsisPrivilegedLifecycleState { + retirement_pending: false, + reboot_required, + }); + } + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + } + verify_system_path(&system_directory, seal_policy)?; + + let first_lock = open_existing_lifecycle_lock_idle(&system_directory, seal_policy)?; + let mut snapshot = validate_terminal_system_store(&system_directory, seal_policy)?; + if first_lock.is_none() { + // Close the missing-lock race without creating a lock: if another + // lifecycle owner appeared, its exclusive handle makes this fail busy; + // if it already completed, validate its new terminal records too. + let second_lock = open_existing_lifecycle_lock_idle(&system_directory, seal_policy)?; + if second_lock.is_some() { + snapshot = validate_terminal_system_store(&system_directory, seal_policy)?; + } + } + Ok(NsisPrivilegedLifecycleState { + retirement_pending: snapshot.retirement.is_some() || snapshot.retirement_staging, + reboot_required, + }) +} + +fn verify_nsis_app_root( + app_root: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + match seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::verify_path_under_trusted_program_files(app_root)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => { + if !app_root.is_absolute() { + return Err(PrivilegedJobsError::InvalidRecord); + } + safe_fs::ensure_no_reparse_ancestors(app_root)?; + } + } + Ok(()) +} + +fn prepare_nsis_system_directory( + app_root: &Path, + system_directory: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + verify_nsis_app_root(app_root, seal_policy)?; + safe_fs::ensure_no_reparse_ancestors(system_directory)?; + match seal_policy { + SystemSealPolicy::AdminOwned => { + safe_fs::create_directory_admin_owned_user_read_only(system_directory)?; + } + #[cfg(debug_assertions)] + SystemSealPolicy::PortableTests => match fs::symlink_metadata(system_directory) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(system_directory)?; + if let Err(error) = seal_system_path(system_directory, seal_policy) { + let _ = fs::remove_dir(system_directory); + return Err(error); + } + } + Err(error) => return Err(error.into()), + }, + } + verify_system_path(system_directory, seal_policy) +} + +struct IdleLifecycleLockLease { + _stable: Option, + _portable: Option, +} + +fn open_existing_lifecycle_lock_idle( + system_directory: &Path, + seal_policy: SystemSealPolicy, +) -> Result, PrivilegedJobsError> { + let path = system_directory.join(LIFECYCLE_LOCK_FILENAME); + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&path)?; + Ok(None) + } + Err(error) => Err(error.into()), + Ok(metadata) if metadata.file_type().is_file() => { + if seal_policy == SystemSealPolicy::AdminOwned { + let lease = safe_fs::open_path_identity_lease( + &path, + safe_fs::StableObjectKind::RegularFile, + ) + .map_err(map_lifecycle_open_error)?; + safe_fs::verify_identity_lease_admin_system_only(&lease)?; + return Ok(Some(IdleLifecycleLockLease { + _stable: Some(lease), + _portable: None, + })); + } + #[cfg(debug_assertions)] + { + verify_lifecycle_lock_path(&path, seal_policy).map_err(|error| match error { + PrivilegedJobsError::Io(inner) + if matches!(inner.raw_os_error(), Some(32 | 33)) => + { + PrivilegedJobsError::LifecycleBusy + } + other => other, + })?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(0); + } + let file = options.open(&path).map_err(map_lifecycle_open_error)?; + Ok(Some(IdleLifecycleLockLease { + _stable: None, + _portable: Some(file), + })) + } + #[cfg(not(debug_assertions))] + Err(PrivilegedJobsError::InvalidRecord) + } + Ok(_) => Err(PrivilegedJobsError::InvalidRecord), + } +} + +fn map_lifecycle_open_error(error: io::Error) -> PrivilegedJobsError { + if matches!(error.raw_os_error(), Some(32 | 33)) { + PrivilegedJobsError::LifecycleBusy + } else { + PrivilegedJobsError::Io(error) + } +} + +fn ensure_no_nsis_lifecycle_markers( + app_root: &Path, + system_directory: &Path, +) -> Result<(), PrivilegedJobsError> { + for path in [ + system_directory.join(NSIS_RETIREMENT_FILENAME), + system_directory.join(NSIS_RETIREMENT_STAGING_FILENAME), + app_root.join(NSIS_REBOOT_REQUIRED_FILENAME), + app_root.join(NSIS_REBOOT_REQUIRED_STAGING_FILENAME), + ] { + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&path)?; + } + Ok(_) => return Err(PrivilegedJobsError::LifecycleBusy), + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NsisRebootMarkerState { + Absent, + Staging, + Published, +} + +fn recover_nsis_reboot_marker( + app_root: &Path, + seal_policy: SystemSealPolicy, +) -> Result<(), PrivilegedJobsError> { + if observe_nsis_reboot_marker(app_root, seal_policy)? != NsisRebootMarkerState::Staging { + return Ok(()); + } + let record = NsisRebootRequiredRecord { + schema_version: NSIS_REBOOT_REQUIRED_SCHEMA_VERSION, + reboot_required: true, + }; + publish_recoverable_system_record( + &app_root.join(NSIS_REBOOT_REQUIRED_STAGING_FILENAME), + &app_root.join(NSIS_REBOOT_REQUIRED_FILENAME), + &encode_record(&record)?, + seal_policy, + ) +} + +fn observe_nsis_reboot_marker( + app_root: &Path, + seal_policy: SystemSealPolicy, +) -> Result { + let path = app_root.join(NSIS_REBOOT_REQUIRED_FILENAME); + let staging = app_root.join(NSIS_REBOOT_REQUIRED_STAGING_FILENAME); + let published = match fs::symlink_metadata(&path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.file_type().is_file() => { + verify_system_path(&path, seal_policy)?; + let bytes = read_bounded_deny_write(&path)?; + let record: NsisRebootRequiredRecord = + serde_json::from_slice(&bytes).map_err(|_| PrivilegedJobsError::InvalidRecord)?; + if record.schema_version != NSIS_REBOOT_REQUIRED_SCHEMA_VERSION + || !record.reboot_required + || bytes != encode_record(&record)? + { + return Err(PrivilegedJobsError::InvalidRecord); + } + true + } + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + }; + let staged = match fs::symlink_metadata(&staging) { + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + Ok(metadata) if metadata.file_type().is_file() => { + verify_system_path(&staging, seal_policy)?; + read_bounded_deny_write(&staging)?; + true + } + Ok(_) => return Err(PrivilegedJobsError::InvalidRecord), + }; + if published && staged { + return Err(PrivilegedJobsError::InvalidRecord); + } + if published { + Ok(NsisRebootMarkerState::Published) + } else if staged { + Ok(NsisRebootMarkerState::Staging) + } else { + safe_fs::ensure_no_reparse_ancestors(&path)?; + safe_fs::ensure_no_reparse_ancestors(&staging)?; + Ok(NsisRebootMarkerState::Absent) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SystemRecordKind { + Claim, + Result, +} + +#[derive(Debug)] +struct TerminalSystemRecord { + job_id: PrivilegedJobId, + name: String, + path: PathBuf, + bytes: Vec, +} + +#[derive(Debug)] +struct ObservedNsisStoreRetirement { + path: PathBuf, + bytes: Vec, + record: NsisStoreRetirementRecord, +} + +#[derive(Debug)] +struct TerminalSystemStoreSnapshot { + records: Vec, + retirement: Option, + retirement_staging: bool, +} + +#[derive(Default)] +struct TerminalSystemPair { + claim: Option, + result: Option, +} + +fn validate_terminal_system_store( + system_directory: &Path, + seal_policy: SystemSealPolicy, +) -> Result { + verify_system_path(system_directory, seal_policy)?; + let mut pairs: HashMap = HashMap::new(); + let mut records = Vec::new(); + let mut retirement = None; + let mut retirement_staging = false; + for entry in fs::read_dir(system_directory)? { + let entry = entry?; + let name = entry + .file_name() + .into_string() + .map_err(|_| PrivilegedJobsError::InvalidRecord)?; + if name == LIFECYCLE_LOCK_FILENAME { + let metadata = fs::symlink_metadata(entry.path())?; + if !metadata.file_type().is_file() { + return Err(PrivilegedJobsError::InvalidRecord); + } + // The read-only probe verified this path immediately before + // taking its share-none handle; the destructive path holds the + // already-verified LifecycleLock. Reopening by path here would + // conflict with that deliberate exclusive handle on Windows. + continue; + } + if name == NSIS_RETIREMENT_FILENAME { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if !metadata.file_type().is_file() || retirement.is_some() { + return Err(PrivilegedJobsError::InvalidRecord); + } + verify_system_path(&path, seal_policy)?; + let bytes = read_bounded_deny_write(&path)?; + let record: NsisStoreRetirementRecord = + serde_json::from_slice(&bytes).map_err(|_| PrivilegedJobsError::InvalidRecord)?; + validate_nsis_retirement_record(&record)?; + if bytes != encode_record(&record)? { + return Err(PrivilegedJobsError::InvalidRecord); + } + retirement = Some(ObservedNsisStoreRetirement { + path, + bytes, + record, + }); + continue; + } + if name == NSIS_RETIREMENT_STAGING_FILENAME { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if !metadata.file_type().is_file() || retirement_staging { + return Err(PrivilegedJobsError::InvalidRecord); + } + verify_system_path(&path, seal_policy)?; + read_bounded_deny_write(&path)?; + retirement_staging = true; + continue; + } + let (job_id, kind) = parse_system_record_filename(&name)?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path)?; + if !metadata.file_type().is_file() { + return Err(PrivilegedJobsError::InvalidRecord); + } + let bytes = read_bounded_deny_write(&path)?; + let pair = pairs.entry(job_id.clone()).or_default(); + match kind { + SystemRecordKind::Claim => { + let claim: PrivilegedJobClaim = serde_json::from_slice(&bytes) + .map_err(|_| PrivilegedJobsError::InvalidRecord)?; + if claim.schema_version != PRIVILEGED_JOB_SCHEMA_VERSION + || claim.job_id != job_id + || claim.claimed_at_epoch_seconds == 0 + || pair.claim.replace(claim).is_some() + { + return Err(PrivilegedJobsError::InvalidRecord); + } + } + SystemRecordKind::Result => { + let result: PrivilegedJobResult = serde_json::from_slice(&bytes) + .map_err(|_| PrivilegedJobsError::InvalidRecord)?; + if result.schema_version != PRIVILEGED_JOB_SCHEMA_VERSION + || result.job_id != job_id + || !valid_terminal_result_semantics(&result) + || pair.result.replace(result).is_some() + { + return Err(PrivilegedJobsError::InvalidRecord); + } + } + } + records.push(TerminalSystemRecord { + job_id, + name, + path, + bytes, + }); + } + if retirement.is_some() && retirement_staging { + return Err(PrivilegedJobsError::InvalidRecord); + } + if retirement.is_none() { + validate_complete_terminal_pairs(pairs)?; + } else { + validate_pairs_during_nsis_retirement( + &pairs, + &records, + &retirement + .as_ref() + .ok_or(PrivilegedJobsError::InvalidRecord)? + .record, + )?; + } + records.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(TerminalSystemStoreSnapshot { + records, + retirement, + retirement_staging, + }) +} + +fn valid_terminal_result_semantics(result: &PrivilegedJobResult) -> bool { + if let Some(ack) = &result.activation { + if !matches!( + result.action, + PrivilegedAction::StartProxifyre | PrivilegedAction::StartSingBox + ) || result.status != PrivilegedJobStatus::Succeeded + || !result.changed + || ack.component != result.action.component() + || !valid_sha256(&ack.config_sha256) + || ack.instance.process_id == 0 + || ack.instance.created_at_filetime == 0 + { + return false; + } + } + let status_matches_code = match result.status { + PrivilegedJobStatus::Running => false, + PrivilegedJobStatus::Succeeded => match result.code { + PrivilegedResultCode::Completed => result.changed, + PrivilegedResultCode::NoChange => !result.changed && !result.reboot_required, + PrivilegedResultCode::CutoverRolledBack => !result.changed, + PrivilegedResultCode::CutoverAwaitingNextStart + | PrivilegedResultCode::CutoverQuarantinePending + | PrivilegedResultCode::CutoverCleanupPending + | PrivilegedResultCode::CutoverComplete => result.changed, + _ => false, + }, + PrivilegedJobStatus::Failed => { + matches!( + result.code, + PrivilegedResultCode::RunnerUnavailable + | PrivilegedResultCode::PreconditionFailed + | PrivilegedResultCode::OwnershipMismatch + | PrivilegedResultCode::PackageVerificationFailed + | PrivilegedResultCode::ServiceCollision + | PrivilegedResultCode::CleanupPending + | PrivilegedResultCode::CutoverRecoveryRequired + | PrivilegedResultCode::CutoverStateConflict + | PrivilegedResultCode::CutoverIdentityRejected + | PrivilegedResultCode::OperationFailed + ) && !result.changed + && !result.reboot_required + } + }; + status_matches_code + && result.completed_at_epoch_seconds.is_some() + && result.message == result_message(result.code) +} + +fn validate_complete_terminal_pairs( + pairs: HashMap, +) -> Result<(), PrivilegedJobsError> { + for (job_id, pair) in pairs { + let (Some(claim), Some(result)) = (pair.claim, pair.result) else { + return Err(PrivilegedJobsError::InvalidRecord); + }; + validate_terminal_pair(&job_id, &claim, &result)?; + } + Ok(()) +} + +fn validate_pairs_during_nsis_retirement( + pairs: &HashMap, + records: &[TerminalSystemRecord], + retirement: &NsisStoreRetirementRecord, +) -> Result<(), PrivilegedJobsError> { + let marked_job_id = nsis_retirement_job_id(retirement)?; + for (job_id, pair) in pairs { + if job_id == &marked_job_id { + if let (Some(claim), Some(result)) = (&pair.claim, &pair.result) { + validate_terminal_pair(job_id, claim, result)?; + } + continue; + } + let (Some(claim), Some(result)) = (&pair.claim, &pair.result) else { + return Err(PrivilegedJobsError::InvalidRecord); + }; + validate_terminal_pair(job_id, claim, result)?; + } + + let expected = retirement + .records + .iter() + .map(|entry| (entry.name.as_str(), entry.sha256.as_str())) + .collect::>(); + for record in records + .iter() + .filter(|record| record.job_id == marked_job_id) + { + let digest = sha256_bytes(&record.bytes); + if expected.get(record.name.as_str()).copied() != Some(digest.as_str()) { + return Err(PrivilegedJobsError::InvalidRecord); + } + } + Ok(()) +} + +fn build_next_nsis_retirement_record( + records: &[TerminalSystemRecord], +) -> Result, PrivilegedJobsError> { + let Some(first) = records.first() else { + return Ok(None); + }; + let mut entries = records + .iter() + .filter(|record| record.job_id == first.job_id) + .map(|record| NsisStoreRetirementEntry { + name: record.name.clone(), + sha256: sha256_bytes(&record.bytes), + }) + .collect::>(); + entries.sort_by(|left, right| left.name.cmp(&right.name)); + let retirement = NsisStoreRetirementRecord { + schema_version: NSIS_RETIREMENT_SCHEMA_VERSION, + records: entries, + }; + validate_nsis_retirement_record(&retirement)?; + Ok(Some(retirement)) +} + +fn validate_terminal_pair( + job_id: &PrivilegedJobId, + claim: &PrivilegedJobClaim, + result: &PrivilegedJobResult, +) -> Result<(), PrivilegedJobsError> { + if &claim.job_id != job_id + || &result.job_id != job_id + || claim.nonce != result.nonce + || claim.action != result.action + || result.completed_at_epoch_seconds < Some(claim.claimed_at_epoch_seconds) + { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(()) +} + +fn validate_nsis_retirement_record( + record: &NsisStoreRetirementRecord, +) -> Result<(), PrivilegedJobsError> { + if record.schema_version != NSIS_RETIREMENT_SCHEMA_VERSION || record.records.len() != 2 { + return Err(PrivilegedJobsError::InvalidRecord); + } + let mut previous = None::<&str>; + let mut pairs: HashMap = HashMap::new(); + for entry in &record.records { + if !valid_sha256(&entry.sha256) + || previous.is_some_and(|previous| previous >= entry.name.as_str()) + { + return Err(PrivilegedJobsError::InvalidRecord); + } + previous = Some(&entry.name); + let (job_id, kind) = parse_system_record_filename(&entry.name)?; + let pair = pairs.entry(job_id).or_default(); + match kind { + SystemRecordKind::Claim if !pair.0 => pair.0 = true, + SystemRecordKind::Result if !pair.1 => pair.1 = true, + _ => return Err(PrivilegedJobsError::InvalidRecord), + } + } + if pairs.len() != 1 || pairs.values().any(|(claim, result)| !claim || !result) { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(()) +} + +fn nsis_retirement_job_id( + record: &NsisStoreRetirementRecord, +) -> Result { + validate_nsis_retirement_record(record)?; + record + .records + .first() + .ok_or(PrivilegedJobsError::InvalidRecord) + .and_then(|entry| parse_system_record_filename(&entry.name).map(|(job_id, _)| job_id)) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn parse_system_record_filename( + name: &str, +) -> Result<(PrivilegedJobId, SystemRecordKind), PrivilegedJobsError> { + let (prefix, kind) = if let Some(prefix) = name.strip_suffix(".claim.json") { + (prefix, SystemRecordKind::Claim) + } else if let Some(prefix) = name.strip_suffix(".result.json") { + (prefix, SystemRecordKind::Result) + } else { + return Err(PrivilegedJobsError::InvalidRecord); + }; + Ok((PrivilegedJobId::parse(prefix)?, kind)) +} + +struct SystemRecordDeleteLease { + file: File, + path: PathBuf, +} + +impl SystemRecordDeleteLease { + fn open( + path: &Path, + seal_policy: SystemSealPolicy, + expected_bytes: &[u8], + ) -> Result { + verify_system_path(path, seal_policy)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_GENERIC_READ, FILE_SHARE_READ, + }; + options + .access_mode(FILE_GENERIC_READ | DELETE) + .share_mode(FILE_SHARE_READ); + } + let mut file = options.open(path)?; + if !file.metadata()?.is_file() || read_bounded_file(&mut file)? != expected_bytes { + return Err(PrivilegedJobsError::InvalidRecord); + } + Ok(Self { + file, + path: path.to_path_buf(), + }) + } + + fn remove_exact(self) -> Result<(), PrivilegedJobsError> { + remove_open_file_exact(&self.file, &self.path)?; + Ok(()) + } +} + +fn verify_only_lifecycle_lock_remains(system_directory: &Path) -> Result<(), PrivilegedJobsError> { + let mut names = fs::read_dir(system_directory)? + .map(|entry| { + entry? + .file_name() + .into_string() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData)) + }) + .collect::, _>>()?; + names.sort(); + if names == [LIFECYCLE_LOCK_FILENAME] { + Ok(()) + } else { + Err(PrivilegedJobsError::InvalidRecord) + } +} + +fn verify_directory_empty(path: &Path) -> Result<(), PrivilegedJobsError> { + if fs::read_dir(path)?.next().transpose()?.is_none() { + Ok(()) + } else { + Err(PrivilegedJobsError::InvalidRecord) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ManagedComponent { + Proxifyre, + SingBox, +} + +impl ManagedComponent { + fn directory_name(self) -> &'static str { + match self { + Self::Proxifyre => "ProxiFyre", + Self::SingBox => "sing-box", + } + } + + fn service_name(self) -> &'static str { + match self { + Self::Proxifyre => "ProxiFyreService", + Self::SingBox => "ProxyWardenSingBox", + } + } + + fn update_repository(self) -> &'static str { + match self { + Self::Proxifyre => "wiresock/proxifyre", + Self::SingBox => "SagerNet/sing-box", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalComponentRoot { + component: ManagedComponent, + path: PathBuf, +} + +impl CanonicalComponentRoot { + pub fn from_current_exe( + current_exe: &Path, + component: ManagedComponent, + ) -> Result { + let app_root = verified_current_exe_parent(current_exe)?; + Ok(Self { + component, + path: app_root.join("components").join(component.directory_name()), + }) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_current_exe_for_tests( + current_exe: &Path, + component: ManagedComponent, + ) -> Result { + if !current_exe.is_absolute() || current_exe.file_name().is_none() { + return Err(PrivilegedJobsError::InvalidReceipt); + } + safe_fs::ensure_no_reparse_ancestors(current_exe)?; + let app_root = current_exe + .parent() + .ok_or(PrivilegedJobsError::InvalidReceipt)?; + Ok(Self { + component, + path: app_root.join("components").join(component.directory_name()), + }) + } + + pub fn component(&self) -> ManagedComponent { + self.component + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn receipt_path(&self) -> PathBuf { + self.path.join(INSTALL_RECEIPT_FILENAME) + } +} + +fn verified_current_exe_parent(current_exe: &Path) -> Result { + if !current_exe.is_absolute() || current_exe.file_name().is_none() { + return Err(PrivilegedJobsError::InvalidRecord); + } + safe_fs::verify_path_under_trusted_program_files(current_exe)?; + let app_root = current_exe + .parent() + .ok_or(PrivilegedJobsError::InvalidRecord)?; + safe_fs::verify_path_under_trusted_program_files(app_root)?; + Ok(app_root.to_path_buf()) +} + +/// Derives the installed Control App root from the running executable. The +/// NSIS bridge accepts no caller-selected paths, so the fixed executable name +/// is checked in addition to the ordinary trusted Program Files boundary. +pub(crate) fn verified_nsis_current_exe_app_root( + current_exe: &Path, +) -> Result { + if !current_exe + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("ProxyWarden.exe")) + { + return Err(PrivilegedJobsError::InvalidRecord); + } + verified_current_exe_parent(current_exe) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum InstallReceiptManager { + #[serde(rename = "ProxyWarden")] + ProxyWarden, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InstalledPackageSource { + Bundled, + Cache, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InstalledAssetReceipt { + pub name: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InstalledFileReceipt { + pub relative_path: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowsPacketFilterOwnership { + pub product_code: String, + pub installed_by_proxy_warden: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct InstallReceipt { + pub schema_version: u32, + pub manager: InstallReceiptManager, + pub component: ManagedComponent, + pub install_root: String, + pub service_name: String, + pub version: String, + pub source: InstalledPackageSource, + pub asset: InstalledAssetReceipt, + pub installed_files: Vec, + pub installed_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub windows_packet_filter: Option, +} + +pub fn write_install_receipt( + root: &CanonicalComponentRoot, + receipt: &InstallReceipt, +) -> Result<(), PrivilegedJobsError> { + validate_install_receipt(root, receipt)?; + let bytes = encode_receipt(receipt)?; + let metadata = fs::symlink_metadata(root.path())?; + if !metadata.file_type().is_dir() { + return Err(PrivilegedJobsError::InvalidReceipt); + } + safe_fs::verify_path_admin_owned_user_read_only(root.path())?; + let receipt_path = root.receipt_path(); + if receipt_path.try_exists()? { + safe_fs::verify_path_admin_owned_user_read_only(&receipt_path)?; + } + safe_fs::write_admin_owned_user_read_only_atomic(&receipt_path, &bytes)?; + safe_fs::verify_path_admin_owned_user_read_only(root.path())?; + safe_fs::verify_path_admin_owned_user_read_only(&receipt_path)?; + Ok(()) +} + +pub fn read_install_receipt( + root: &CanonicalComponentRoot, +) -> Result { + safe_fs::ensure_no_reparse_ancestors(root.path())?; + safe_fs::verify_path_admin_owned_user_read_only(root.path())?; + safe_fs::verify_path_admin_owned_user_read_only(&root.receipt_path())?; + let bytes = read_bounded_deny_write(&root.receipt_path())?; + let receipt: InstallReceipt = + serde_json::from_slice(&bytes).map_err(|_| PrivilegedJobsError::InvalidReceipt)?; + validate_install_receipt(root, &receipt)?; + Ok(receipt) +} + +pub fn validate_install_receipt( + root: &CanonicalComponentRoot, + receipt: &InstallReceipt, +) -> Result<(), PrivilegedJobsError> { + if receipt.schema_version != INSTALL_RECEIPT_SCHEMA_VERSION + || receipt.manager != InstallReceiptManager::ProxyWarden + || receipt.component != root.component + || !same_windows_path(&receipt.install_root, root.path()) + || receipt.service_name != root.component.service_name() + || !valid_numeric_version(&receipt.version) + || receipt.installed_at == 0 + || !valid_asset_name(&receipt.asset.name) + || !valid_sha256(&receipt.asset.sha256) + || receipt.asset.size == 0 + || receipt.installed_files.is_empty() + { + return Err(PrivilegedJobsError::InvalidReceipt); + } + + let mut relative_paths = HashSet::new(); + for installed in &receipt.installed_files { + if !valid_relative_path(&installed.relative_path) + || !valid_sha256(&installed.sha256) + || installed.size == 0 + || !relative_paths.insert(installed.relative_path.to_lowercase()) + { + return Err(PrivilegedJobsError::InvalidReceipt); + } + } + + match (&receipt.component, &receipt.windows_packet_filter) { + (ManagedComponent::SingBox, None) => {} + (ManagedComponent::Proxifyre, None) => {} + (ManagedComponent::Proxifyre, Some(ownership)) + if ownership.installed_by_proxy_warden + && ownership + .product_code + .eq_ignore_ascii_case(WINDOWS_PACKET_FILTER_PRODUCT_CODE) => {} + _ => return Err(PrivilegedJobsError::InvalidReceipt), + } + Ok(()) +} + +fn encode_receipt(receipt: &InstallReceipt) -> Result, PrivilegedJobsError> { + let bytes = + serde_json::to_vec_pretty(receipt).map_err(|_| PrivilegedJobsError::InvalidReceipt)?; + if bytes.len() as u64 > MAX_PRIVILEGED_RECORD_BYTES { + return Err(PrivilegedJobsError::RecordTooLarge); + } + Ok(bytes) +} + +fn valid_asset_name(value: &str) -> bool { + !value.contains(['/', '\\', ':']) && valid_windows_filename_segment(value) +} + +fn valid_relative_path(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['/', '\\']) + && !value.contains(':') + && !value.chars().any(char::is_control) + && value.split(['/', '\\']).all(valid_windows_filename_segment) +} + +fn valid_windows_filename_segment(value: &str) -> bool { + if value.is_empty() + || value == "." + || value == ".." + || value.ends_with(['.', ' ']) + || value.chars().any(|character| { + character.is_control() + || matches!( + character, + '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' + ) + }) + { + return false; + } + let stem = value + .split_once('.') + .map(|(stem, _)| stem) + .unwrap_or(value) + .to_ascii_uppercase(); + !matches!( + stem.as_str(), + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" | "CLOCK$" + ) && !matches_reserved_numbered_device(&stem, "COM") + && !matches_reserved_numbered_device(&stem, "LPT") +} + +fn matches_reserved_numbered_device(value: &str, prefix: &str) -> bool { + value.strip_prefix(prefix).is_some_and(|suffix| { + matches!( + suffix, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }) +} + +fn valid_numeric_version(value: &str) -> bool { + let segments = value.split('.').collect::>(); + (1..=4).contains(&segments.len()) + && segments.iter().all(|segment| { + !segment.is_empty() + && segment.bytes().all(|byte| byte.is_ascii_digit()) + && (segment == &"0" || !segment.starts_with('0')) + && segment.parse::().is_ok() + }) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn same_windows_path(value: &str, expected: &Path) -> bool { + let normalize = |text: &str| { + text.replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() + }; + normalize(value) == normalize(&expected.to_string_lossy()) +} + +#[cfg(test)] +mod nsis_terminal_validation_tests { + use super::*; + + #[test] + fn activation_requires_changed_successful_start_of_the_same_component() { + let mut result = PrivilegedJobResult { + activation: Some(ActivationAcknowledgement { + component: ManagedComponent::Proxifyre, + config_sha256: "a".repeat(64), + instance: crate::process::ServiceInstance { + process_id: 42, + created_at_filetime: 100, + }, + }), + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: PrivilegedJobId::new(), + nonce: PrivilegedJobNonce::new(), + action: PrivilegedAction::StartProxifyre, + status: PrivilegedJobStatus::Succeeded, + changed: true, + reboot_required: false, + code: PrivilegedResultCode::Completed, + message: result_message(PrivilegedResultCode::Completed).to_string(), + completed_at_epoch_seconds: Some(2), + }; + assert!(valid_terminal_result_semantics(&result)); + result.action = PrivilegedAction::StartSingBox; + assert!(!valid_terminal_result_semantics(&result)); + result.action = PrivilegedAction::StartProxifyre; + result.changed = false; + result.code = PrivilegedResultCode::NoChange; + result.message = result_message(PrivilegedResultCode::NoChange).to_string(); + assert!(!valid_terminal_result_semantics(&result)); + result.activation = None; + assert!(valid_terminal_result_semantics(&result)); + } + + #[test] + fn terminal_store_rejects_noncanonical_status_code_and_message_combinations() { + let mut result = PrivilegedJobResult { + activation: None, + schema_version: PRIVILEGED_JOB_SCHEMA_VERSION, + job_id: PrivilegedJobId::new(), + nonce: PrivilegedJobNonce::new(), + action: PrivilegedAction::StopProxifyre, + status: PrivilegedJobStatus::Succeeded, + changed: false, + reboot_required: false, + code: PrivilegedResultCode::NoChange, + message: result_message(PrivilegedResultCode::NoChange).to_string(), + completed_at_epoch_seconds: Some(2), + }; + assert!(valid_terminal_result_semantics(&result)); + + result.code = PrivilegedResultCode::Running; + result.message = result_message(PrivilegedResultCode::Running).to_string(); + assert!(!valid_terminal_result_semantics(&result)); + + result.status = PrivilegedJobStatus::Failed; + result.code = PrivilegedResultCode::OperationFailed; + result.message = "forged".to_string(); + assert!(!valid_terminal_result_semantics(&result)); + + result.message = result_message(PrivilegedResultCode::OperationFailed).to_string(); + result.changed = true; + assert!(!valid_terminal_result_semantics(&result)); + + result.status = PrivilegedJobStatus::Succeeded; + result.code = PrivilegedResultCode::NoChange; + result.message = result_message(PrivilegedResultCode::NoChange).to_string(); + assert!(!valid_terminal_result_semantics(&result)); + + result.changed = false; + result.reboot_required = true; + assert!(!valid_terminal_result_semantics(&result)); + + result.code = PrivilegedResultCode::Completed; + result.message = result_message(PrivilegedResultCode::Completed).to_string(); + assert!(!valid_terminal_result_semantics(&result)); + result.changed = true; + assert!(valid_terminal_result_semantics(&result)); + + result.code = PrivilegedResultCode::CutoverRolledBack; + result.message = result_message(PrivilegedResultCode::CutoverRolledBack).to_string(); + assert!(!valid_terminal_result_semantics(&result)); + result.changed = false; + assert!(valid_terminal_result_semantics(&result)); + } + + #[test] + fn retirement_marker_is_bounded_to_one_pair_even_for_large_stores() { + let mut records = Vec::new(); + for _ in 0..205 { + let job_id = PrivilegedJobId::new(); + for suffix in ["claim", "result"] { + let name = format!("{}.{suffix}.json", job_id.as_str()); + records.push(TerminalSystemRecord { + job_id: job_id.clone(), + path: PathBuf::from(&name), + bytes: format!("{name}-bytes").into_bytes(), + name, + }); + } + } + records.sort_by(|left, right| left.path.cmp(&right.path)); + + let marker = build_next_nsis_retirement_record(&records) + .expect("marker") + .expect("nonempty store"); + let encoded = encode_record(&marker).expect("bounded marker encoding"); + + assert_eq!(marker.records.len(), 2); + assert!(encoded.len() as u64 <= MAX_PRIVILEGED_RECORD_BYTES); + } +} diff --git a/src-tauri/src/privileged_runtime.rs b/src-tauri/src/privileged_runtime.rs new file mode 100644 index 0000000..283acdf --- /dev/null +++ b/src-tauri/src/privileged_runtime.rs @@ -0,0 +1,2928 @@ +//! Concrete privileged lifecycle coordinator. +//! +//! The normal process resolves a local, stateless plan. The elevated process +//! rebuilds that plan while owning all verified package/config leases and lets +//! the runner consume the prepared action exactly once. + +use crate::adapters::proxifyre::PROXIFYRE_OUTPUT_FILE; +use crate::adapters::singbox::{ + DEFAULT_BLOCK_OUTBOUND_TAG, DEFAULT_DIRECT_OUTBOUND_TAG, DEFAULT_MIXED_INBOUND_TAG, + DEFAULT_VPN_OUTBOUND_TAG, SINGBOX_OUTPUT_FILE, +}; +use crate::component_catalog::ComponentId as CatalogComponentId; +use crate::component_cutover::{ + begin_proxifyre_cutover, candidate_operation_state_fingerprint, cleanup_proxifyre_quarantine, + component_cutover_user_evidence_fingerprint, confirm_proxifyre_cleanup, + cutover_terminal_record_fingerprint, cutover_transaction_fingerprint, + legacy_service_snapshot_fingerprint, mark_proxifyre_next_start_verified, + read_existing_sealed_cutover_journal, read_sealed_cutover_status, recover_proxifyre_cutover, + retire_existing_proxifyre_cutover_terminal_state, retire_proxifyre_cutover_terminal_state, + validate_component_cutover_user_evidence, verify_cutover_external_mutation_status, + ComponentCutoverObservation, ComponentCutoverUserEvidence, CutoverDisplayState, CutoverError, + CutoverExecutionMode, CutoverExternalMutationStatus, CutoverHost, CutoverHostError, + CutoverJournal, CutoverJournalStore, CutoverOperation, CutoverPhase, CutoverRunOutcome, + CutoverTerminalRetirementExpectation, LegacyBeforeState, LegacyServiceState, + MutationAuthorityEvidence, PrerequisiteBeforeState, ProxifyreCutoverPlan, + ProxifyreLegacyIdentity, SealedCutoverStore, StateFingerprint, + SystemProxifyreCutoverFilesystem, +}; +use crate::component_detection::{ + has_additional_matching_legacy_proxifyre_service, inventory_proxyfier, inventory_singbox, +}; +use crate::component_inventory::{ + component_inventory_fingerprint_for_cutover, CandidateRole, ComponentClassification, + ComponentInventory, LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT, LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION, +}; +use crate::component_packages::{ + ComponentPackageService, GithubReleaseDigestProof, NativePackageSignatureVerifier, + NativePrivilegedBundleVerifier, PackageSignatureVerifier, PrivilegedBundleVerifier, + PrivilegedCachedUpdatePlan, PrivilegedPackageLease, PrivilegedPackageProof, + ReqwestUpdateTransport, UpdateTransport, +}; +use crate::privileged_jobs::{ + cutover_recovery_probe_fingerprint, InstalledPackageSource, ManagedComponent, + PlannedAssetFingerprint, PlannedGithubOriginProof, PlannedPackageFingerprint, PrivilegedAction, + PrivilegedActionRunner, PrivilegedCutoverContext, PrivilegedCutoverMode, + PrivilegedMutationResult, PrivilegedPlanResolver, PrivilegedResultCode, + PrivilegedRunnerFailure, ResolvedActionPlan, +}; +use crate::proxifyre_runtime::{ + configure_proxifyre_firewall_native, expected_proxifyre_cutover_scm_effect, + install_proxifyre_native, map_cutover_packet_filter_install_exit, map_proxifyre_installer_exit, + mutate_proxifyre_cutover_candidate, mutate_proxifyre_cutover_scm, + observe_proxifyre_cutover_scm_state, preflight_start_proxifyre_native, + prepare_proxifyre_cutover_candidate, prepare_proxifyre_cutover_package, stop_proxifyre_native, + uninstall_cutover_transaction_packet_filter, uninstall_proxifyre_native, + update_proxifyre_native, PreparedProxifyreCutoverCandidate, + ProxifyreCutoverCandidateObservation, ProxifyreCutoverCandidateWriter, ProxifyreCutoverScm, + ProxifyreInstallerCommand, ProxifyreInstallerKind, ProxifyreNativeError, ProxifyreNativeHost, + ProxifyreNativeHostError, ProxifyreNativeOwnership, ProxifyreNativeServiceStatus, + ProxifyreNativeStartDisposition, RuntimeConfigVerification, + SystemProxifyreCutoverCandidateWriter, SystemProxifyreCutoverScm, SystemProxifyreNativeHost, +}; +use crate::safe_fs; +use crate::singbox_runtime::{ + install_singbox_native, preflight_start_singbox_native, stop_singbox_native, + uninstall_singbox_native, update_singbox_native, SingBoxNativeError, SingBoxNativeOutcome, + SingBoxStartPreflight, SystemSingBoxNativeHost, +}; +use crate::storage::{JsonStorage, StoragePaths}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const MAX_GENERATED_CONFIG_BYTES: u64 = 16 * 1024 * 1024; +const PACKAGE_STAGING_DIRECTORY: &str = ".proxywarden-package-staging"; + +type SharedBundleVerifier = Arc; +type SharedUpdateTransport = Arc; +type SharedSignatureVerifier = Arc; + +/// Stateless resolver used by the issuing, non-elevated process. It has no +/// transport field, so fresh/install/start planning cannot accidentally use +/// the network. +pub struct LocalPrivilegedPlanResolver { + packages: Option, + storage_paths: StoragePaths, + bundle_verifier: SharedBundleVerifier, + startup_session_id: String, + app_root: Option, +} + +impl LocalPrivilegedPlanResolver { + pub fn production(startup_session_id: &str) -> Result { + let startup_session_id = canonical_uuid_v4(startup_session_id) + .ok_or(PrivilegedRunnerFailure::PreconditionFailed)?; + let storage_paths = StoragePaths::default(); + let app_root = installed_app_root()?; + let packages = ComponentPackageService::open( + app_root.join("bundled").join("components"), + &storage_paths, + ) + .ok(); + Ok(Self { + packages, + storage_paths, + bundle_verifier: Arc::new(NativePrivilegedBundleVerifier), + startup_session_id, + app_root: Some(app_root), + }) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_parts_for_tests( + packages: Option, + storage_paths: StoragePaths, + bundle_verifier: SharedBundleVerifier, + ) -> Self { + Self { + packages, + storage_paths, + bundle_verifier, + startup_session_id: Uuid::new_v4().hyphenated().to_string(), + app_root: None, + } + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_parts_for_tests_with_startup_session( + packages: Option, + storage_paths: StoragePaths, + bundle_verifier: SharedBundleVerifier, + startup_session_id: &str, + ) -> Self { + Self { + packages, + storage_paths, + bundle_verifier, + startup_session_id: canonical_uuid_v4(startup_session_id) + .expect("test startup session must be a canonical UUID v4"), + app_root: None, + } + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_parts_for_tests_with_cutover_app_root( + packages: Option, + storage_paths: StoragePaths, + bundle_verifier: SharedBundleVerifier, + startup_session_id: &str, + app_root: PathBuf, + ) -> Self { + Self { + packages, + storage_paths, + bundle_verifier, + startup_session_id: canonical_uuid_v4(startup_session_id) + .expect("test startup session must be a canonical UUID v4"), + app_root: Some(app_root), + } + } + + fn resolve_package( + &self, + action: PrivilegedAction, + ) -> Result, PrivilegedRunnerFailure> { + let bundled_ids: &[CatalogComponentId] = match action { + PrivilegedAction::InstallProxifyre | PrivilegedAction::CutoverProxifyre => &[ + CatalogComponentId::Proxifyre, + CatalogComponentId::WindowsPacketFilter, + CatalogComponentId::VcRuntime, + ], + PrivilegedAction::InstallSingBox => { + &[CatalogComponentId::SingBox, CatalogComponentId::Winsw] + } + PrivilegedAction::UpdateProxifyre => { + return self + .packages + .as_ref() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)? + .plan_cached_update(CatalogComponentId::Proxifyre) + .map(|plan| Some(planned_cached_package(&plan))) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed); + } + PrivilegedAction::UpdateSingBox => { + return self + .packages + .as_ref() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)? + .plan_cached_update(CatalogComponentId::SingBox) + .map(|plan| Some(planned_cached_package(&plan))) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed); + } + _ => return Ok(None), + }; + + let packages = self + .packages + .as_ref() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?; + let leases = bundled_ids + .iter() + .map(|component_id| { + packages.lease_bundled_for_privileged_install( + *component_id, + self.bundle_verifier.as_ref(), + ) + }) + .collect::, _>>() + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + Ok(Some(planned_leased_package( + InstalledPackageSource::Bundled, + &leases.iter().collect::>(), + )?)) + } +} + +impl PrivilegedPlanResolver for LocalPrivilegedPlanResolver { + fn resolve( + &self, + action: PrivilegedAction, + ) -> Result { + if matches!( + action, + PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine + ) { + return self.resolve_cutover_action(action); + } + let package = self.resolve_package(action)?; + let configuration_fingerprint = resolve_local_configuration(action, &self.storage_paths)?; + let inventory_fingerprint = + component_inventory_fingerprint_for_cutover(&live_inventory(action)); + Ok(ResolvedActionPlan::new( + action, + package, + inventory_fingerprint, + configuration_fingerprint, + None, + )) + } +} + +impl LocalPrivilegedPlanResolver { + fn resolve_cutover_action( + &self, + action: PrivilegedAction, + ) -> Result { + self.resolve_cutover_action_with_inventory(action, live_inventory(action)) + } + + fn resolve_cutover_action_with_inventory( + &self, + action: PrivilegedAction, + inventory: ComponentInventory, + ) -> Result { + let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory); + let external_status = self + .app_root + .as_deref() + .map(verify_cutover_external_mutation_status); + let cutover_infrastructure_absent = + external_status == Some(CutoverExternalMutationStatus::Absent); + let observation = (!cutover_infrastructure_absent) + .then(|| read_local_cutover_observation(&self.storage_paths)) + .flatten(); + match action { + PrivilegedAction::CutoverProxifyre => { + if external_status + .is_some_and(|status| status != CutoverExternalMutationStatus::Absent) + { + return Ok(ResolvedActionPlan::new( + action, + None, + inventory_fingerprint.clone(), + no_config_fingerprint(action), + Some(recovery_probe_context(&inventory_fingerprint)), + )); + } + if let Some(observation) = observation.as_ref().and_then(valid_cutover_observation) + { + return Ok(ResolvedActionPlan::new( + action, + None, + inventory_fingerprint, + no_config_fingerprint(action), + Some(recovery_context(observation)?), + )); + } + let original_state = validate_new_cutover_discovery(&inventory).ok(); + let package = self.resolve_package(action)?; + let configuration_fingerprint = match original_state { + Some(_) => { + GeneratedConfigLease::open( + &self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE), + ManagedComponent::Proxifyre, + )? + .sha256 + } + None => no_config_fingerprint(action), + }; + Ok(ResolvedActionPlan::new( + action, + package, + inventory_fingerprint.clone(), + configuration_fingerprint, + Some(new_cutover_context( + &inventory_fingerprint, + original_state, + &self.startup_session_id, + )), + )) + } + PrivilegedAction::CleanupProxifyreQuarantine => { + let observation = observation + .as_ref() + .and_then(valid_cutover_observation) + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let user_evidence = read_local_cutover_user_evidence(&self.storage_paths)?; + if user_evidence.startup_session_id != self.startup_session_id + || user_evidence.current_inventory_fingerprint != inventory_fingerprint + { + return Err(PrivilegedRunnerFailure::CutoverStateConflict); + } + Ok(ResolvedActionPlan::new( + action, + None, + inventory_fingerprint, + no_config_fingerprint(action), + Some(cleanup_context(observation, &user_evidence)?), + )) + } + _ => Err(PrivilegedRunnerFailure::PreconditionFailed), + } + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn resolve_cutover_action_with_inventory_for_tests( + &self, + action: PrivilegedAction, + inventory: ComponentInventory, + ) -> Result { + self.resolve_cutover_action_with_inventory(action, inventory) + } +} + +/// Elevated resolver and one-shot runner. The pending value owns every live +/// lease; a mismatch consumes and drops it before any native host is created. +pub struct SystemPrivilegedRuntime { + app_root: PathBuf, + packages: Option, + storage_paths: StoragePaths, + bundle_verifier: SharedBundleVerifier, + update_transport: Option, + signature_verifier: SharedSignatureVerifier, + package_staging_parent: PathBuf, + verify_staging_app_root: bool, + enforce_cutover_journal: bool, + pending: Mutex>, +} + +impl SystemPrivilegedRuntime { + pub fn production() -> Result { + let storage_paths = StoragePaths::default(); + let app_root = installed_app_root()?; + let packages = ComponentPackageService::open( + app_root.join("bundled").join("components"), + &storage_paths, + ) + .ok(); + let update_transport = ReqwestUpdateTransport::new() + .ok() + .map(|transport| Arc::new(transport) as SharedUpdateTransport); + Ok(Self { + app_root: app_root.clone(), + packages, + storage_paths, + bundle_verifier: Arc::new(NativePrivilegedBundleVerifier), + update_transport, + signature_verifier: Arc::new(NativePackageSignatureVerifier), + package_staging_parent: app_root.join(PACKAGE_STAGING_DIRECTORY), + verify_staging_app_root: true, + enforce_cutover_journal: true, + pending: Mutex::new(None), + }) + } + + #[cfg(debug_assertions)] + #[doc(hidden)] + pub fn from_parts_for_tests( + packages: Option, + storage_paths: StoragePaths, + package_staging_parent: PathBuf, + bundle_verifier: SharedBundleVerifier, + update_transport: Option, + signature_verifier: SharedSignatureVerifier, + ) -> Self { + let app_root = package_staging_parent + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| package_staging_parent.clone()); + Self { + app_root, + packages, + storage_paths, + bundle_verifier, + update_transport, + signature_verifier, + package_staging_parent, + verify_staging_app_root: false, + enforce_cutover_journal: false, + pending: Mutex::new(None), + } + } + + fn prepare(&self, action: PrivilegedAction) -> Result { + match action { + PrivilegedAction::InstallProxifyre => Ok(PreparedInput::InstallProxifyre { + proxifyre: Box::new(self.lease_bundled(CatalogComponentId::Proxifyre)?), + packet_filter: Box::new( + self.lease_bundled(CatalogComponentId::WindowsPacketFilter)?, + ), + vc_runtime: Box::new(self.lease_bundled(CatalogComponentId::VcRuntime)?), + }), + PrivilegedAction::InstallSingBox => Ok(PreparedInput::InstallSingBox { + runtime: Box::new(self.lease_bundled(CatalogComponentId::SingBox)?), + wrapper: Box::new(self.lease_bundled(CatalogComponentId::Winsw)?), + }), + PrivilegedAction::UpdateProxifyre => Ok(PreparedInput::UpdateProxifyre(Box::new( + self.lease_cached(CatalogComponentId::Proxifyre)?, + ))), + PrivilegedAction::UpdateSingBox => Ok(PreparedInput::UpdateSingBox(Box::new( + self.lease_cached(CatalogComponentId::SingBox)?, + ))), + PrivilegedAction::StartProxifyre => { + Ok(PreparedInput::StartProxifyre(GeneratedConfigLease::open( + &self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE), + ManagedComponent::Proxifyre, + )?)) + } + PrivilegedAction::StartSingBox => { + Ok(PreparedInput::StartSingBox(GeneratedConfigLease::open( + &self.storage_paths.generated_dir.join(SINGBOX_OUTPUT_FILE), + ManagedComponent::SingBox, + )?)) + } + PrivilegedAction::StopProxifyre + | PrivilegedAction::ConfigureProxifyreFirewall + | PrivilegedAction::UninstallProxifyre + | PrivilegedAction::CutoverProxifyre + | PrivilegedAction::CleanupProxifyreQuarantine + | PrivilegedAction::StopSingBox + | PrivilegedAction::UninstallSingBox => Ok(PreparedInput::None), + } + } + + fn lease_bundled( + &self, + component_id: CatalogComponentId, + ) -> Result { + self.packages + .as_ref() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)? + .lease_bundled_for_privileged_install(component_id, self.bundle_verifier.as_ref()) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed) + } + + fn lease_cached( + &self, + component_id: CatalogComponentId, + ) -> Result { + let packages = self + .packages + .as_ref() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?; + let update_transport = self + .update_transport + .as_ref() + .ok_or(PrivilegedRunnerFailure::RunnerUnavailable)?; + let plan = packages + .plan_cached_update(component_id) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + let app_root = self + .package_staging_parent + .parent() + .filter(|_| { + self.package_staging_parent + .file_name() + .and_then(|name| name.to_str()) + == Some(PACKAGE_STAGING_DIRECTORY) + }) + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?; + #[cfg(debug_assertions)] + if !self.verify_staging_app_root { + std::fs::create_dir_all(&self.package_staging_parent) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + safe_fs::protect_path_for_owner_admin_system(&self.package_staging_parent) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + return packages + .lease_cached_update_with_owner_protected_staging_for_tests( + &plan, + &self.package_staging_parent, + update_transport.as_ref(), + self.signature_verifier.as_ref(), + ) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed); + } + if self.verify_staging_app_root { + safe_fs::verify_path_under_trusted_program_files(app_root) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + } + safe_fs::create_directory_admin_owned_user_read_only(&self.package_staging_parent) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed)?; + packages + .lease_cached_update_for_privileged_install( + &plan, + &self.package_staging_parent, + update_transport.as_ref(), + self.signature_verifier.as_ref(), + ) + .map_err(|_| PrivilegedRunnerFailure::PackageVerificationFailed) + } + + fn sealed_cutover_store(&self) -> Result { + if !self.enforce_cutover_journal { + return Err(PrivilegedRunnerFailure::RunnerUnavailable); + } + let storage = JsonStorage::new(self.storage_paths.root.clone()); + SealedCutoverStore::prepare(&self.app_root, &storage) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired) + } + + fn ensure_ordinary_proxifyre_action_allowed( + &self, + action: PrivilegedAction, + ) -> Result, PrivilegedRunnerFailure> { + if action.component() != ManagedComponent::Proxifyre + || matches!( + action, + PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine + ) + || !self.enforce_cutover_journal + { + return Ok(None); + } + match verify_cutover_external_mutation_status(&self.app_root) { + CutoverExternalMutationStatus::Absent => return Ok(None), + CutoverExternalMutationStatus::Active => {} + CutoverExternalMutationStatus::TerminalRetirementPending + | CutoverExternalMutationStatus::RolledBackRetained + | CutoverExternalMutationStatus::RecoveryRequired => { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + } + let journal = read_existing_sealed_cutover_journal(&self.app_root) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + if let Some(config_fingerprint) = stopped_cutover_route_lifecycle_exception( + action, + journal.phase, + journal.execution_mode, + journal.plan.original_service_state, + &journal.plan.config_fingerprint, + ) { + return Ok(Some(config_fingerprint)); + } + Err(PrivilegedRunnerFailure::CutoverRecoveryRequired) + } + + fn prepare_cutover_action( + &self, + action: PrivilegedAction, + requested_plan: Option<&ResolvedActionPlan>, + ) -> Result<(ResolvedActionPlan, PreparedInput), PrivilegedRunnerFailure> { + let inventory = live_inventory(action); + let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory); + let recovery_probe = recovery_probe_context(&inventory_fingerprint); + let requested_recovery_probe = requested_plan + .and_then(|plan| plan.cutover_context.as_ref()) + .is_some_and(|context| context == &recovery_probe); + let external_status = verify_cutover_external_mutation_status(&self.app_root); + let existing_journal = match external_status { + CutoverExternalMutationStatus::Active => Some( + read_existing_sealed_cutover_journal(&self.app_root) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?, + ), + CutoverExternalMutationStatus::Absent => { + if action != PrivilegedAction::CutoverProxifyre { + return Err(PrivilegedRunnerFailure::CutoverStateConflict); + } + None + } + CutoverExternalMutationStatus::RecoveryRequired => { + if action != PrivilegedAction::CutoverProxifyre { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + let journal = read_existing_sealed_cutover_journal(&self.app_root) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + if journal.phase != CutoverPhase::RecoveryRequired { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + Some(journal) + } + CutoverExternalMutationStatus::TerminalRetirementPending + | CutoverExternalMutationStatus::RolledBackRetained => { + if action != PrivilegedAction::CutoverProxifyre { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + match read_existing_sealed_cutover_journal(&self.app_root) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + { + Some(journal) => { + if journal.execution_mode != CutoverExecutionMode::Finished + || !matches!( + journal.phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) + || (external_status + == CutoverExternalMutationStatus::RolledBackRetained + && journal.phase != CutoverPhase::RolledBack) + { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + let expected_terminal_record_fingerprint = + cutover_terminal_record_fingerprint(&journal) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + let plan = ResolvedActionPlan::new( + action, + None, + inventory_fingerprint, + no_config_fingerprint(action), + Some(if requested_recovery_probe { + recovery_probe + } else { + recovery_context_from_journal(&journal)? + }), + ); + return Ok(( + plan, + PreparedInput::Cutover(Box::new(PreparedSystemCutover { + mode: PreparedCutoverMode::Retirement, + store: None, + new: None, + retirement: Some(PreparedTerminalCutoverRetirement { + app_root: self.app_root.clone(), + expected_phase: Some(journal.phase), + expectation: CutoverTerminalRetirementExpectation::Journal { + cutover_id: journal.plan.cutover_id.clone(), + phase: journal.phase, + terminal_record_fingerprint: + expected_terminal_record_fingerprint, + }, + }), + host: None, + user_evidence: None, + })), + )); + } + None => { + let plan = ResolvedActionPlan::new( + action, + None, + inventory_fingerprint, + no_config_fingerprint(action), + Some(recovery_probe), + ); + return Ok(( + plan, + PreparedInput::Cutover(Box::new(PreparedSystemCutover { + mode: PreparedCutoverMode::Retirement, + store: None, + new: None, + retirement: Some(PreparedTerminalCutoverRetirement { + app_root: self.app_root.clone(), + expected_phase: None, + expectation: + CutoverTerminalRetirementExpectation::EmptyInfrastructure, + }), + host: None, + user_evidence: None, + })), + )); + } + } + } + }; + + if let Some(sealed_journal) = existing_journal { + let store = self.sealed_cutover_store()?; + let sealed_observation = read_sealed_cutover_status(&store) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + let journal = store + .load() + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + let observation = sealed_observation; + if journal != sealed_journal || observation.cutover_id != journal.plan.cutover_id { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + let (mode, context, user_evidence) = match action { + PrivilegedAction::CutoverProxifyre => ( + PreparedCutoverMode::Recovery, + if requested_recovery_probe { + recovery_probe + } else { + recovery_context(&observation)? + }, + None, + ), + PrivilegedAction::CleanupProxifyreQuarantine => { + let evidence = read_local_cutover_user_evidence(&self.storage_paths)?; + validate_elevated_cleanup_evidence( + &journal, + &inventory_fingerprint, + &evidence, + unix_now_epoch_seconds()?, + )?; + let context = cleanup_context(&observation, &evidence)?; + (PreparedCutoverMode::Cleanup, context, Some(evidence)) + } + _ => return Err(PrivilegedRunnerFailure::CutoverStateConflict), + }; + let plan = ResolvedActionPlan::new( + action, + None, + inventory_fingerprint, + no_config_fingerprint(action), + Some(context), + ); + let host = SystemCutoverHost::recover(&journal.plan)?; + return Ok(( + plan, + PreparedInput::Cutover(Box::new(PreparedSystemCutover { + mode, + store: Some(store), + new: None, + retirement: None, + host: Some(host), + user_evidence, + })), + )); + } + let initiating_startup_session_id = requested_plan + .and_then(|plan| plan.cutover_context.as_ref()) + .filter(|context| { + context.mode == PrivilegedCutoverMode::New + && context.cutover_id.is_none() + && context.user_evidence.is_none() + }) + .and_then(|context| context.initiating_startup_session_id.as_deref()) + .and_then(canonical_uuid_v4) + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let original_state = validate_new_cutover_discovery(&inventory)?; + let proxifyre = Box::new(self.lease_bundled(CatalogComponentId::Proxifyre)?); + let packet_filter = Box::new(self.lease_bundled(CatalogComponentId::WindowsPacketFilter)?); + let vc_runtime = Box::new(self.lease_bundled(CatalogComponentId::VcRuntime)?); + let config = GeneratedConfigLease::open( + &self.storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE), + ManagedComponent::Proxifyre, + )?; + let package = planned_leased_package( + InstalledPackageSource::Bundled, + &[ + proxifyre.as_ref(), + packet_filter.as_ref(), + vc_runtime.as_ref(), + ], + )?; + let context = new_cutover_context( + &inventory_fingerprint, + Some(original_state), + &initiating_startup_session_id, + ); + let plan = ResolvedActionPlan::new( + action, + Some(package), + inventory_fingerprint, + config.sha256.clone(), + Some(context), + ); + let core_plan = ProxifyreCutoverPlan::new( + &self.app_root, + PathBuf::from(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT), + original_state, + proxifyre.proof().version.clone(), + proxifyre.proof().sha256.clone(), + config.sha256.clone(), + plan.operation_fingerprint.clone(), + initiating_startup_session_id, + ); + let mut native = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?; + let native_before = native + .inspect() + .map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?; + let runtime = prepare_proxifyre_cutover_package(&proxifyre).map_err(map_proxifyre_error)?; + let candidate = prepare_proxifyre_cutover_candidate( + &core_plan, + runtime, + &config.bytes, + &config.sha256, + native_before.packet_filter_present, + unix_now_epoch_seconds()?, + ) + .map_err(map_proxifyre_error)?; + Ok(( + plan, + PreparedInput::Cutover(Box::new(PreparedSystemCutover { + mode: PreparedCutoverMode::New, + store: None, + new: Some(PreparedNewSystemCutover { + app_root: self.app_root.clone(), + storage_root: self.storage_paths.root.clone(), + plan: core_plan, + candidate, + native, + proxifyre, + packet_filter, + vc_runtime, + config, + packet_filter_present: native_before.packet_filter_present, + vc_runtime_present: native_before.vc_runtime_present, + }), + retirement: None, + host: None, + user_evidence: None, + })), + )) + } + + fn resolve_internal( + &self, + action: PrivilegedAction, + requested_plan: Option<&ResolvedActionPlan>, + ) -> Result { + let mut pending = self + .pending + .lock() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + pending.take(); + + if matches!( + action, + PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine + ) { + let (plan, input) = self.prepare_cutover_action(action, requested_plan)?; + *pending = Some(PreparedAction { + action, + plan: plan.clone(), + input, + }); + return Ok(plan); + } + + let sealed_config_fingerprint = self.ensure_ordinary_proxifyre_action_allowed(action)?; + let input = self.prepare(action)?; + if action == PrivilegedAction::StartProxifyre + && sealed_config_fingerprint + .as_deref() + .is_some_and(|expected| input.configuration_fingerprint(action) != expected) + { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + let plan = ResolvedActionPlan::new( + action, + input.package_fingerprint()?, + component_inventory_fingerprint_for_cutover(&live_inventory(action)), + input.configuration_fingerprint(action), + None, + ); + *pending = Some(PreparedAction { + action, + plan: plan.clone(), + input, + }); + Ok(plan) + } +} + +fn stopped_cutover_route_lifecycle_exception( + action: PrivilegedAction, + phase: CutoverPhase, + execution_mode: CutoverExecutionMode, + original_service_state: LegacyServiceState, + config_fingerprint: &str, +) -> Option { + (phase == CutoverPhase::LegacyQuarantined + && execution_mode == CutoverExecutionMode::AwaitingUser + && original_service_state == LegacyServiceState::Stopped + && matches!( + action, + PrivilegedAction::StartProxifyre | PrivilegedAction::StopProxifyre + )) + .then(|| config_fingerprint.to_string()) +} + +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn stopped_cutover_route_lifecycle_exception_for_tests( + action: PrivilegedAction, + phase: CutoverPhase, + execution_mode: CutoverExecutionMode, + original_service_state: LegacyServiceState, + config_fingerprint: &str, +) -> Option { + stopped_cutover_route_lifecycle_exception( + action, + phase, + execution_mode, + original_service_state, + config_fingerprint, + ) +} + +impl PrivilegedPlanResolver for SystemPrivilegedRuntime { + fn resolve( + &self, + action: PrivilegedAction, + ) -> Result { + self.resolve_internal(action, None) + } + + fn resolve_elevated( + &self, + action: PrivilegedAction, + requested_plan: &ResolvedActionPlan, + ) -> Result { + self.resolve_internal(action, Some(requested_plan)) + } +} + +impl PrivilegedActionRunner for SystemPrivilegedRuntime { + fn run( + &self, + action: PrivilegedAction, + plan: &ResolvedActionPlan, + ) -> Result { + let prepared = self + .pending + .lock() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)? + .take() + .ok_or(PrivilegedRunnerFailure::PreconditionFailed)?; + if prepared.action != action || &prepared.plan != plan { + return Err(PrivilegedRunnerFailure::PreconditionFailed); + } + execute_prepared(action, prepared.input) + } +} + +struct PreparedAction { + action: PrivilegedAction, + plan: ResolvedActionPlan, + input: PreparedInput, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PreparedCutoverMode { + New, + Recovery, + Cleanup, + Retirement, +} + +struct PreparedNewSystemCutover { + app_root: PathBuf, + storage_root: PathBuf, + plan: ProxifyreCutoverPlan, + candidate: PreparedProxifyreCutoverCandidate, + native: SystemProxifyreNativeHost, + proxifyre: Box, + packet_filter: Box, + vc_runtime: Box, + config: GeneratedConfigLease, + packet_filter_present: bool, + vc_runtime_present: bool, +} + +struct PreparedTerminalCutoverRetirement { + app_root: PathBuf, + expected_phase: Option, + expectation: CutoverTerminalRetirementExpectation, +} + +struct PreparedSystemCutover { + mode: PreparedCutoverMode, + store: Option, + new: Option, + retirement: Option, + host: Option, + user_evidence: Option, +} + +impl PreparedSystemCutover { + fn execute(mut self) -> Result { + let now = unix_now_epoch_seconds()?; + let (outcome, reboot_required) = match self.mode { + PreparedCutoverMode::New => { + let prepared = self + .new + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + execute_new_system_cutover(prepared, now)? + } + PreparedCutoverMode::Recovery => { + let store = self + .store + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let mut host = self + .host + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let outcome = recover_proxifyre_cutover(&store, &mut host, now) + .map_err(|error| map_cutover_error(self.mode, error))?; + (outcome, host.reboot_required) + } + PreparedCutoverMode::Cleanup => { + let store = self + .store + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let mut host = self + .host + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let evidence = self + .user_evidence + .as_ref() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + let journal = store + .load() + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)? + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + match journal.phase { + CutoverPhase::LegacyQuarantined => { + mark_proxifyre_next_start_verified(&store, &mut host, evidence, now) + .map_err(|error| map_cutover_error(self.mode, error))?; + confirm_proxifyre_cleanup(&store, evidence, now) + .map_err(|error| map_cutover_error(self.mode, error))?; + } + CutoverPhase::NextStartVerified => { + confirm_proxifyre_cleanup(&store, evidence, now) + .map_err(|error| map_cutover_error(self.mode, error))?; + } + CutoverPhase::CleanupConfirmed => {} + _ => return Err(PrivilegedRunnerFailure::CutoverStateConflict), + } + let outcome = cleanup_proxifyre_quarantine(&store, &mut host, now) + .map_err(|error| map_cutover_error(self.mode, error))?; + (outcome, host.reboot_required) + } + PreparedCutoverMode::Retirement => { + let prepared = self + .retirement + .take() + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + execute_terminal_cutover_retirement(prepared)? + } + }; + let (changed, result_code) = match outcome { + CutoverRunOutcome::AwaitingNextStart => { + (true, PrivilegedResultCode::CutoverAwaitingNextStart) + } + CutoverRunOutcome::QuarantinePending => { + (true, PrivilegedResultCode::CutoverQuarantinePending) + } + CutoverRunOutcome::RolledBack => (false, PrivilegedResultCode::CutoverRolledBack), + CutoverRunOutcome::CleanupPending => { + (true, PrivilegedResultCode::CutoverCleanupPending) + } + CutoverRunOutcome::Complete => (true, PrivilegedResultCode::CutoverComplete), + }; + Ok(PrivilegedMutationResult { + activation: None, + changed, + reboot_required, + result_code: Some(result_code), + }) + } +} + +fn execute_terminal_cutover_retirement( + prepared: PreparedTerminalCutoverRetirement, +) -> Result<(CutoverRunOutcome, bool), PrivilegedRunnerFailure> { + if prepared.expected_phase.is_some_and(|phase| { + !matches!( + phase, + CutoverPhase::ComponentComplete | CutoverPhase::RolledBack + ) + }) { + return Err(PrivilegedRunnerFailure::CutoverRecoveryRequired); + } + retire_existing_proxifyre_cutover_terminal_state(&prepared.app_root, &prepared.expectation) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + let outcome = if prepared.expected_phase == Some(CutoverPhase::ComponentComplete) { + CutoverRunOutcome::Complete + } else { + CutoverRunOutcome::RolledBack + }; + Ok((outcome, false)) +} + +fn execute_new_system_cutover( + prepared: PreparedNewSystemCutover, + now: u64, +) -> Result<(CutoverRunOutcome, bool), PrivilegedRunnerFailure> { + let storage = JsonStorage::new(prepared.storage_root); + let store = SealedCutoverStore::prepare(&prepared.app_root, &storage) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + let mut host = match SystemCutoverHost::new( + &prepared.plan, + prepared.candidate, + prepared.native, + prepared.proxifyre, + prepared.packet_filter, + prepared.vc_runtime, + prepared.config, + prepared.packet_filter_present, + prepared.vc_runtime_present, + ) { + Ok(host) => host, + Err(error) => { + retire_empty_new_cutover_store(&store)?; + return Err(error); + } + }; + let outcome = match begin_proxifyre_cutover(&store, &mut host, prepared.plan, now) { + Ok(outcome) => outcome, + Err(error) => { + drop(host); + preserve_journal_or_retire_empty_new_cutover(&store)?; + return Err(map_cutover_error(PreparedCutoverMode::New, error)); + } + }; + Ok((outcome, host.reboot_required)) +} + +fn preserve_journal_or_retire_empty_new_cutover( + store: &SealedCutoverStore, +) -> Result<(), PrivilegedRunnerFailure> { + match store.load() { + Ok(Some(_)) => Ok(()), + Ok(None) => retire_empty_new_cutover_store(store), + Err(_) => Err(PrivilegedRunnerFailure::CutoverRecoveryRequired), + } +} + +fn retire_empty_new_cutover_store( + store: &SealedCutoverStore, +) -> Result<(), PrivilegedRunnerFailure> { + retire_proxifyre_cutover_terminal_state(store) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired) +} + +fn unix_now_epoch_seconds() -> Result { + let value = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + (value > 0) + .then_some(value) + .ok_or(PrivilegedRunnerFailure::OperationFailed) +} + +fn map_cutover_error(mode: PreparedCutoverMode, error: CutoverError) -> PrivilegedRunnerFailure { + match error { + CutoverError::AlreadyExists | CutoverError::Missing | CutoverError::StateConflict => { + PrivilegedRunnerFailure::CutoverStateConflict + } + CutoverError::RecoveryRequired | CutoverError::Interrupted => { + PrivilegedRunnerFailure::CutoverRecoveryRequired + } + CutoverError::InvalidPlan | CutoverError::Host(CutoverHostError::PreconditionFailed) + if mode == PreparedCutoverMode::New => + { + PrivilegedRunnerFailure::CutoverIdentityRejected + } + CutoverError::InvalidPlan | CutoverError::Host(CutoverHostError::PreconditionFailed) => { + PrivilegedRunnerFailure::CutoverRecoveryRequired + } + CutoverError::Host(_) | CutoverError::Storage(_) if mode != PreparedCutoverMode::New => { + PrivilegedRunnerFailure::CutoverRecoveryRequired + } + CutoverError::Host(_) | CutoverError::Storage(_) => { + PrivilegedRunnerFailure::OperationFailed + } + } +} + +struct SystemCutoverHost { + filesystem: SystemProxifyreCutoverFilesystem, + scm: SystemProxifyreCutoverScm, + candidate: Option, + native: SystemProxifyreNativeHost, + _proxifyre_lease: Option>, + packet_filter_lease: Option>, + vc_runtime_lease: Option>, + _config_lease: Option, + captured_packet_filter_present: Option, + captured_vc_runtime_present: Option, + last_observed_identity: Option, + reboot_required: bool, +} + +impl SystemCutoverHost { + #[allow(clippy::too_many_arguments)] + fn new( + plan: &ProxifyreCutoverPlan, + candidate: PreparedProxifyreCutoverCandidate, + native: SystemProxifyreNativeHost, + proxifyre_lease: Box, + packet_filter_lease: Box, + vc_runtime_lease: Box, + config_lease: GeneratedConfigLease, + packet_filter_present: bool, + vc_runtime_present: bool, + ) -> Result { + let filesystem = SystemProxifyreCutoverFilesystem::open(plan) + .map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?; + let scm = SystemProxifyreCutoverScm::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?; + let candidate = SystemProxifyreCutoverCandidateWriter::begin(candidate) + .map_err(|_| PrivilegedRunnerFailure::CutoverIdentityRejected)?; + Ok(Self { + filesystem, + scm, + candidate: Some(candidate), + native, + _proxifyre_lease: Some(proxifyre_lease), + packet_filter_lease: Some(packet_filter_lease), + vc_runtime_lease: Some(vc_runtime_lease), + _config_lease: Some(config_lease), + captured_packet_filter_present: Some(packet_filter_present), + captured_vc_runtime_present: Some(vc_runtime_present), + last_observed_identity: None, + reboot_required: false, + }) + } + + fn recover(plan: &ProxifyreCutoverPlan) -> Result { + Ok(Self { + filesystem: SystemProxifyreCutoverFilesystem::open(plan) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?, + scm: SystemProxifyreCutoverScm::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?, + candidate: None, + native: SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?, + _proxifyre_lease: None, + packet_filter_lease: None, + vc_runtime_lease: None, + _config_lease: None, + captured_packet_filter_present: None, + captured_vc_runtime_present: None, + last_observed_identity: None, + reboot_required: false, + }) + } + + fn handoff_complete_candidate( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let Some(candidate) = self.candidate.take() else { + return Ok(()); + }; + let (root, entries) = candidate + .into_complete_candidate_leases() + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.filesystem + .adopt_current_candidate(journal, root, entries) + } + + fn handoff_partial_candidate_for_rollback( + &mut self, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + let Some(candidate) = self.candidate.take() else { + return Ok(()); + }; + let (root, entries) = candidate + .into_partial_candidate_leases_for_rollback(journal) + .map_err(|_| CutoverHostError::PreconditionFailed)?; + self.filesystem + .adopt_partial_current_candidate_for_rollback(journal, root, entries) + } + + fn candidate_writer(&self) -> Result<&SystemProxifyreCutoverCandidateWriter, CutoverHostError> { + self.candidate + .as_ref() + .ok_or(CutoverHostError::ObservationFailed) + } + + fn candidate_writer_mut( + &mut self, + ) -> Result<&mut SystemProxifyreCutoverCandidateWriter, CutoverHostError> { + self.candidate + .as_mut() + .ok_or(CutoverHostError::MutationFailed) + } + + fn observe_prerequisite( + &mut self, + operation: &CutoverOperation, + ) -> Result { + let snapshot = self + .native + .inspect() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let matches = match operation { + CutoverOperation::InstallWindowsPacketFilterPrerequisite => { + snapshot.packet_filter_present + } + CutoverOperation::InstallVcRuntimePrerequisite => snapshot.vc_runtime_present, + CutoverOperation::UninstallTransactionWindowsPacketFilter => { + !snapshot.packet_filter_present + } + _ => return Err(CutoverHostError::Unsupported), + }; + cutover_delegate_fingerprint( + "proxywarden:privileged-cutover-prerequisite-state:v1", + operation, + if matches { "expected" } else { "unexpected" }, + ) + } + + fn observe_scm( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + observe_proxifyre_cutover_scm_state(&mut self.scm, operation, &journal.before_state.service) + .map_err(|_| CutoverHostError::ObservationFailed) + } + + fn mutate_prerequisite( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + let (effect, authority_evidence) = match operation { + CutoverOperation::InstallWindowsPacketFilterPrerequisite => { + if journal + .before_state + .prerequisites + .windows_packet_filter_present + { + return Err(CutoverHostError::MutationFailed); + } + let lease = self + .packet_filter_lease + .as_ref() + .ok_or(CutoverHostError::MutationFailed)?; + let code = self + .native + .run_installer(ProxifyreInstallerCommand::InstallPacketFilter( + lease.asset_path(), + )) + .map_err(|_| CutoverHostError::MutationFailed)?; + ( + map_cutover_packet_filter_install_exit(code) + .map_err(|_| CutoverHostError::MutationFailed)?, + Some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction), + ) + } + CutoverOperation::InstallVcRuntimePrerequisite => { + if journal.before_state.prerequisites.vc_runtime_present { + return Err(CutoverHostError::MutationFailed); + } + let lease = self + .vc_runtime_lease + .as_ref() + .ok_or(CutoverHostError::MutationFailed)?; + let code = self + .native + .run_installer(ProxifyreInstallerCommand::InstallVcRuntime( + lease.asset_path(), + )) + .map_err(|_| CutoverHostError::MutationFailed)?; + ( + map_proxifyre_installer_exit(ProxifyreInstallerKind::VcRuntimeInstall, code) + .map_err(|_| CutoverHostError::MutationFailed)?, + None, + ) + } + CutoverOperation::UninstallTransactionWindowsPacketFilter => ( + uninstall_cutover_transaction_packet_filter(journal) + .map_err(|_| CutoverHostError::MutationFailed)?, + None, + ), + _ => return Err(CutoverHostError::Unsupported), + }; + self.reboot_required |= effect.reboot_required; + Ok(authority_evidence) + } +} + +impl CutoverHost for SystemCutoverHost { + fn capture_and_lease( + &mut self, + plan: &ProxifyreCutoverPlan, + ) -> Result { + if self.candidate.is_none() + || self + .scm + .query_current_service() + .map_err(|_| CutoverHostError::PreconditionFailed)? + .path_matches + { + return Err(CutoverHostError::PreconditionFailed); + } + let service = self + .scm + .capture_legacy_service() + .map_err(|_| CutoverHostError::PreconditionFailed)?; + let filesystem = self.filesystem.capture_legacy_filesystem()?; + let current_components_parent = self.filesystem.capture_current_components_parent()?; + let native = self + .native + .inspect() + .map_err(|_| CutoverHostError::PreconditionFailed)?; + if self.captured_packet_filter_present != Some(native.packet_filter_present) + || self.captured_vc_runtime_present != Some(native.vc_runtime_present) + { + return Err(CutoverHostError::PreconditionFailed); + } + let current_candidate = self.candidate_writer()?.prepared().snapshot().clone(); + let identity_fingerprint = legacy_service_snapshot_fingerprint(&service); + Ok(LegacyBeforeState { + identity: ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService, + identity_fingerprint, + service, + filesystem, + current_components_parent, + current_candidate, + prerequisites: PrerequisiteBeforeState { + windows_packet_filter_present: native.packet_filter_present, + vc_runtime_present: native.vc_runtime_present, + }, + package_fingerprint: plan.package_fingerprint.clone(), + config_fingerprint: plan.config_fingerprint.clone(), + additional_matching_service: has_additional_matching_legacy_proxifyre_service( + &plan.legacy_root, + ), + }) + } + + fn reacquire_leases(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + self.filesystem.reacquire_leases(journal) + } + + fn observe( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + self.last_observed_identity = None; + if *operation == CutoverOperation::PromoteCurrentCandidate && self.candidate.is_some() { + self.handoff_complete_candidate(journal)?; + } + if *operation == CutoverOperation::TombstoneCurrentCandidate && self.candidate.is_some() { + self.handoff_partial_candidate_for_rollback(journal)?; + } + if SystemProxifyreCutoverFilesystem::handles_operation(operation) { + let observed = self.filesystem.observe_operation(operation, journal)?; + self.last_observed_identity = self.filesystem.observed_object_identity(); + return Ok(observed); + } + if candidate_operation(operation) { + let has_live_candidate = self.candidate.is_some(); + let candidate = &mut self.candidate; + let filesystem = &mut self.filesystem; + let (observed, identity) = dispatch_candidate_observation( + has_live_candidate, + || observe_live_candidate(candidate.as_mut(), operation), + || observe_recovered_candidate(filesystem, operation, journal), + )?; + self.last_observed_identity = identity; + return Ok(observed); + } + if prerequisite_operation(operation) { + return self.observe_prerequisite(operation); + } + if scm_operation(operation) { + return self.observe_scm(operation, journal); + } + Err(CutoverHostError::Unsupported) + } + + fn observed_object_identity( + &mut self, + _operation: &CutoverOperation, + _journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + Ok(self.last_observed_identity.clone()) + } + + fn expected_effect( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + if SystemProxifyreCutoverFilesystem::handles_operation(operation) { + return self + .filesystem + .expected_operation_effect(operation, journal); + } + if scm_operation(operation) { + return expected_proxifyre_cutover_scm_effect(operation) + .map_err(|_| CutoverHostError::ObservationFailed); + } + if candidate_operation(operation) { + return candidate_operation_state_fingerprint(operation, true); + } + if prerequisite_operation(operation) { + return cutover_delegate_fingerprint( + "proxywarden:privileged-cutover-prerequisite-state:v1", + operation, + "expected", + ); + } + Err(CutoverHostError::Unsupported) + } + + fn mutate( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + if SystemProxifyreCutoverFilesystem::handles_operation(operation) { + self.filesystem.mutate_operation(operation, journal)?; + return Ok(None); + } + if candidate_operation(operation) { + mutate_proxifyre_cutover_candidate(self.candidate_writer_mut()?, operation) + .map_err(|_| CutoverHostError::MutationFailed) + .and_then(|handled| handled.then_some(()).ok_or(CutoverHostError::Unsupported))?; + return Ok(None); + } + if prerequisite_operation(operation) { + return self.mutate_prerequisite(operation, journal); + } + if scm_operation(operation) { + match operation { + CutoverOperation::StartCurrentService => self + .filesystem + .verify_current_runtime_read_leases(journal)?, + CutoverOperation::StartLegacyService => self + .filesystem + .prepare_legacy_service_start_leases(journal)?, + _ => {} + } + mutate_proxifyre_cutover_scm(&mut self.scm, operation, &journal.before_state.service) + .map_err(|_| CutoverHostError::MutationFailed) + .and_then(|handled| handled.then_some(()).ok_or(CutoverHostError::Unsupported))?; + return Ok(None); + } + Err(CutoverHostError::Unsupported) + } + + fn verify_current( + &mut self, + journal: &CutoverJournal, + ) -> Result { + let snapshot = self + .native + .inspect() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let expected_service = match journal.plan.original_service_state { + LegacyServiceState::Running => ProxifyreNativeServiceStatus::Running, + LegacyServiceState::Stopped => ProxifyreNativeServiceStatus::Stopped, + }; + if snapshot.ownership != ProxifyreNativeOwnership::Managed + || snapshot.service_status != expected_service + || snapshot.installed_version.as_deref() != Some(&journal.plan.bundled_version) + || !snapshot.install_root_trusted + || !snapshot.install_root_reparse_free + || !snapshot.receipt_valid + || !snapshot.receipt_files_match + || !snapshot.service_path_matches + || !snapshot.demand_start + || !snapshot.failure_recovery_disabled + || !snapshot.builtin_users_start_denied + || !snapshot.vc_runtime_present + || !snapshot.packet_filter_present + { + return Err(CutoverHostError::VerificationFailed); + } + classify_cutover_runtime_config(self.native.verify_runtime_config( + &self.native.install_root().join("app-config.json"), + &journal.plan.config_fingerprint, + ))?; + let value = json!({ + "package": journal.plan.package_fingerprint, + "config": journal.plan.config_fingerprint, + "candidate": journal.before_state.current_candidate.manifest_fingerprint, + "service": match expected_service { + ProxifyreNativeServiceStatus::Running => "running", + _ => "stopped", + }, + }); + Ok(StateFingerprint::digest( + "proxywarden:privileged-cutover-current-verified:v1", + value.to_string().as_bytes(), + )) + } + + fn verify_current_inventory( + &mut self, + _journal: &CutoverJournal, + ) -> Result { + StateFingerprint::from_sha256(component_inventory_fingerprint_for_cutover( + &inventory_proxyfier(), + )) + .map_err(|_| CutoverHostError::VerificationFailed) + } + + fn verify_rollback(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + self.filesystem.verify_rollback_restored(journal)?; + let legacy = self + .scm + .capture_legacy_service() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let current = self + .scm + .query_current_service() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let native = self + .native + .inspect() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let vc_installed_by_transaction = journal.mutations.iter().any(|record| { + record.direction == crate::component_cutover::MutationDirection::Forward + && record.operation == CutoverOperation::InstallVcRuntimePrerequisite + && record.effect.as_ref().is_some_and(|effect| { + effect.disposition + == crate::component_cutover::EffectDisposition::ExpectedEffect + }) + }); + let expected_vc_runtime = + journal.before_state.prerequisites.vc_runtime_present || vc_installed_by_transaction; + if !legacy_service_owns_shared_name( + legacy == journal.before_state.service, + current.path_matches, + ) || native.packet_filter_present + != journal + .before_state + .prerequisites + .windows_packet_filter_present + || native.vc_runtime_present != expected_vc_runtime + { + return Err(CutoverHostError::VerificationFailed); + } + Ok(()) + } + + fn verify_cleanup(&mut self, journal: &CutoverJournal) -> Result<(), CutoverHostError> { + self.filesystem.verify_cleanup_ready(journal)?; + let legacy = self + .scm + .query_legacy_service() + .map_err(|_| CutoverHostError::ObservationFailed)?; + let current = self + .native + .inspect() + .map_err(|_| CutoverHostError::ObservationFailed)?; + if !current_service_owns_shared_name(legacy.path_matches, current.service_path_matches) + || current.ownership != ProxifyreNativeOwnership::Managed + || !current.receipt_valid + || !current.receipt_files_match + { + return Err(CutoverHostError::VerificationFailed); + } + Ok(()) + } +} + +fn classify_cutover_runtime_config( + result: Result, +) -> Result<(), CutoverHostError> { + match result { + Ok(RuntimeConfigVerification::Match) => Ok(()), + Ok(RuntimeConfigVerification::Mismatch) => Err(CutoverHostError::VerificationFailed), + Err(_) => Err(CutoverHostError::ObservationFailed), + } +} + +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn classify_cutover_runtime_config_for_tests( + result: Result, +) -> Result<(), CutoverHostError> { + classify_cutover_runtime_config(result) +} + +type CandidateObservationResult = + Result<(StateFingerprint, Option), CutoverHostError>; + +fn dispatch_candidate_observation( + has_live_candidate: bool, + observe_live: impl FnOnce() -> CandidateObservationResult, + observe_recovered: impl FnOnce() -> CandidateObservationResult, +) -> CandidateObservationResult { + if has_live_candidate { + observe_live() + } else { + observe_recovered() + } +} + +fn observe_live_candidate( + candidate: Option<&mut SystemProxifyreCutoverCandidateWriter>, + operation: &CutoverOperation, +) -> CandidateObservationResult { + let observation = candidate + .ok_or(CutoverHostError::ObservationFailed)? + .observe_candidate(operation) + .map_err(|_| CutoverHostError::ObservationFailed)?; + let (state, identity) = match observation { + ProxifyreCutoverCandidateObservation::Absent => ("absent", None), + ProxifyreCutoverCandidateObservation::Expected(snapshot) => { + ("expected", Some(snapshot.identity)) + } + ProxifyreCutoverCandidateObservation::Unknown => { + return Err(CutoverHostError::ObservationFailed) + } + }; + Ok(( + cutover_delegate_fingerprint( + "proxywarden:privileged-cutover-candidate-state:v1", + operation, + state, + )?, + identity, + )) +} + +fn observe_recovered_candidate( + filesystem: &mut SystemProxifyreCutoverFilesystem, + operation: &CutoverOperation, + journal: &CutoverJournal, +) -> CandidateObservationResult { + let observed = filesystem.observe_recovered_candidate_operation(operation, journal)?; + Ok((observed, filesystem.observed_object_identity())) +} + +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn dispatch_candidate_observation_for_tests( + has_live_candidate: bool, + observe_live: impl FnOnce() -> CandidateObservationResult, + observe_recovered: impl FnOnce() -> CandidateObservationResult, +) -> CandidateObservationResult { + dispatch_candidate_observation(has_live_candidate, observe_live, observe_recovered) +} + +fn legacy_service_owns_shared_name(legacy_path_matches: bool, current_path_matches: bool) -> bool { + legacy_path_matches && !current_path_matches +} + +fn current_service_owns_shared_name(legacy_path_matches: bool, current_path_matches: bool) -> bool { + !legacy_path_matches && current_path_matches +} + +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn shared_service_name_path_ownership_for_tests( + legacy_path_matches: bool, + current_path_matches: bool, +) -> (bool, bool) { + ( + legacy_service_owns_shared_name(legacy_path_matches, current_path_matches), + current_service_owns_shared_name(legacy_path_matches, current_path_matches), + ) +} + +fn candidate_operation(operation: &CutoverOperation) -> bool { + matches!( + operation, + CutoverOperation::CreateCurrentCandidateRoot + | CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt + ) +} + +fn prerequisite_operation(operation: &CutoverOperation) -> bool { + matches!( + operation, + CutoverOperation::InstallWindowsPacketFilterPrerequisite + | CutoverOperation::InstallVcRuntimePrerequisite + | CutoverOperation::UninstallTransactionWindowsPacketFilter + ) +} + +fn scm_operation(operation: &CutoverOperation) -> bool { + matches!( + operation, + CutoverOperation::StopLegacyService + | CutoverOperation::DeleteLegacyService + | CutoverOperation::CreateCurrentService + | CutoverOperation::SetCurrentServicePolicy(_) + | CutoverOperation::SetCurrentServiceSecurity + | CutoverOperation::StartCurrentService + | CutoverOperation::StopCurrentService + | CutoverOperation::DeleteCurrentService + | CutoverOperation::CreateLegacyService + | CutoverOperation::RestoreLegacyServicePolicy(_) + | CutoverOperation::RestoreLegacyServiceSecurity + | CutoverOperation::StartLegacyService + ) +} + +fn cutover_delegate_fingerprint( + domain: &str, + operation: &CutoverOperation, + state: &str, +) -> Result { + let value = + serde_json::to_vec(&(operation, state)).map_err(|_| CutoverHostError::ObservationFailed)?; + Ok(StateFingerprint::digest(domain, &value)) +} + +enum PreparedInput { + None, + Cutover(Box), + InstallProxifyre { + proxifyre: Box, + packet_filter: Box, + vc_runtime: Box, + }, + UpdateProxifyre(Box), + StartProxifyre(GeneratedConfigLease), + InstallSingBox { + runtime: Box, + wrapper: Box, + }, + UpdateSingBox(Box), + StartSingBox(GeneratedConfigLease), +} + +impl PreparedInput { + fn package_fingerprint( + &self, + ) -> Result, PrivilegedRunnerFailure> { + let (source, leases): (InstalledPackageSource, Vec<&PrivilegedPackageLease>) = match self { + Self::InstallProxifyre { + proxifyre, + packet_filter, + vc_runtime, + } => ( + InstalledPackageSource::Bundled, + vec![ + proxifyre.as_ref(), + packet_filter.as_ref(), + vc_runtime.as_ref(), + ], + ), + Self::UpdateProxifyre(runtime) | Self::UpdateSingBox(runtime) => { + (InstalledPackageSource::Cache, vec![runtime.as_ref()]) + } + Self::InstallSingBox { runtime, wrapper } => ( + InstalledPackageSource::Bundled, + vec![runtime.as_ref(), wrapper.as_ref()], + ), + Self::None | Self::Cutover(_) | Self::StartProxifyre(_) | Self::StartSingBox(_) => { + return Ok(None) + } + }; + planned_leased_package(source, &leases).map(Some) + } + + fn configuration_fingerprint(&self, action: PrivilegedAction) -> String { + match self { + Self::StartProxifyre(config) | Self::StartSingBox(config) => config.sha256.clone(), + _ => no_config_fingerprint(action), + } + } +} + +struct GeneratedConfigLease { + _file: File, + bytes: Vec, + sha256: String, +} + +impl GeneratedConfigLease { + fn open(path: &Path, component: ManagedComponent) -> Result { + let mut file = safe_fs::open_restricted_file_read_lease(path) + .map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?; + let mut bytes = Vec::new(); + file.by_ref() + .take(MAX_GENERATED_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?; + if bytes.is_empty() + || bytes.len() as u64 > MAX_GENERATED_CONFIG_BYTES + || validate_generated_config(component, &bytes).is_err() + { + return Err(PrivilegedRunnerFailure::PreconditionFailed); + } + file.seek(SeekFrom::Start(0)) + .map_err(|_| PrivilegedRunnerFailure::PreconditionFailed)?; + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + Ok(Self { + _file: file, + bytes, + sha256, + }) + } +} + +fn execute_prepared( + action: PrivilegedAction, + input: PreparedInput, +) -> Result { + match (action, input) { + ( + PrivilegedAction::CutoverProxifyre | PrivilegedAction::CleanupProxifyreQuarantine, + PreparedInput::Cutover(cutover), + ) => cutover.execute(), + ( + PrivilegedAction::InstallProxifyre, + PreparedInput::InstallProxifyre { + proxifyre, + packet_filter, + vc_runtime, + }, + ) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + install_proxifyre_native(&mut host, &proxifyre, &packet_filter, &vc_runtime) + .map(proxifyre_result) + .map_err(map_proxifyre_error) + } + (PrivilegedAction::UpdateProxifyre, PreparedInput::UpdateProxifyre(runtime)) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + update_proxifyre_native(&mut host, &runtime) + .map(proxifyre_result) + .map_err(map_proxifyre_error) + } + (PrivilegedAction::StartProxifyre, PreparedInput::StartProxifyre(config)) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + match preflight_start_proxifyre_native(&mut host).map_err(map_proxifyre_error)? { + ProxifyreNativeStartDisposition::AlreadyRunning => Ok(PrivilegedMutationResult { + activation: None, + changed: false, + reboot_required: false, + result_code: None, + }), + ProxifyreNativeStartDisposition::ReadyForConfigPromotion => { + let path = host + .promote_runtime_config_bytes(&config.bytes, &config.sha256) + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + let mut activation = None; + let outcome = crate::proxifyre_runtime::start_proxifyre_native_observed( + &mut host, + &path, + &config.sha256, + || { + activation = + observe_activation(ManagedComponent::Proxifyre, &config.sha256); + }, + ) + .map_err(map_proxifyre_error)?; + let mut result = proxifyre_result(outcome); + result.activation = activation; + Ok(result) + } + } + } + (PrivilegedAction::StopProxifyre, PreparedInput::None) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + stop_proxifyre_native(&mut host) + .map(proxifyre_result) + .map_err(map_proxifyre_error) + } + (PrivilegedAction::ConfigureProxifyreFirewall, PreparedInput::None) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + configure_proxifyre_firewall_native(&mut host) + .map(proxifyre_result) + .map_err(map_proxifyre_error) + } + (PrivilegedAction::UninstallProxifyre, PreparedInput::None) => { + let mut host = SystemProxifyreNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + uninstall_proxifyre_native(&mut host) + .map(proxifyre_result) + .map_err(map_proxifyre_error) + } + (PrivilegedAction::InstallSingBox, PreparedInput::InstallSingBox { runtime, wrapper }) => { + let mut host = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + install_singbox_native(&mut host, &runtime, &wrapper) + .map(singbox_result) + .map_err(map_singbox_error) + } + (PrivilegedAction::UpdateSingBox, PreparedInput::UpdateSingBox(runtime)) => { + let mut host = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + update_singbox_native(&mut host, &runtime) + .map(singbox_result) + .map_err(map_singbox_error) + } + (PrivilegedAction::StartSingBox, PreparedInput::StartSingBox(config)) => { + let mut host = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + match preflight_start_singbox_native(&mut host).map_err(map_singbox_error)? { + SingBoxStartPreflight::AlreadyRunning => Ok(PrivilegedMutationResult { + activation: None, + changed: false, + reboot_required: false, + result_code: None, + }), + SingBoxStartPreflight::Ready => { + let path = host + .promote_runtime_config_bytes(&config.bytes, &config.sha256) + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + let mut activation = None; + let outcome = crate::singbox_runtime::start_singbox_native_observed( + &mut host, + &path, + &config.sha256, + || { + activation = + observe_activation(ManagedComponent::SingBox, &config.sha256); + }, + ) + .map_err(map_singbox_error)?; + let mut result = singbox_result(outcome); + result.activation = activation; + Ok(result) + } + } + } + (PrivilegedAction::StopSingBox, PreparedInput::None) => { + let mut host = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + stop_singbox_native(&mut host) + .map(singbox_result) + .map_err(map_singbox_error) + } + (PrivilegedAction::UninstallSingBox, PreparedInput::None) => { + let mut host = SystemSingBoxNativeHost::from_current_exe() + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + uninstall_singbox_native(&mut host) + .map(singbox_result) + .map_err(map_singbox_error) + } + _ => Err(PrivilegedRunnerFailure::PreconditionFailed), + } +} + +fn observe_activation( + component: ManagedComponent, + hash: &str, +) -> Option { + let service = match component { + ManagedComponent::Proxifyre => crate::process::KnownWindowsService::Proxifyre, + ManagedComponent::SingBox => crate::process::KnownWindowsService::SingBox, + }; + Some(crate::privileged_jobs::ActivationAcknowledgement { + component, + config_sha256: hash.to_string(), + instance: crate::process::running_service_instance(service).ok()?, + }) +} + +fn proxifyre_result( + outcome: crate::proxifyre_runtime::ProxifyreNativeOutcome, +) -> PrivilegedMutationResult { + PrivilegedMutationResult { + activation: None, + changed: outcome.changed, + reboot_required: outcome.reboot_required, + result_code: None, + } +} + +fn singbox_result(outcome: SingBoxNativeOutcome) -> PrivilegedMutationResult { + let changed = matches!( + outcome, + SingBoxNativeOutcome::Installed + | SingBoxNativeOutcome::Updated + | SingBoxNativeOutcome::Started + | SingBoxNativeOutcome::Stopped + | SingBoxNativeOutcome::Uninstalled + ); + PrivilegedMutationResult { + activation: None, + changed, + reboot_required: false, + result_code: None, + } +} + +fn map_proxifyre_error(error: ProxifyreNativeError) -> PrivilegedRunnerFailure { + match error { + ProxifyreNativeError::InvalidPackage | ProxifyreNativeError::InvalidArchive => { + PrivilegedRunnerFailure::PackageVerificationFailed + } + ProxifyreNativeError::ServiceCollision => PrivilegedRunnerFailure::ServiceCollision, + ProxifyreNativeError::InvalidReceipt | ProxifyreNativeError::OwnershipMismatch => { + PrivilegedRunnerFailure::OwnershipMismatch + } + ProxifyreNativeError::ServiceRunning + | ProxifyreNativeError::ServiceNotStopped + | ProxifyreNativeError::ServiceContract + | ProxifyreNativeError::DowngradeRejected + | ProxifyreNativeError::RuntimeConfigInvalid => PrivilegedRunnerFailure::PreconditionFailed, + ProxifyreNativeError::InstallerExit { .. } + | ProxifyreNativeError::HostStep(_) + | ProxifyreNativeError::RollbackFailed => PrivilegedRunnerFailure::OperationFailed, + } +} + +fn map_singbox_error(error: SingBoxNativeError) -> PrivilegedRunnerFailure { + match error { + SingBoxNativeError::InvalidPackage | SingBoxNativeError::InvalidArchive => { + PrivilegedRunnerFailure::PackageVerificationFailed + } + SingBoxNativeError::ServiceCollision => PrivilegedRunnerFailure::ServiceCollision, + SingBoxNativeError::InvalidReceipt | SingBoxNativeError::OwnershipMismatch => { + PrivilegedRunnerFailure::OwnershipMismatch + } + SingBoxNativeError::ServiceRunning + | SingBoxNativeError::ServiceNotStopped + | SingBoxNativeError::ServiceContract + | SingBoxNativeError::DowngradeRejected + | SingBoxNativeError::RuntimeConfigInvalid => PrivilegedRunnerFailure::PreconditionFailed, + SingBoxNativeError::CleanupPending => PrivilegedRunnerFailure::CleanupPending, + SingBoxNativeError::HostStep(_) | SingBoxNativeError::RollbackFailed => { + PrivilegedRunnerFailure::OperationFailed + } + } +} + +fn installed_app_root() -> Result { + let executable = + std::env::current_exe().map_err(|_| PrivilegedRunnerFailure::RunnerUnavailable)?; + safe_fs::verify_path_under_trusted_program_files(&executable) + .map_err(|_| PrivilegedRunnerFailure::RunnerUnavailable)?; + executable + .parent() + .map(Path::to_path_buf) + .ok_or(PrivilegedRunnerFailure::RunnerUnavailable) +} + +pub(crate) fn installed_cutover_external_status() -> Option { + installed_app_root() + .ok() + .map(|app_root| verify_cutover_external_mutation_status(&app_root)) +} + +fn read_local_cutover_observation( + storage_paths: &StoragePaths, +) -> Option { + JsonStorage::new(storage_paths.root.clone()) + .read_component_cutover_observation() + .ok() + .flatten() +} + +fn read_local_cutover_user_evidence( + storage_paths: &StoragePaths, +) -> Result { + JsonStorage::new(storage_paths.root.clone()) + .read_component_cutover_user_evidence() + .map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)? + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict) +} + +fn valid_cutover_observation( + observation: &ComponentCutoverObservation, +) -> Option<&ComponentCutoverObservation> { + let parsed = Uuid::parse_str(&observation.cutover_id).ok()?; + (observation.component == "proxifyre" + && parsed.get_version() == Some(uuid::Version::Random) + && parsed.hyphenated().to_string() == observation.cutover_id + && is_sha256(&observation.operation_fingerprint) + && is_sha256(&observation.transaction_fingerprint)) + .then_some(observation) +} + +fn new_cutover_context( + inventory_fingerprint: &str, + original_state: Option, + initiating_startup_session_id: &str, +) -> PrivilegedCutoverContext { + let state = match original_state { + Some(LegacyServiceState::Running) => "running", + Some(LegacyServiceState::Stopped) => "stopped", + None => "sealed-recovery-probe", + }; + PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::New, + cutover_id: None, + initiating_startup_session_id: Some(initiating_startup_session_id.to_string()), + evidence_fingerprint: cutover_context_fingerprint( + "proxywarden:privileged-cutover-new-evidence:v1", + &[ + "proxifyre-2.2.1-tools-primary-topshelf", + LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT, + LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION, + state, + inventory_fingerprint, + initiating_startup_session_id, + ], + ), + user_evidence: None, + } +} + +fn recovery_context( + observation: &ComponentCutoverObservation, +) -> Result { + valid_cutover_observation(observation) + .ok_or(PrivilegedRunnerFailure::CutoverRecoveryRequired)?; + Ok(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: Some(observation.cutover_id.clone()), + initiating_startup_session_id: None, + evidence_fingerprint: observation.transaction_fingerprint.clone(), + user_evidence: None, + }) +} + +fn recovery_context_from_journal( + journal: &CutoverJournal, +) -> Result { + Ok(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: Some(journal.plan.cutover_id.clone()), + initiating_startup_session_id: None, + evidence_fingerprint: cutover_transaction_fingerprint(journal) + .map_err(|_| PrivilegedRunnerFailure::CutoverRecoveryRequired)?, + user_evidence: None, + }) +} + +fn recovery_probe_context(inventory_fingerprint: &str) -> PrivilegedCutoverContext { + PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: None, + initiating_startup_session_id: None, + evidence_fingerprint: cutover_recovery_probe_fingerprint(inventory_fingerprint), + user_evidence: None, + } +} + +fn cleanup_context( + observation: &ComponentCutoverObservation, + user_evidence: &ComponentCutoverUserEvidence, +) -> Result { + valid_cutover_observation(observation) + .filter(|value| { + matches!( + value.state, + CutoverDisplayState::AwaitingNextStart + | CutoverDisplayState::AwaitingRouteSmoke + | CutoverDisplayState::CleanupReady + | CutoverDisplayState::CleanupPending + ) + }) + .ok_or(PrivilegedRunnerFailure::CutoverStateConflict)?; + validate_component_cutover_user_evidence(user_evidence) + .map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?; + if user_evidence.cutover_id != observation.cutover_id + || !user_evidence.route_smoke_confirmed + || user_evidence.confirmed_at_epoch_seconds.is_none() + { + return Err(PrivilegedRunnerFailure::CutoverStateConflict); + } + let user_evidence_fingerprint = component_cutover_user_evidence_fingerprint(user_evidence) + .map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?; + Ok(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Cleanup, + cutover_id: Some(observation.cutover_id.clone()), + initiating_startup_session_id: None, + evidence_fingerprint: cutover_context_fingerprint( + "proxywarden:privileged-cutover-pending-evidence:v1", + &[ + &observation.transaction_fingerprint, + &user_evidence_fingerprint, + ], + ), + user_evidence: Some(user_evidence.clone()), + }) +} + +fn validate_elevated_cleanup_evidence( + journal: &CutoverJournal, + live_inventory_fingerprint: &str, + evidence: &ComponentCutoverUserEvidence, + now_epoch_seconds: u64, +) -> Result<(), PrivilegedRunnerFailure> { + validate_component_cutover_user_evidence(evidence) + .map_err(|_| PrivilegedRunnerFailure::CutoverStateConflict)?; + if evidence.cutover_id != journal.plan.cutover_id + || evidence.startup_session_id == journal.plan.created_startup_session_id + || evidence.current_inventory_fingerprint != live_inventory_fingerprint + || !evidence.route_smoke_confirmed + || evidence.observed_at_epoch_seconds > now_epoch_seconds + || evidence.confirmed_at_epoch_seconds > Some(now_epoch_seconds) + || journal.next_start.as_ref().is_some_and(|sealed| { + sealed.startup_session_id != evidence.startup_session_id + || sealed.current_inventory_fingerprint != evidence.current_inventory_fingerprint + || sealed.observed_at_epoch_seconds != evidence.observed_at_epoch_seconds + }) + || journal.route_smoke.as_ref().is_some_and(|sealed| { + !sealed.confirmed_by_user + || Some(sealed.confirmed_at_epoch_seconds) != evidence.confirmed_at_epoch_seconds + }) + { + return Err(PrivilegedRunnerFailure::CutoverStateConflict); + } + Ok(()) +} + +fn validate_new_cutover_discovery( + inventory: &ComponentInventory, +) -> Result { + if !inventory.issues.is_empty() + || inventory.candidates.len() != 1 + || has_additional_matching_legacy_proxifyre_service(Path::new( + LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT, + )) + { + return Err(PrivilegedRunnerFailure::CutoverIdentityRejected); + } + let candidate = inventory + .selected_candidate() + .filter(|candidate| { + candidate.classification == ComponentClassification::ManagedLegacy + && candidate.role == CandidateRole::Legacy + && candidate.issues.is_empty() + && normalized_path(&candidate.root) + == normalized_text_path(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT) + && candidate.binary_version.as_deref() + == Some(LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION) + && candidate.executable_path.as_deref().is_some_and(|path| { + normalized_path(path) + == normalized_text_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + }) + }) + .ok_or(PrivilegedRunnerFailure::CutoverIdentityRejected)?; + let service = candidate + .service + .as_ref() + .filter(|service| { + service.name.eq_ignore_ascii_case("ProxiFyreService") + && service.path_matches_candidate + && service.binary_version.as_deref() == Some(LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION) + }) + .ok_or(PrivilegedRunnerFailure::CutoverIdentityRejected)?; + match service.status.trim().to_ascii_lowercase().as_str() { + "running" => Ok(LegacyServiceState::Running), + "stopped" => Ok(LegacyServiceState::Stopped), + _ => Err(PrivilegedRunnerFailure::CutoverIdentityRejected), + } +} + +fn cutover_context_fingerprint(domain: &str, fields: &[&str]) -> String { + let mut digest = Sha256::new(); + digest.update(domain.as_bytes()); + digest.update([0]); + for field in fields { + digest.update((field.len() as u64).to_le_bytes()); + digest.update(field.as_bytes()); + } + format!("{:x}", digest.finalize()) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn canonical_uuid_v4(value: &str) -> Option { + let parsed = Uuid::parse_str(value).ok()?; + (parsed.get_version() == Some(uuid::Version::Random) + && parsed.hyphenated().to_string() == value) + .then(|| value.to_string()) +} + +fn resolve_local_configuration( + action: PrivilegedAction, + storage_paths: &StoragePaths, +) -> Result { + let (path, component) = match action { + PrivilegedAction::StartProxifyre => ( + storage_paths.generated_dir.join(PROXIFYRE_OUTPUT_FILE), + ManagedComponent::Proxifyre, + ), + PrivilegedAction::StartSingBox => ( + storage_paths.generated_dir.join(SINGBOX_OUTPUT_FILE), + ManagedComponent::SingBox, + ), + _ => return Ok(no_config_fingerprint(action)), + }; + Ok(GeneratedConfigLease::open(&path, component)?.sha256) +} + +fn no_config_fingerprint(action: PrivilegedAction) -> String { + let action = match action { + PrivilegedAction::InstallProxifyre => "proxifyre.install", + PrivilegedAction::UpdateProxifyre => "proxifyre.update", + PrivilegedAction::StartProxifyre => "proxifyre.start", + PrivilegedAction::StopProxifyre => "proxifyre.stop", + PrivilegedAction::ConfigureProxifyreFirewall => "proxifyre.configure-firewall", + PrivilegedAction::UninstallProxifyre => "proxifyre.uninstall", + PrivilegedAction::CutoverProxifyre => "proxifyre.cutover", + PrivilegedAction::CleanupProxifyreQuarantine => "proxifyre.cleanup-quarantine", + PrivilegedAction::InstallSingBox => "sing-box.install", + PrivilegedAction::UpdateSingBox => "sing-box.update", + PrivilegedAction::StartSingBox => "sing-box.start", + PrivilegedAction::StopSingBox => "sing-box.stop", + PrivilegedAction::UninstallSingBox => "sing-box.uninstall", + }; + format!( + "{:x}", + Sha256::digest(format!("proxywarden:privileged:no-config:v1:{action}").as_bytes()) + ) +} + +fn planned_leased_package( + source: InstalledPackageSource, + leases: &[&PrivilegedPackageLease], +) -> Result { + let first = leases + .first() + .ok_or(PrivilegedRunnerFailure::PackageVerificationFailed)?; + let origin_proof = match source { + InstalledPackageSource::Bundled => None, + InstalledPackageSource::Cache => { + first.proof().independent_proof.as_ref().map(planned_origin) + } + }; + if source == InstalledPackageSource::Cache && (leases.len() != 1 || origin_proof.is_none()) { + return Err(PrivilegedRunnerFailure::PackageVerificationFailed); + } + Ok(PlannedPackageFingerprint { + source, + version: first.proof().version.clone(), + assets: leases + .iter() + .map(|lease| planned_asset(lease.proof())) + .collect(), + origin_proof, + }) +} + +fn planned_cached_package(plan: &PrivilegedCachedUpdatePlan) -> PlannedPackageFingerprint { + PlannedPackageFingerprint { + source: InstalledPackageSource::Cache, + version: plan.version.clone(), + assets: vec![PlannedAssetFingerprint { + component_id: plan.component_id, + version: plan.version.clone(), + name: plan.independent_proof.asset_name.clone(), + sha256: plan.independent_proof.sha256_from_api.clone(), + size: plan.independent_proof.size, + }], + origin_proof: Some(planned_origin(&plan.independent_proof)), + } +} + +fn planned_asset(proof: &PrivilegedPackageProof) -> PlannedAssetFingerprint { + PlannedAssetFingerprint { + component_id: proof.component_id, + version: proof.version.clone(), + name: proof.asset_name.clone(), + sha256: proof.sha256.clone(), + size: proof.size, + } +} + +fn planned_origin(proof: &GithubReleaseDigestProof) -> PlannedGithubOriginProof { + PlannedGithubOriginProof { + repository: proof.repository.clone(), + stable_tag: proof.stable_tag.clone(), + release_id: proof.release_id, + asset_id: proof.asset_id, + asset_name: proof.asset_name.clone(), + size: proof.size, + sha256_from_api: proof.sha256_from_api.clone(), + } +} + +fn live_inventory(action: PrivilegedAction) -> ComponentInventory { + match action.component() { + ManagedComponent::Proxifyre => inventory_proxyfier(), + ManagedComponent::SingBox => inventory_singbox(), + } +} + +fn normalized_path(path: &Path) -> String { + normalized_text_path(&path.to_string_lossy()) +} + +fn normalized_text_path(value: &str) -> String { + value.trim().replace('/', "\\").to_ascii_lowercase() +} + +#[cfg(debug_assertions)] +#[doc(hidden)] +pub fn validate_generated_config_bytes_for_tests( + component: ManagedComponent, + bytes: &[u8], +) -> bool { + validate_generated_config(component, bytes).is_ok() +} + +fn validate_generated_config(component: ManagedComponent, bytes: &[u8]) -> Result<(), ()> { + match component { + ManagedComponent::Proxifyre => validate_proxifyre_config(bytes), + ManagedComponent::SingBox => validate_singbox_config(bytes), + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StrictProxifyreConfig { + log_level: String, + bypass_lan: bool, + proxies: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StrictProxifyreProxy { + app_names: Vec, + socks5_proxy_endpoint: String, + supported_protocols: Vec, +} + +fn validate_proxifyre_config(bytes: &[u8]) -> Result<(), ()> { + let config: StrictProxifyreConfig = serde_json::from_slice(bytes).map_err(|_| ())?; + if config.log_level != "Info" || !config.bypass_lan { + return Err(()); + } + for proxy in config.proxies { + if proxy.app_names.is_empty() + || proxy.app_names.iter().any(|name| !safe_nonempty_text(name)) + || !strict_plain_endpoint(&proxy.socks5_proxy_endpoint) + || proxy.supported_protocols.is_empty() + { + return Err(()); + } + let protocol_count = proxy.supported_protocols.len(); + let mut protocols = proxy.supported_protocols; + protocols.sort(); + protocols.dedup(); + if protocols + .iter() + .any(|value| value != "TCP" && value != "UDP") + || protocols.len() != protocol_count + || protocols.len() > 2 + { + return Err(()); + } + } + Ok(()) +} + +fn strict_plain_endpoint(value: &str) -> bool { + if value.contains(['/', '@', '?', '#', '\\']) || value.chars().any(char::is_whitespace) { + return false; + } + let Some((host, port)) = value.rsplit_once(':') else { + return false; + }; + let valid_host = if let Some(ipv6) = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + { + ipv6.parse::().is_ok() + } else { + !host.contains([':', '[', ']']) && safe_nonempty_text(host) + }; + valid_host + && port.bytes().all(|byte| byte.is_ascii_digit()) + && port.parse::().is_ok_and(|parsed_port| parsed_port > 0) +} + +#[test] +fn strict_proxy_endpoint_accepts_bracketed_ipv6_without_relaxing_url_rejection() { + assert!(strict_plain_endpoint("[2001:db8::1]:1080")); + assert!(strict_plain_endpoint("proxy.example.test:1080")); + for endpoint in [ + "2001:db8::1:1080", + "[wrong]:1080", + "host:0", + "host:65536", + "host:1080/path", + "user@host:1080", + "host:1080?x", + "host :1080", + ] { + assert!(!strict_plain_endpoint(endpoint), "{endpoint}"); + } +} + +fn validate_singbox_config(bytes: &[u8]) -> Result<(), ()> { + let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?; + let root = exact_object(&value, &["log", "inbounds", "outbounds", "route"])?; + validate_singbox_log(root.get("log").ok_or(())?)?; + validate_singbox_inbounds(root.get("inbounds").ok_or(())?)?; + validate_singbox_outbounds(root.get("outbounds").ok_or(())?)?; + validate_singbox_route(root.get("route").ok_or(())?) +} + +fn validate_singbox_log(value: &Value) -> Result<(), ()> { + let log = exact_object(value, &["disabled", "level", "timestamp"])?; + if log.get("disabled").and_then(Value::as_bool) != Some(false) + || log.get("level").and_then(Value::as_str) != Some("info") + || log.get("timestamp").and_then(Value::as_bool) != Some(true) + { + return Err(()); + } + Ok(()) +} + +fn validate_singbox_inbounds(value: &Value) -> Result<(), ()> { + let inbounds = value.as_array().ok_or(())?; + if inbounds.len() != 1 { + return Err(()); + } + let inbound = exact_object( + &inbounds[0], + &[ + "type", + "tag", + "listen", + "listen_port", + "users", + "set_system_proxy", + ], + )?; + let listen = inbound.get("listen").and_then(Value::as_str).ok_or(())?; + if inbound.get("type").and_then(Value::as_str) != Some("mixed") + || inbound.get("tag").and_then(Value::as_str) != Some(DEFAULT_MIXED_INBOUND_TAG) + || !matches!(listen, "127.0.0.1" | "::1" | "localhost") + || !valid_json_port(inbound.get("listen_port").ok_or(())?) + || !inbound + .get("users") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + || inbound.get("set_system_proxy").and_then(Value::as_bool) != Some(false) + { + return Err(()); + } + Ok(()) +} + +fn validate_singbox_outbounds(value: &Value) -> Result<(), ()> { + let outbounds = value.as_array().ok_or(())?; + if outbounds.len() != 3 { + return Err(()); + } + validate_vpn_outbound(&outbounds[0])?; + validate_fixed_outbound(&outbounds[1], "direct", DEFAULT_DIRECT_OUTBOUND_TAG)?; + validate_fixed_outbound(&outbounds[2], "block", DEFAULT_BLOCK_OUTBOUND_TAG) +} + +fn validate_fixed_outbound(value: &Value, kind: &str, tag: &str) -> Result<(), ()> { + let outbound = exact_object(value, &["type", "tag"])?; + if outbound.get("type").and_then(Value::as_str) != Some(kind) + || outbound.get("tag").and_then(Value::as_str) != Some(tag) + { + return Err(()); + } + Ok(()) +} + +fn validate_vpn_outbound(value: &Value) -> Result<(), ()> { + let outbound = value.as_object().ok_or(())?; + let kind = outbound.get("type").and_then(Value::as_str).ok_or(())?; + let allowed = match kind { + "vless" => &[ + "type", + "tag", + "server", + "server_port", + "uuid", + "flow", + "tls", + "transport", + "packet_encoding", + ][..], + "vmess" => &[ + "type", + "tag", + "server", + "server_port", + "uuid", + "security", + "tls", + "transport", + ][..], + "trojan" => &[ + "type", + "tag", + "server", + "server_port", + "password", + "tls", + "transport", + ][..], + "shadowsocks" => &["type", "tag", "server", "server_port", "method", "password"][..], + "hysteria2" => &[ + "type", + "tag", + "server", + "server_port", + "password", + "up_mbps", + "down_mbps", + "obfs", + "tls", + ][..], + _ => return Err(()), + }; + require_allowed_keys(outbound, allowed)?; + if outbound.get("tag").and_then(Value::as_str) != Some(DEFAULT_VPN_OUTBOUND_TAG) + || !outbound + .get("server") + .and_then(Value::as_str) + .is_some_and(safe_nonempty_text) + || !outbound.get("server_port").is_some_and(valid_json_port) + { + return Err(()); + } + + match kind { + "vless" => { + require_text(outbound, "uuid")?; + if outbound.get("flow").is_some_and(|value| { + value + .as_str() + .is_none_or(|text| text.chars().any(|character| character.is_control())) + }) { + return Err(()); + } + if outbound + .get("packet_encoding") + .is_some_and(|value| value.as_str() != Some("xudp")) + { + return Err(()); + } + } + "vmess" => { + require_text(outbound, "uuid")?; + require_text(outbound, "security")?; + } + "trojan" | "hysteria2" => require_text(outbound, "password")?, + "shadowsocks" => { + require_text(outbound, "method")?; + require_text(outbound, "password")?; + } + _ => return Err(()), + } + if let Some(tls) = outbound.get("tls") { + validate_tls(tls)?; + } + if let Some(transport) = outbound.get("transport") { + validate_transport(transport)?; + } + if let Some(obfs) = outbound.get("obfs") { + let obfs = exact_object(obfs, &["type", "password"])?; + if obfs.get("type").and_then(Value::as_str) != Some("salamander") { + return Err(()); + } + require_text(obfs, "password")?; + } + for key in ["up_mbps", "down_mbps"] { + if outbound + .get(key) + .is_some_and(|value| value.as_u64().is_none_or(|number| number == 0)) + { + return Err(()); + } + } + Ok(()) +} + +fn validate_tls(value: &Value) -> Result<(), ()> { + let tls = value.as_object().ok_or(())?; + require_allowed_keys( + tls, + &[ + "enabled", + "server_name", + "insecure", + "alpn", + "utls", + "reality", + ], + )?; + if tls.get("enabled").and_then(Value::as_bool) != Some(true) + || tls + .get("server_name") + .is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text)) + || tls.get("insecure").is_some_and(|value| !value.is_boolean()) + || tls.get("alpn").is_some_and(|value| { + value.as_array().is_none_or(|items| { + items.is_empty() + || items + .iter() + .any(|item| !item.as_str().is_some_and(safe_nonempty_text)) + }) + }) + { + return Err(()); + } + if let Some(utls) = tls.get("utls") { + let utls = exact_object(utls, &["enabled", "fingerprint"])?; + if utls.get("enabled").and_then(Value::as_bool) != Some(true) { + return Err(()); + } + require_text(utls, "fingerprint")?; + } + if let Some(reality) = tls.get("reality") { + let reality = exact_object(reality, &["enabled", "public_key", "short_id"])?; + if reality.get("enabled").and_then(Value::as_bool) != Some(true) { + return Err(()); + } + require_text(reality, "public_key")?; + require_text(reality, "short_id")?; + } + Ok(()) +} + +fn validate_transport(value: &Value) -> Result<(), ()> { + let transport = value.as_object().ok_or(())?; + require_allowed_keys(transport, &["type", "path", "headers"])?; + if transport.get("type").and_then(Value::as_str) != Some("ws") + || transport + .get("path") + .is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text)) + { + return Err(()); + } + if let Some(headers) = transport.get("headers") { + let headers = headers.as_object().ok_or(())?; + require_allowed_keys(headers, &["Host"])?; + if headers + .get("Host") + .is_some_and(|value| !value.as_str().is_some_and(safe_nonempty_text)) + { + return Err(()); + } + } + Ok(()) +} + +fn validate_singbox_route(value: &Value) -> Result<(), ()> { + let route = exact_object(value, &["rules", "final"])?; + let rules = route.get("rules").and_then(Value::as_array).ok_or(())?; + if rules.len() != 1 + || route.get("final").and_then(Value::as_str) != Some(DEFAULT_VPN_OUTBOUND_TAG) + { + return Err(()); + } + let rule = exact_object(&rules[0], &["ip_is_private", "outbound"])?; + if rule.get("ip_is_private").and_then(Value::as_bool) != Some(true) + || rule.get("outbound").and_then(Value::as_str) != Some(DEFAULT_DIRECT_OUTBOUND_TAG) + { + return Err(()); + } + Ok(()) +} + +fn exact_object<'a>(value: &'a Value, keys: &[&str]) -> Result<&'a Map, ()> { + let object = value.as_object().ok_or(())?; + if object.len() != keys.len() || keys.iter().any(|key| !object.contains_key(*key)) { + return Err(()); + } + Ok(object) +} + +fn require_allowed_keys(object: &Map, keys: &[&str]) -> Result<(), ()> { + if object.keys().any(|key| !keys.contains(&key.as_str())) { + return Err(()); + } + Ok(()) +} + +fn require_text(object: &Map, key: &str) -> Result<(), ()> { + if object + .get(key) + .and_then(Value::as_str) + .is_some_and(safe_nonempty_text) + { + Ok(()) + } else { + Err(()) + } +} + +fn safe_nonempty_text(value: &str) -> bool { + !value.trim().is_empty() && !value.chars().any(|character| character.is_control()) +} + +fn valid_json_port(value: &Value) -> bool { + value + .as_u64() + .is_some_and(|port| (1..=u64::from(u16::MAX)).contains(&port)) +} diff --git a/src-tauri/src/process.rs b/src-tauri/src/process.rs index fd13ea1..53f3e3c 100644 --- a/src-tauri/src/process.rs +++ b/src-tauri/src/process.rs @@ -1,4 +1,384 @@ -use std::{ffi::OsStr, process::Command}; +use std::{ + ffi::OsStr, + io, + path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, + time::Duration, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ElevatedHelperLaunchOutcome { + Completed { exit_code: u32 }, + Canceled, + Indeterminate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnownWindowsService { + Proxifyre, + SingBox, +} + +impl KnownWindowsService { + pub const fn name(self) -> &'static str { + match self { + Self::Proxifyre => "ProxiFyreService", + Self::SingBox => "ProxyWardenSingBox", + } + } + + const fn display_name(self) -> &'static str { + match self { + Self::Proxifyre => "ProxyWarden ProxiFyre", + Self::SingBox => "ProxyWarden Local sing-box", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServiceState { + Stopped, + StartPending, + StopPending, + Running, + ContinuePending, + PausePending, + Paused, + Unknown(u32), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceStableState { + Stopped, + Running, +} + +/// Complete, serializable SCM state needed to recreate a service without +/// running its executable. These DTOs are internal journal material; callers +/// must never expose them through user-facing commands or diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceBaseConfigSnapshot { + pub service_type: u32, + pub start_type: u32, + pub error_control: u32, + pub binary_path_name: String, + pub load_order_group: Option, + pub tag_id: u32, + pub dependencies: Vec, + pub service_start_name: String, + pub display_name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ServiceConfig2Kind { + Description, + FailureActions, + FailureActionsFlag, + DelayedAutoStart, + SidType, + RequiredPrivileges, + PreshutdownTimeout, + Triggers, + PreferredNode, + LaunchProtected, +} + +pub const SERVICE_CONFIG2_KINDS: [ServiceConfig2Kind; 10] = [ + ServiceConfig2Kind::Description, + ServiceConfig2Kind::FailureActions, + ServiceConfig2Kind::FailureActionsFlag, + ServiceConfig2Kind::DelayedAutoStart, + ServiceConfig2Kind::SidType, + ServiceConfig2Kind::RequiredPrivileges, + ServiceConfig2Kind::PreshutdownTimeout, + ServiceConfig2Kind::Triggers, + ServiceConfig2Kind::PreferredNode, + ServiceConfig2Kind::LaunchProtected, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceFailureActionSnapshot { + pub action_type: i32, + pub delay_ms: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceFailureActionsSnapshot { + pub reset_period_seconds: u32, + pub reboot_message: Option, + pub command: Option, + pub actions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceTriggerDataSnapshot { + pub data_type: u32, + pub bytes: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceTriggerSnapshot { + pub trigger_type: u32, + pub action: u32, + pub subtype: Option<[u8; 16]>, + pub data_items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum ServiceConfig2Snapshot { + Description(Option), + FailureActions(ServiceFailureActionsSnapshot), + FailureActionsFlag(bool), + DelayedAutoStart(bool), + SidType(u32), + RequiredPrivileges(Vec), + PreshutdownTimeout(u32), + Triggers(Vec), + PreferredNode { node: u16, delete: bool }, + LaunchProtected(u32), +} + +impl ServiceConfig2Snapshot { + pub const fn kind(&self) -> ServiceConfig2Kind { + match self { + Self::Description(_) => ServiceConfig2Kind::Description, + Self::FailureActions(_) => ServiceConfig2Kind::FailureActions, + Self::FailureActionsFlag(_) => ServiceConfig2Kind::FailureActionsFlag, + Self::DelayedAutoStart(_) => ServiceConfig2Kind::DelayedAutoStart, + Self::SidType(_) => ServiceConfig2Kind::SidType, + Self::RequiredPrivileges(_) => ServiceConfig2Kind::RequiredPrivileges, + Self::PreshutdownTimeout(_) => ServiceConfig2Kind::PreshutdownTimeout, + Self::Triggers(_) => ServiceConfig2Kind::Triggers, + Self::PreferredNode { .. } => ServiceConfig2Kind::PreferredNode, + Self::LaunchProtected(_) => ServiceConfig2Kind::LaunchProtected, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceSecuritySnapshot { + /// Exact self-relative owner/group/DACL/SACL bytes returned by SCM. + pub self_relative_descriptor: Vec, + pub untrusted_mutation_rights: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct FullServiceSnapshot { + pub service_name: String, + pub base: ServiceBaseConfigSnapshot, + pub config2: Vec, + pub security: ServiceSecuritySnapshot, + pub original_state: ServiceStableState, +} + +impl FullServiceSnapshot { + pub fn config2(&self, kind: ServiceConfig2Kind) -> Option<&ServiceConfig2Snapshot> { + self.config2.iter().find(|value| value.kind() == kind) + } + + pub fn has_complete_config2(&self) -> bool { + self.config2.len() == SERVICE_CONFIG2_KINDS.len() + && SERVICE_CONFIG2_KINDS.iter().all(|kind| { + self.config2 + .iter() + .filter(|value| value.kind() == *kind) + .count() + == 1 + }) + } +} + +pub type ServiceRestoreSnapshot = FullServiceSnapshot; + +/// Exact read-only observation of the primary ProxiFyre SCM record. A present +/// observation contains every QueryServiceConfig/Config2 field and the full +/// owner/group/DACL/SACL descriptor captured through one service handle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum CompleteServiceObservation { + Missing, + Present { + snapshot: Box, + current_dacl_matches: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceSnapshot { + pub exists: bool, + pub state: Option, + pub path_name: Option, + pub process_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ServiceInstance { + pub process_id: u32, + pub created_at_filetime: u64, +} + +/// Read-only identity of one continuously observed Running SCM process. +#[cfg(windows)] +pub fn running_service_instance(service: KnownWindowsService) -> io::Result { + use windows_sys::Win32::{ + Foundation::{CloseHandle, FILETIME}, + System::Threading::{GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}, + }; + let expected = match service { + KnownWindowsService::Proxifyre => { + crate::component_detection::default_proxifyre_install_dir().join("ProxiFyre.exe") + } + KnownWindowsService::SingBox => crate::component_detection::default_singbox_install_dir() + .join(crate::singbox_service::WINSW_WRAPPER_FILE), + }; + crate::safe_fs::ensure_no_reparse_ancestors(&expected)?; + let before = query_known_service(service)?; + if !before + .path_name + .as_deref() + .is_some_and(|path| known_service_path_matches_exact(service, path, &expected)) + { + return Err(io::Error::other("service executable is not managed")); + } + let pid = before + .process_id + .filter(|pid| *pid != 0) + .filter(|_| before.state == Some(ServiceState::Running)) + .ok_or_else(|| io::Error::other("service is not running"))?; + // SAFETY: only query rights; handle is checked and closed after GetProcessTimes. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + let mut creation = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit = creation; + let mut kernel = creation; + let mut user = creation; + let ok = unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) }; + let error = io::Error::last_os_error(); + if ok == 0 { + unsafe { + CloseHandle(handle); + } + return Err(error); + } + let after = query_known_service(service); + unsafe { + CloseHandle(handle); + } + let after = after?; + if after.state != Some(ServiceState::Running) + || after.process_id != Some(pid) + || after.path_name != before.path_name + { + return Err(io::Error::other( + "service instance changed during observation", + )); + } + Ok(ServiceInstance { + process_id: pid, + created_at_filetime: ((creation.dwHighDateTime as u64) << 32) + | creation.dwLowDateTime as u64, + }) +} + +#[cfg(not(windows))] +pub fn running_service_instance(_service: KnownWindowsService) -> io::Result { + Err(io::Error::other( + "service instance observation requires Windows", + )) +} + +impl ServiceSnapshot { + fn missing() -> Self { + Self { + exists: false, + state: None, + path_name: None, + process_id: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ServiceMutation { + pub changed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServicePolicySnapshot { + pub service: ServiceSnapshot, + pub path_matches: bool, + pub demand_start: bool, + pub failure_recovery_disabled: bool, + pub dacl_matches: bool, +} + +impl ServicePolicySnapshot { + fn missing() -> Self { + Self { + service: ServiceSnapshot::missing(), + path_matches: false, + demand_start: false, + failure_recovery_disabled: false, + dacl_matches: false, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KnownSystemExecutable { + MsiExec, + Netsh, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticodeVerification { + pub is_trusted: bool, + pub publisher: Option, + pub status_code: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticodePublisher { + pub common_name: String, + pub organization: String, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum AuthenticodeError { + #[error("Authenticode verification is supported only on Windows")] + UnsupportedPlatform, + #[error("Authenticode target must be an existing file")] + InvalidTarget, + #[error("Authenticode target path contains a reparse point or cannot be inspected safely")] + UnsafeTarget, + #[error("Authenticode target path contains a null character")] + InvalidPath, + #[error("trusted Authenticode signer certificate is unavailable")] + SignerCertificateUnavailable, + #[error("trusted Authenticode publisher name is unavailable")] + PublisherUnavailable, + #[error("trusted Authenticode publisher name is not valid UTF-16")] + InvalidPublisherName, +} pub fn command_no_window(program: impl AsRef) -> Command { let mut command = Command::new(program); @@ -6,6 +386,3373 @@ pub fn command_no_window(program: impl AsRef) -> Command { command } +/// Runs one already-selected executable directly, without a shell. +pub fn run_fixed_process( + program: &Path, + arguments: &[std::ffi::OsString], + timeout: Duration, +) -> io::Result { + validate_fixed_process(program)?; + run_command_bounded(command_no_window(program).args(arguments), timeout) +} + +/// Runs a fixed executable directly from a verified working directory. +pub fn run_fixed_process_in_directory( + program: &Path, + arguments: &[std::ffi::OsString], + working_directory: &Path, + timeout: Duration, +) -> io::Result { + if !working_directory.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixed process working directory must be absolute", + )); + } + crate::safe_fs::ensure_no_reparse_ancestors(working_directory)?; + if !std::fs::symlink_metadata(working_directory)? + .file_type() + .is_dir() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixed process working directory must be a directory", + )); + } + validate_fixed_process(program)?; + run_command_bounded( + command_no_window(program) + .args(arguments) + .current_dir(working_directory), + timeout, + ) +} + +fn validate_fixed_process(program: &Path) -> io::Result<()> { + if !program.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixed process path must be absolute", + )); + } + crate::safe_fs::ensure_no_reparse_ancestors(program)?; + if !std::fs::symlink_metadata(program)?.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixed process target must be a regular file", + )); + } + Ok(()) +} + +fn run_command_bounded(command: &mut Command, timeout: Duration) -> io::Result { + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "fixed process timeout must be non-zero", + )); + } + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + let started = std::time::Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if started.elapsed() >= timeout { + if child.kill().is_ok() { + let kill_deadline = std::time::Instant::now() + Duration::from_secs(2); + while std::time::Instant::now() < kill_deadline { + match child.try_wait() { + Ok(Some(_)) | Err(_) => break, + Ok(None) => std::thread::sleep(Duration::from_millis(25)), + } + } + } + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "fixed process exceeded its deadline", + )); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + +pub fn known_system_executable(executable: KnownSystemExecutable) -> io::Result { + known_system_executable_platform(executable) +} + +pub fn is_process_elevated() -> bool { + query_process_elevation().unwrap_or(false) +} + +pub fn query_process_elevation() -> io::Result { + query_process_elevation_platform() +} + +pub(crate) fn launch_elevated_helper( + executable: &Path, + job_id: &str, +) -> io::Result { + launch_elevated_helper_platform(executable, job_id) +} + +pub fn query_service_exact(service_name: &str) -> io::Result { + query_service_exact_platform(service_name) +} + +pub fn query_known_service(service: KnownWindowsService) -> io::Result { + query_service_exact(service.name()) +} + +/// Captures every restorable service field through one read-only service +/// handle. The capture fails if the service is missing, unstable, has an +/// unsafe PathName, cannot expose its SACL, or grants mutation rights to an +/// untrusted principal. +pub fn capture_service_restore_snapshot( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + capture_service_restore_snapshot_platform(service_name, expected_executable) +} + +/// Reads the complete primary ProxiFyre service state without requiring its +/// PathName to match either the legacy or current executable. This is used only +/// for crash reconciliation: unsafe or foreign live state must remain +/// observable as exact state rather than collapsing into a boolean mismatch. +pub fn query_proxifyre_complete_service_observation() -> io::Result { + query_proxifyre_complete_service_observation_platform() +} + +/// Creates only the fixed current ProxiFyre base SCM record. Extended policy +/// and security are separate journaled mutations. +pub fn create_current_proxifyre_service_base( + expected_executable: &Path, +) -> io::Result { + let base = expected_current_proxifyre_service_base(expected_executable)?; + create_service_base_exact_platform( + KnownWindowsService::Proxifyre.name(), + expected_executable, + &base, + ProxifyreBaseContract::Current, + ) +} + +/// Recreates only the sealed historical ProxiFyre base SCM record. The +/// expected executable must pass the shared frozen C:\Tools identity matcher. +pub fn restore_legacy_proxifyre_service_base( + expected_executable: &Path, + base: &ServiceBaseConfigSnapshot, +) -> io::Result { + create_service_base_exact_platform( + KnownWindowsService::Proxifyre.name(), + expected_executable, + base, + ProxifyreBaseContract::LegacyRestore, + ) +} + +/// Restores exactly one QueryServiceConfig2 record and queries it back before +/// returning. It never starts or stops the service. +pub fn restore_service_config2_exact( + service_name: &str, + expected_executable: &Path, + snapshot: &ServiceConfig2Snapshot, +) -> io::Result<()> { + restore_service_config2_exact_platform(service_name, expected_executable, snapshot) +} + +/// Reads one exact QueryServiceConfig2 record without mutation. +pub fn query_service_config2_exact( + service_name: &str, + expected_executable: &Path, + kind: ServiceConfig2Kind, +) -> io::Result> { + query_service_config2_exact_platform(service_name, expected_executable, kind) +} + +/// Restores owner/group/DACL/SACL from the captured self-relative descriptor +/// and queries the descriptor back before returning. +pub fn restore_service_security_exact( + service_name: &str, + expected_executable: &Path, + snapshot: &ServiceSecuritySnapshot, +) -> io::Result<()> { + restore_service_security_exact_platform(service_name, expected_executable, snapshot) +} + +/// Applies only the fixed current-service DACL. Start type and Config2 records +/// are deliberately outside this call so each mutation has its own journal intent. +pub fn set_current_proxifyre_service_security(expected_executable: &Path) -> io::Result<()> { + set_current_proxifyre_service_security_platform(expected_executable) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxifyreBaseContract { + Current, + LegacyRestore, +} + +pub fn expected_current_proxifyre_service_base( + expected_executable: &Path, +) -> io::Result { + if !expected_executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "current ProxiFyre executable must be absolute", + )); + } + let executable = expected_executable.to_string_lossy(); + if executable.contains(['\0', '"']) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "current ProxiFyre executable contains an invalid character", + )); + } + Ok(ServiceBaseConfigSnapshot { + service_type: 0x10, + start_type: 3, + error_control: 1, + binary_path_name: format!( + "\"{executable}\" -displayname \"ProxiFyre Service\" -servicename \"ProxiFyreService\"" + ), + load_order_group: None, + tag_id: 0, + dependencies: Vec::new(), + service_start_name: "LocalSystem".to_owned(), + display_name: KnownWindowsService::Proxifyre.display_name().to_owned(), + }) +} + +pub fn start_known_service( + service: KnownWindowsService, + expected_executable: &Path, +) -> io::Result { + control_known_service_platform(service, expected_executable, KnownServiceMutation::Start) +} + +pub fn stop_known_service( + service: KnownWindowsService, + expected_executable: &Path, +) -> io::Result { + control_known_service_platform(service, expected_executable, KnownServiceMutation::Stop) +} + +pub fn delete_known_service( + service: KnownWindowsService, + expected_executable: &Path, +) -> io::Result { + control_known_service_platform(service, expected_executable, KnownServiceMutation::Delete) +} + +/// Creates one allowlisted service as stopped LocalSystem own-process service, +/// or verifies an already-existing service with the same exact executable. +/// The complete hardened policy is enforced and queried back before success. +pub fn create_known_service( + service: KnownWindowsService, + expected_executable: &Path, +) -> io::Result { + create_known_service_platform(service, expected_executable) +} + +/// Queries the exact live SCM service contract without mutating it. +pub fn query_service_policy_exact( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + query_service_policy_exact_platform(service_name, expected_executable) +} + +/// Enforces manual start, no recovery actions/non-crash recovery, and an exact +/// protected DACL: Administrators/SYSTEM full control, Builtin Users query-only. +pub fn enforce_service_policy_exact( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + enforce_service_policy_exact_platform(service_name, expected_executable) +} + +pub fn wait_service_state_exact( + service_name: &str, + expected_executable: &Path, + expected_state: ServiceState, + timeout: Duration, +) -> io::Result { + let started = std::time::Instant::now(); + loop { + let snapshot = query_service_policy_exact(service_name, expected_executable)?; + if !snapshot.service.exists || !snapshot.path_matches { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service is missing or its PathName does not match", + )); + } + if snapshot.service.state == Some(expected_state) { + return Ok(snapshot); + } + if started.elapsed() >= timeout { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "service did not reach the requested state", + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +pub fn wait_known_service_deleted( + service: KnownWindowsService, + expected_executable: &Path, + timeout: Duration, +) -> io::Result<()> { + wait_known_service_deleted_platform(service, expected_executable, timeout) +} + +#[cfg(windows)] +fn wait_known_service_deleted_platform( + service: KnownWindowsService, + expected_executable: &Path, + timeout: Duration, +) -> io::Result<()> { + use windows_sys::Win32::Foundation::ERROR_SERVICE_MARKED_FOR_DELETE; + use windows_sys::Win32::System::Services::{SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS}; + + if timeout.is_zero() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "service deletion timeout must be non-zero", + )); + } + let started = std::time::Instant::now(); + loop { + match open_service_handle(service.name(), SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS) { + Ok(None) => return Ok(()), + Ok(Some(handle)) => { + let (path_name, _) = query_service_config(&handle)?; + let _ = query_service_status(&handle)?; + if !service_path_matches_for_name(service.name(), &path_name, expected_executable) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service PathName changed while waiting for deletion", + )); + } + } + Err(error) if error.raw_os_error() == Some(ERROR_SERVICE_MARKED_FOR_DELETE as i32) => {} + Err(error) => return Err(error), + } + if started.elapsed() >= timeout { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "service was not deleted before the deadline", + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +#[cfg(not(windows))] +fn wait_known_service_deleted_platform( + _service: KnownWindowsService, + _expected_executable: &Path, + _timeout: Duration, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +pub fn service_path_matches_exact(path_name: &str, expected_executable: &Path) -> bool { + if !expected_executable.is_absolute() { + return false; + } + split_windows_command_line(path_name).is_some_and(|arguments| { + arguments.len() == 1 + && normalize_windows_path(&arguments[0]) + == normalize_windows_path(&expected_executable.to_string_lossy()) + }) +} + +pub fn known_service_path_matches_exact( + service: KnownWindowsService, + path_name: &str, + expected_executable: &Path, +) -> bool { + match service { + KnownWindowsService::Proxifyre => { + proxifyre_service_path_matches_exact(path_name, expected_executable) + } + KnownWindowsService::SingBox => service_path_matches_exact(path_name, expected_executable), + } +} + +fn service_path_matches_for_name( + service_name: &str, + path_name: &str, + expected_executable: &Path, +) -> bool { + if service_name.eq_ignore_ascii_case(KnownWindowsService::Proxifyre.name()) { + known_service_path_matches_exact( + KnownWindowsService::Proxifyre, + path_name, + expected_executable, + ) + } else if service_name.eq_ignore_ascii_case(KnownWindowsService::SingBox.name()) { + known_service_path_matches_exact( + KnownWindowsService::SingBox, + path_name, + expected_executable, + ) + } else { + service_path_matches_exact(path_name, expected_executable) + } +} + +fn proxifyre_service_path_matches_exact(path_name: &str, expected_executable: &Path) -> bool { + if !expected_executable.is_absolute() { + return false; + } + let Some(arguments) = split_windows_command_line(path_name) else { + return false; + }; + if arguments.len() != 5 + || normalize_windows_path(&arguments[0]) + != normalize_windows_path(&expected_executable.to_string_lossy()) + { + return false; + } + let mut display_name = false; + let mut service_name = false; + for pair in arguments[1..].chunks_exact(2) { + match (pair[0].to_ascii_lowercase().as_str(), pair[1].as_str()) { + ("-displayname", "ProxiFyre Service") if !display_name => display_name = true, + ("-servicename", "ProxiFyreService") if !service_name => service_name = true, + _ => return false, + } + } + display_name && service_name +} + +fn split_windows_command_line(value: &str) -> Option> { + if value.contains('\0') { + return None; + } + let characters: Vec = value.chars().collect(); + let mut index = 0; + let mut arguments = Vec::new(); + while index < characters.len() { + while index < characters.len() && characters[index].is_whitespace() { + index += 1; + } + if index == characters.len() { + break; + } + let mut argument = String::new(); + let mut quoted = false; + while index < characters.len() { + if characters[index] == '\\' { + let start = index; + while index < characters.len() && characters[index] == '\\' { + index += 1; + } + let count = index - start; + if index < characters.len() && characters[index] == '"' { + argument.extend(std::iter::repeat_n('\\', count / 2)); + if count % 2 == 0 { + quoted = !quoted; + } else { + argument.push('"'); + } + index += 1; + } else { + argument.extend(std::iter::repeat_n('\\', count)); + } + continue; + } + match characters[index] { + '"' => quoted = !quoted, + character if character.is_whitespace() && !quoted => break, + character => argument.push(character), + } + index += 1; + } + if quoted || argument.is_empty() { + return None; + } + arguments.push(argument); + while index < characters.len() && characters[index].is_whitespace() { + index += 1; + } + } + (!arguments.is_empty()).then_some(arguments) +} + +#[derive(Debug, Clone, Copy)] +enum KnownServiceMutation { + Start, + Stop, + Delete, +} + +#[cfg(windows)] +fn query_process_elevation_platform() -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token: HANDLE = null_mut(); + // SAFETY: token is a valid out pointer and the pseudo process handle is always valid. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0; + // SAFETY: elevation points to writable TOKEN_ELEVATION storage of the stated size. + let ok = unsafe { + GetTokenInformation( + token, + TokenElevation, + (&mut elevation as *mut TOKEN_ELEVATION).cast(), + size_of::() as u32, + &mut returned, + ) + }; + // SAFETY: token was returned by OpenProcessToken and is closed exactly once. + unsafe { CloseHandle(token) }; + if ok == 0 || returned < size_of::() as u32 { + return Err(io::Error::last_os_error()); + } + Ok(elevation.TokenIsElevated != 0) +} + +#[cfg(not(windows))] +fn query_process_elevation_platform() -> io::Result { + Ok(false) +} + +#[cfg(windows)] +fn launch_elevated_helper_platform( + executable: &Path, + job_id: &str, +) -> io::Result { + use std::mem::size_of; + use std::os::windows::ffi::OsStrExt; + use std::ptr::{null, null_mut}; + use uuid::Version; + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, ERROR_CANCELLED, WAIT_FAILED}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, WaitForSingleObject, INFINITE, + }; + use windows_sys::Win32::UI::Shell::{ + ShellExecuteExW, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let parsed = uuid::Uuid::parse_str(job_id) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid privileged job id"))?; + if parsed.hyphenated().to_string() != job_id || parsed.get_version() != Some(Version::Random) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "privileged job id must be a canonical lowercase UUID v4", + )); + } + if !executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "elevated helper executable must be absolute", + )); + } + crate::safe_fs::ensure_no_reparse_ancestors(executable)?; + if !std::fs::symlink_metadata(executable)?.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "elevated helper executable must be a regular file", + )); + } + + fn wide_null(value: &OsStr) -> io::Result> { + let mut wide = value.encode_wide().collect::>(); + if wide.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "elevated helper argument contains a null character", + )); + } + wide.push(0); + Ok(wide) + } + + let verb = wide_null(OsStr::new("runas"))?; + let executable = wide_null(executable.as_os_str())?; + let parameters = wide_null(OsStr::new(&format!("--elevated-helper {job_id}")))?; + let mut info = SHELLEXECUTEINFOW { + cbSize: size_of::() as u32, + fMask: SEE_MASK_NOCLOSEPROCESS, + hwnd: null_mut(), + lpVerb: verb.as_ptr(), + lpFile: executable.as_ptr(), + lpParameters: parameters.as_ptr(), + lpDirectory: null(), + nShow: SW_SHOWNORMAL, + ..Default::default() + }; + // SAFETY: every pointer references null-terminated storage that remains live for the call. + if unsafe { ShellExecuteExW(&mut info) } != 0 { + if info.hProcess.is_null() { + return Ok(ElevatedHelperLaunchOutcome::Indeterminate); + } + // The caller holds the immutable request lease for this whole wait. No process is + // terminated on failure; Windows closes the process object only after completion. + // SAFETY: hProcess is a live handle returned by ShellExecuteExW. + let wait = unsafe { WaitForSingleObject(info.hProcess, INFINITE) }; + if wait == WAIT_FAILED { + // SAFETY: hProcess is closed exactly once. + unsafe { CloseHandle(info.hProcess) }; + return Ok(ElevatedHelperLaunchOutcome::Indeterminate); + } + let mut exit_code = 0; + // SAFETY: the process has completed and exit_code is a valid out pointer. + let exit_ok = unsafe { GetExitCodeProcess(info.hProcess, &mut exit_code) }; + // SAFETY: hProcess is closed exactly once. + unsafe { CloseHandle(info.hProcess) }; + if exit_ok == 0 { + return Ok(ElevatedHelperLaunchOutcome::Indeterminate); + } + return Ok(ElevatedHelperLaunchOutcome::Completed { exit_code }); + } + // SAFETY: read immediately after the failed Win32 call. + let code = unsafe { GetLastError() }; + if code == ERROR_CANCELLED { + Ok(ElevatedHelperLaunchOutcome::Canceled) + } else { + Err(io::Error::from_raw_os_error(code as i32)) + } +} + +#[cfg(not(windows))] +fn launch_elevated_helper_platform( + _executable: &Path, + _job_id: &str, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "elevated helper is supported only on Windows", + )) +} + +#[cfg(windows)] +fn known_system_executable_platform(executable: KnownSystemExecutable) -> io::Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buffer = vec![0_u16; 260]; + // SAFETY: buffer is writable for the supplied number of UTF-16 units. + let mut length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 { + return Err(io::Error::last_os_error()); + } + if length as usize >= buffer.len() { + buffer.resize(length as usize + 1, 0); + // SAFETY: the resized buffer is writable for the supplied number of units. + length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + if length == 0 || length as usize >= buffer.len() { + return Err(io::Error::last_os_error()); + } + } + buffer.truncate(length as usize); + let directory = PathBuf::from(std::ffi::OsString::from_wide(&buffer)); + let filename = match executable { + KnownSystemExecutable::MsiExec => "msiexec.exe", + KnownSystemExecutable::Netsh => "netsh.exe", + }; + let path = directory.join(filename); + crate::safe_fs::verify_path_installed_read_only(&path)?; + Ok(path) +} + +#[cfg(not(windows))] +fn known_system_executable_platform(_executable: KnownSystemExecutable) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows system executables are unavailable", + )) +} + +#[cfg(windows)] +fn create_known_service_platform( + known: KnownWindowsService, + expected_executable: &Path, +) -> io::Result { + use std::os::windows::ffi::OsStrExt; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_SERVICE_EXISTS}; + use windows_sys::Win32::System::Services::{ + CreateServiceW, OpenSCManagerW, SC_MANAGER_CONNECT, SC_MANAGER_CREATE_SERVICE, + SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + SERVICE_WIN32_OWN_PROCESS, + }; + + if known != KnownWindowsService::SingBox { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ProxiFyre service creation is owned by its fixed Topshelf installer", + )); + } + crate::safe_fs::ensure_no_reparse_ancestors(expected_executable)?; + if !expected_executable.is_absolute() + || !std::fs::symlink_metadata(expected_executable)? + .file_type() + .is_file() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "service executable must be a regular absolute file", + )); + } + + let before = query_service_policy_exact_platform(known.name(), expected_executable)?; + if before.service.exists { + if !before.path_matches { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service name is owned by a foreign executable", + )); + } + if before.service.state != Some(ServiceState::Stopped) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "existing managed service must be stopped", + )); + } + return enforce_service_policy_exact_platform(known.name(), expected_executable); + } + + let service_name = wide_service_name(known.name())?; + let mut display_name: Vec = OsStr::new(known.display_name()).encode_wide().collect(); + display_name.push(0); + let mut binary_path = vec![u16::from(b'\"')]; + binary_path.extend(expected_executable.as_os_str().encode_wide()); + if binary_path.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "service executable path contains a null character", + )); + } + binary_path.push(u16::from(b'\"')); + binary_path.push(0); + + // SAFETY: null machine/database select the local active SCM database. + let manager = unsafe { + OpenSCManagerW( + null(), + null(), + SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE, + ) + }; + let manager = ServiceHandle::new(manager)?; + // SAFETY: all strings are NUL-terminated and remain live for the call. Null + // account/password select LocalSystem and no dependencies are configured. + let service = unsafe { + CreateServiceW( + manager.0, + service_name.as_ptr(), + display_name.as_ptr(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + SERVICE_WIN32_OWN_PROCESS, + SERVICE_DEMAND_START, + SERVICE_ERROR_NORMAL, + binary_path.as_ptr(), + null(), + null_mut(), + null(), + null(), + null(), + ) + }; + let created = if service.is_null() { + // SAFETY: read immediately after CreateServiceW failed. + let code = unsafe { GetLastError() }; + if code != ERROR_SERVICE_EXISTS { + return Err(io::Error::from_raw_os_error(code as i32)); + } + let raced = query_service_policy_exact_platform(known.name(), expected_executable)?; + if !raced.service.exists || !raced.path_matches { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service creation collided with a foreign service", + )); + } + if raced.service.state != Some(ServiceState::Stopped) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "raced managed service must be stopped", + )); + } + false + } else { + drop(ServiceHandle(service)); + true + }; + + let enforced = enforce_service_policy_exact_platform(known.name(), expected_executable) + .and_then(|snapshot| { + if snapshot.service.state == Some(ServiceState::Stopped) { + Ok(snapshot) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "created service did not remain stopped", + )) + } + }); + if created && enforced.is_err() { + let _ = delete_known_service(known, expected_executable); + } + enforced +} + +#[cfg(not(windows))] +fn create_known_service_platform( + _known: KnownWindowsService, + _expected_executable: &Path, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +#[cfg(windows)] +fn query_service_exact_platform(service_name: &str) -> io::Result { + use std::mem::size_of; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_SERVICE_DOES_NOT_EXIST}; + use windows_sys::Win32::System::Services::{ + OpenSCManagerW, OpenServiceW, QueryServiceConfigW, QueryServiceStatusEx, + QUERY_SERVICE_CONFIGW, SC_MANAGER_CONNECT, SC_STATUS_PROCESS_INFO, SERVICE_QUERY_CONFIG, + SERVICE_QUERY_STATUS, SERVICE_STATUS_PROCESS, + }; + + let service_name = wide_service_name(service_name)?; + // SAFETY: null machine/database select the local active service database. + let manager = unsafe { OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT) }; + let manager = ServiceHandle::new(manager)?; + // SAFETY: manager is live and service_name is null-terminated. + let service = unsafe { + OpenServiceW( + manager.0, + service_name.as_ptr(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + ) + }; + if service.is_null() { + // SAFETY: read immediately after the failed Win32 call. + let code = unsafe { GetLastError() }; + return if code == ERROR_SERVICE_DOES_NOT_EXIST { + Ok(ServiceSnapshot::missing()) + } else { + Err(io::Error::from_raw_os_error(code as i32)) + }; + } + let service = ServiceHandle(service); + + let mut needed = 0; + // SAFETY: the first call intentionally requests the required buffer size. + unsafe { QueryServiceConfigW(service.0, null_mut(), 0, &mut needed) }; + if needed < size_of::() as u32 { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut config_buffer = vec![0usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: config_buffer is aligned and contains at least needed writable bytes. + if unsafe { + QueryServiceConfigW( + service.0, + config_buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: QueryServiceConfigW populated QUERY_SERVICE_CONFIGW at the aligned buffer start. + let config = unsafe { &*(config_buffer.as_ptr().cast::()) }; + let path_name = wide_pointer_in_buffer(config.lpBinaryPathName, &config_buffer)?; + + let mut status = SERVICE_STATUS_PROCESS::default(); + let mut returned = 0; + // SAFETY: status points to writable storage of the stated size. + if unsafe { + QueryServiceStatusEx( + service.0, + SC_STATUS_PROCESS_INFO, + (&mut status as *mut SERVICE_STATUS_PROCESS).cast(), + size_of::() as u32, + &mut returned, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + Ok(ServiceSnapshot { + exists: true, + state: Some(service_state(status.dwCurrentState)), + path_name: Some(path_name), + process_id: (status.dwProcessId != 0).then_some(status.dwProcessId), + }) +} + +#[cfg(not(windows))] +fn query_service_exact_platform(_service_name: &str) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +#[cfg(windows)] +fn query_service_policy_exact_platform( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + use windows_sys::Win32::System::Services::{SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS}; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + + if !expected_executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected service executable must be absolute", + )); + } + let Some(service) = open_service_handle( + service_name, + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | READ_CONTROL_ACCESS, + )? + else { + return Ok(ServicePolicySnapshot::missing()); + }; + service_policy_from_handle(service_name, expected_executable, &service) +} + +#[cfg(windows)] +fn service_policy_from_handle( + service_name: &str, + expected_executable: &Path, + service: &ServiceHandle, +) -> io::Result { + let (path_name, start_type) = query_service_config(service)?; + let status = query_service_status(service)?; + Ok(ServicePolicySnapshot { + service: ServiceSnapshot { + exists: true, + state: Some(service_state(status.dwCurrentState)), + path_name: Some(path_name.clone()), + process_id: (status.dwProcessId != 0).then_some(status.dwProcessId), + }, + path_matches: service_path_matches_for_name(service_name, &path_name, expected_executable), + demand_start: start_type == windows_sys::Win32::System::Services::SERVICE_DEMAND_START, + failure_recovery_disabled: query_failure_recovery_disabled(service)?, + dacl_matches: query_service_dacl_matches(service)?, + }) +} + +#[cfg(not(windows))] +fn query_service_policy_exact_platform( + _service_name: &str, + _expected_executable: &Path, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +#[cfg(windows)] +fn enforce_service_policy_exact_platform( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + }; + use windows_sys::Win32::System::Services::{ + ChangeServiceConfig2W, ChangeServiceConfigW, SetServiceObjectSecurity, SC_ACTION, + SERVICE_CHANGE_CONFIG, SERVICE_CONFIG_FAILURE_ACTIONS, SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, + SERVICE_DEMAND_START, SERVICE_FAILURE_ACTIONSW, SERVICE_FAILURE_ACTIONS_FLAG, + SERVICE_NO_CHANGE, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + const WRITE_DAC_ACCESS: u32 = 0x0004_0000; + const SERVICE_DACL_SDDL: &str = concat!( + "D:P", + "(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)", + "(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)", + "(A;;CCLCSWLORC;;;BU)" + ); + + crate::safe_fs::ensure_no_reparse_ancestors(expected_executable)?; + if !expected_executable.is_absolute() + || !std::fs::symlink_metadata(expected_executable)? + .file_type() + .is_file() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected service executable must be a regular absolute file", + )); + } + let service = open_service_handle( + service_name, + SERVICE_QUERY_CONFIG + | SERVICE_QUERY_STATUS + | SERVICE_CHANGE_CONFIG + | READ_CONTROL_ACCESS + | WRITE_DAC_ACCESS, + )? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "service disappeared"))?; + let before = service_policy_from_handle(service_name, expected_executable, &service)?; + if !before.path_matches || before.service.state != Some(ServiceState::Stopped) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service must be stopped and its PathName must match exactly", + )); + } + + // SAFETY: service is live; null pointers and SERVICE_NO_CHANGE preserve every field + // except the explicitly supplied manual start type. + if unsafe { + ChangeServiceConfigW( + service.0, + SERVICE_NO_CHANGE, + SERVICE_DEMAND_START, + SERVICE_NO_CHANGE, + null(), + null(), + null_mut(), + null(), + null(), + null(), + null(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + // A non-null action pointer with cActions == 0 deletes the complete action array. + let mut unused_action = SC_ACTION::default(); + let empty = [0_u16]; + let failure_actions = SERVICE_FAILURE_ACTIONSW { + dwResetPeriod: 0, + lpRebootMsg: empty.as_ptr().cast_mut(), + lpCommand: empty.as_ptr().cast_mut(), + cActions: 0, + lpsaActions: &mut unused_action, + }; + // SAFETY: the configuration structure and its pointed-to storage remain live for the call. + if unsafe { + ChangeServiceConfig2W( + service.0, + SERVICE_CONFIG_FAILURE_ACTIONS, + (&failure_actions as *const SERVICE_FAILURE_ACTIONSW).cast(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let failure_flag = SERVICE_FAILURE_ACTIONS_FLAG { + fFailureActionsOnNonCrashFailures: 0, + }; + // SAFETY: failure_flag is valid immutable storage for the duration of the call. + if unsafe { + ChangeServiceConfig2W( + service.0, + SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, + (&failure_flag as *const SERVICE_FAILURE_ACTIONS_FLAG).cast(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let descriptor = service_security_descriptor_from_sddl(SERVICE_DACL_SDDL)?; + // SAFETY: service has WRITE_DAC and descriptor remains live for the call. + if unsafe { + SetServiceObjectSecurity( + service.0, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor.0, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let after = service_policy_from_handle(service_name, expected_executable, &service)?; + if !after.path_matches + || !after.demand_start + || !after.failure_recovery_disabled + || !after.dacl_matches + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service policy query-back did not match the enforced contract", + )); + } + Ok(after) +} + +#[cfg(not(windows))] +fn enforce_service_policy_exact_platform( + _service_name: &str, + _expected_executable: &Path, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +#[cfg(windows)] +fn open_service_handle(service_name: &str, access: u32) -> io::Result> { + use std::ptr::null; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_SERVICE_DOES_NOT_EXIST}; + use windows_sys::Win32::System::Services::{OpenSCManagerW, OpenServiceW, SC_MANAGER_CONNECT}; + + let service_name = wide_service_name(service_name)?; + // SAFETY: null machine/database select the local active service database. + let manager = unsafe { OpenSCManagerW(null(), null(), SC_MANAGER_CONNECT) }; + let manager = ServiceHandle::new(manager)?; + // SAFETY: manager is live and service_name is a NUL-terminated buffer. + let service = unsafe { OpenServiceW(manager.0, service_name.as_ptr(), access) }; + if service.is_null() { + // SAFETY: read immediately after the failed Win32 call. + let code = unsafe { GetLastError() }; + if code == ERROR_SERVICE_DOES_NOT_EXIST { + Ok(None) + } else { + Err(io::Error::from_raw_os_error(code as i32)) + } + } else { + Ok(Some(ServiceHandle(service))) + } +} + +#[cfg(windows)] +fn query_service_config(service: &ServiceHandle) -> io::Result<(String, u32)> { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::System::Services::{QueryServiceConfigW, QUERY_SERVICE_CONFIGW}; + + let mut needed = 0; + // SAFETY: the first call intentionally requests the required buffer size. + unsafe { QueryServiceConfigW(service.0, null_mut(), 0, &mut needed) }; + if needed < size_of::() as u32 { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut buffer = vec![0_usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: buffer is aligned and contains at least needed writable bytes. + if unsafe { QueryServiceConfigW(service.0, buffer.as_mut_ptr().cast(), needed, &mut needed) } + == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: QueryServiceConfigW populated the aligned structure at the buffer start. + let config = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(( + wide_pointer_in_buffer(config.lpBinaryPathName, &buffer)?, + config.dwStartType, + )) +} + +#[cfg(windows)] +fn query_service_status( + service: &ServiceHandle, +) -> io::Result { + use std::mem::size_of; + use windows_sys::Win32::System::Services::{ + QueryServiceStatusEx, SC_STATUS_PROCESS_INFO, SERVICE_STATUS_PROCESS, + }; + + let mut status = SERVICE_STATUS_PROCESS::default(); + let mut returned = 0; + // SAFETY: status is writable storage of the stated size. + if unsafe { + QueryServiceStatusEx( + service.0, + SC_STATUS_PROCESS_INFO, + (&mut status as *mut SERVICE_STATUS_PROCESS).cast(), + size_of::() as u32, + &mut returned, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if returned < size_of::() as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated service status", + )); + } + Ok(status) +} + +#[cfg(windows)] +fn query_service_config2(service: &ServiceHandle, info_level: u32) -> io::Result> { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::System::Services::QueryServiceConfig2W; + + let mut needed = 0; + // SAFETY: the first call intentionally requests the required buffer size. + let first = unsafe { QueryServiceConfig2W(service.0, info_level, null_mut(), 0, &mut needed) }; + if first != 0 || needed == 0 || needed > 256 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service extended configuration has no payload", + )); + } + // SAFETY: read immediately after the failed size-query call. + if unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut buffer = vec![0_usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: buffer is aligned and contains at least needed writable bytes. + if unsafe { + QueryServiceConfig2W( + service.0, + info_level, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(buffer) +} + +#[cfg(windows)] +fn query_failure_recovery_disabled(service: &ServiceHandle) -> io::Result { + use windows_sys::Win32::System::Services::{ + SERVICE_CONFIG_FAILURE_ACTIONS, SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, + SERVICE_FAILURE_ACTIONSW, SERVICE_FAILURE_ACTIONS_FLAG, + }; + + let actions = query_service_config2(service, SERVICE_CONFIG_FAILURE_ACTIONS)?; + let flag = query_service_config2(service, SERVICE_CONFIG_FAILURE_ACTIONS_FLAG)?; + if std::mem::size_of_val(actions.as_slice()) < std::mem::size_of::() + || std::mem::size_of_val(flag.as_slice()) + < std::mem::size_of::() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated service failure configuration", + )); + } + // SAFETY: both aligned buffers were populated by QueryServiceConfig2W and size-checked. + let actions = unsafe { &*(actions.as_ptr().cast::()) }; + let flag = unsafe { &*(flag.as_ptr().cast::()) }; + Ok(actions.cActions == 0 && flag.fFailureActionsOnNonCrashFailures == 0) +} + +#[cfg(windows)] +struct ServiceSecurityDescriptor(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR); + +#[cfg(windows)] +impl Drop for ServiceSecurityDescriptor { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the descriptor was allocated by the Windows local allocator. + unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) }; + } + } +} + +#[cfg(windows)] +fn service_security_descriptor_from_sddl(sddl: &str) -> io::Result { + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + + let mut wide: Vec = sddl.encode_utf16().collect(); + wide.push(0); + let mut descriptor = null_mut(); + // SAFETY: wide is NUL-terminated and descriptor is a valid out pointer. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + wide.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + } == 0 + || descriptor.is_null() + { + return Err(io::Error::last_os_error()); + } + Ok(ServiceSecurityDescriptor(descriptor)) +} + +#[cfg(windows)] +fn query_service_dacl_matches(service: &ServiceHandle) -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Security::{ + AclSizeInformation, GetAce, GetAclInformation, GetSecurityDescriptorControl, + GetSecurityDescriptorDacl, IsValidSid, WinBuiltinAdministratorsSid, WinBuiltinUsersSid, + WinLocalSystemSid, ACCESS_ALLOWED_ACE, ACE_HEADER, ACL_SIZE_INFORMATION, + DACL_SECURITY_INFORMATION, PSID, SECURITY_MAX_SID_SIZE, SE_DACL_PROTECTED, + }; + use windows_sys::Win32::System::Services::{ + QueryServiceObjectSecurity, SERVICE_ALL_ACCESS, SERVICE_ENUMERATE_DEPENDENTS, + SERVICE_INTERROGATE, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + const SID_OFFSET: usize = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + const USERS_QUERY_ONLY: u32 = READ_CONTROL_ACCESS + | SERVICE_QUERY_CONFIG + | SERVICE_QUERY_STATUS + | SERVICE_ENUMERATE_DEPENDENTS + | SERVICE_INTERROGATE; + + let mut needed = 0; + // SAFETY: the first call intentionally requests the required descriptor size. + let first = unsafe { + QueryServiceObjectSecurity( + service.0, + DACL_SECURITY_INFORMATION, + null_mut(), + 0, + &mut needed, + ) + }; + if first != 0 || needed == 0 { + return Ok(false); + } + // SAFETY: read immediately after the failed size-query call. + if unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut descriptor = vec![0_usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: descriptor is aligned and contains at least needed writable bytes. + if unsafe { + QueryServiceObjectSecurity( + service.0, + DACL_SECURITY_INFORMATION, + descriptor.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let descriptor_ptr = descriptor.as_mut_ptr().cast(); + let mut present = 0; + let mut defaulted = 0; + let mut acl = null_mut(); + // SAFETY: descriptor contains a live security descriptor returned by SCM. + if unsafe { GetSecurityDescriptorDacl(descriptor_ptr, &mut present, &mut acl, &mut defaulted) } + == 0 + || present == 0 + || acl.is_null() + { + return Ok(false); + } + let mut control = 0; + let mut revision = 0; + // SAFETY: descriptor_ptr remains live for the call. + if unsafe { GetSecurityDescriptorControl(descriptor_ptr, &mut control, &mut revision) } == 0 { + return Err(io::Error::last_os_error()); + } + if control & SE_DACL_PROTECTED == 0 { + return Ok(false); + } + let mut info = ACL_SIZE_INFORMATION::default(); + // SAFETY: acl came from the live descriptor and info is writable. + if unsafe { + GetAclInformation( + acl, + (&mut info as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + } == 0 + || info.AceCount != 3 + { + return Ok(false); + } + + let make_sid = |kind| -> io::Result> { + let mut sid = vec![0_u8; SECURITY_MAX_SID_SIZE as usize]; + let mut size = sid.len() as u32; + // SAFETY: sid is writable for size bytes and no domain SID is required. + if unsafe { + windows_sys::Win32::Security::CreateWellKnownSid( + kind, + null_mut(), + sid.as_mut_ptr().cast(), + &mut size, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + sid.truncate(size as usize); + Ok(sid) + }; + let administrators = make_sid(WinBuiltinAdministratorsSid)?; + let system = make_sid(WinLocalSystemSid)?; + let users = make_sid(WinBuiltinUsersSid)?; + let mut seen = [false; 3]; + + for index in 0..info.AceCount { + let mut raw = null_mut(); + // SAFETY: index is below the ACE count reported by GetAclInformation. + if unsafe { GetAce(acl, index, &mut raw) } == 0 || raw.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetAce returned a valid ACE pointer in the live ACL. + let header = unsafe { std::ptr::read_unaligned(raw.cast::()) }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE + || header.AceFlags != 0 + || usize::from(header.AceSize) < SID_OFFSET + 8 + { + return Ok(false); + } + // SAFETY: the fixed allowed-ACE fields fit in the size checked above. + let ace = unsafe { std::ptr::read_unaligned(raw.cast::()) }; + // SAFETY: SID_OFFSET points to the first SID byte in ACCESS_ALLOWED_ACE. + let sid: PSID = unsafe { raw.cast::().add(SID_OFFSET).cast() }; + if unsafe { IsValidSid(sid) } == 0 { + return Ok(false); + } + let principal = if unsafe { + windows_sys::Win32::Security::EqualSid(sid, administrators.as_ptr().cast_mut().cast()) + } != 0 + { + 0 + } else if unsafe { + windows_sys::Win32::Security::EqualSid(sid, system.as_ptr().cast_mut().cast()) + } != 0 + { + 1 + } else if unsafe { + windows_sys::Win32::Security::EqualSid(sid, users.as_ptr().cast_mut().cast()) + } != 0 + { + 2 + } else { + return Ok(false); + }; + let expected_mask = if principal == 2 { + USERS_QUERY_ONLY + } else { + SERVICE_ALL_ACCESS + }; + if seen[principal] || ace.Mask != expected_mask { + return Ok(false); + } + seen[principal] = true; + } + Ok(seen.into_iter().all(|value| value)) +} + +#[cfg(windows)] +fn control_known_service_platform( + known: KnownWindowsService, + expected_executable: &Path, + mutation: KnownServiceMutation, +) -> io::Result { + use std::ptr::null; + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_SERVICE_ALREADY_RUNNING, ERROR_SERVICE_DOES_NOT_EXIST, + ERROR_SERVICE_MARKED_FOR_DELETE, ERROR_SERVICE_NOT_ACTIVE, + }; + use windows_sys::Win32::Storage::FileSystem::DELETE; + use windows_sys::Win32::System::Services::{ + ControlService, DeleteService, StartServiceW, SERVICE_CONTROL_STOP, SERVICE_QUERY_CONFIG, + SERVICE_QUERY_STATUS, SERVICE_START, SERVICE_STATUS, SERVICE_STOP, + }; + + crate::safe_fs::ensure_no_reparse_ancestors(expected_executable)?; + if !expected_executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected service executable must be absolute", + )); + } + let mutation_access = match mutation { + KnownServiceMutation::Start => SERVICE_START, + KnownServiceMutation::Stop => SERVICE_STOP, + KnownServiceMutation::Delete => DELETE, + }; + let Some(service) = open_service_handle( + known.name(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | mutation_access, + )? + else { + return Ok(ServiceMutation { changed: false }); + }; + let (path_name, _) = query_service_config(&service)?; + let status = query_service_status(&service)?; + if !service_path_matches_for_name(known.name(), &path_name, expected_executable) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "managed service PathName does not match the expected executable", + )); + } + let state = service_state(status.dwCurrentState); + match (mutation, state) { + (KnownServiceMutation::Start, ServiceState::Running) + | (KnownServiceMutation::Stop, ServiceState::Stopped) => { + return Ok(ServiceMutation { changed: false }); + } + (KnownServiceMutation::Start, ServiceState::Stopped) + | (KnownServiceMutation::Stop, ServiceState::Running) + | (KnownServiceMutation::Delete, ServiceState::Stopped) => {} + _ => { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "service is not in a stable state for the requested mutation", + )); + } + } + + let result = match mutation { + // SAFETY: no service arguments are supplied; the handle has SERVICE_START. + KnownServiceMutation::Start => unsafe { StartServiceW(service.0, 0, null()) }, + KnownServiceMutation::Stop => { + let mut status = SERVICE_STATUS::default(); + // SAFETY: status is writable and the handle has SERVICE_STOP. + unsafe { ControlService(service.0, SERVICE_CONTROL_STOP, &mut status) } + } + // SAFETY: the handle has DELETE access and remains live for the call. + KnownServiceMutation::Delete => unsafe { DeleteService(service.0) }, + }; + if result != 0 { + return Ok(ServiceMutation { changed: true }); + } + // SAFETY: read immediately after the failed Win32 call. + let code = unsafe { GetLastError() }; + let no_change = match mutation { + KnownServiceMutation::Start => code == ERROR_SERVICE_ALREADY_RUNNING, + KnownServiceMutation::Stop => code == ERROR_SERVICE_NOT_ACTIVE, + KnownServiceMutation::Delete => { + code == ERROR_SERVICE_DOES_NOT_EXIST || code == ERROR_SERVICE_MARKED_FOR_DELETE + } + }; + if no_change { + Ok(ServiceMutation { changed: false }) + } else { + Err(io::Error::from_raw_os_error(code as i32)) + } +} + +#[cfg(not(windows))] +fn control_known_service_platform( + _known: KnownWindowsService, + _expected_executable: &Path, + _mutation: KnownServiceMutation, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows Service Control Manager is unavailable", + )) +} + +#[cfg(windows)] +fn capture_service_restore_snapshot_platform( + service_name: &str, + expected_executable: &Path, +) -> io::Result { + use windows_sys::Win32::System::Services::{SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS}; + use windows_sys::Win32::System::SystemServices::ACCESS_SYSTEM_SECURITY; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + + validate_cutover_service_target(service_name, expected_executable)?; + let _privilege = SecurityPrivilegeGuard::enable()?; + let service = open_service_handle( + service_name, + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | READ_CONTROL_ACCESS | ACCESS_SYSTEM_SECURITY, + )? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "service does not exist"))?; + let first = query_full_service_snapshot_from_handle(service_name, &service)?; + let second = query_full_service_snapshot_from_handle(service_name, &service)?; + if first != second { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "service changed during the full before-state capture", + )); + } + if first.security.untrusted_mutation_rights { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service grants mutation rights to an untrusted principal", + )); + } + validate_service_base_snapshot( + service_name, + expected_executable, + &first.base, + ProxifyreBaseContract::LegacyRestore, + )?; + Ok(first) +} + +#[cfg(not(windows))] +fn capture_service_restore_snapshot_platform( + _service_name: &str, + _expected_executable: &Path, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "full SCM capture is supported only on Windows", + )) +} + +#[cfg(windows)] +fn query_proxifyre_complete_service_observation_platform() -> io::Result +{ + use windows_sys::Win32::System::Services::{SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS}; + use windows_sys::Win32::System::SystemServices::ACCESS_SYSTEM_SECURITY; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + + let _privilege = SecurityPrivilegeGuard::enable()?; + let Some(service) = open_service_handle( + KnownWindowsService::Proxifyre.name(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | READ_CONTROL_ACCESS | ACCESS_SYSTEM_SECURITY, + )? + else { + return Ok(CompleteServiceObservation::Missing); + }; + let query = || -> io::Result { + Ok(CompleteServiceObservation::Present { + snapshot: Box::new(query_full_service_snapshot_from_handle( + KnownWindowsService::Proxifyre.name(), + &service, + )?), + current_dacl_matches: query_service_dacl_matches(&service)?, + }) + }; + let first = query()?; + let second = query()?; + if first != second { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "service changed during complete SCM observation", + )); + } + Ok(first) +} + +#[cfg(not(windows))] +fn query_proxifyre_complete_service_observation_platform() -> io::Result +{ + Err(io::Error::new( + io::ErrorKind::Unsupported, + "complete SCM observation is supported only on Windows", + )) +} + +#[cfg(windows)] +fn create_service_base_exact_platform( + service_name: &str, + expected_executable: &Path, + base: &ServiceBaseConfigSnapshot, + contract: ProxifyreBaseContract, +) -> io::Result { + use std::ptr::null; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_SERVICE_EXISTS}; + use windows_sys::Win32::System::Services::{ + CreateServiceW, OpenSCManagerW, SC_MANAGER_CONNECT, SC_MANAGER_CREATE_SERVICE, + SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + + validate_cutover_service_target(service_name, expected_executable)?; + validate_service_base_snapshot(service_name, expected_executable, base, contract)?; + if open_service_handle(service_name, SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS)?.is_some() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "service name collision", + )); + } + + let service_name_wide = wide_service_name(service_name)?; + let display_name = wide_null(OsStr::new(&base.display_name))?; + let binary_path = wide_null(OsStr::new(&base.binary_path_name))?; + let load_order_group = base + .load_order_group + .as_deref() + .map(OsStr::new) + .map(wide_null) + .transpose()?; + let dependencies = wide_multi_sz(&base.dependencies)?; + let account = wide_null(OsStr::new(&base.service_start_name))?; + let mut tag_id = base.tag_id; + + // SAFETY: null machine/database select the local active SCM database. + let manager = unsafe { + OpenSCManagerW( + null(), + null(), + SC_MANAGER_CONNECT | SC_MANAGER_CREATE_SERVICE, + ) + }; + let manager = ServiceHandle::new(manager)?; + // SAFETY: all optional pointers are either null or point to live NUL-terminated buffers. + let service = unsafe { + CreateServiceW( + manager.0, + service_name_wide.as_ptr(), + display_name.as_ptr(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS, + base.service_type, + base.start_type, + base.error_control, + binary_path.as_ptr(), + load_order_group + .as_ref() + .map_or(null(), |value| value.as_ptr()), + &mut tag_id, + dependencies.as_ref().map_or(null(), |value| value.as_ptr()), + account.as_ptr(), + null(), + ) + }; + if service.is_null() { + // SAFETY: read immediately after the failed CreateServiceW call. + let code = unsafe { GetLastError() }; + return if code == ERROR_SERVICE_EXISTS { + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "service name collision", + )) + } else { + Err(io::Error::from_raw_os_error(code as i32)) + }; + } + let service = ServiceHandle(service); + let observed = query_service_base_config(&service)?; + let state = service_state(query_service_status(&service)?.dwCurrentState); + if observed != *base || state != ServiceState::Stopped { + // The transaction owner records CreateService before this call and owns rollback. + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "created service base configuration did not query back exactly", + )); + } + Ok(ServiceMutation { changed: true }) +} + +#[cfg(not(windows))] +fn create_service_base_exact_platform( + _service_name: &str, + _expected_executable: &Path, + _base: &ServiceBaseConfigSnapshot, + _contract: ProxifyreBaseContract, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCM service creation is supported only on Windows", + )) +} + +#[cfg(not(windows))] +fn restore_service_config2_exact_platform( + _service_name: &str, + _expected_executable: &Path, + _snapshot: &ServiceConfig2Snapshot, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCM policy restore is supported only on Windows", + )) +} + +#[cfg(windows)] +fn query_service_config2_exact_platform( + service_name: &str, + expected_executable: &Path, + kind: ServiceConfig2Kind, +) -> io::Result> { + use windows_sys::Win32::System::Services::{SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS}; + + validate_cutover_service_target(service_name, expected_executable)?; + let Some(service) = + open_service_handle(service_name, SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS)? + else { + return Ok(None); + }; + let base = query_service_base_config(&service)?; + if !service_path_matches_for_name(service_name, &base.binary_path_name, expected_executable) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service PathName does not match the expected executable", + )); + } + query_service_config2_snapshot(&service, kind).map(Some) +} + +#[cfg(not(windows))] +fn query_service_config2_exact_platform( + _service_name: &str, + _expected_executable: &Path, + _kind: ServiceConfig2Kind, +) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCM policy query is supported only on Windows", + )) +} + +#[cfg(not(windows))] +fn restore_service_security_exact_platform( + _service_name: &str, + _expected_executable: &Path, + _snapshot: &ServiceSecuritySnapshot, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCM security restore is supported only on Windows", + )) +} + +#[cfg(windows)] +fn validate_cutover_service_target( + service_name: &str, + expected_executable: &Path, +) -> io::Result<()> { + if !service_name.eq_ignore_ascii_case(KnownWindowsService::Proxifyre.name()) + || !expected_executable.is_absolute() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cutover supports only the primary ProxiFyre service and an absolute executable", + )); + } + crate::safe_fs::ensure_no_reparse_ancestors(expected_executable)?; + if !std::fs::symlink_metadata(expected_executable)? + .file_type() + .is_file() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "cutover service executable must be a regular non-reparse file", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_service_base_snapshot( + service_name: &str, + expected_executable: &Path, + base: &ServiceBaseConfigSnapshot, + contract: ProxifyreBaseContract, +) -> io::Result<()> { + use windows_sys::Win32::System::Services::{ + SERVICE_AUTO_START, SERVICE_DEMAND_START, SERVICE_ERROR_NORMAL, SERVICE_WIN32_OWN_PROCESS, + }; + + let strings_are_safe = [ + base.binary_path_name.as_str(), + base.service_start_name.as_str(), + base.display_name.as_str(), + ] + .into_iter() + .all(|value| !value.is_empty() && !value.contains('\0')) + && base + .load_order_group + .as_deref() + .is_none_or(|value| !value.contains('\0')) + && base + .dependencies + .iter() + .all(|value| !value.is_empty() && !value.contains('\0')); + if !strings_are_safe + || base.service_type != SERVICE_WIN32_OWN_PROCESS + || !matches!(base.start_type, SERVICE_AUTO_START | SERVICE_DEMAND_START) + || base.error_control != SERVICE_ERROR_NORMAL + || !base.service_start_name.eq_ignore_ascii_case("LocalSystem") + || match contract { + ProxifyreBaseContract::Current => { + base.start_type != SERVICE_DEMAND_START + || base.display_name != KnownWindowsService::Proxifyre.display_name() + } + ProxifyreBaseContract::LegacyRestore => { + base.start_type != SERVICE_AUTO_START + || base.display_name != "ProxiFyre Service" + || !crate::component_inventory::legacy_proxifyre_topshelf_path_matches( + &base.binary_path_name, + expected_executable, + ) + } + } + || base + .load_order_group + .as_deref() + .is_some_and(|value| !value.is_empty()) + || base.tag_id != 0 + || !base.dependencies.is_empty() + || !service_path_matches_for_name(service_name, &base.binary_path_name, expected_executable) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service base configuration is outside the frozen ProxiFyre contract", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn query_full_service_snapshot_from_handle( + service_name: &str, + service: &ServiceHandle, +) -> io::Result { + let base = query_service_base_config(service)?; + let original_state = match service_state(query_service_status(service)?.dwCurrentState) { + ServiceState::Stopped => ServiceStableState::Stopped, + ServiceState::Running => ServiceStableState::Running, + _ => { + return Err(io::Error::new( + io::ErrorKind::WouldBlock, + "service is not in a stable Running/Stopped state", + )) + } + }; + let config2 = SERVICE_CONFIG2_KINDS + .iter() + .copied() + .map(|kind| query_service_config2_snapshot(service, kind)) + .collect::>>()?; + let security = query_service_security_snapshot(service)?; + Ok(ServiceRestoreSnapshot { + service_name: service_name.to_owned(), + base, + config2, + security, + original_state, + }) +} + +#[cfg(windows)] +fn query_service_base_config(service: &ServiceHandle) -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::System::Services::{QueryServiceConfigW, QUERY_SERVICE_CONFIGW}; + + let mut needed = 0; + // SAFETY: the first call requests the exact buffer size. + unsafe { QueryServiceConfigW(service.0, null_mut(), 0, &mut needed) }; + if needed < size_of::() as u32 || needed > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid service base configuration size", + )); + } + let word_size = size_of::(); + let mut buffer = vec![0_usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: the aligned buffer is writable for at least needed bytes. + if unsafe { QueryServiceConfigW(service.0, buffer.as_mut_ptr().cast(), needed, &mut needed) } + == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: SCM populated QUERY_SERVICE_CONFIGW at the aligned buffer start. + let config = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceBaseConfigSnapshot { + service_type: config.dwServiceType, + start_type: config.dwStartType, + error_control: config.dwErrorControl, + binary_path_name: wide_pointer_in_buffer(config.lpBinaryPathName, &buffer)?, + load_order_group: optional_wide_pointer_in_buffer(config.lpLoadOrderGroup, &buffer)?, + tag_id: config.dwTagId, + dependencies: wide_multi_sz_in_buffer(config.lpDependencies, &buffer)?, + service_start_name: wide_pointer_in_buffer(config.lpServiceStartName, &buffer)?, + display_name: wide_pointer_in_buffer(config.lpDisplayName, &buffer)?, + }) +} + +#[cfg(windows)] +fn optional_wide_pointer_in_buffer( + pointer: *mut u16, + buffer: &[usize], +) -> io::Result> { + if pointer.is_null() { + Ok(None) + } else { + wide_pointer_in_buffer(pointer, buffer).map(Some) + } +} + +#[cfg(windows)] +fn wide_multi_sz_in_buffer(pointer: *mut u16, buffer: &[usize]) -> io::Result> { + if pointer.is_null() { + return Ok(Vec::new()); + } + let start = buffer.as_ptr() as usize; + let end = start.saturating_add(std::mem::size_of_val(buffer)); + let address = pointer as usize; + if address < start || address >= end || !(address - start).is_multiple_of(2) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service MULTI_SZ points outside the SCM buffer", + )); + } + let units = unsafe { std::slice::from_raw_parts(pointer, (end - address) / 2) }; + let mut values = Vec::new(); + let mut offset = 0; + while offset < units.len() { + let Some(length) = units[offset..].iter().position(|unit| *unit == 0) else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unterminated service MULTI_SZ", + )); + }; + if length == 0 { + return Ok(values); + } + values.push( + String::from_utf16(&units[offset..offset + length]).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid service MULTI_SZ UTF-16", + ) + })?, + ); + offset += length + 1; + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + "service MULTI_SZ has no terminal empty string", + )) +} + +#[cfg(windows)] +fn wide_null(value: &OsStr) -> io::Result> { + use std::os::windows::ffi::OsStrExt; + let mut wide = value.encode_wide().collect::>(); + if wide.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCM string contains a null character", + )); + } + wide.push(0); + Ok(wide) +} + +#[cfg(windows)] +fn wide_multi_sz(values: &[String]) -> io::Result>> { + use std::os::windows::ffi::OsStrExt; + if values.is_empty() { + return Ok(None); + } + let mut wide = Vec::new(); + for value in values { + if value.is_empty() || value.contains('\0') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCM MULTI_SZ contains an invalid entry", + )); + } + wide.extend(OsStr::new(value).encode_wide()); + wide.push(0); + } + wide.push(0); + Ok(Some(wide)) +} + +#[cfg(windows)] +struct SecurityPrivilegeGuard { + token: windows_sys::Win32::Foundation::HANDLE, + previous: windows_sys::Win32::Security::TOKEN_PRIVILEGES, +} + +#[cfg(windows)] +impl SecurityPrivilegeGuard { + fn enable() -> io::Result { + Self::enable_named(windows_sys::Win32::Security::SE_SECURITY_NAME) + } + + fn enable_restore() -> io::Result { + Self::enable_named(windows_sys::Win32::Security::SE_RESTORE_NAME) + } + + fn enable_named(privilege_name: windows_sys::core::PCWSTR) -> io::Result { + use std::mem::size_of; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{ + GetLastError, SetLastError, ERROR_NOT_ALL_ASSIGNED, ERROR_SUCCESS, HANDLE, + }; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token: HANDLE = null_mut(); + // SAFETY: token is a valid out pointer and the pseudo process handle is valid. + if unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut token, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let mut luid = Default::default(); + // SAFETY: privilege_name is a static NUL-terminated Win32 privilege name. + if unsafe { LookupPrivilegeValueW(null(), privilege_name, &mut luid) } == 0 { + unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; + return Err(io::Error::last_os_error()); + } + let requested = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + let mut previous = TOKEN_PRIVILEGES::default(); + let mut returned = 0; + // SAFETY: both privilege structures are live and correctly sized. + unsafe { SetLastError(ERROR_SUCCESS) }; + let adjusted = unsafe { + AdjustTokenPrivileges( + token, + 0, + &requested, + size_of::() as u32, + &mut previous, + &mut returned, + ) + }; + // SAFETY: read immediately after AdjustTokenPrivileges. + let code = unsafe { GetLastError() }; + if adjusted == 0 || code == ERROR_NOT_ALL_ASSIGNED { + unsafe { windows_sys::Win32::Foundation::CloseHandle(token) }; + return if code == ERROR_NOT_ALL_ASSIGNED { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "SeSecurityPrivilege is required for exact service SACL capture", + )) + } else { + Err(io::Error::last_os_error()) + }; + } + Ok(Self { token, previous }) + } +} + +#[cfg(windows)] +impl Drop for SecurityPrivilegeGuard { + fn drop(&mut self) { + use std::ptr::null_mut; + use windows_sys::Win32::Security::AdjustTokenPrivileges; + // SAFETY: token remains live; restoring the captured single-privilege state is best effort. + unsafe { + AdjustTokenPrivileges(self.token, 0, &self.previous, 0, null_mut(), null_mut()); + windows_sys::Win32::Foundation::CloseHandle(self.token); + } + } +} + +#[cfg(windows)] +fn query_service_security_snapshot(service: &ServiceHandle) -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Security::{ + GetSecurityDescriptorLength, IsValidSecurityDescriptor, DACL_SECURITY_INFORMATION, + GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, SACL_SECURITY_INFORMATION, + }; + use windows_sys::Win32::System::Services::QueryServiceObjectSecurity; + + let information = OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | SACL_SECURITY_INFORMATION; + let mut needed = 0; + // SAFETY: the first call requests the exact descriptor size. + let first = + unsafe { QueryServiceObjectSecurity(service.0, information, null_mut(), 0, &mut needed) }; + if first != 0 || needed == 0 || needed > 64 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid service security descriptor size", + )); + } + // SAFETY: read immediately after the size-query call. + if unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut aligned = vec![0_usize; (needed as usize).div_ceil(word_size)]; + // SAFETY: aligned is writable for at least needed bytes. + if unsafe { + QueryServiceObjectSecurity( + service.0, + information, + aligned.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let descriptor = aligned.as_mut_ptr().cast(); + // SAFETY: SCM returned a descriptor in the live aligned buffer. + if unsafe { IsValidSecurityDescriptor(descriptor) } == 0 + || unsafe { GetSecurityDescriptorLength(descriptor) } != needed + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SCM returned an invalid service security descriptor", + )); + } + let bytes = unsafe { + std::slice::from_raw_parts(aligned.as_ptr().cast::(), needed as usize).to_vec() + }; + let untrusted_mutation_rights = service_security_has_untrusted_mutation_rights(descriptor)?; + Ok(ServiceSecuritySnapshot { + self_relative_descriptor: bytes, + untrusted_mutation_rights, + }) +} + +#[cfg(windows)] +fn query_service_config2_snapshot( + service: &ServiceHandle, + kind: ServiceConfig2Kind, +) -> io::Result { + use std::mem::size_of; + use windows_sys::Win32::System::Services::{ + SC_ACTION, SERVICE_DELAYED_AUTO_START_INFO, SERVICE_DESCRIPTIONW, SERVICE_FAILURE_ACTIONSW, + SERVICE_FAILURE_ACTIONS_FLAG, SERVICE_LAUNCH_PROTECTED_INFO, SERVICE_PREFERRED_NODE_INFO, + SERVICE_PRESHUTDOWN_INFO, SERVICE_REQUIRED_PRIVILEGES_INFOW, SERVICE_SID_INFO, + SERVICE_TRIGGER, SERVICE_TRIGGER_INFO, SERVICE_TRIGGER_SPECIFIC_DATA_ITEM, + }; + + let buffer = query_service_config2(service, service_config2_level(kind))?; + let bytes = std::mem::size_of_val(buffer.as_slice()); + let structure = |required: usize| -> io::Result<()> { + if bytes < required { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated QueryServiceConfig2 record", + )) + } else { + Ok(()) + } + }; + match kind { + ServiceConfig2Kind::Description => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::Description( + optional_wide_pointer_in_buffer(value.lpDescription, &buffer)?, + )) + } + ServiceConfig2Kind::FailureActions => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + let actions = + copy_array_from_aligned_buffer(value.lpsaActions, value.cActions, &buffer, 128)? + .into_iter() + .map(|action: SC_ACTION| ServiceFailureActionSnapshot { + action_type: action.Type, + delay_ms: action.Delay, + }) + .collect(); + Ok(ServiceConfig2Snapshot::FailureActions( + ServiceFailureActionsSnapshot { + reset_period_seconds: value.dwResetPeriod, + reboot_message: optional_wide_pointer_in_buffer(value.lpRebootMsg, &buffer)?, + command: optional_wide_pointer_in_buffer(value.lpCommand, &buffer)?, + actions, + }, + )) + } + ServiceConfig2Kind::FailureActionsFlag => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::FailureActionsFlag( + value.fFailureActionsOnNonCrashFailures != 0, + )) + } + ServiceConfig2Kind::DelayedAutoStart => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::DelayedAutoStart( + value.fDelayedAutostart != 0, + )) + } + ServiceConfig2Kind::SidType => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::SidType(value.dwServiceSidType)) + } + ServiceConfig2Kind::RequiredPrivileges => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::RequiredPrivileges( + wide_multi_sz_in_buffer(value.pmszRequiredPrivileges, &buffer)?, + )) + } + ServiceConfig2Kind::PreshutdownTimeout => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::PreshutdownTimeout( + value.dwPreshutdownTimeout, + )) + } + ServiceConfig2Kind::Triggers => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + if !value.pReserved.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service trigger record has non-null reserved data", + )); + } + let triggers: Vec = + copy_array_from_aligned_buffer(value.pTriggers, value.cTriggers, &buffer, 128)?; + let mut snapshots = Vec::with_capacity(triggers.len()); + for trigger in triggers { + let subtype = if trigger.pTriggerSubtype.is_null() { + None + } else { + let guid = copy_one_from_aligned_buffer(trigger.pTriggerSubtype, &buffer)?; + let mut bytes = [0_u8; 16]; + unsafe { + std::ptr::copy_nonoverlapping( + (&guid as *const windows_sys::core::GUID).cast::(), + bytes.as_mut_ptr(), + bytes.len(), + ); + } + Some(bytes) + }; + let items: Vec = + copy_array_from_aligned_buffer( + trigger.pDataItems, + trigger.cDataItems, + &buffer, + 128, + )?; + let mut data_items = Vec::with_capacity(items.len()); + for item in items { + let item_bytes = copy_bytes_from_aligned_buffer( + item.pData, + item.cbData, + &buffer, + 64 * 1024, + )?; + data_items.push(ServiceTriggerDataSnapshot { + data_type: item.dwDataType, + bytes: item_bytes, + }); + } + snapshots.push(ServiceTriggerSnapshot { + trigger_type: trigger.dwTriggerType, + action: trigger.dwAction, + subtype, + data_items, + }); + } + Ok(ServiceConfig2Snapshot::Triggers(snapshots)) + } + ServiceConfig2Kind::PreferredNode => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::PreferredNode { + node: value.usPreferredNode, + delete: value.fDelete, + }) + } + ServiceConfig2Kind::LaunchProtected => { + structure(size_of::())?; + let value = unsafe { &*(buffer.as_ptr().cast::()) }; + Ok(ServiceConfig2Snapshot::LaunchProtected( + value.dwLaunchProtected, + )) + } + } +} + +#[cfg(windows)] +fn service_config2_level(kind: ServiceConfig2Kind) -> u32 { + use windows_sys::Win32::System::Services::*; + match kind { + ServiceConfig2Kind::Description => SERVICE_CONFIG_DESCRIPTION, + ServiceConfig2Kind::FailureActions => SERVICE_CONFIG_FAILURE_ACTIONS, + ServiceConfig2Kind::FailureActionsFlag => SERVICE_CONFIG_FAILURE_ACTIONS_FLAG, + ServiceConfig2Kind::DelayedAutoStart => SERVICE_CONFIG_DELAYED_AUTO_START_INFO, + ServiceConfig2Kind::SidType => SERVICE_CONFIG_SERVICE_SID_INFO, + ServiceConfig2Kind::RequiredPrivileges => SERVICE_CONFIG_REQUIRED_PRIVILEGES_INFO, + ServiceConfig2Kind::PreshutdownTimeout => SERVICE_CONFIG_PRESHUTDOWN_INFO, + ServiceConfig2Kind::Triggers => SERVICE_CONFIG_TRIGGER_INFO, + ServiceConfig2Kind::PreferredNode => SERVICE_CONFIG_PREFERRED_NODE, + ServiceConfig2Kind::LaunchProtected => SERVICE_CONFIG_LAUNCH_PROTECTED, + } +} + +#[cfg(windows)] +fn copy_array_from_aligned_buffer( + pointer: *mut T, + count: u32, + buffer: &[usize], + maximum: usize, +) -> io::Result> { + let count = count as usize; + if count > maximum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SCM record exceeds the bounded item count", + )); + } + if count == 0 { + return Ok(Vec::new()); + } + checked_buffer_region( + pointer.cast(), + count.saturating_mul(std::mem::size_of::()), + buffer, + )?; + Ok(unsafe { std::slice::from_raw_parts(pointer, count) }.to_vec()) +} + +#[cfg(windows)] +fn copy_one_from_aligned_buffer(pointer: *mut T, buffer: &[usize]) -> io::Result { + checked_buffer_region(pointer.cast(), std::mem::size_of::(), buffer)?; + Ok(unsafe { std::ptr::read_unaligned(pointer) }) +} + +#[cfg(windows)] +fn copy_bytes_from_aligned_buffer( + pointer: *mut u8, + count: u32, + buffer: &[usize], + maximum: usize, +) -> io::Result> { + let count = count as usize; + if count > maximum { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SCM byte record exceeds the bounded size", + )); + } + if count == 0 { + return Ok(Vec::new()); + } + checked_buffer_region(pointer, count, buffer)?; + Ok(unsafe { std::slice::from_raw_parts(pointer, count) }.to_vec()) +} + +#[cfg(windows)] +fn checked_buffer_region(pointer: *mut u8, bytes: usize, buffer: &[usize]) -> io::Result<()> { + if pointer.is_null() || bytes == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SCM record contains a null or empty pointer region", + )); + } + let start = buffer.as_ptr() as usize; + let end = start.saturating_add(std::mem::size_of_val(buffer)); + let address = pointer as usize; + if address < start || address.checked_add(bytes).is_none_or(|value| value > end) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "SCM record pointer escapes its query buffer", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn restore_service_config2_exact_platform( + service_name: &str, + expected_executable: &Path, + snapshot: &ServiceConfig2Snapshot, +) -> io::Result<()> { + use windows_sys::Win32::System::Services::{ + SERVICE_CHANGE_CONFIG, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + + validate_cutover_service_target(service_name, expected_executable)?; + let service = open_service_handle( + service_name, + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | SERVICE_CHANGE_CONFIG, + )? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "service disappeared"))?; + ensure_service_handle_stopped_and_exact(service_name, expected_executable, &service)?; + apply_service_config2_snapshot(&service, snapshot)?; + let observed = query_service_config2_snapshot(&service, snapshot.kind())?; + if observed != *snapshot { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "restored QueryServiceConfig2 record did not query back exactly", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn apply_service_config2_snapshot( + service: &ServiceHandle, + snapshot: &ServiceConfig2Snapshot, +) -> io::Result<()> { + use std::ptr::null_mut; + use windows_sys::Win32::System::Services::{ + ChangeServiceConfig2W, SC_ACTION, SERVICE_DELAYED_AUTO_START_INFO, SERVICE_DESCRIPTIONW, + SERVICE_FAILURE_ACTIONSW, SERVICE_FAILURE_ACTIONS_FLAG, SERVICE_LAUNCH_PROTECTED_INFO, + SERVICE_PREFERRED_NODE_INFO, SERVICE_PRESHUTDOWN_INFO, SERVICE_REQUIRED_PRIVILEGES_INFOW, + SERVICE_SID_INFO, SERVICE_TRIGGER, SERVICE_TRIGGER_INFO, + SERVICE_TRIGGER_SPECIFIC_DATA_ITEM, + }; + + let level = service_config2_level(snapshot.kind()); + let changed = match snapshot { + ServiceConfig2Snapshot::Description(description) => { + let wide = description + .as_deref() + .map(OsStr::new) + .map(wide_null) + .transpose()?; + let value = SERVICE_DESCRIPTIONW { + lpDescription: wide + .as_ref() + .map_or(null_mut(), |value| value.as_ptr().cast_mut()), + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_DESCRIPTIONW).cast(), + ) + } + } + ServiceConfig2Snapshot::FailureActions(snapshot) => { + if snapshot.actions.len() > 128 + || snapshot + .actions + .iter() + .any(|action| !matches!(action.action_type, 0..=4)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid captured failure-action record", + )); + } + let reboot = snapshot + .reboot_message + .as_deref() + .map(OsStr::new) + .map(wide_null) + .transpose()?; + let command = snapshot + .command + .as_deref() + .map(OsStr::new) + .map(wide_null) + .transpose()?; + let empty = [0_u16]; + let mut actions: Vec = snapshot + .actions + .iter() + .map(|action| SC_ACTION { + Type: action.action_type, + Delay: action.delay_ms, + }) + .collect(); + let mut unused = SC_ACTION::default(); + let value = SERVICE_FAILURE_ACTIONSW { + dwResetPeriod: snapshot.reset_period_seconds, + // An empty string deletes an existing message/command; null would preserve it. + lpRebootMsg: reboot + .as_ref() + .map_or(empty.as_ptr().cast_mut(), |value| value.as_ptr().cast_mut()), + lpCommand: command + .as_ref() + .map_or(empty.as_ptr().cast_mut(), |value| value.as_ptr().cast_mut()), + cActions: actions.len() as u32, + lpsaActions: if actions.is_empty() { + &mut unused + } else { + actions.as_mut_ptr() + }, + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_FAILURE_ACTIONSW).cast(), + ) + } + } + ServiceConfig2Snapshot::FailureActionsFlag(enabled) => { + let value = SERVICE_FAILURE_ACTIONS_FLAG { + fFailureActionsOnNonCrashFailures: i32::from(*enabled), + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_FAILURE_ACTIONS_FLAG).cast(), + ) + } + } + ServiceConfig2Snapshot::DelayedAutoStart(enabled) => { + let value = SERVICE_DELAYED_AUTO_START_INFO { + fDelayedAutostart: i32::from(*enabled), + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_DELAYED_AUTO_START_INFO).cast(), + ) + } + } + ServiceConfig2Snapshot::SidType(sid_type) => { + let value = SERVICE_SID_INFO { + dwServiceSidType: *sid_type, + }; + unsafe { + ChangeServiceConfig2W(service.0, level, (&value as *const SERVICE_SID_INFO).cast()) + } + } + ServiceConfig2Snapshot::RequiredPrivileges(privileges) => { + let wide = if privileges.is_empty() { + vec![0_u16, 0] + } else { + wide_multi_sz(privileges)?.ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid privilege MULTI_SZ") + })? + }; + let value = SERVICE_REQUIRED_PRIVILEGES_INFOW { + pmszRequiredPrivileges: wide.as_ptr().cast_mut(), + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_REQUIRED_PRIVILEGES_INFOW).cast(), + ) + } + } + ServiceConfig2Snapshot::PreshutdownTimeout(timeout) => { + let value = SERVICE_PRESHUTDOWN_INFO { + dwPreshutdownTimeout: *timeout, + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_PRESHUTDOWN_INFO).cast(), + ) + } + } + ServiceConfig2Snapshot::Triggers(triggers) => { + if triggers.len() > 128 + || triggers + .iter() + .any(|trigger| trigger.data_items.len() > 128) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "captured trigger record exceeds its bounded shape", + )); + } + let mut subtype_storage: Vec>> = triggers + .iter() + .map(|trigger| { + trigger.subtype.map(|bytes| { + let guid = unsafe { + std::ptr::read_unaligned( + bytes.as_ptr().cast::(), + ) + }; + Box::new(guid) + }) + }) + .collect(); + let mut data_storage: Vec>> = triggers + .iter() + .map(|trigger| { + trigger + .data_items + .iter() + .map(|item| item.bytes.clone()) + .collect() + }) + .collect(); + if data_storage + .iter() + .flatten() + .any(|bytes| bytes.len() > 64 * 1024) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "captured trigger data exceeds its bounded size", + )); + } + let mut native_items: Vec> = triggers + .iter() + .enumerate() + .map(|(trigger_index, trigger)| { + trigger + .data_items + .iter() + .enumerate() + .map(|(item_index, item)| SERVICE_TRIGGER_SPECIFIC_DATA_ITEM { + dwDataType: item.data_type, + cbData: data_storage[trigger_index][item_index].len() as u32, + pData: if data_storage[trigger_index][item_index].is_empty() { + null_mut() + } else { + data_storage[trigger_index][item_index].as_mut_ptr() + }, + }) + .collect() + }) + .collect(); + let mut native_triggers: Vec = triggers + .iter() + .enumerate() + .map(|(index, trigger)| SERVICE_TRIGGER { + dwTriggerType: trigger.trigger_type, + dwAction: trigger.action, + pTriggerSubtype: subtype_storage[index] + .as_mut() + .map_or(null_mut(), |guid| guid.as_mut()), + cDataItems: native_items[index].len() as u32, + pDataItems: if native_items[index].is_empty() { + null_mut() + } else { + native_items[index].as_mut_ptr() + }, + }) + .collect(); + let value = SERVICE_TRIGGER_INFO { + cTriggers: native_triggers.len() as u32, + pTriggers: if native_triggers.is_empty() { + null_mut() + } else { + native_triggers.as_mut_ptr() + }, + pReserved: null_mut(), + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_TRIGGER_INFO).cast(), + ) + } + } + ServiceConfig2Snapshot::PreferredNode { node, delete } => { + let value = SERVICE_PREFERRED_NODE_INFO { + usPreferredNode: *node, + fDelete: *delete, + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_PREFERRED_NODE_INFO).cast(), + ) + } + } + ServiceConfig2Snapshot::LaunchProtected(value) => { + let value = SERVICE_LAUNCH_PROTECTED_INFO { + dwLaunchProtected: *value, + }; + unsafe { + ChangeServiceConfig2W( + service.0, + level, + (&value as *const SERVICE_LAUNCH_PROTECTED_INFO).cast(), + ) + } + } + }; + if changed == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(windows)] +fn service_security_has_untrusted_mutation_rights( + descriptor: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, +) -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Security::{ + AclSizeInformation, CreateWellKnownSid, EqualSid, GetAce, GetAclInformation, + GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, IsValidSid, + WinBuiltinAdministratorsSid, WinLocalSystemSid, ACCESS_ALLOWED_ACE, ACE_HEADER, + ACL_SIZE_INFORMATION, PSID, SECURITY_MAX_SID_SIZE, + }; + use windows_sys::Win32::System::Services::{ + SERVICE_CHANGE_CONFIG, SERVICE_START, SERVICE_STOP, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const ACCESS_ALLOWED_OBJECT_ACE_TYPE: u8 = 5; + const ACCESS_ALLOWED_CALLBACK_ACE_TYPE: u8 = 9; + const ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE: u8 = 11; + const DELETE_ACCESS: u32 = 0x0001_0000; + const WRITE_DAC_ACCESS: u32 = 0x0004_0000; + const WRITE_OWNER_ACCESS: u32 = 0x0008_0000; + const GENERIC_EXECUTE_ACCESS: u32 = 0x2000_0000; + const GENERIC_WRITE_ACCESS: u32 = 0x4000_0000; + const GENERIC_ALL_ACCESS: u32 = 0x1000_0000; + const DANGEROUS: u32 = SERVICE_CHANGE_CONFIG + | SERVICE_START + | SERVICE_STOP + | DELETE_ACCESS + | WRITE_DAC_ACCESS + | WRITE_OWNER_ACCESS + | GENERIC_EXECUTE_ACCESS + | GENERIC_WRITE_ACCESS + | GENERIC_ALL_ACCESS; + const SID_OFFSET: usize = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + + let make_sid = |kind| -> io::Result> { + let mut sid = vec![0_u8; SECURITY_MAX_SID_SIZE as usize]; + let mut size = sid.len() as u32; + if unsafe { CreateWellKnownSid(kind, null_mut(), sid.as_mut_ptr().cast(), &mut size) } == 0 + { + return Err(io::Error::last_os_error()); + } + sid.truncate(size as usize); + Ok(sid) + }; + let administrators = make_sid(WinBuiltinAdministratorsSid)?; + let system = make_sid(WinLocalSystemSid)?; + let trusted = |sid: PSID| unsafe { + EqualSid(sid, administrators.as_ptr().cast_mut().cast()) != 0 + || EqualSid(sid, system.as_ptr().cast_mut().cast()) != 0 + }; + + let mut owner = null_mut(); + let mut owner_defaulted = 0; + if unsafe { GetSecurityDescriptorOwner(descriptor, &mut owner, &mut owner_defaulted) } == 0 + || owner.is_null() + || unsafe { IsValidSid(owner) } == 0 + || !trusted(owner) + { + return Ok(true); + } + + let mut present = 0; + let mut defaulted = 0; + let mut acl = null_mut(); + if unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) } == 0 + { + return Err(io::Error::last_os_error()); + } + if present == 0 || acl.is_null() { + return Ok(true); + } + let mut info = ACL_SIZE_INFORMATION::default(); + if unsafe { + GetAclInformation( + acl, + (&mut info as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + for index in 0..info.AceCount { + let mut raw = null_mut(); + if unsafe { GetAce(acl, index, &mut raw) } == 0 || raw.is_null() { + return Err(io::Error::last_os_error()); + } + let header = unsafe { std::ptr::read_unaligned(raw.cast::()) }; + let allowed = matches!( + header.AceType, + ACCESS_ALLOWED_ACE_TYPE + | ACCESS_ALLOWED_OBJECT_ACE_TYPE + | ACCESS_ALLOWED_CALLBACK_ACE_TYPE + | ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE + ); + if !allowed { + continue; + } + if usize::from(header.AceSize) < size_of::() + size_of::() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated allow ACE in service DACL", + )); + } + let mask = unsafe { std::ptr::read_unaligned(raw.cast::().add(4).cast::()) }; + if mask & DANGEROUS == 0 { + continue; + } + if header.AceType != ACCESS_ALLOWED_ACE_TYPE || usize::from(header.AceSize) < SID_OFFSET + 8 + { + return Ok(true); + } + let sid: PSID = unsafe { raw.cast::().add(SID_OFFSET).cast() }; + if unsafe { IsValidSid(sid) } == 0 || !trusted(sid) { + return Ok(true); + } + } + Ok(false) +} + +#[cfg(windows)] +fn restore_service_security_exact_platform( + service_name: &str, + expected_executable: &Path, + snapshot: &ServiceSecuritySnapshot, +) -> io::Result<()> { + use windows_sys::Win32::Security::{ + GetSecurityDescriptorControl, GetSecurityDescriptorLength, IsValidSecurityDescriptor, + DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PROTECTED_SACL_SECURITY_INFORMATION, + SACL_SECURITY_INFORMATION, SE_DACL_PROTECTED, SE_SACL_PROTECTED, SE_SELF_RELATIVE, + UNPROTECTED_DACL_SECURITY_INFORMATION, UNPROTECTED_SACL_SECURITY_INFORMATION, + }; + use windows_sys::Win32::System::Services::{ + SetServiceObjectSecurity, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + use windows_sys::Win32::System::SystemServices::ACCESS_SYSTEM_SECURITY; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + const WRITE_DAC_ACCESS: u32 = 0x0004_0000; + const WRITE_OWNER_ACCESS: u32 = 0x0008_0000; + if snapshot.untrusted_mutation_rights + || snapshot.self_relative_descriptor.is_empty() + || snapshot.self_relative_descriptor.len() > 64 * 1024 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unsafe or invalid service security snapshot", + )); + } + validate_cutover_service_target(service_name, expected_executable)?; + let _privilege = SecurityPrivilegeGuard::enable()?; + let _restore_privilege = SecurityPrivilegeGuard::enable_restore()?; + let service = open_service_handle( + service_name, + SERVICE_QUERY_CONFIG + | SERVICE_QUERY_STATUS + | READ_CONTROL_ACCESS + | WRITE_DAC_ACCESS + | WRITE_OWNER_ACCESS + | ACCESS_SYSTEM_SECURITY, + )? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "service disappeared"))?; + ensure_service_handle_stopped_and_exact(service_name, expected_executable, &service)?; + let descriptor = snapshot.self_relative_descriptor.as_ptr().cast_mut().cast(); + if unsafe { IsValidSecurityDescriptor(descriptor) } == 0 + || unsafe { GetSecurityDescriptorLength(descriptor) } as usize + != snapshot.self_relative_descriptor.len() + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "captured service security descriptor is invalid", + )); + } + let mut control = 0; + let mut revision = 0; + if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 + || control & SE_SELF_RELATIVE == 0 + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "captured service security descriptor is not self-relative", + )); + } + let information = OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | SACL_SECURITY_INFORMATION + | if control & SE_DACL_PROTECTED != 0 { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + } + | if control & SE_SACL_PROTECTED != 0 { + PROTECTED_SACL_SECURITY_INFORMATION + } else { + UNPROTECTED_SACL_SECURITY_INFORMATION + }; + if unsafe { SetServiceObjectSecurity(service.0, information, descriptor) } == 0 { + return Err(io::Error::last_os_error()); + } + let observed = query_service_security_snapshot(&service)?; + if observed != *snapshot { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "restored service security did not query back exactly", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn set_current_proxifyre_service_security_platform(expected_executable: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + }; + use windows_sys::Win32::System::Services::{ + SetServiceObjectSecurity, SERVICE_QUERY_CONFIG, SERVICE_QUERY_STATUS, + }; + + const READ_CONTROL_ACCESS: u32 = 0x0002_0000; + const WRITE_DAC_ACCESS: u32 = 0x0004_0000; + const SERVICE_DACL_SDDL: &str = concat!( + "D:P", + "(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;SY)", + "(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)", + "(A;;CCLCSWLORC;;;BU)" + ); + + validate_cutover_service_target(KnownWindowsService::Proxifyre.name(), expected_executable)?; + let service = open_service_handle( + KnownWindowsService::Proxifyre.name(), + SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | READ_CONTROL_ACCESS | WRITE_DAC_ACCESS, + )? + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "service disappeared"))?; + ensure_service_handle_stopped_and_exact( + KnownWindowsService::Proxifyre.name(), + expected_executable, + &service, + )?; + let descriptor = service_security_descriptor_from_sddl(SERVICE_DACL_SDDL)?; + if unsafe { + SetServiceObjectSecurity( + service.0, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + descriptor.0, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if !query_service_dacl_matches(&service)? { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "current ProxiFyre service DACL did not query back exactly", + )); + } + Ok(()) +} + +#[cfg(not(windows))] +fn set_current_proxifyre_service_security_platform(_expected_executable: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "SCM security mutation is supported only on Windows", + )) +} + +#[cfg(windows)] +fn ensure_service_handle_stopped_and_exact( + service_name: &str, + expected_executable: &Path, + service: &ServiceHandle, +) -> io::Result<()> { + let base = query_service_base_config(service)?; + if !service_path_matches_for_name(service_name, &base.binary_path_name, expected_executable) + || service_state(query_service_status(service)?.dwCurrentState) != ServiceState::Stopped + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "service must be stopped with the exact expected PathName", + )); + } + Ok(()) +} + +fn normalize_windows_path(value: &str) -> String { + value + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} + +#[cfg(windows)] +struct ServiceHandle(windows_sys::Win32::System::Services::SC_HANDLE); + +#[cfg(windows)] +impl ServiceHandle { + fn new(handle: windows_sys::Win32::System::Services::SC_HANDLE) -> io::Result { + if handle.is_null() { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } +} + +#[cfg(windows)] +impl Drop for ServiceHandle { + fn drop(&mut self) { + // SAFETY: the handle came from an SCM open call and is closed exactly once. + unsafe { windows_sys::Win32::System::Services::CloseServiceHandle(self.0) }; + } +} + +#[cfg(windows)] +fn wide_service_name(value: &str) -> io::Result> { + use std::os::windows::ffi::OsStrExt; + if value.is_empty() || value.len() > 256 || value.contains(['\0', '/', '\\']) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid service name", + )); + } + let mut wide = OsStr::new(value).encode_wide().collect::>(); + wide.push(0); + Ok(wide) +} + +#[cfg(windows)] +fn wide_pointer_in_buffer(pointer: *mut u16, buffer: &[usize]) -> io::Result { + if pointer.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service configuration has no binary path", + )); + } + let start = buffer.as_ptr() as usize; + let end = start.saturating_add(std::mem::size_of_val(buffer)); + let pointer_address = pointer as usize; + if pointer_address < start + || pointer_address >= end + || !(pointer_address - start).is_multiple_of(2) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "service binary path points outside the configuration buffer", + )); + } + let remaining_units = (end - pointer_address) / 2; + // SAFETY: the pointer was range-checked against the live aligned configuration buffer. + let units = unsafe { std::slice::from_raw_parts(pointer, remaining_units) }; + let length = units + .iter() + .position(|unit| *unit == 0) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "unterminated service path"))?; + String::from_utf16(&units[..length]) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "invalid service path UTF-16")) +} + +#[cfg(windows)] +fn service_state(value: u32) -> ServiceState { + use windows_sys::Win32::System::Services::{ + SERVICE_CONTINUE_PENDING, SERVICE_PAUSED, SERVICE_PAUSE_PENDING, SERVICE_RUNNING, + SERVICE_START_PENDING, SERVICE_STOPPED, SERVICE_STOP_PENDING, + }; + match value { + SERVICE_STOPPED => ServiceState::Stopped, + SERVICE_START_PENDING => ServiceState::StartPending, + SERVICE_STOP_PENDING => ServiceState::StopPending, + SERVICE_RUNNING => ServiceState::Running, + SERVICE_CONTINUE_PENDING => ServiceState::ContinuePending, + SERVICE_PAUSE_PENDING => ServiceState::PausePending, + SERVICE_PAUSED => ServiceState::Paused, + other => ServiceState::Unknown(other), + } +} + +pub fn verify_authenticode( + path: impl AsRef, +) -> Result { + verify_authenticode_platform(path.as_ref()) +} + +#[cfg(windows)] +fn verify_authenticode_platform( + path: &Path, +) -> Result { + use std::{os::windows::ffi::OsStrExt, ptr}; + + use windows_sys::Win32::Security::WinTrust::{ + WinVerifyTrust, WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_FILE_INFO, + WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE, WTD_DISABLE_MD2_MD4, + WTD_REVOCATION_CHECK_NONE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, + WTD_UI_NONE, + }; + + crate::safe_fs::ensure_no_reparse_ancestors(path) + .map_err(|_| AuthenticodeError::UnsafeTarget)?; + let metadata = std::fs::symlink_metadata(path).map_err(|_| AuthenticodeError::InvalidTarget)?; + if !metadata.file_type().is_file() { + return Err(AuthenticodeError::InvalidTarget); + } + + let mut wide_path = path.as_os_str().encode_wide().collect::>(); + if wide_path.contains(&0) { + return Err(AuthenticodeError::InvalidPath); + } + wide_path.push(0); + + let mut file_info = WINTRUST_FILE_INFO { + cbStruct: size_of::() as u32, + pcwszFilePath: wide_path.as_ptr(), + ..Default::default() + }; + let mut trust_data = WINTRUST_DATA { + cbStruct: size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_NONE, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: windows_sys::Win32::Security::WinTrust::WINTRUST_DATA_0 { + pFile: &mut file_info, + }, + dwStateAction: WTD_STATEACTION_VERIFY, + dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_REVOCATION_CHECK_NONE | WTD_DISABLE_MD2_MD4, + ..Default::default() + }; + let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2; + + // SAFETY: every pointer references live stack/vector storage for the whole call. The + // WinTrust state is closed below before those values are dropped. + let status_code = unsafe { + WinVerifyTrust( + ptr::null_mut(), + &mut action, + (&mut trust_data as *mut WINTRUST_DATA).cast(), + ) + }; + + let publisher = if status_code == 0 { + // SAFETY: a successful stateful WinVerifyTrust call owns hWVTStateData until the + // matching WTD_STATEACTION_CLOSE below. Provider helpers return borrowed pointers. + unsafe { publisher_from_trust_state(&trust_data) }.map(Some) + } else { + Ok(None) + }; + + trust_data.dwStateAction = WTD_STATEACTION_CLOSE; + // SAFETY: closes the state created by the matching verify call. No provider pointer is + // accessed after this call. + unsafe { + WinVerifyTrust( + ptr::null_mut(), + &mut action, + (&mut trust_data as *mut WINTRUST_DATA).cast(), + ); + } + + Ok(AuthenticodeVerification { + is_trusted: status_code == 0, + publisher: publisher?, + status_code, + }) +} + +#[cfg(windows)] +unsafe fn publisher_from_trust_state( + trust_data: &windows_sys::Win32::Security::WinTrust::WINTRUST_DATA, +) -> Result { + use windows_sys::Win32::Security::{ + Cryptography::{szOID_COMMON_NAME, szOID_ORGANIZATION_NAME}, + WinTrust::{ + WTHelperGetProvCertFromChain, WTHelperGetProvSignerFromChain, + WTHelperProvDataFromStateData, + }, + }; + + let provider_data = WTHelperProvDataFromStateData(trust_data.hWVTStateData); + if provider_data.is_null() { + return Err(AuthenticodeError::SignerCertificateUnavailable); + } + + let signer = WTHelperGetProvSignerFromChain(provider_data, 0, 0, 0); + if signer.is_null() { + return Err(AuthenticodeError::SignerCertificateUnavailable); + } + + let provider_cert = WTHelperGetProvCertFromChain(signer, 0); + if provider_cert.is_null() || (*provider_cert).pCert.is_null() { + return Err(AuthenticodeError::SignerCertificateUnavailable); + } + let cert = (*provider_cert).pCert; + + Ok(AuthenticodePublisher { + common_name: certificate_attribute(cert, szOID_COMMON_NAME)?, + organization: certificate_attribute(cert, szOID_ORGANIZATION_NAME)?, + }) +} + +#[cfg(windows)] +unsafe fn certificate_attribute( + cert: *const windows_sys::Win32::Security::Cryptography::CERT_CONTEXT, + oid: windows_sys::core::PCSTR, +) -> Result { + use std::ptr; + + use windows_sys::Win32::Security::Cryptography::{CertGetNameStringW, CERT_NAME_ATTR_TYPE}; + + let name_len = CertGetNameStringW(cert, CERT_NAME_ATTR_TYPE, 0, oid.cast(), ptr::null_mut(), 0); + if name_len <= 1 { + return Err(AuthenticodeError::PublisherUnavailable); + } + + let mut name = vec![0_u16; name_len as usize]; + let written = CertGetNameStringW( + cert, + CERT_NAME_ATTR_TYPE, + 0, + oid.cast(), + name.as_mut_ptr(), + name_len, + ); + if written != name_len || name.last() != Some(&0) { + return Err(AuthenticodeError::PublisherUnavailable); + } + + String::from_utf16(&name[..name.len() - 1]).map_err(|_| AuthenticodeError::InvalidPublisherName) +} + +#[cfg(not(windows))] +fn verify_authenticode_platform( + _path: &Path, +) -> Result { + Err(AuthenticodeError::UnsupportedPlatform) +} + #[cfg(windows)] fn hide_console_window(command: &mut Command) { use std::os::windows::process::CommandExt; @@ -16,3 +3763,196 @@ fn hide_console_window(command: &mut Command) { #[cfg(not(windows))] fn hide_console_window(_command: &mut Command) {} + +#[cfg(test)] +mod service_policy_tests { + use super::*; + + #[test] + fn bounded_process_timeout_child() { + if std::env::var_os("PROXYWARDEN_BOUNDED_PROCESS_TIMEOUT_CHILD").is_some() { + std::thread::sleep(Duration::from_secs(30)); + } + } + + #[test] + fn bounded_process_timeout_never_waits_unbounded() { + let test_binary = std::env::current_exe().expect("current test executable"); + let started = std::time::Instant::now(); + let error = run_command_bounded( + command_no_window(test_binary) + .arg("bounded_process_timeout_child") + .env("PROXYWARDEN_BOUNDED_PROCESS_TIMEOUT_CHILD", "1"), + Duration::from_millis(100), + ) + .expect_err("sleeping child must hit the fixed deadline"); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert!( + started.elapsed() < Duration::from_secs(5), + "timed-out child cleanup exceeded its bounded grace period" + ); + } + + #[test] + fn exact_service_path_rejects_arguments_and_prefixes() { + let expected = std::env::current_dir() + .expect("current directory") + .join("components") + .join("proxifyre") + .join("ProxiFyre.exe"); + let exact = expected.to_string_lossy(); + assert!(service_path_matches_exact(&exact, &expected)); + assert!(service_path_matches_exact( + &format!("\"{exact}\""), + &expected + )); + assert!(!service_path_matches_exact( + &format!("\"{exact}\" --run"), + &expected + )); + assert!(!service_path_matches_exact( + &format!("{exact}.foreign"), + &expected + )); + } + + #[test] + fn proxifyre_topshelf_path_accepts_only_the_frozen_argument_set() { + let expected = std::env::current_dir() + .expect("current directory") + .join("components with spaces") + .join("proxifyre") + .join("ProxiFyre.exe"); + let exact = expected.to_string_lossy(); + for path_name in [ + format!( + "\"{exact}\" -displayname \"ProxiFyre Service\" -servicename \"ProxiFyreService\"" + ), + format!("\"{exact}\" -servicename ProxiFyreService -displayname \"ProxiFyre Service\""), + ] { + assert!(service_path_matches_for_name( + KnownWindowsService::Proxifyre.name(), + &path_name, + &expected, + )); + } + for path_name in [ + format!("\"{exact}\""), + format!( + "\"{exact}\" -displayname \"ProxiFyre Service\" -servicename ProxiFyreService --run" + ), + format!("\"{exact}\" -displayname \"Foreign Service\" -servicename ProxiFyreService"), + format!("\"{exact}\" -displayname \"ProxiFyre Service\" -servicename ForeignService"), + format!("\"{exact}\" -displayname \"ProxiFyre Service -servicename ProxiFyreService"), + ] { + assert!(!service_path_matches_for_name( + KnownWindowsService::Proxifyre.name(), + &path_name, + &expected, + )); + } + assert!(!service_path_matches_for_name( + KnownWindowsService::SingBox.name(), + &format!( + "\"{exact}\" -displayname \"ProxiFyre Service\" -servicename ProxiFyreService" + ), + &expected, + )); + } + + #[test] + fn full_service_snapshot_requires_each_config2_record_once() { + let values = vec![ + ServiceConfig2Snapshot::Description(None), + ServiceConfig2Snapshot::FailureActions(ServiceFailureActionsSnapshot { + reset_period_seconds: 0, + reboot_message: None, + command: None, + actions: Vec::new(), + }), + ServiceConfig2Snapshot::FailureActionsFlag(false), + ServiceConfig2Snapshot::DelayedAutoStart(false), + ServiceConfig2Snapshot::SidType(0), + ServiceConfig2Snapshot::RequiredPrivileges(Vec::new()), + ServiceConfig2Snapshot::PreshutdownTimeout(180_000), + ServiceConfig2Snapshot::Triggers(Vec::new()), + ServiceConfig2Snapshot::PreferredNode { + node: 0, + delete: false, + }, + ServiceConfig2Snapshot::LaunchProtected(0), + ]; + let snapshot = FullServiceSnapshot { + service_name: KnownWindowsService::Proxifyre.name().to_owned(), + base: expected_current_proxifyre_service_base( + &std::env::current_dir() + .expect("current directory") + .join("ProxiFyre.exe"), + ) + .expect("fixed base"), + config2: values.clone(), + security: ServiceSecuritySnapshot { + self_relative_descriptor: vec![1], + untrusted_mutation_rights: false, + }, + original_state: ServiceStableState::Stopped, + }; + assert!(snapshot.has_complete_config2()); + + let mut duplicate = snapshot; + duplicate.config2.pop(); + duplicate.config2.push(values[0].clone()); + assert!(!duplicate.has_complete_config2()); + } + + #[test] + fn current_proxifyre_base_is_demand_start_with_current_display_name() { + let executable = std::env::current_dir() + .expect("current directory") + .join("components") + .join("proxifyre") + .join("ProxiFyre.exe"); + let base = + expected_current_proxifyre_service_base(&executable).expect("current fixed base"); + assert_eq!(base.start_type, 3); + assert_eq!(base.display_name, "ProxyWarden ProxiFyre"); + assert!(known_service_path_matches_exact( + KnownWindowsService::Proxifyre, + &base.binary_path_name, + &executable, + )); + } + + #[cfg(windows)] + #[test] + fn untrusted_generic_execute_ace_blocks_cutover_capture() { + let descriptor = service_security_descriptor_from_sddl("O:SYG:SYD:(A;;GX;;;BU)") + .expect("synthetic descriptor"); + assert!(service_security_has_untrusted_mutation_rights(descriptor.0) + .expect("synthetic DACL inspection")); + + let trusted = service_security_descriptor_from_sddl( + "O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;CCLCSWLORC;;;BU)", + ) + .expect("trusted descriptor"); + assert!(!service_security_has_untrusted_mutation_rights(trusted.0) + .expect("trusted DACL inspection")); + } + + #[cfg(windows)] + #[test] + fn missing_service_policy_query_is_read_only_and_exact() { + let service_name = format!("ProxyWardenMissingService{}", uuid::Uuid::new_v4().simple()); + let expected = std::env::current_exe().expect("current executable"); + let snapshot = query_service_policy_exact(&service_name, &expected) + .expect("missing service query must succeed"); + assert_eq!(snapshot, ServicePolicySnapshot::missing()); + assert_eq!( + wait_known_service_deleted(KnownWindowsService::SingBox, &expected, Duration::ZERO,) + .expect_err("zero timeout must fail before querying SCM") + .kind(), + io::ErrorKind::InvalidInput, + ); + } +} diff --git a/src-tauri/src/proxifyre_ownership.rs b/src-tauri/src/proxifyre_ownership.rs index a53a505..fcd41d4 100644 --- a/src-tauri/src/proxifyre_ownership.rs +++ b/src-tauri/src/proxifyre_ownership.rs @@ -23,6 +23,22 @@ struct ProxiFyreInstallMarker { packet_filter_installed_by_proxy_warden: bool, } +pub fn validate_proxifyre_marker_text( + marker_text: &str, + expected_install_dir: &Path, +) -> Result { + let marker = parse_marker(marker_text)?; + validate_marker_identity(&marker)?; + if !same_path(Path::new(&marker.install_root), expected_install_dir) { + return Err("installRoot из marker не совпадает с управляемой папкой".to_string()); + } + + Ok(ManagedProxiFyreOwnership { + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(), + remove_packet_filter: marker.packet_filter_installed_by_proxy_warden, + }) +} + pub fn verify_managed_proxifyre_install( install_dir: &Path, executable_path: &Path, @@ -68,25 +84,13 @@ pub fn verify_managed_proxifyre_install( marker_path.display() ) })?; - let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(&marker_text); - let marker: ProxiFyreInstallMarker = serde_json::from_str(marker_text).map_err(|error| { + let marker = parse_marker(&marker_text).map_err(|error| { format!( - "marker установки {} содержит некорректный JSON: {error}", + "marker установки {} содержит некорректные данные: {error}", marker_path.display() ) })?; - - if !marker.manager.eq_ignore_ascii_case("ProxyWarden") - || !marker.component.eq_ignore_ascii_case("proxifyre") - { - return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string()); - } - if !marker - .service_name - .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) - { - return Err("marker установки содержит неподдерживаемое имя службы".to_string()); - } + validate_marker_identity(&marker)?; let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?; if marker_root != install_dir { @@ -99,6 +103,39 @@ pub fn verify_managed_proxifyre_install( }) } +fn parse_marker(marker_text: &str) -> Result { + let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(marker_text); + serde_json::from_str(marker_text) + .map_err(|error| format!("marker содержит некорректный JSON: {error}")) +} + +fn validate_marker_identity(marker: &ProxiFyreInstallMarker) -> Result<(), String> { + if !marker.manager.eq_ignore_ascii_case("ProxyWarden") + || !marker.component.eq_ignore_ascii_case("proxifyre") + { + return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string()); + } + if !marker + .service_name + .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) + { + return Err("marker установки содержит неподдерживаемое имя службы".to_string()); + } + Ok(()) +} + +fn same_path(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .eq_ignore_ascii_case( + right + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\'), + ) +} + fn canonical_path(path: &Path, label: &str) -> Result { fs::canonicalize(path) .map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display())) diff --git a/src-tauri/src/proxifyre_runtime.rs b/src-tauri/src/proxifyre_runtime.rs index 18164bb..cca39e8 100644 --- a/src-tauri/src/proxifyre_runtime.rs +++ b/src-tauri/src/proxifyre_runtime.rs @@ -1,538 +1,4527 @@ -//! Explicit ProxiFyre service, package, setup-status, and UAC orchestration. +//! Explicit native ProxiFyre service, package, and setup-status orchestration. //! -//! The webview cannot execute these scripts directly. Tauri handlers call the -//! bounded functions here only after explicit user actions. +//! Tauri handlers call these bounded operations only after explicit user +//! actions; this module does not generate or execute PowerShell scripts. -use crate::clock::{Clock, SystemClock}; use crate::command_dto::*; +use crate::component_catalog::ComponentId; +use crate::component_cutover::{ + current_candidate_manifest_fingerprint, CurrentCandidateFile, CurrentCandidateFileRole, + CurrentCandidateSnapshot, CutoverExecutionMode, CutoverJournal, CutoverOperation, + EffectDisposition, MutationDirection, ProxifyreCutoverPlan, SealedPathSnapshot, + StateFingerprint, CURRENT_PROXIFYRE_PACKAGE_FILES, +}; use crate::component_detection::{ app_install_dir_from_current_exe, detect_proxyfier_install, proxifyre_install_dir_from_app_dir, - proxyfier_component_from_detection, singbox_install_dir_from_app_dir, DetectedProxyfier, + singbox_install_dir_from_app_dir, DetectedProxyfier, }; -use crate::elevated_scripts; -use crate::powershell::{ - escape_single as escape_powershell_single, is_elevated as is_running_elevated, - package_failure_details, run_command as run_powershell_command, - run_file as run_powershell_file, write_script as write_powershell_script, +use crate::component_inventory::{ + run_authorized_component_action, AuthorizedActionError, ComponentCandidate, ComponentInventory, + InventoryAction, InventoryIssue, }; -use crate::process::command_no_window; -use crate::proxifyre_ownership::verify_managed_proxifyre_install; -use crate::proxifyre_scripts::{ - configure_proxifyre_firewall_script, install_proxifyre_script_for_target, - uninstall_proxifyre_script, +use crate::component_packages::{PackageSource, PrivilegedPackageLease, PrivilegedPackageProof}; +use crate::privileged_jobs::{ + read_install_receipt, write_install_receipt, CanonicalComponentRoot, InstallReceipt, + InstallReceiptManager, InstalledAssetReceipt, InstalledFileReceipt, InstalledPackageSource, + ManagedComponent, WindowsPacketFilterOwnership, INSTALL_RECEIPT_FILENAME, + INSTALL_RECEIPT_SCHEMA_VERSION, WINDOWS_PACKET_FILTER_PRODUCT_CODE, }; +use crate::process::{ + capture_service_restore_snapshot, create_current_proxifyre_service_base, delete_known_service, + enforce_service_policy_exact, expected_current_proxifyre_service_base, known_system_executable, + query_proxifyre_complete_service_observation, query_service_config2_exact, + query_service_policy_exact, restore_legacy_proxifyre_service_base, + restore_service_config2_exact, restore_service_security_exact, run_fixed_process, + run_fixed_process_in_directory, set_current_proxifyre_service_security, start_known_service, + stop_known_service, wait_known_service_deleted, wait_service_state_exact, + CompleteServiceObservation, KnownSystemExecutable, KnownWindowsService, + ServiceBaseConfigSnapshot, ServiceConfig2Kind, ServiceConfig2Snapshot, + ServiceFailureActionsSnapshot, ServicePolicySnapshot, ServiceRestoreSnapshot, + ServiceStableState, ServiceState, SERVICE_CONFIG2_KINDS, +}; +use crate::proxifyre_ownership::{PROXIFYRE_MANAGED_SERVICE_NAME, PROXIFYRE_MARKER_FILE}; use crate::safe_fs; -use crate::storage::{default_config_root, JsonStorage}; -use serde::Deserialize; -use std::fs; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri::Manager; +use thiserror::Error; -pub(crate) fn control_proxifyre_service( - action: ServiceControlAction, -) -> Result { - let Some(detected) = detect_proxyfier_install() else { - return Err(CommandError::new( - "proxifyre_not_found", - "ProxiFyre не найден на компьютере.", - )); - }; +const MAX_PROXIFYRE_ARCHIVE_ENTRIES: usize = 32; +const MAX_PROXIFYRE_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_PROXIFYRE_EXPANDED_BYTES: u64 = 128 * 1024 * 1024; +const MAX_NATIVE_PACKAGE_BYTES: u64 = 256 * 1024 * 1024; +const MAX_RUNTIME_CONFIG_BYTES: u64 = 16 * 1024 * 1024; +const INSTALLER_PROCESS_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const SERVICE_INSTALLER_PROCESS_TIMEOUT: Duration = Duration::from_secs(2 * 60); +const FIREWALL_PROCESS_TIMEOUT: Duration = Duration::from_secs(30); +const PROXIFYRE_FIREWALL_INBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Inbound"; +const PROXIFYRE_FIREWALL_OUTBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Outbound"; +const PROXIFYRE_NLOG_CONFIG: &[u8] = br#" + + + + + + + + +"#; - let service_name = detected.service_name.as_deref().ok_or_else(|| { - CommandError::new( - action.error_code(), - "Служба ProxiFyre найдена не была или ее PathName не совпадает с обнаруженным ProxiFyre.exe. Управление службой заблокировано.", - ) - })?; - run_proxifyre_service_command(action, service_name, &detected.executable_path)?; - let refreshed = detect_proxyfier_install(); - let component = proxyfier_component_from_detection(refreshed.as_ref()); - - Ok(ComponentStatusDto::from(&component)) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ServiceCommandOutput { - success: bool, - code: String, - service_name: Option, - status: Option, - process_id: Option, -} - -fn run_proxifyre_service_command( - action: ServiceControlAction, - service_name: &str, - executable_path: &Path, -) -> Result<(), CommandError> { - let escaped_service_name = escape_powershell_single(service_name); - let executable_path = executable_path.display().to_string(); - let escaped_executable_path = escape_powershell_single(&executable_path); - let action_name = match action { - ServiceControlAction::Start => "start", - ServiceControlAction::Stop => "stop", - }; - let script = format!( - r#" -$ErrorActionPreference = 'Stop' -$serviceName = '{escaped_service_name}' -$exePath = '{escaped_executable_path}' -$action = '{action_name}' -$service = $null - -function Get-ServiceBinaryPath([string]$pathName) {{ - if ([string]::IsNullOrWhiteSpace($pathName)) {{ return $null }} - $pathName = $pathName.Trim() - if ($pathName.StartsWith('"')) {{ - $closingQuote = $pathName.IndexOf('"', 1) - if ($closingQuote -lt 2) {{ return $null }} - return $pathName.Substring(1, $closingQuote - 1) - }} - return ($pathName -split '\s+', 2)[0] -}} - -function Find-ManagedProxiFyreService {{ - $escapedName = $serviceName.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return $null }} - $binaryPath = Get-ServiceBinaryPath $record.PathName - if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) {{ return $null }} - return Get-Service -Name $serviceName -ErrorAction SilentlyContinue -}} - -function Get-ServiceProcessId([string]$name) {{ - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return 0 }} - return [int]$record.ProcessId -}} - -function Get-ServiceStatus([string]$name) {{ - $current = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -eq $current) {{ return $null }} - return $current.Status.ToString() -}} - -function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{ - [PSCustomObject]@{{ - success = $success - code = $code - serviceName = if ($null -ne $service) {{ $service.Name }} else {{ $null }} - status = $status - processId = $processId - }} | ConvertTo-Json -Compress - exit 0 -}} - -$service = Find-ManagedProxiFyreService -if ($null -eq $service) {{ - Write-ServiceResult $false 'service_not_found' $null 0 -}} - -$status = $service.Status.ToString() -$processId = Get-ServiceProcessId $service.Name - -if ($action -eq 'start') {{ - if ($status -eq 'Running') {{ - Write-ServiceResult $true 'already_running' $status $processId - }} - - try {{ - Start-Service -Name $service.Name -ErrorAction Stop - $service = Get-Service -Name $service.Name - $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) - }} catch {{ - Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $service.Name) (Get-ServiceProcessId $service.Name) - }} - - Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $service.Name) -}} - -if ($status -eq 'Stopped') {{ - Write-ServiceResult $true 'already_stopped' $status $processId -}} - -try {{ - if ($service.CanStop) {{ - Stop-Service -Name $service.Name -Force -ErrorAction Stop - }} -}} catch {{}} - -try {{ - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $null = & sc.exe stop $service.Name 2>$null - }} -}} catch {{}} - -try {{ - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) - }} -}} catch {{}} - -$status = Get-ServiceStatus $service.Name -$processId = Get-ServiceProcessId $service.Name -if ($status -ne 'Stopped' -and $processId -gt 0) {{ - try {{ - $null = & taskkill.exe /PID $processId /F 2>$null - Start-Sleep -Milliseconds 700 - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) - }} - }} catch {{}} -}} - -$status = Get-ServiceStatus $service.Name -$processId = Get-ServiceProcessId $service.Name -if ($status -eq 'Stopped') {{ - Write-ServiceResult $true 'stopped' $status $processId -}} - -Write-ServiceResult $false 'stop_failed' $status $processId -"# - ); - - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .map_err(|error| { - CommandError::new( - action.error_code(), - format!("Не удалось {} службу ProxiFyre: {error}", action.label()), - ) - })?; - - let result = parse_service_command_output(&output.stdout).ok_or_else(|| { - CommandError::new( - action.error_code(), - service_script_failed_message(action, output.status.code()), - ) - })?; - - if result.success { - return Ok(()); - } - - if matches!(result.code.as_str(), "start_failed" | "stop_failed") { - run_elevated_proxifyre_service_command(action, service_name, &executable_path, &result)?; - return Ok(()); - } - - Err(CommandError::new( - action.error_code(), - service_command_failed_message(action, &result), - )) -} - -fn run_elevated_proxifyre_service_command( - action: ServiceControlAction, - service_name: &str, - executable_path: &str, - direct_result: &ServiceCommandOutput, -) -> Result<(), CommandError> { - let script_path = write_elevated_service_script(action, service_name, executable_path)?; - let launch_script = format!( - "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => Err(CommandError::new( - action.error_code(), - elevated_service_failed_message(action, direct_result, output.status.code()), - )), - Err(error) => Err(CommandError::new( - action.error_code(), - format!( - "Не удалось запросить права администратора, чтобы {} службу ProxiFyre: {error}", - action.label() - ), - )), - } -} - -fn write_elevated_service_script( - action: ServiceControlAction, - service_name: &str, - executable_path: &str, -) -> Result { - let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service"); - let script = elevated_service_script(action, service_name, executable_path); - - write_powershell_script(&script_path, &script).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить временный скрипт для управления ProxiFyre '{}': {error}", - script_path.display() - ), - ) - })?; - - Ok(script_path) -} - -fn elevated_service_script( - action: ServiceControlAction, - service_name: &str, - executable_path: &str, -) -> String { - let service_name = escape_powershell_single(service_name); - let executable_path = escape_powershell_single(executable_path); - let action_name = match action { - ServiceControlAction::Start => "start", - ServiceControlAction::Stop => "stop", - }; - - format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$serviceName = '{service_name}' -$exePath = '{executable_path}' -$action = '{action_name}' -$service = $null - -function Get-ServiceBinaryPath([string]$pathName) {{ - if ([string]::IsNullOrWhiteSpace($pathName)) {{ return $null }} - $pathName = $pathName.Trim() - if ($pathName.StartsWith('"')) {{ - $closingQuote = $pathName.IndexOf('"', 1) - if ($closingQuote -lt 2) {{ return $null }} - return $pathName.Substring(1, $closingQuote - 1) - }} - return ($pathName -split '\s+', 2)[0] -}} - -function Find-ManagedProxiFyreService {{ - $escapedName = $serviceName.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return $null }} - $binaryPath = Get-ServiceBinaryPath $record.PathName - if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) {{ return $null }} - return Get-Service -Name $serviceName -ErrorAction SilentlyContinue -}} - -$service = Find-ManagedProxiFyreService -if ($null -eq $service) {{ exit 2 }} - -function Get-ServiceProcessId([string]$name) {{ - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return 0 }} - return [int]$record.ProcessId -}} - -if ($action -eq 'start') {{ - if ($service.Status -eq 'Running') {{ exit 0 }} - Start-Service -Name $service.Name -ErrorAction SilentlyContinue - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Running') {{ exit 0 }} - }} - exit 3 -}} - -if ($service.Status -eq 'Stopped') {{ exit 0 }} - -if ($service.CanStop) {{ - Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $null = & sc.exe stop $service.Name 2>$null -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -ne $service -and $service.Status -ne 'Stopped') {{ - $processId = Get-ServiceProcessId $service.Name - if ($processId -gt 0) {{ - $null = & taskkill.exe /PID $processId /F 2>$null - Start-Sleep -Milliseconds 700 - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }} catch {{}} - }} - }} -}} - -$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue -if ($null -eq $service -or $service.Status -eq 'Stopped') {{ exit 0 }} -exit 4 -"# - ) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreNativeOwnership { + Missing, + Managed, + Foreign, + Incomplete, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProxiFyrePackageAction { - Install, - Uninstall, - ConfigureFirewall, +pub enum ProxifyreNativeServiceStatus { + Missing, + Stopped, + Running, + Other, } -impl ProxiFyrePackageAction { - fn error_code(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "proxifyre_install_failed", - ProxiFyrePackageAction::Uninstall => "proxifyre_uninstall_failed", - ProxiFyrePackageAction::ConfigureFirewall => "proxifyre_firewall_failed", - } - } - - fn label(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "установить", - ProxiFyrePackageAction::Uninstall => "удалить", - ProxiFyrePackageAction::ConfigureFirewall => "настроить Windows Firewall для", - } - } - - fn file_label(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "install", - ProxiFyrePackageAction::Uninstall => "uninstall", - ProxiFyrePackageAction::ConfigureFirewall => "firewall", - } - } - - fn operation(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "install", - ProxiFyrePackageAction::Uninstall => "uninstall", - ProxiFyrePackageAction::ConfigureFirewall => "firewall", - } - } - - fn start_message(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "Готовлю установку ProxiFyre.", - ProxiFyrePackageAction::Uninstall => "Готовлю удаление ProxiFyre и сетевого драйвера.", - ProxiFyrePackageAction::ConfigureFirewall => { - "Готовлю правила Windows Firewall для ProxiFyre." - } - } - } - - fn success_message(self) -> &'static str { - match self { - ProxiFyrePackageAction::Install => "ProxiFyre и сетевой драйвер готовы.", - ProxiFyrePackageAction::Uninstall => "ProxiFyre и сетевой драйвер удалены.", - ProxiFyrePackageAction::ConfigureFirewall => { - "Правила Windows Firewall для ProxiFyre добавлены." - } - } - } +/// A fresh, handle-backed view of the installed runtime and SCM policy. +/// Implementations must derive these fields from live files, receipt, and SCM +/// state on every call; cached detection is not sufficient at this boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxifyreNativeSnapshot { + pub ownership: ProxifyreNativeOwnership, + pub service_status: ProxifyreNativeServiceStatus, + pub installed_version: Option, + pub install_root_trusted: bool, + pub install_root_reparse_free: bool, + pub receipt_valid: bool, + pub receipt_files_match: bool, + pub service_path_matches: bool, + pub demand_start: bool, + pub failure_recovery_disabled: bool, + pub builtin_users_start_denied: bool, + pub vc_runtime_present: bool, + pub packet_filter_present: bool, + pub packet_filter_owned_by_proxywarden: bool, } -pub(crate) fn install_proxifyre_component( - storage: &JsonStorage, - app: &tauri::AppHandle, -) -> Result { - let generated_config_path = storage - .paths() - .generated_dir - .join("proxifyre-app-config.json"); - let bundled_asset_dir = bundled_proxifyre_asset_dir(app); - let install_dir = proxifyre_install_dir_for_app(app)?; - let script = install_proxifyre_script_for_target( - &generated_config_path, - bundled_asset_dir.as_deref(), - &install_dir, - ); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxifyreNativeOutcome { + pub changed: bool, + pub reboot_required: bool, +} - run_elevated_package_script( - ProxiFyrePackageAction::Install, - script, - &storage.paths().state_dir, - )?; - - if detect_windows_packet_filter().is_none() { - return Err(CommandError::new( - ProxiFyrePackageAction::Install.error_code(), - "Установка ProxiFyre завершилась, но Windows Packet Filter не найден после проверки.", - )); - } - - let refreshed = detect_proxyfier_install(); - let Some(detected) = refreshed.as_ref() else { - return Err(CommandError::new( - ProxiFyrePackageAction::Install.error_code(), - "Установка ProxiFyre завершилась, но приложение не найдено после проверки.", - )); +impl ProxifyreNativeOutcome { + const UNCHANGED: Self = Self { + changed: false, + reboot_required: false, }; +} - Ok(ComponentStatusDto::from( - &proxyfier_component_from_detection(Some(detected)), +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreNativeMode { + Install, + Update, + Uninstall, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreNativeServiceAction { + Start, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreNativeStartDisposition { + AlreadyRunning, + ReadyForConfigPromotion, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeConfigVerification { + Match, + Mismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreFirewallDirection { + Inbound, + Outbound, +} + +/// Typed commands deliberately expose no argument vector. A Windows host must +/// map them only to these fixed invocations: +/// - VC runtime: `/install /quiet /norestart` +/// - packet filter: `msiexec /i /qn /norestart` +/// - service: `ProxiFyre.exe install|uninstall` +/// - owned packet filter: `msiexec /x /qn /norestart` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreInstallerCommand<'a> { + InstallVcRuntime(&'a Path), + InstallPacketFilter(&'a Path), + InstallService, + UninstallService, + UninstallOwnedPacketFilter, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreInstallerKind { + VcRuntimeInstall, + PacketFilterInstall, + ServiceInstall, + ServiceUninstall, + PacketFilterUninstall, +} + +impl ProxifyreInstallerCommand<'_> { + const fn kind(self) -> ProxifyreInstallerKind { + match self { + Self::InstallVcRuntime(_) => ProxifyreInstallerKind::VcRuntimeInstall, + Self::InstallPacketFilter(_) => ProxifyreInstallerKind::PacketFilterInstall, + Self::InstallService => ProxifyreInstallerKind::ServiceInstall, + Self::UninstallService => ProxifyreInstallerKind::ServiceUninstall, + Self::UninstallOwnedPacketFilter => ProxifyreInstallerKind::PacketFilterUninstall, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProxifyreInstallerEffect { + pub changed: bool, + pub reboot_required: bool, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("native ProxiFyre host operation failed")] +pub struct ProxifyreNativeHostError; + +/// Injectable one-mutation-at-a-time SCM boundary used by the durable cutover +/// coordinator. No method executes ProxiFyre.exe, and create/restore never +/// imply start or stop. +pub trait ProxifyreCutoverScm { + fn capture_legacy_service( + &mut self, + ) -> Result; + fn query_legacy_service(&mut self) -> Result; + fn query_current_service(&mut self) -> Result; + fn query_complete_service( + &mut self, + ) -> Result; + fn expected_current_service_base( + &self, + ) -> Result; + fn query_legacy_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError>; + fn query_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError>; + fn current_service_security_matches(&mut self) -> Result; + fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn set_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result<(), ProxifyreNativeHostError>; + fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn create_legacy_service( + &mut self, + before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError>; + fn restore_legacy_service_policy( + &mut self, + snapshot: &ServiceConfig2Snapshot, + ) -> Result<(), ProxifyreNativeHostError>; + fn restore_legacy_service_security( + &mut self, + before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError>; + fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError>; +} + +pub struct SystemProxifyreCutoverScm { + legacy_executable: PathBuf, + current_executable: PathBuf, +} + +impl SystemProxifyreCutoverScm { + pub fn from_current_exe() -> Result { + let current_exe = std::env::current_exe().map_err(|_| ProxifyreNativeHostError)?; + let current_root = + CanonicalComponentRoot::from_current_exe(¤t_exe, ManagedComponent::Proxifyre) + .map_err(|_| ProxifyreNativeHostError)?; + Ok(Self { + legacy_executable: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"), + current_executable: current_root.path().join("ProxiFyre.exe"), + }) + } + + fn query_exact( + &self, + executable: &Path, + ) -> Result { + query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, executable) + .map_err(|_| ProxifyreNativeHostError) + } + + fn stop_exact(&self, executable: &Path) -> Result<(), ProxifyreNativeHostError> { + let before = self.query_exact(executable)?; + if !before.service.exists || !before.path_matches { + return Err(ProxifyreNativeHostError); + } + stop_known_service(KnownWindowsService::Proxifyre, executable) + .map_err(|_| ProxifyreNativeHostError)?; + wait_service_state_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + executable, + ServiceState::Stopped, + Duration::from_secs(15), + ) + .map(|_| ()) + .map_err(|_| ProxifyreNativeHostError) + } + + fn start_exact(&self, executable: &Path) -> Result<(), ProxifyreNativeHostError> { + let before = self.query_exact(executable)?; + if !before.service.exists || !before.path_matches { + return Err(ProxifyreNativeHostError); + } + start_known_service(KnownWindowsService::Proxifyre, executable) + .map_err(|_| ProxifyreNativeHostError)?; + wait_service_state_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + executable, + ServiceState::Running, + Duration::from_secs(15), + ) + .map(|_| ()) + .map_err(|_| ProxifyreNativeHostError) + } + + fn delete_exact(&self, executable: &Path) -> Result<(), ProxifyreNativeHostError> { + let before = self.query_exact(executable)?; + if !before.service.exists + || !before.path_matches + || before.service.state != Some(ServiceState::Stopped) + { + return Err(ProxifyreNativeHostError); + } + delete_known_service(KnownWindowsService::Proxifyre, executable) + .map_err(|_| ProxifyreNativeHostError)?; + wait_known_service_deleted( + KnownWindowsService::Proxifyre, + executable, + Duration::from_secs(15), + ) + .map_err(|_| ProxifyreNativeHostError) + } +} + +impl ProxifyreCutoverScm for SystemProxifyreCutoverScm { + fn capture_legacy_service( + &mut self, + ) -> Result { + let snapshot = capture_service_restore_snapshot( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.legacy_executable, + ) + .map_err(|_| ProxifyreNativeHostError)?; + if !crate::component_inventory::legacy_proxifyre_topshelf_path_matches( + &snapshot.base.binary_path_name, + &self.legacy_executable, + ) || !snapshot.has_complete_config2() + { + return Err(ProxifyreNativeHostError); + } + Ok(snapshot) + } + + fn query_legacy_service(&mut self) -> Result { + self.query_exact(&self.legacy_executable) + } + + fn query_current_service(&mut self) -> Result { + self.query_exact(&self.current_executable) + } + + fn query_complete_service( + &mut self, + ) -> Result { + query_proxifyre_complete_service_observation().map_err(|_| ProxifyreNativeHostError) + } + + fn expected_current_service_base( + &self, + ) -> Result { + expected_current_proxifyre_service_base(&self.current_executable) + .map_err(|_| ProxifyreNativeHostError) + } + + fn query_legacy_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError> { + query_service_config2_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.legacy_executable, + kind, + ) + .map_err(|_| ProxifyreNativeHostError) + } + + fn query_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError> { + query_service_config2_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.current_executable, + kind, + ) + .map_err(|_| ProxifyreNativeHostError) + } + + fn current_service_security_matches(&mut self) -> Result { + self.query_exact(&self.current_executable).map(|snapshot| { + snapshot.service.exists && snapshot.path_matches && snapshot.dacl_matches + }) + } + + fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.stop_exact(&self.legacy_executable) + } + + fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.delete_exact(&self.legacy_executable) + } + + fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + if self.query_exact(&self.current_executable)?.service.exists { + return Err(ProxifyreNativeHostError); + } + create_current_proxifyre_service_base(&self.current_executable) + .map(|_| ()) + .map_err(|_| ProxifyreNativeHostError) + } + + fn set_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result<(), ProxifyreNativeHostError> { + let snapshot = expected_current_proxifyre_service_policy(kind); + restore_service_config2_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.current_executable, + &snapshot, + ) + .map_err(|_| ProxifyreNativeHostError) + } + + fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError> { + set_current_proxifyre_service_security(&self.current_executable) + .map_err(|_| ProxifyreNativeHostError) + } + + fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.start_exact(&self.current_executable) + } + + fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.stop_exact(&self.current_executable) + } + + fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.delete_exact(&self.current_executable) + } + + fn create_legacy_service( + &mut self, + before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError> { + if !before + .service_name + .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) + || self.query_exact(&self.legacy_executable)?.service.exists + { + return Err(ProxifyreNativeHostError); + } + restore_legacy_proxifyre_service_base(&self.legacy_executable, &before.base) + .map(|_| ()) + .map_err(|_| ProxifyreNativeHostError) + } + + fn restore_legacy_service_policy( + &mut self, + snapshot: &ServiceConfig2Snapshot, + ) -> Result<(), ProxifyreNativeHostError> { + restore_service_config2_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.legacy_executable, + snapshot, + ) + .map_err(|_| ProxifyreNativeHostError) + } + + fn restore_legacy_service_security( + &mut self, + before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError> { + if !before + .service_name + .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) + { + return Err(ProxifyreNativeHostError); + } + restore_service_security_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.legacy_executable, + &before.security, + ) + .map_err(|_| ProxifyreNativeHostError) + } + + fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.start_exact(&self.legacy_executable) + } +} + +pub fn expected_current_proxifyre_service_policy( + kind: ServiceConfig2Kind, +) -> ServiceConfig2Snapshot { + match kind { + ServiceConfig2Kind::Description => ServiceConfig2Snapshot::Description(None), + ServiceConfig2Kind::FailureActions => { + ServiceConfig2Snapshot::FailureActions(ServiceFailureActionsSnapshot { + reset_period_seconds: 0, + reboot_message: None, + command: None, + actions: Vec::new(), + }) + } + ServiceConfig2Kind::FailureActionsFlag => ServiceConfig2Snapshot::FailureActionsFlag(false), + ServiceConfig2Kind::DelayedAutoStart => ServiceConfig2Snapshot::DelayedAutoStart(false), + ServiceConfig2Kind::SidType => ServiceConfig2Snapshot::SidType(0), + ServiceConfig2Kind::RequiredPrivileges => { + ServiceConfig2Snapshot::RequiredPrivileges(Vec::new()) + } + ServiceConfig2Kind::PreshutdownTimeout => { + ServiceConfig2Snapshot::PreshutdownTimeout(180_000) + } + ServiceConfig2Kind::Triggers => ServiceConfig2Snapshot::Triggers(Vec::new()), + ServiceConfig2Kind::PreferredNode => ServiceConfig2Snapshot::PreferredNode { + node: 0, + delete: false, + }, + ServiceConfig2Kind::LaunchProtected => ServiceConfig2Snapshot::LaunchProtected(0), + } +} + +/// Canonical typed state hashed by the SCM cutover adapter. A satisfied +/// operation uses one stable token. Every non-satisfying live service is +/// hashed with its complete SCM snapshot, so unrelated drift can never collapse +/// into a shared `unexpected` value and be mistaken for `NoEffect`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", content = "observation", rename_all = "snake_case")] +pub enum ProxifyreCutoverScmFingerprintState { + ExpectedEffect, + Observed(CompleteServiceObservation), +} + +/// Observes one journaled SCM operation through a complete, stable SCM +/// snapshot and returns its typed state fingerprint. This function is +/// read-only and never executes the service binary. +pub fn observe_proxifyre_cutover_scm_state( + host: &mut dyn ProxifyreCutoverScm, + operation: &CutoverOperation, + before: &ServiceRestoreSnapshot, +) -> Result { + if !proxifyre_cutover_scm_operation(operation) { + return Err(ProxifyreNativeHostError); + } + let live = host.query_complete_service()?; + let current_base = host.expected_current_service_base()?; + let state = if proxifyre_cutover_scm_effect_matches(operation, before, ¤t_base, &live) { + ProxifyreCutoverScmFingerprintState::ExpectedEffect + } else { + ProxifyreCutoverScmFingerprintState::Observed(live) + }; + proxifyre_cutover_scm_state_fingerprint(operation, &state) +} + +/// Returns the exact fingerprint used when the selected SCM operation's owned +/// effect is satisfied. Any non-satisfying observation is instead fingerprinted +/// with all of its live SCM fields by `observe_proxifyre_cutover_scm_state`. +pub fn expected_proxifyre_cutover_scm_effect( + operation: &CutoverOperation, +) -> Result { + if !proxifyre_cutover_scm_operation(operation) { + return Err(ProxifyreNativeHostError); + } + proxifyre_cutover_scm_state_fingerprint( + operation, + &ProxifyreCutoverScmFingerprintState::ExpectedEffect, + ) +} + +fn proxifyre_cutover_scm_state_fingerprint( + operation: &CutoverOperation, + state: &ProxifyreCutoverScmFingerprintState, +) -> Result { + let bytes = serde_json::to_vec(&(operation, state)).map_err(|_| ProxifyreNativeHostError)?; + Ok(StateFingerprint::digest( + "proxywarden:proxifyre-cutover-scm-state:v1", + &bytes, )) } -pub(crate) fn configure_proxifyre_firewall(app: &tauri::AppHandle) -> Result<(), CommandError> { - let Some(detected) = detect_proxyfier_install() else { - return Err(CommandError::new( - ProxiFyrePackageAction::ConfigureFirewall.error_code(), - "ProxiFyre не найден на компьютере.", - )); - }; - - let expected_install_dir = proxifyre_install_dir_for_app(app)?; - verify_managed_proxifyre_install( - &detected.install_dir, - &detected.executable_path, - &expected_install_dir, - ) - .map_err(|reason| { - CommandError::new( - ProxiFyrePackageAction::ConfigureFirewall.error_code(), - format!("Настройка Windows Firewall заблокирована: {reason}"), - ) - })?; - - let script = configure_proxifyre_firewall_script(&detected.executable_path); - let artifact_dir = default_config_root().join("state"); - run_elevated_package_script( - ProxiFyrePackageAction::ConfigureFirewall, - script, - &artifact_dir, +fn proxifyre_cutover_scm_operation(operation: &CutoverOperation) -> bool { + matches!( + operation, + CutoverOperation::StopLegacyService + | CutoverOperation::DeleteLegacyService + | CutoverOperation::CreateCurrentService + | CutoverOperation::SetCurrentServicePolicy(_) + | CutoverOperation::SetCurrentServiceSecurity + | CutoverOperation::StartCurrentService + | CutoverOperation::StopCurrentService + | CutoverOperation::DeleteCurrentService + | CutoverOperation::CreateLegacyService + | CutoverOperation::RestoreLegacyServicePolicy(_) + | CutoverOperation::RestoreLegacyServiceSecurity + | CutoverOperation::StartLegacyService ) } -fn bundled_proxifyre_asset_dir(app: &tauri::AppHandle) -> Option { - let mut candidates = Vec::new(); - if let Ok(resource_dir) = app.path().resource_dir() { - candidates.push(resource_dir.join("bundled").join("proxifyre")); +fn proxifyre_cutover_scm_effect_matches( + operation: &CutoverOperation, + before: &ServiceRestoreSnapshot, + current_base: &ServiceBaseConfigSnapshot, + live: &CompleteServiceObservation, +) -> bool { + let CompleteServiceObservation::Present { + snapshot, + current_dacl_matches, + } = live + else { + return matches!( + operation, + CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService + ); + }; + let snapshot = snapshot.as_ref(); + if !snapshot + .service_name + .eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME) + || !snapshot.has_complete_config2() + { + return false; } - candidates.push( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("bundled") - .join("proxifyre"), - ); - candidates.into_iter().find(|path| path.is_dir()) + let trusted_security = !snapshot.security.untrusted_mutation_rights; + let current_base_stopped = || { + trusted_security + && snapshot.base == *current_base + && snapshot.original_state == ServiceStableState::Stopped + }; + let legacy_base_stopped = || { + trusted_security + && snapshot.base == before.base + && snapshot.original_state == ServiceStableState::Stopped + }; + let current_policy_complete = || { + SERVICE_CONFIG2_KINDS.iter().copied().all(|kind| { + snapshot.config2(kind) == Some(&expected_current_proxifyre_service_policy(kind)) + }) + }; + let legacy_policy_complete = || { + SERVICE_CONFIG2_KINDS + .iter() + .copied() + .all(|kind| snapshot.config2(kind) == before.config2(kind)) + }; + + match operation { + CutoverOperation::StopLegacyService => { + let mut expected = before.clone(); + expected.original_state = ServiceStableState::Stopped; + snapshot == &expected + } + CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService => false, + CutoverOperation::CreateCurrentService => { + current_base_stopped() && current_policy_complete() + } + CutoverOperation::SetCurrentServicePolicy(kind) => { + current_base_stopped() + && snapshot.config2(*kind) + == Some(&expected_current_proxifyre_service_policy(*kind)) + } + CutoverOperation::SetCurrentServiceSecurity => { + current_base_stopped() + && *current_dacl_matches + && !snapshot.security.untrusted_mutation_rights + } + CutoverOperation::StartCurrentService => { + snapshot.base == *current_base + && snapshot.original_state == ServiceStableState::Running + && current_policy_complete() + && *current_dacl_matches + && !snapshot.security.untrusted_mutation_rights + } + CutoverOperation::StopCurrentService => { + current_base_stopped() + && current_policy_complete() + && *current_dacl_matches + && !snapshot.security.untrusted_mutation_rights + } + CutoverOperation::CreateLegacyService => legacy_base_stopped(), + CutoverOperation::RestoreLegacyServicePolicy(kind) => { + legacy_base_stopped() && snapshot.config2(*kind) == before.config2(*kind) + } + CutoverOperation::RestoreLegacyServiceSecurity => { + legacy_base_stopped() + && legacy_policy_complete() + && snapshot.security == before.security + } + CutoverOperation::StartLegacyService => { + before.original_state == ServiceStableState::Running && snapshot == before + } + _ => false, + } +} + +/// Delegates exactly one service mutation from the durable cutover journal. +/// Returns `Ok(false)` for filesystem/package operations owned by the composite +/// host. In particular, create/restore arms never call a start/stop method. +pub fn mutate_proxifyre_cutover_scm( + host: &mut dyn ProxifyreCutoverScm, + operation: &crate::component_cutover::CutoverOperation, + before: &ServiceRestoreSnapshot, +) -> Result { + use crate::component_cutover::CutoverOperation; + + match operation { + CutoverOperation::StopLegacyService => host.stop_legacy_service()?, + CutoverOperation::DeleteLegacyService => host.delete_legacy_service()?, + CutoverOperation::CreateCurrentService => host.create_current_service()?, + CutoverOperation::SetCurrentServicePolicy(kind) => { + host.set_current_service_policy(*kind)? + } + CutoverOperation::SetCurrentServiceSecurity => host.set_current_service_security()?, + CutoverOperation::StartCurrentService => host.start_current_service()?, + CutoverOperation::StopCurrentService => host.stop_current_service()?, + CutoverOperation::DeleteCurrentService => host.delete_current_service()?, + CutoverOperation::CreateLegacyService => host.create_legacy_service(before)?, + CutoverOperation::RestoreLegacyServicePolicy(kind) => { + let snapshot = before.config2(*kind).ok_or(ProxifyreNativeHostError)?; + host.restore_legacy_service_policy(snapshot)?; + } + CutoverOperation::RestoreLegacyServiceSecurity => { + host.restore_legacy_service_security(before)? + } + CutoverOperation::StartLegacyService => host.start_legacy_service()?, + _ => return Ok(false), + } + Ok(true) +} + +/// Rolls back only the Windows Packet Filter installed by this cutover. +/// Authority comes from the sealed journal: it must show the prerequisite was +/// absent before cutover, a completed forward install, and the current pending +/// rollback intent. VC Runtime is deliberately never removed. +pub fn uninstall_cutover_transaction_packet_filter( + journal: &crate::component_cutover::CutoverJournal, +) -> Result { + uninstall_cutover_transaction_packet_filter_with_runner(journal, || { + let current_exe = std::env::current_exe() + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + let root = + CanonicalComponentRoot::from_current_exe(¤t_exe, ManagedComponent::Proxifyre) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + let msiexec = known_system_executable(KnownSystemExecutable::MsiExec) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + let invocation = system_installer_invocation( + ProxifyreInstallerCommand::UninstallOwnedPacketFilter, + root.path(), + &msiexec, + ) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + let status = run_fixed_process( + &invocation.program, + &invocation.arguments, + INSTALLER_PROCESS_TIMEOUT, + ) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + status.code().ok_or(ProxifyreNativeError::HostStep( + ProxifyreNativeStep::Installer, + )) + }) +} + +fn uninstall_cutover_transaction_packet_filter_with_runner( + journal: &crate::component_cutover::CutoverJournal, + runner: impl FnOnce() -> Result, +) -> Result { + use crate::component_cutover::{ + CutoverExecutionMode, CutoverOperation, EffectDisposition, MutationAuthorityEvidence, + MutationDirection, + }; + + let mut install_records = journal.mutations.iter().filter(|record| { + record.direction == MutationDirection::Forward + && record.operation == CutoverOperation::InstallWindowsPacketFilterPrerequisite + }); + let installed_by_transaction = install_records.next().is_some_and(|record| { + record.authority_evidence + == Some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction) + && record.effect.as_ref().is_some_and(|effect| { + effect.disposition == EffectDisposition::ExpectedEffect + && effect.observed == record.expected_effect + }) + }) && install_records.next().is_none(); + let pending_rollback_intent = journal.mutations.last().is_some_and(|record| { + record.direction == MutationDirection::Rollback + && record.operation == CutoverOperation::UninstallTransactionWindowsPacketFilter + && record.authority_evidence.is_none() + && record.effect.is_none() + }); + if journal + .before_state + .prerequisites + .windows_packet_filter_present + || journal.execution_mode != CutoverExecutionMode::RollingBack + || !installed_by_transaction + || !pending_rollback_intent + { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + + let code = runner()?; + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterUninstall, code) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxifyreNativeStep { + Inspect, + Stage, + Promote, + Installer, + EnforceServiceContract, + CommitMetadata, + VerifyRuntimeConfig, + ServiceControl, + ServiceWait, + Firewall, + Cleanup, + RemoveManagedFiles, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum ProxifyreNativeError { + #[error("the privileged package lease is invalid")] + InvalidPackage, + #[error("the ProxiFyre archive layout is invalid")] + InvalidArchive, + #[error("a foreign or colliding ProxiFyre service exists")] + ServiceCollision, + #[error("the managed ProxiFyre receipt is invalid")] + InvalidReceipt, + #[error("the ProxiFyre install is not owned by ProxyWarden")] + OwnershipMismatch, + #[error("ProxiFyre must be stopped for this operation")] + ServiceRunning, + #[error("ProxiFyre is not stopped")] + ServiceNotStopped, + #[error("the ProxiFyre service policy does not match the managed contract")] + ServiceContract, + #[error("the selected update is not newer than the installed version")] + DowngradeRejected, + #[error("the promoted runtime config is invalid")] + RuntimeConfigInvalid, + #[error("installer {kind:?} returned exit code {code}")] + InstallerExit { + kind: ProxifyreInstallerKind, + code: i32, + }, + #[error("native ProxiFyre host step {0:?} failed")] + HostStep(ProxifyreNativeStep), + #[error("the operation failed and rollback could not restore the prior state")] + RollbackFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxifyreStagedFile { + pub relative_path: String, + pub bytes: Vec, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxifyreInstalledFile { + pub relative_path: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxifyreNativeInstallMetadata { + pub runtime: PrivilegedPackageProof, + pub packet_filter: PrivilegedPackageProof, + pub vc_runtime: PrivilegedPackageProof, + pub installed_files: Vec, + pub packet_filter_owned_by_proxywarden: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProxifyreNativeMetadata { + Install(Box), + Update { + runtime: Box, + installed_files: Vec, + }, +} + +/// Narrow OS boundary for native lifecycle integration. Implementations own +/// all handle, ACL, SCM, firewall, and atomic promotion details. Staging must +/// be a fresh admin/SYSTEM-sealed directory; removal must use only the sealed +/// receipt inventory and reject reparse points on a second live check. +pub trait ProxifyreNativeHost { + fn inspect(&mut self) -> Result; + fn stage_runtime( + &mut self, + mode: ProxifyreNativeMode, + version: &str, + files: &[ProxifyreStagedFile], + ) -> Result<(), ProxifyreNativeHostError>; + fn promote_runtime( + &mut self, + mode: ProxifyreNativeMode, + ) -> Result<(), ProxifyreNativeHostError>; + fn discard_staging(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn run_installer( + &mut self, + command: ProxifyreInstallerCommand<'_>, + ) -> Result; + /// Applies demand-start, disables failure recovery, and sets the service + /// DACL so Builtin Users cannot start it. The next live `inspect` must + /// query all three settings plus the exact PathName back from SCM. + fn enforce_service_contract(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn control_service( + &mut self, + action: ProxifyreNativeServiceAction, + ) -> Result<(), ProxifyreNativeHostError>; + fn wait_service( + &mut self, + status: ProxifyreNativeServiceStatus, + ) -> Result<(), ProxifyreNativeHostError>; + fn verify_runtime_config( + &mut self, + path: &Path, + expected_sha256: &str, + ) -> Result; + fn commit_metadata( + &mut self, + metadata: &ProxifyreNativeMetadata, + ) -> Result<(), ProxifyreNativeHostError>; + fn rollback( + &mut self, + mode: ProxifyreNativeMode, + remove_owned_packet_filter: bool, + ) -> Result<(), ProxifyreNativeHostError>; + fn configure_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result; + fn remove_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result; + fn remove_managed_files(&mut self) -> Result<(), ProxifyreNativeHostError>; +} + +#[derive(Clone)] +struct SystemPendingRuntime { + path: PathBuf, + files: Vec, + version: String, +} + +#[derive(Clone)] +struct SystemBackupRoot { + path: PathBuf, + receipt: InstallReceipt, +} + +enum VerifiedNativeRoot { + PendingTransaction, + Managed(InstallReceipt), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SystemProxifyreMarker { + schema_version: u32, + manager: String, + component: String, + service_name: String, + install_root: String, + packet_filter_installed_by_proxy_warden: bool, +} + +/// Elevated native host. Paths are derived only from the verified installed +/// Control App executable; callers cannot supply a component root, service +/// name, product code, system executable, or command line. +pub struct SystemProxifyreNativeHost { + root: CanonicalComponentRoot, + staging: Option, + backup: Option, + promoted_files: Option>, + promoted_version: Option, + last_receipt: Option, + runtime_config_lease: Option, + removed_firewall: Vec, +} + +impl SystemProxifyreNativeHost { + pub fn from_current_exe() -> Result { + let current_exe = std::env::current_exe().map_err(|_| ProxifyreNativeHostError)?; + let root = + CanonicalComponentRoot::from_current_exe(¤t_exe, ManagedComponent::Proxifyre) + .map_err(|_| ProxifyreNativeHostError)?; + Ok(Self { + root, + staging: None, + backup: None, + promoted_files: None, + promoted_version: None, + last_receipt: None, + runtime_config_lease: None, + removed_firewall: Vec::new(), + }) + } + + pub fn install_root(&self) -> &Path { + self.root.path() + } + + /// Promotes only already-leased coordinator bytes to the fixed sealed + /// runtime config. The source path is intentionally not accepted here. + pub fn promote_runtime_config_bytes( + &mut self, + bytes: &[u8], + expected_sha256: &str, + ) -> Result { + if !valid_sha256(expected_sha256) + || bytes.is_empty() + || bytes.len() as u64 > MAX_RUNTIME_CONFIG_BYTES + || format!("{:x}", Sha256::digest(bytes)) != expected_sha256 + || serde_json::from_slice::(bytes).is_err() + { + return Err(ProxifyreNativeHostError); + } + let snapshot = self.inspect()?; + if snapshot.ownership != ProxifyreNativeOwnership::Managed + || snapshot.service_status != ProxifyreNativeServiceStatus::Stopped + { + return Err(ProxifyreNativeHostError); + } + + let destination = self.runtime_config_path(); + let staging = unique_sibling(&destination, "config")?; + let mut sealed = safe_fs::create_new_admin_system_only_file(&staging) + .map_err(|_| ProxifyreNativeHostError)?; + let write_result = sealed + .write_all(bytes) + .and_then(|()| sealed.sync_all()) + .and_then(|()| sealed.seek(SeekFrom::Start(0)).map(|_| ())); + if write_result.is_err() { + drop(sealed); + let _ = fs::remove_file(&staging); + return Err(ProxifyreNativeHostError); + } + if safe_fs::verify_admin_system_only_file(&staging).is_err() { + drop(sealed); + let _ = fs::remove_file(&staging); + return Err(ProxifyreNativeHostError); + } + drop(sealed); + if safe_fs::promote_admin_system_only_file_same_parent(&staging, &destination).is_err() { + let _ = fs::remove_file(&staging); + return Err(ProxifyreNativeHostError); + } + if self.verify_runtime_config(&destination, expected_sha256)? + != RuntimeConfigVerification::Match + { + return Err(ProxifyreNativeHostError); + } + Ok(destination) + } + + fn executable_path(&self) -> PathBuf { + self.root.path().join("ProxiFyre.exe") + } + + fn runtime_config_path(&self) -> PathBuf { + self.root.path().join("app-config.json") + } + + fn inspect_root_receipt(&self) -> Result<(InstallReceipt, bool), ProxifyreNativeHostError> { + let receipt = read_install_receipt(&self.root).map_err(|_| ProxifyreNativeHostError)?; + validate_system_marker(self.root.path(), &receipt)?; + verify_receipt_files_and_shape(self.root.path(), &receipt)?; + Ok((receipt, true)) + } + + fn restore_removed_firewall(&mut self) -> Result<(), ProxifyreNativeHostError> { + let removed = self.removed_firewall.clone(); + for direction in removed { + self.configure_firewall(direction)?; + } + self.removed_firewall.clear(); + Ok(()) + } +} + +fn verify_existing_native_root( + root_exists: bool, + verify_reparse_free: impl FnOnce() -> Result<(), ProxifyreNativeHostError>, + verify_trusted_acl: impl FnOnce() -> Result<(), ProxifyreNativeHostError>, + verify_receipt_and_inventory: impl FnOnce() -> Result, +) -> Result, ProxifyreNativeHostError> { + if !root_exists { + return Ok(None); + } + verify_reparse_free()?; + verify_trusted_acl()?; + verify_receipt_and_inventory().map(Some) +} + +impl ProxifyreNativeHost for SystemProxifyreNativeHost { + fn inspect(&mut self) -> Result { + let executable = self.executable_path(); + let service = query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &executable) + .map_err(|_| ProxifyreNativeHostError)?; + let service_status = native_service_status(&service); + let service_exists = service.service.exists; + + let root_metadata = fs::symlink_metadata(self.root.path()); + let root_exists = match &root_metadata { + Ok(metadata) => metadata.file_type().is_dir(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => return Err(ProxifyreNativeHostError), + }; + let promoted_transaction_pending = + self.promoted_version.as_deref().is_some_and(|version| { + self.last_receipt + .as_ref() + .is_none_or(|receipt| receipt.version != version) + }); + let root = verify_existing_native_root( + root_exists, + || { + safe_fs::ensure_no_reparse_ancestors(self.root.path()) + .map_err(|_| ProxifyreNativeHostError) + }, + || { + safe_fs::verify_path_admin_owned_user_read_only(self.root.path()) + .map_err(|_| ProxifyreNativeHostError) + }, + || { + if promoted_transaction_pending { + let files = self + .promoted_files + .as_ref() + .ok_or(ProxifyreNativeHostError)?; + verify_runtime_inventory(self.root.path(), files, false)?; + Ok(VerifiedNativeRoot::PendingTransaction) + } else { + self.inspect_root_receipt() + .map(|(receipt, _)| VerifiedNativeRoot::Managed(receipt)) + } + }, + )?; + let install_root_reparse_free = root.is_some(); + let install_root_trusted = root.is_some(); + let receipt_valid = matches!(&root, Some(VerifiedNativeRoot::Managed(_))); + let receipt_files_match = receipt_valid; + let receipt = root.and_then(|verified| match verified { + VerifiedNativeRoot::PendingTransaction => None, + VerifiedNativeRoot::Managed(receipt) => Some(receipt), + }); + if let Some(verified) = receipt.as_ref() { + self.last_receipt = Some(verified.clone()); + } + let installed_version = receipt + .as_ref() + .map(|receipt| receipt.version.clone()) + .or_else(|| self.promoted_version.clone()); + let packet_filter_owned_by_proxywarden = receipt + .as_ref() + .and_then(|receipt| receipt.windows_packet_filter.as_ref()) + .is_some_and(|ownership| { + ownership.installed_by_proxy_warden + && ownership + .product_code + .eq_ignore_ascii_case(WINDOWS_PACKET_FILTER_PRODUCT_CODE) + }); + let ownership = if service_exists && !service.path_matches { + ProxifyreNativeOwnership::Foreign + } else if !root_exists && !service_exists { + ProxifyreNativeOwnership::Missing + } else if install_root_trusted + && receipt_valid + && receipt_files_match + && service_exists + && service.path_matches + { + ProxifyreNativeOwnership::Managed + } else { + ProxifyreNativeOwnership::Incomplete + }; + + Ok(ProxifyreNativeSnapshot { + ownership, + service_status, + installed_version, + install_root_trusted, + install_root_reparse_free, + receipt_valid, + receipt_files_match, + service_path_matches: service.path_matches, + demand_start: service.demand_start, + failure_recovery_disabled: service.failure_recovery_disabled, + builtin_users_start_denied: service.dacl_matches, + vc_runtime_present: native_vc_runtime_present()?, + packet_filter_present: native_packet_filter_present()?, + packet_filter_owned_by_proxywarden, + }) + } + + fn stage_runtime( + &mut self, + mode: ProxifyreNativeMode, + version: &str, + files: &[ProxifyreStagedFile], + ) -> Result<(), ProxifyreNativeHostError> { + if mode == ProxifyreNativeMode::Uninstall || self.staging.is_some() || self.backup.is_some() + { + return Err(ProxifyreNativeHostError); + } + if parse_numeric_version(version).is_none() { + return Err(ProxifyreNativeHostError); + } + validate_staged_runtime(files)?; + let parent = self.root.path().parent().ok_or(ProxifyreNativeHostError)?; + safe_fs::create_directory_admin_owned_user_read_only(parent) + .map_err(|_| ProxifyreNativeHostError)?; + if mode == ProxifyreNativeMode::Install && self.root.path().try_exists().unwrap_or(true) { + return Err(ProxifyreNativeHostError); + } + if mode == ProxifyreNativeMode::Update { + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + self.last_receipt = Some(receipt); + } + + let staging_path = unique_sibling(self.root.path(), "staging")?; + safe_fs::create_directory_admin_owned_user_read_only(&staging_path) + .map_err(|_| ProxifyreNativeHostError)?; + let inventory = installed_file_inventory(files); + self.staging = Some(SystemPendingRuntime { + path: staging_path.clone(), + files: inventory.clone(), + version: version.to_owned(), + }); + write_staged_runtime(&staging_path, files)?; + verify_runtime_inventory(&staging_path, &inventory, false)?; + Ok(()) + } + + fn promote_runtime( + &mut self, + mode: ProxifyreNativeMode, + ) -> Result<(), ProxifyreNativeHostError> { + let pending = self.staging.clone().ok_or(ProxifyreNativeHostError)?; + verify_runtime_inventory(&pending.path, &pending.files, false)?; + match mode { + ProxifyreNativeMode::Install => { + if self.root.path().try_exists().unwrap_or(true) { + return Err(ProxifyreNativeHostError); + } + fs::rename(&pending.path, self.root.path()) + .map_err(|_| ProxifyreNativeHostError)?; + } + ProxifyreNativeMode::Update => { + let receipt = self.last_receipt.clone().ok_or(ProxifyreNativeHostError)?; + verify_receipt_files_and_shape(self.root.path(), &receipt)?; + let backup_path = unique_sibling(self.root.path(), "backup")?; + fs::rename(self.root.path(), &backup_path).map_err(|_| ProxifyreNativeHostError)?; + self.backup = Some(SystemBackupRoot { + path: backup_path.clone(), + receipt, + }); + if fs::rename(&pending.path, self.root.path()).is_err() { + if fs::rename(&backup_path, self.root.path()).is_ok() { + self.backup = None; + } + return Err(ProxifyreNativeHostError); + } + } + ProxifyreNativeMode::Uninstall => return Err(ProxifyreNativeHostError), + } + self.staging = None; + self.promoted_files = Some(pending.files); + self.promoted_version = Some(pending.version); + Ok(()) + } + + fn discard_staging(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.runtime_config_lease = None; + if let Some(staging) = self.staging.clone() { + remove_sealed_staging(&staging.path, &staging.files)?; + self.staging = None; + } + if let Some(backup) = self.backup.clone() { + remove_receipt_root_exact(&backup.path, &backup.receipt)?; + self.backup = None; + } + self.promoted_files = None; + self.promoted_version = None; + self.removed_firewall.clear(); + Ok(()) + } + + fn run_installer( + &mut self, + command: ProxifyreInstallerCommand<'_>, + ) -> Result { + match command { + ProxifyreInstallerCommand::InstallService => { + let service = query_service_policy_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.executable_path(), + ) + .map_err(|_| ProxifyreNativeHostError)?; + if service.service.exists { + return Err(ProxifyreNativeHostError); + } + let files = self + .promoted_files + .as_ref() + .ok_or(ProxifyreNativeHostError)?; + verify_runtime_inventory(self.root.path(), files, false)?; + } + ProxifyreInstallerCommand::UninstallService => { + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + let service = query_service_policy_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.executable_path(), + ) + .map_err(|_| ProxifyreNativeHostError)?; + if !service.service.exists + || !service.path_matches + || service.service.state != Some(ServiceState::Stopped) + { + return Err(ProxifyreNativeHostError); + } + self.last_receipt = Some(receipt); + } + ProxifyreInstallerCommand::UninstallOwnedPacketFilter => { + let owned = self + .last_receipt + .as_ref() + .and_then(|receipt| receipt.windows_packet_filter.as_ref()) + .is_some_and(|ownership| { + ownership.installed_by_proxy_warden + && ownership + .product_code + .eq_ignore_ascii_case(WINDOWS_PACKET_FILTER_PRODUCT_CODE) + }); + if !owned { + return Err(ProxifyreNativeHostError); + } + } + ProxifyreInstallerCommand::InstallVcRuntime(_) + | ProxifyreInstallerCommand::InstallPacketFilter(_) => {} + } + let invocation = system_installer_invocation( + command, + self.root.path(), + &known_system_executable(KnownSystemExecutable::MsiExec) + .map_err(|_| ProxifyreNativeHostError)?, + )?; + let timeout = match command.kind() { + ProxifyreInstallerKind::ServiceInstall | ProxifyreInstallerKind::ServiceUninstall => { + SERVICE_INSTALLER_PROCESS_TIMEOUT + } + _ => INSTALLER_PROCESS_TIMEOUT, + }; + let status = match invocation.working_directory { + Some(directory) => run_fixed_process_in_directory( + &invocation.program, + &invocation.arguments, + &directory, + timeout, + ), + None => run_fixed_process(&invocation.program, &invocation.arguments, timeout), + } + .map_err(|_| ProxifyreNativeHostError)?; + let code = status.code().ok_or(ProxifyreNativeHostError)?; + if command.kind() == ProxifyreInstallerKind::ServiceUninstall && code == 0 { + wait_known_service_deleted( + KnownWindowsService::Proxifyre, + &self.executable_path(), + Duration::from_secs(15), + ) + .map_err(|_| ProxifyreNativeHostError)?; + } + Ok(code) + } + + fn enforce_service_contract(&mut self) -> Result<(), ProxifyreNativeHostError> { + let policy = + enforce_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &self.executable_path()) + .map_err(|_| ProxifyreNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) + || !system_service_policy_matches(&policy) + { + return Err(ProxifyreNativeHostError); + } + Ok(()) + } + + fn control_service( + &mut self, + action: ProxifyreNativeServiceAction, + ) -> Result<(), ProxifyreNativeHostError> { + let policy = + query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &self.executable_path()) + .map_err(|_| ProxifyreNativeHostError)?; + if !system_service_policy_matches(&policy) { + return Err(ProxifyreNativeHostError); + } + match action { + ProxifyreNativeServiceAction::Start => { + if self.runtime_config_lease.is_none() { + return Err(ProxifyreNativeHostError); + } + start_known_service(KnownWindowsService::Proxifyre, &self.executable_path()) + } + ProxifyreNativeServiceAction::Stop => { + stop_known_service(KnownWindowsService::Proxifyre, &self.executable_path()) + } + } + .map(|_| ()) + .map_err(|_| ProxifyreNativeHostError) + } + + fn wait_service( + &mut self, + status: ProxifyreNativeServiceStatus, + ) -> Result<(), ProxifyreNativeHostError> { + let expected = match status { + ProxifyreNativeServiceStatus::Stopped => ServiceState::Stopped, + ProxifyreNativeServiceStatus::Running => ServiceState::Running, + ProxifyreNativeServiceStatus::Missing | ProxifyreNativeServiceStatus::Other => { + return Err(ProxifyreNativeHostError); + } + }; + let policy = wait_service_state_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.executable_path(), + expected, + Duration::from_secs(15), + ) + .map_err(|_| ProxifyreNativeHostError)?; + if !system_service_policy_matches(&policy) { + return Err(ProxifyreNativeHostError); + } + Ok(()) + } + + fn verify_runtime_config( + &mut self, + path: &Path, + expected_sha256: &str, + ) -> Result { + if !same_windows_path(path, &self.runtime_config_path()) || !valid_sha256(expected_sha256) { + return Err(ProxifyreNativeHostError); + } + safe_fs::verify_admin_system_only_file(path).map_err(|_| ProxifyreNativeHostError)?; + let mut file = open_file_deny_write_delete(path)?; + if !file.metadata().is_ok_and(|metadata| metadata.is_file()) { + return Err(ProxifyreNativeHostError); + } + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(MAX_RUNTIME_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| ProxifyreNativeHostError)?; + if bytes.is_empty() + || bytes.len() as u64 > MAX_RUNTIME_CONFIG_BYTES + || format!("{:x}", Sha256::digest(&bytes)) != expected_sha256 + || serde_json::from_slice::(&bytes).is_err() + { + return Ok(RuntimeConfigVerification::Mismatch); + } + file.seek(SeekFrom::Start(0)) + .map_err(|_| ProxifyreNativeHostError)?; + safe_fs::verify_admin_system_only_file(path).map_err(|_| ProxifyreNativeHostError)?; + self.runtime_config_lease = Some(file); + Ok(RuntimeConfigVerification::Match) + } + + fn commit_metadata( + &mut self, + metadata: &ProxifyreNativeMetadata, + ) -> Result<(), ProxifyreNativeHostError> { + let (runtime, files, filter_ownership, installed_at) = match metadata { + ProxifyreNativeMetadata::Install(metadata) => ( + &metadata.runtime, + &metadata.installed_files, + metadata.packet_filter_owned_by_proxywarden, + unix_now()?, + ), + ProxifyreNativeMetadata::Update { + runtime, + installed_files, + } => ( + runtime.as_ref(), + installed_files, + self.last_receipt + .as_ref() + .and_then(|receipt| receipt.windows_packet_filter.as_ref()) + .is_some_and(|ownership| ownership.installed_by_proxy_warden), + self.last_receipt + .as_ref() + .map(|receipt| receipt.installed_at) + .ok_or(ProxifyreNativeHostError)?, + ), + }; + verify_runtime_inventory(self.root.path(), files, false)?; + let receipt = InstallReceipt { + schema_version: INSTALL_RECEIPT_SCHEMA_VERSION, + manager: InstallReceiptManager::ProxyWarden, + component: ManagedComponent::Proxifyre, + install_root: self + .root + .path() + .to_str() + .ok_or(ProxifyreNativeHostError)? + .to_owned(), + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + version: runtime.version.clone(), + source: match runtime.source { + PackageSource::Bundled => InstalledPackageSource::Bundled, + PackageSource::Cache => InstalledPackageSource::Cache, + }, + asset: InstalledAssetReceipt { + name: runtime.asset_name.clone(), + sha256: runtime.sha256.clone(), + size: runtime.size, + }, + installed_files: files + .iter() + .map(|file| InstalledFileReceipt { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect(), + installed_at, + windows_packet_filter: filter_ownership.then(|| WindowsPacketFilterOwnership { + product_code: WINDOWS_PACKET_FILTER_PRODUCT_CODE.to_owned(), + installed_by_proxy_warden: true, + }), + }; + write_system_marker(self.root.path(), &receipt)?; + write_install_receipt(&self.root, &receipt).map_err(|_| ProxifyreNativeHostError)?; + let (verified, files_match) = self.inspect_root_receipt()?; + if !files_match || verified != receipt { + return Err(ProxifyreNativeHostError); + } + self.last_receipt = Some(receipt); + Ok(()) + } + + fn rollback( + &mut self, + mode: ProxifyreNativeMode, + remove_owned_packet_filter: bool, + ) -> Result<(), ProxifyreNativeHostError> { + match mode { + ProxifyreNativeMode::Install => { + rollback_new_service(self.root.path())?; + if let Some(files) = self.promoted_files.as_ref() { + remove_runtime_tree_without_receipt(self.root.path(), files)?; + } + if remove_owned_packet_filter { + let msiexec = known_system_executable(KnownSystemExecutable::MsiExec) + .map_err(|_| ProxifyreNativeHostError)?; + let invocation = system_installer_invocation( + ProxifyreInstallerCommand::UninstallOwnedPacketFilter, + self.root.path(), + &msiexec, + )?; + let status = run_fixed_process( + &invocation.program, + &invocation.arguments, + INSTALLER_PROCESS_TIMEOUT, + ) + .map_err(|_| ProxifyreNativeHostError)?; + if !matches!(status.code(), Some(0 | 1605 | 3010)) { + return Err(ProxifyreNativeHostError); + } + } + } + ProxifyreNativeMode::Update => { + let Some(backup) = self.backup.clone() else { + let receipt = self.last_receipt.as_ref().ok_or(ProxifyreNativeHostError)?; + verify_receipt_files_and_shape(self.root.path(), receipt)?; + return Ok(()); + }; + let active_exists = self + .root + .path() + .try_exists() + .map_err(|_| ProxifyreNativeHostError)?; + let failed = active_exists + .then(|| unique_sibling(self.root.path(), "failed")) + .transpose()?; + if let Some(failed) = &failed { + fs::rename(self.root.path(), failed).map_err(|_| ProxifyreNativeHostError)?; + } + if fs::rename(&backup.path, self.root.path()).is_err() { + if let Some(failed) = &failed { + let _ = fs::rename(failed, self.root.path()); + } + return Err(ProxifyreNativeHostError); + } + self.backup = None; + if let (Some(files), Some(failed)) = (self.promoted_files.as_ref(), failed.as_ref()) + { + remove_runtime_tree_without_receipt(failed, files)?; + } + } + ProxifyreNativeMode::Uninstall => { + let Some(backup) = self.backup.clone() else { + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + let service = query_service_policy_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.executable_path(), + ) + .map_err(|_| ProxifyreNativeHostError)?; + if service.service.exists { + if service.service.state != Some(ServiceState::Stopped) + || !service.path_matches + { + return Err(ProxifyreNativeHostError); + } + let restored = enforce_service_policy_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &self.executable_path(), + ) + .map_err(|_| ProxifyreNativeHostError)?; + if restored.service.state != Some(ServiceState::Stopped) + || !system_service_policy_matches(&restored) + { + return Err(ProxifyreNativeHostError); + } + } else { + rollback_restore_service(self.root.path())?; + } + self.last_receipt = Some(receipt); + self.restore_removed_firewall()?; + return Ok(()); + }; + if self.root.path().try_exists().unwrap_or(true) { + self.backup = Some(backup); + return Err(ProxifyreNativeHostError); + } + fs::rename(&backup.path, self.root.path()).map_err(|_| ProxifyreNativeHostError)?; + self.backup = None; + rollback_restore_service(self.root.path())?; + self.restore_removed_firewall()?; + } + } + self.promoted_files = None; + self.promoted_version = None; + self.staging = None; + Ok(()) + } + + fn configure_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result { + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + self.last_receipt = Some(receipt); + let netsh = known_system_executable(KnownSystemExecutable::Netsh) + .map_err(|_| ProxifyreNativeHostError)?; + let delete = fixed_firewall_arguments( + ProxifyreFirewallMutation::Delete, + direction, + &self.executable_path(), + )?; + let delete_status = run_fixed_process(&netsh, &delete, FIREWALL_PROCESS_TIMEOUT) + .map_err(|_| ProxifyreNativeHostError)?; + if !matches!(delete_status.code(), Some(0 | 1)) { + return Err(ProxifyreNativeHostError); + } + let add = fixed_firewall_arguments( + ProxifyreFirewallMutation::Add, + direction, + &self.executable_path(), + )?; + let add_status = run_fixed_process(&netsh, &add, FIREWALL_PROCESS_TIMEOUT) + .map_err(|_| ProxifyreNativeHostError)?; + if add_status.success() { + Ok(true) + } else { + Err(ProxifyreNativeHostError) + } + } + + fn remove_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result { + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + self.last_receipt = Some(receipt); + let netsh = known_system_executable(KnownSystemExecutable::Netsh) + .map_err(|_| ProxifyreNativeHostError)?; + let delete = fixed_firewall_arguments( + ProxifyreFirewallMutation::Delete, + direction, + &self.executable_path(), + )?; + let status = run_fixed_process(&netsh, &delete, FIREWALL_PROCESS_TIMEOUT) + .map_err(|_| ProxifyreNativeHostError)?; + match status.code() { + Some(0) => { + if !self.removed_firewall.contains(&direction) { + self.removed_firewall.push(direction); + } + Ok(true) + } + Some(1) => Ok(false), + _ => Err(ProxifyreNativeHostError), + } + } + + fn remove_managed_files(&mut self) -> Result<(), ProxifyreNativeHostError> { + if self.backup.is_some() || self.staging.is_some() { + return Err(ProxifyreNativeHostError); + } + let service = + query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &self.executable_path()) + .map_err(|_| ProxifyreNativeHostError)?; + if service.service.exists { + return Err(ProxifyreNativeHostError); + } + let (receipt, files_match) = self.inspect_root_receipt()?; + if !files_match { + return Err(ProxifyreNativeHostError); + } + let backup_path = unique_sibling(self.root.path(), "uninstall")?; + fs::rename(self.root.path(), &backup_path).map_err(|_| ProxifyreNativeHostError)?; + self.last_receipt = Some(receipt.clone()); + self.backup = Some(SystemBackupRoot { + path: backup_path, + receipt, + }); + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SystemInstallerInvocation { + program: PathBuf, + arguments: Vec, + working_directory: Option, +} + +fn native_service_status(policy: &ServicePolicySnapshot) -> ProxifyreNativeServiceStatus { + if !policy.service.exists { + return ProxifyreNativeServiceStatus::Missing; + } + match policy.service.state { + Some(ServiceState::Stopped) => ProxifyreNativeServiceStatus::Stopped, + Some(ServiceState::Running) => ProxifyreNativeServiceStatus::Running, + _ => ProxifyreNativeServiceStatus::Other, + } +} + +fn system_service_policy_matches(policy: &ServicePolicySnapshot) -> bool { + policy.service.exists + && policy.path_matches + && policy.demand_start + && policy.failure_recovery_disabled + && policy.dacl_matches +} + +fn same_windows_path(left: &Path, right: &Path) -> bool { + fn normalized(path: &Path) -> String { + path.as_os_str() + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() + } + left.is_absolute() && right.is_absolute() && normalized(left) == normalized(right) +} + +fn open_file_deny_write_delete(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + }; + options + .share_mode(FILE_SHARE_READ) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + options.open(path).map_err(|_| ProxifyreNativeHostError) +} + +fn validate_staged_runtime(files: &[ProxifyreStagedFile]) -> Result<(), ProxifyreNativeHostError> { + if files.len() != CURRENT_PROXIFYRE_PACKAGE_FILES.len() { + return Err(ProxifyreNativeHostError); + } + let expected: HashSet = CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let mut actual = HashSet::new(); + let mut total = 0_u64; + for file in files { + if !valid_runtime_relative_path(&file.relative_path) + || !actual.insert(file.relative_path.to_ascii_lowercase()) + || file.size == 0 + || file.size > MAX_PROXIFYRE_FILE_BYTES + || file.size != file.bytes.len() as u64 + || !valid_sha256(&file.sha256) + || format!("{:x}", Sha256::digest(&file.bytes)) != file.sha256 + { + return Err(ProxifyreNativeHostError); + } + total = total + .checked_add(file.size) + .filter(|value| *value <= MAX_PROXIFYRE_EXPANDED_BYTES) + .ok_or(ProxifyreNativeHostError)?; + } + if actual != expected { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +fn valid_runtime_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.contains(['/', '\\', ':', '\0']) + && !path.starts_with('.') + && CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .any(|expected| expected.eq_ignore_ascii_case(path)) +} + +fn write_staged_runtime( + root: &Path, + files: &[ProxifyreStagedFile], +) -> Result<(), ProxifyreNativeHostError> { + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + for staged in files { + let destination = root.join(&staged.relative_path); + let mut file = safe_fs::create_new_admin_owned_user_read_only_file(&destination) + .map_err(|_| ProxifyreNativeHostError)?; + file.write_all(&staged.bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| ProxifyreNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(&destination) + .map_err(|_| ProxifyreNativeHostError)?; + } + Ok(()) +} + +fn receipt_inventory( + receipt: &InstallReceipt, +) -> Result, ProxifyreNativeHostError> { + let files: Vec<_> = receipt + .installed_files + .iter() + .map(|file| ProxifyreInstalledFile { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect(); + validate_installed_inventory(&files)?; + Ok(files) +} + +fn validate_installed_inventory( + files: &[ProxifyreInstalledFile], +) -> Result<(), ProxifyreNativeHostError> { + if files.len() != CURRENT_PROXIFYRE_PACKAGE_FILES.len() { + return Err(ProxifyreNativeHostError); + } + let expected: HashSet = CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let mut actual = HashSet::new(); + let mut total = 0_u64; + for file in files { + if !valid_runtime_relative_path(&file.relative_path) + || !actual.insert(file.relative_path.to_ascii_lowercase()) + || file.size == 0 + || file.size > MAX_PROXIFYRE_FILE_BYTES + || !valid_sha256(&file.sha256) + { + return Err(ProxifyreNativeHostError); + } + total = total + .checked_add(file.size) + .filter(|value| *value <= MAX_PROXIFYRE_EXPANDED_BYTES) + .ok_or(ProxifyreNativeHostError)?; + } + if actual != expected { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +fn verify_runtime_inventory( + root: &Path, + files: &[ProxifyreInstalledFile], + allow_metadata: bool, +) -> Result<(), ProxifyreNativeHostError> { + validate_installed_inventory(files)?; + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + let metadata = fs::symlink_metadata(root).map_err(|_| ProxifyreNativeHostError)?; + if !metadata.file_type().is_dir() { + return Err(ProxifyreNativeHostError); + } + + let expected_names: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + let mut found = HashSet::new(); + for entry in fs::read_dir(root).map_err(|_| ProxifyreNativeHostError)? { + let entry = entry.map_err(|_| ProxifyreNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| ProxifyreNativeHostError)?; + let folded = name.to_ascii_lowercase(); + if expected_names.contains(&folded) { + if !found.insert(folded) { + return Err(ProxifyreNativeHostError); + } + continue; + } + if allow_metadata + && matches!( + folded.as_str(), + "app-config.json" + | "proxywarden-component.json" + | "proxywarden-install-receipt.json" + ) + { + continue; + } + return Err(ProxifyreNativeHostError); + } + if found != expected_names { + return Err(ProxifyreNativeHostError); + } + for expected in files { + verify_runtime_file(root, expected)?; + } + if allow_metadata { + let config = root.join("app-config.json"); + if config.try_exists().map_err(|_| ProxifyreNativeHostError)? { + safe_fs::verify_admin_system_only_file(&config) + .map_err(|_| ProxifyreNativeHostError)?; + } + for name in [PROXIFYRE_MARKER_FILE, INSTALL_RECEIPT_FILENAME] { + let path = root.join(name); + if path.try_exists().map_err(|_| ProxifyreNativeHostError)? { + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| ProxifyreNativeHostError)?; + } + } + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + Ok(()) +} + +fn verify_runtime_file( + root: &Path, + expected: &ProxifyreInstalledFile, +) -> Result<(), ProxifyreNativeHostError> { + let path = root.join(&expected.relative_path); + safe_fs::verify_path_admin_owned_user_read_only(&path).map_err(|_| ProxifyreNativeHostError)?; + let file = open_file_deny_write_delete(&path)?; + let metadata = file.metadata().map_err(|_| ProxifyreNativeHostError)?; + if !metadata.is_file() || metadata.len() != expected.size { + return Err(ProxifyreNativeHostError); + } + let mut reader = file.take(expected.size.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| ProxifyreNativeHostError)?; + if count == 0 { + break; + } + size = size + .checked_add(count as u64) + .ok_or(ProxifyreNativeHostError)?; + hasher.update(&buffer[..count]); + } + if size != expected.size || format!("{:x}", hasher.finalize()) != expected.sha256 { + return Err(ProxifyreNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(&path).map_err(|_| ProxifyreNativeHostError)?; + Ok(()) +} + +fn read_admin_metadata(path: &Path, max_bytes: u64) -> Result, ProxifyreNativeHostError> { + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| ProxifyreNativeHostError)?; + let file = open_file_deny_write_delete(path)?; + let metadata = file.metadata().map_err(|_| ProxifyreNativeHostError)?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > max_bytes { + return Err(ProxifyreNativeHostError); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| ProxifyreNativeHostError)?; + if bytes.len() as u64 != metadata.len() { + return Err(ProxifyreNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| ProxifyreNativeHostError)?; + Ok(bytes) +} + +fn validate_system_marker( + root: &Path, + receipt: &InstallReceipt, +) -> Result<(), ProxifyreNativeHostError> { + let bytes = read_admin_metadata(&root.join(PROXIFYRE_MARKER_FILE), 64 * 1024)?; + let marker: SystemProxifyreMarker = + serde_json::from_slice(&bytes).map_err(|_| ProxifyreNativeHostError)?; + let packet_owned = receipt + .windows_packet_filter + .as_ref() + .is_some_and(|ownership| ownership.installed_by_proxy_warden); + if marker.schema_version != 1 + || marker.manager != "ProxyWarden" + || marker.component != "proxifyre" + || marker.service_name != PROXIFYRE_MANAGED_SERVICE_NAME + || !same_windows_path(Path::new(&marker.install_root), root) + || marker.packet_filter_installed_by_proxy_warden != packet_owned + { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +fn verify_receipt_files_and_shape( + root: &Path, + receipt: &InstallReceipt, +) -> Result<(), ProxifyreNativeHostError> { + if !same_windows_path(Path::new(&receipt.install_root), root) + || receipt.component != ManagedComponent::Proxifyre + || receipt.manager != InstallReceiptManager::ProxyWarden + || receipt.schema_version != INSTALL_RECEIPT_SCHEMA_VERSION + || receipt.service_name != PROXIFYRE_MANAGED_SERVICE_NAME + { + return Err(ProxifyreNativeHostError); + } + let files = receipt_inventory(receipt)?; + verify_runtime_inventory(root, &files, true)?; + validate_system_marker(root, receipt) +} + +fn write_system_marker( + root: &Path, + receipt: &InstallReceipt, +) -> Result<(), ProxifyreNativeHostError> { + let marker = SystemProxifyreMarker { + schema_version: 1, + manager: "ProxyWarden".to_owned(), + component: "proxifyre".to_owned(), + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + install_root: receipt.install_root.clone(), + packet_filter_installed_by_proxy_warden: receipt + .windows_packet_filter + .as_ref() + .is_some_and(|ownership| ownership.installed_by_proxy_warden), + }; + let bytes = serde_json::to_vec_pretty(&marker).map_err(|_| ProxifyreNativeHostError)?; + if bytes.len() > 64 * 1024 { + return Err(ProxifyreNativeHostError); + } + safe_fs::write_admin_owned_user_read_only_atomic(&root.join(PROXIFYRE_MARKER_FILE), &bytes) + .map_err(|_| ProxifyreNativeHostError) +} + +fn remove_runtime_tree_without_receipt( + root: &Path, + files: &[ProxifyreInstalledFile], +) -> Result<(), ProxifyreNativeHostError> { + validate_installed_inventory(files)?; + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + let expected: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + let mut paths = Vec::new(); + for entry in fs::read_dir(root).map_err(|_| ProxifyreNativeHostError)? { + let entry = entry.map_err(|_| ProxifyreNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| ProxifyreNativeHostError)?; + let folded = name.to_ascii_lowercase(); + let path = entry.path(); + if expected.contains(&folded) { + let expected_file = files + .iter() + .find(|file| file.relative_path.eq_ignore_ascii_case(&name)) + .ok_or(ProxifyreNativeHostError)?; + verify_runtime_file(root, expected_file)?; + } else if folded == "app-config.json" { + safe_fs::verify_admin_system_only_file(&path).map_err(|_| ProxifyreNativeHostError)?; + } else if folded == PROXIFYRE_MARKER_FILE.to_ascii_lowercase() + || folded == INSTALL_RECEIPT_FILENAME.to_ascii_lowercase() + { + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| ProxifyreNativeHostError)?; + } else { + return Err(ProxifyreNativeHostError); + } + paths.push(path); + } + for path in paths { + fs::remove_file(path).map_err(|_| ProxifyreNativeHostError)?; + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + fs::remove_dir(root).map_err(|_| ProxifyreNativeHostError) +} + +fn remove_sealed_staging( + root: &Path, + files: &[ProxifyreInstalledFile], +) -> Result<(), ProxifyreNativeHostError> { + validate_installed_inventory(files)?; + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + let expected: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + let mut paths = Vec::new(); + for entry in fs::read_dir(root).map_err(|_| ProxifyreNativeHostError)? { + let entry = entry.map_err(|_| ProxifyreNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| ProxifyreNativeHostError)?; + if !expected.contains(&name.to_ascii_lowercase()) { + return Err(ProxifyreNativeHostError); + } + let path = entry.path(); + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| ProxifyreNativeHostError)?; + if !fs::symlink_metadata(&path) + .map_err(|_| ProxifyreNativeHostError)? + .file_type() + .is_file() + { + return Err(ProxifyreNativeHostError); + } + paths.push(path); + } + for path in paths { + fs::remove_file(path).map_err(|_| ProxifyreNativeHostError)?; + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + fs::remove_dir(root).map_err(|_| ProxifyreNativeHostError) +} + +fn remove_receipt_root_exact( + root: &Path, + receipt: &InstallReceipt, +) -> Result<(), ProxifyreNativeHostError> { + let stored_bytes = read_admin_metadata(&root.join(INSTALL_RECEIPT_FILENAME), 128 * 1024)?; + let stored: InstallReceipt = + serde_json::from_slice(&stored_bytes).map_err(|_| ProxifyreNativeHostError)?; + if &stored != receipt { + return Err(ProxifyreNativeHostError); + } + let files = receipt_inventory(receipt)?; + verify_runtime_inventory(root, &files, true)?; + let marker_bytes = read_admin_metadata(&root.join(PROXIFYRE_MARKER_FILE), 64 * 1024)?; + let marker: SystemProxifyreMarker = + serde_json::from_slice(&marker_bytes).map_err(|_| ProxifyreNativeHostError)?; + if marker.schema_version != 1 + || marker.manager != "ProxyWarden" + || marker.component != "proxifyre" + || marker.service_name != PROXIFYRE_MANAGED_SERVICE_NAME + || !same_windows_path( + Path::new(&marker.install_root), + Path::new(&receipt.install_root), + ) + || marker.packet_filter_installed_by_proxy_warden + != receipt + .windows_packet_filter + .as_ref() + .is_some_and(|ownership| ownership.installed_by_proxy_warden) + { + return Err(ProxifyreNativeHostError); + } + remove_runtime_tree_without_receipt(root, &files) +} + +fn unique_sibling(path: &Path, label: &str) -> Result { + let parent = path.parent().ok_or(ProxifyreNativeHostError)?; + let name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or(ProxifyreNativeHostError)?; + for _ in 0..8 { + let candidate = parent.join(format!( + "{name}.{label}.{}", + uuid::Uuid::new_v4().hyphenated() + )); + if !candidate + .try_exists() + .map_err(|_| ProxifyreNativeHostError)? + { + safe_fs::ensure_no_reparse_ancestors(&candidate) + .map_err(|_| ProxifyreNativeHostError)?; + return Ok(candidate); + } + } + Err(ProxifyreNativeHostError) +} + +fn system_installer_invocation( + command: ProxifyreInstallerCommand<'_>, + root: &Path, + msiexec: &Path, +) -> Result { + if !root.is_absolute() || !msiexec.is_absolute() { + return Err(ProxifyreNativeHostError); + } + let service_executable = root.join("ProxiFyre.exe"); + let invocation = match command { + ProxifyreInstallerCommand::InstallVcRuntime(path) => SystemInstallerInvocation { + program: path.to_path_buf(), + arguments: ["/install", "/quiet", "/norestart"] + .into_iter() + .map(OsString::from) + .collect(), + working_directory: None, + }, + ProxifyreInstallerCommand::InstallPacketFilter(path) => SystemInstallerInvocation { + program: msiexec.to_path_buf(), + arguments: vec![ + OsString::from("/i"), + path.as_os_str().to_owned(), + OsString::from("/qn"), + OsString::from("/norestart"), + ], + working_directory: None, + }, + ProxifyreInstallerCommand::InstallService => SystemInstallerInvocation { + program: service_executable, + arguments: vec![OsString::from("install")], + working_directory: Some(root.to_path_buf()), + }, + ProxifyreInstallerCommand::UninstallService => SystemInstallerInvocation { + program: service_executable, + arguments: vec![OsString::from("uninstall")], + working_directory: Some(root.to_path_buf()), + }, + ProxifyreInstallerCommand::UninstallOwnedPacketFilter => SystemInstallerInvocation { + program: msiexec.to_path_buf(), + arguments: [ + "/x", + WINDOWS_PACKET_FILTER_PRODUCT_CODE, + "/qn", + "/norestart", + ] + .into_iter() + .map(OsString::from) + .collect(), + working_directory: None, + }, + }; + if !invocation.program.is_absolute() { + return Err(ProxifyreNativeHostError); + } + Ok(invocation) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxifyreFirewallMutation { + Add, + Delete, +} + +fn fixed_firewall_arguments( + mutation: ProxifyreFirewallMutation, + direction: ProxifyreFirewallDirection, + executable: &Path, +) -> Result, ProxifyreNativeHostError> { + if !executable.is_absolute() + || executable.file_name().and_then(OsStr::to_str) != Some("ProxiFyre.exe") + || executable.as_os_str().to_string_lossy().contains('\0') + { + return Err(ProxifyreNativeHostError); + } + let (name, direction) = match direction { + ProxifyreFirewallDirection::Inbound => (PROXIFYRE_FIREWALL_INBOUND_RULE, "dir=in"), + ProxifyreFirewallDirection::Outbound => (PROXIFYRE_FIREWALL_OUTBOUND_RULE, "dir=out"), + }; + let mut arguments = vec![ + OsString::from("advfirewall"), + OsString::from("firewall"), + OsString::from(match mutation { + ProxifyreFirewallMutation::Add => "add", + ProxifyreFirewallMutation::Delete => "delete", + }), + OsString::from("rule"), + OsString::from(format!("name={name}")), + OsString::from(direction), + OsString::from(format!("program={}", executable.display())), + ]; + if mutation == ProxifyreFirewallMutation::Add { + arguments.extend([ + OsString::from("action=allow"), + OsString::from("enable=yes"), + OsString::from("profile=any"), + ]); + } + Ok(arguments) +} + +fn unix_now() -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| ProxifyreNativeHostError)?; + (now > 0).then_some(now).ok_or(ProxifyreNativeHostError) +} + +fn rollback_new_service(root: &Path) -> Result<(), ProxifyreNativeHostError> { + let executable = root.join("ProxiFyre.exe"); + let service = query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &executable) + .map_err(|_| ProxifyreNativeHostError)?; + if !service.service.exists { + return Ok(()); + } + if !service.path_matches || service.service.state != Some(ServiceState::Stopped) { + return Err(ProxifyreNativeHostError); + } + let status = run_fixed_process_in_directory( + &executable, + &[OsString::from("uninstall")], + root, + SERVICE_INSTALLER_PROCESS_TIMEOUT, + ) + .map_err(|_| ProxifyreNativeHostError)?; + if status.code() != Some(0) { + return Err(ProxifyreNativeHostError); + } + wait_known_service_deleted( + KnownWindowsService::Proxifyre, + &executable, + Duration::from_secs(15), + ) + .map_err(|_| ProxifyreNativeHostError)?; + Ok(()) +} + +fn rollback_restore_service(root: &Path) -> Result<(), ProxifyreNativeHostError> { + let executable = root.join("ProxiFyre.exe"); + let before = query_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &executable) + .map_err(|_| ProxifyreNativeHostError)?; + if before.service.exists { + return Err(ProxifyreNativeHostError); + } + let status = run_fixed_process_in_directory( + &executable, + &[OsString::from("install")], + root, + SERVICE_INSTALLER_PROCESS_TIMEOUT, + ) + .map_err(|_| ProxifyreNativeHostError)?; + if status.code() != Some(0) { + return Err(ProxifyreNativeHostError); + } + let policy = enforce_service_policy_exact(PROXIFYRE_MANAGED_SERVICE_NAME, &executable) + .map_err(|_| ProxifyreNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) + || !system_service_policy_matches(&policy) + { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +#[cfg(windows)] +fn native_vc_runtime_present() -> Result { + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; + use winreg::RegKey; + + match RegKey::predef(HKEY_LOCAL_MACHINE).open_subkey_with_flags( + r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64", + KEY_READ | KEY_WOW64_64KEY, + ) { + Ok(key) => key + .get_value::("Installed") + .map(|installed| installed == 1) + .map_err(|_| ProxifyreNativeHostError), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(ProxifyreNativeHostError), + } +} + +#[cfg(not(windows))] +fn native_vc_runtime_present() -> Result { + Ok(false) +} + +#[cfg(windows)] +fn native_packet_filter_present() -> Result { + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; + use winreg::RegKey; + + let path = format!( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{}", + WINDOWS_PACKET_FILTER_PRODUCT_CODE + ); + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + let key = match RegKey::predef(HKEY_LOCAL_MACHINE) + .open_subkey_with_flags(&path, KEY_READ | view) + { + Ok(key) => key, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(ProxifyreNativeHostError), + }; + let name = key + .get_value::("DisplayName") + .map_err(|_| ProxifyreNativeHostError)?; + if !name + .trim() + .eq_ignore_ascii_case("Windows Packet Filter x64") + { + return Err(ProxifyreNativeHostError); + } + return Ok(true); + } + Ok(false) +} + +#[cfg(not(windows))] +fn native_packet_filter_present() -> Result { + Ok(false) +} + +#[derive(Clone, Copy)] +struct PackageLeaseView<'a> { + proof: &'a PrivilegedPackageProof, + path: &'a Path, +} + +impl<'a> From<&'a PrivilegedPackageLease> for PackageLeaseView<'a> { + fn from(lease: &'a PrivilegedPackageLease) -> Self { + Self { + proof: lease.proof(), + path: lease.asset_path(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedProxifyreRuntime { + proof: PrivilegedPackageProof, + files: Vec, + installed_files: Vec, +} + +impl PreparedProxifyreRuntime { + pub fn proof(&self) -> &PrivilegedPackageProof { + &self.proof + } + + pub fn version(&self) -> &str { + &self.proof.version + } + + pub fn files(&self) -> &[ProxifyreStagedFile] { + &self.files + } + + pub fn installed_files(&self) -> &[ProxifyreInstalledFile] { + &self.installed_files + } +} + +/// Read-only offline package preparation for the cutover composite host. +/// It verifies the still-live bundled lease, parses the bounded archive, +/// applies the frozen NLog override in memory, and performs no writes, +/// process launches, network calls, or SCM operations. The caller must retain +/// the original lease until the cutover reaches success or completes rollback. +pub fn prepare_proxifyre_cutover_package( + lease: &PrivilegedPackageLease, +) -> Result { + let view = PackageLeaseView::from(lease); + validate_package(view, ComponentId::Proxifyre, PackageSource::Bundled)?; + let files = read_proxifyre_archive(view)?; + validate_staged_runtime(&files).map_err(|_| ProxifyreNativeError::InvalidArchive)?; + let installed_files = installed_file_inventory(&files); + Ok(PreparedProxifyreRuntime { + proof: lease.proof().clone(), + files, + installed_files, + }) +} + +/// Immutable, journal-bindable bytes for the complete ProxiFyre cutover +/// candidate. Preparing this value performs no filesystem, process, network, +/// or SCM mutation. Package bytes remain owned here until success or rollback. +pub struct PreparedProxifyreCutoverCandidate { + candidate_path: PathBuf, + current_root: PathBuf, + snapshot: CurrentCandidateSnapshot, + runtime: PreparedProxifyreRuntime, + config_bytes: Vec, + marker_bytes: Vec, + receipt_bytes: Vec, +} + +impl PreparedProxifyreCutoverCandidate { + pub fn candidate_path(&self) -> &Path { + &self.candidate_path + } + + pub fn current_root(&self) -> &Path { + &self.current_root + } + + pub fn snapshot(&self) -> &CurrentCandidateSnapshot { + &self.snapshot + } + + pub fn file_spec(&self, relative_path: &Path) -> Option<&CurrentCandidateFile> { + self.snapshot + .files + .iter() + .find(|file| file.relative_path == relative_path) + } + + pub fn file_bytes(&self, relative_path: &Path) -> Option<&[u8]> { + let spec = self.file_spec(relative_path)?; + match spec.role { + CurrentCandidateFileRole::Package => self + .runtime + .files + .iter() + .find(|file| Path::new(&file.relative_path) == relative_path) + .map(|file| file.bytes.as_slice()), + CurrentCandidateFileRole::Config => Some(&self.config_bytes), + CurrentCandidateFileRole::Marker => Some(&self.marker_bytes), + CurrentCandidateFileRole::Receipt => Some(&self.receipt_bytes), + } + } +} + +/// Freezes the exact package, config, marker, and receipt bytes before the +/// first cutover journal record is created. The deterministic destination is +/// validated against the plan, but no candidate path is created or opened. +#[allow(clippy::too_many_arguments)] +pub fn prepare_proxifyre_cutover_candidate( + plan: &ProxifyreCutoverPlan, + runtime: PreparedProxifyreRuntime, + runtime_config_bytes: &[u8], + runtime_config_sha256: &str, + windows_packet_filter_present_before: bool, + installed_at_epoch_seconds: u64, +) -> Result { + validate_prepared_cutover_runtime(plan, &runtime)?; + validate_cutover_candidate_paths(plan)?; + if installed_at_epoch_seconds == 0 + || runtime_config_bytes.is_empty() + || runtime_config_bytes.len() as u64 > MAX_RUNTIME_CONFIG_BYTES + || !valid_sha256(runtime_config_sha256) + || runtime_config_sha256 != plan.config_fingerprint + || format!("{:x}", Sha256::digest(runtime_config_bytes)) != runtime_config_sha256 + || serde_json::from_slice::(runtime_config_bytes).is_err() + { + return Err(ProxifyreNativeError::RuntimeConfigInvalid); + } + + let install_root = plan + .current_root + .to_str() + .ok_or(ProxifyreNativeError::InvalidReceipt)? + .to_owned(); + // The forward plan always installs WPF before writing the receipt when it + // was absent in the captured before-state. If that install never reaches + // its journaled expected effect, the receipt operation is never allowed. + let packet_filter_owned_by_proxywarden = !windows_packet_filter_present_before; + let receipt = InstallReceipt { + schema_version: INSTALL_RECEIPT_SCHEMA_VERSION, + manager: InstallReceiptManager::ProxyWarden, + component: ManagedComponent::Proxifyre, + install_root: install_root.clone(), + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + version: runtime.proof.version.clone(), + source: InstalledPackageSource::Bundled, + asset: InstalledAssetReceipt { + name: runtime.proof.asset_name.clone(), + sha256: runtime.proof.sha256.clone(), + size: runtime.proof.size, + }, + installed_files: runtime + .installed_files + .iter() + .map(|file| InstalledFileReceipt { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect(), + installed_at: installed_at_epoch_seconds, + windows_packet_filter: packet_filter_owned_by_proxywarden.then(|| { + WindowsPacketFilterOwnership { + product_code: WINDOWS_PACKET_FILTER_PRODUCT_CODE.to_owned(), + installed_by_proxy_warden: true, + } + }), + }; + let marker = SystemProxifyreMarker { + schema_version: 1, + manager: "ProxyWarden".to_owned(), + component: "proxifyre".to_owned(), + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + install_root, + packet_filter_installed_by_proxy_warden: packet_filter_owned_by_proxywarden, + }; + let marker_bytes = + serde_json::to_vec_pretty(&marker).map_err(|_| ProxifyreNativeError::InvalidReceipt)?; + let receipt_bytes = + serde_json::to_vec_pretty(&receipt).map_err(|_| ProxifyreNativeError::InvalidReceipt)?; + if marker_bytes.is_empty() + || marker_bytes.len() > 64 * 1024 + || receipt_bytes.is_empty() + || receipt_bytes.len() > 128 * 1024 + { + return Err(ProxifyreNativeError::InvalidReceipt); + } + + let mut files: Vec = runtime + .files + .iter() + .map(|file| CurrentCandidateFile { + relative_path: PathBuf::from(&file.relative_path), + role: CurrentCandidateFileRole::Package, + size: file.size, + sha256: file.sha256.clone(), + }) + .collect(); + files.push(candidate_file( + "app-config.json", + CurrentCandidateFileRole::Config, + runtime_config_bytes, + )?); + files.push(candidate_file( + PROXIFYRE_MARKER_FILE, + CurrentCandidateFileRole::Marker, + &marker_bytes, + )?); + files.push(candidate_file( + INSTALL_RECEIPT_FILENAME, + CurrentCandidateFileRole::Receipt, + &receipt_bytes, + )?); + files.sort_by(|left, right| { + candidate_relative_label(&left.relative_path) + .cmp(&candidate_relative_label(&right.relative_path)) + }); + let snapshot = CurrentCandidateSnapshot { + manifest_fingerprint: current_candidate_manifest_fingerprint(&files) + .map_err(|_| ProxifyreNativeError::InvalidReceipt)?, + files, + }; + + Ok(PreparedProxifyreCutoverCandidate { + candidate_path: plan.current_candidate_path.clone(), + current_root: plan.current_root.clone(), + snapshot, + runtime, + config_bytes: runtime_config_bytes.to_vec(), + marker_bytes, + receipt_bytes, + }) +} + +fn validate_prepared_cutover_runtime( + plan: &ProxifyreCutoverPlan, + runtime: &PreparedProxifyreRuntime, +) -> Result<(), ProxifyreNativeError> { + validate_staged_runtime(&runtime.files).map_err(|_| ProxifyreNativeError::InvalidArchive)?; + validate_installed_inventory(&runtime.installed_files) + .map_err(|_| ProxifyreNativeError::InvalidArchive)?; + if runtime.proof.component_id != ComponentId::Proxifyre + || runtime.proof.source != PackageSource::Bundled + || runtime.proof.version != plan.bundled_version + || runtime.proof.sha256 != plan.package_fingerprint + || runtime.installed_files != installed_file_inventory(&runtime.files) + { + return Err(ProxifyreNativeError::InvalidPackage); + } + Ok(()) +} + +fn validate_cutover_candidate_paths( + plan: &ProxifyreCutoverPlan, +) -> Result<(), ProxifyreNativeError> { + let parsed = uuid::Uuid::parse_str(&plan.cutover_id) + .map_err(|_| ProxifyreNativeError::OwnershipMismatch)?; + if parsed.get_version() != Some(uuid::Version::Random) + || parsed.hyphenated().to_string() != plan.cutover_id + || !plan.current_root.is_absolute() + || !plan.current_candidate_path.is_absolute() + || !plan.current_tombstone_path.is_absolute() + { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + let components = plan + .current_root + .parent() + .ok_or(ProxifyreNativeError::OwnershipMismatch)?; + let app_root = components + .parent() + .ok_or(ProxifyreNativeError::OwnershipMismatch)?; + let expected_current = app_root.join("components").join("ProxiFyre"); + let cutover_root = app_root.join(crate::component_cutover::CUTOVER_JOURNAL_DIRECTORY); + let expected_candidate = cutover_root.join(format!("proxifyre-{}.candidate", plan.cutover_id)); + let expected_tombstone = cutover_root.join(format!("proxifyre-{}.rollback", plan.cutover_id)); + if !same_windows_path(&plan.current_root, &expected_current) + || !same_windows_path(&plan.current_candidate_path, &expected_candidate) + || !same_windows_path(&plan.current_tombstone_path, &expected_tombstone) + { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + Ok(()) +} + +fn candidate_file( + relative_path: &str, + role: CurrentCandidateFileRole, + bytes: &[u8], +) -> Result { + let size = u64::try_from(bytes.len()).map_err(|_| ProxifyreNativeError::InvalidReceipt)?; + if size == 0 { + return Err(ProxifyreNativeError::InvalidReceipt); + } + Ok(CurrentCandidateFile { + relative_path: PathBuf::from(relative_path), + role, + size, + sha256: format!("{:x}", Sha256::digest(bytes)), + }) +} + +fn candidate_relative_label(path: &Path) -> String { + path.as_os_str().to_string_lossy().to_ascii_lowercase() +} + +/// Injectable one-mutation-at-a-time candidate writer boundary. A caller must +/// write and flush the matching durable intent before invoking any method. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", content = "snapshot", rename_all = "snake_case")] +pub enum ProxifyreCutoverCandidateObservation { + Absent, + Expected(SealedPathSnapshot), + Unknown, +} + +pub trait ProxifyreCutoverCandidateWriter { + fn observe_candidate( + &mut self, + operation: &CutoverOperation, + ) -> Result; + fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn write_candidate_package_entry( + &mut self, + relative_path: &Path, + ) -> Result<(), ProxifyreNativeHostError>; + fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError>; + fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError>; +} + +/// Live handle owner for a partial or complete journaled candidate. Root and +/// every materialized entry lease are retained until they are transferred to +/// core's filesystem delegate; core separately owns both parent leases. +pub struct SystemProxifyreCutoverCandidateWriter { + prepared: PreparedProxifyreCutoverCandidate, + root_lease: Option, + entry_leases: BTreeMap, +} + +impl SystemProxifyreCutoverCandidateWriter { + /// Verifies the fixed destination and sealed candidate parent, then + /// requires both candidate and final current roots to be absent. No + /// candidate object is created. + pub fn begin( + prepared: PreparedProxifyreCutoverCandidate, + ) -> Result { + let writer = Self::open(prepared)?; + if candidate_path_exists(writer.prepared.candidate_path())? + || candidate_path_exists(writer.prepared.current_root())? + { + return Err(ProxifyreNativeHostError); + } + Ok(writer) + } + + /// Reacquires an interrupted journal's exact partial candidate. Unknown + /// entries, wrong contents, ACL drift, reparse points, and a simultaneous + /// current root all fail closed. After handoff, core owns recovery. + pub fn recover( + prepared: PreparedProxifyreCutoverCandidate, + ) -> Result { + let mut writer = Self::open(prepared)?; + let candidate_exists = candidate_path_exists(writer.prepared.candidate_path())?; + let current_exists = candidate_path_exists(writer.prepared.current_root())?; + if current_exists { + return Err(ProxifyreNativeHostError); + } + if candidate_exists { + let path = writer.prepared.candidate_path.clone(); + writer.reacquire_root_at(&path)?; + } + Ok(writer) + } + + pub fn prepared(&self) -> &PreparedProxifyreCutoverCandidate { + &self.prepared + } + + pub fn observed_root_identity(&self) -> Option { + self.root_lease.as_ref().map(sealed_path_snapshot) + } + + pub fn observed_entry_identity(&self, relative_path: &Path) -> Option { + self.entry_leases + .get(relative_path) + .map(sealed_path_snapshot) + } + + /// Observes exactly one candidate object. If a create/write returned after + /// making its external effect but before retaining the lease, this method + /// reopens that exact path, verifies final ACL plus bytes through the new + /// stable handle, and retains it for effect evidence and rollback. + pub fn observe_operation( + &mut self, + operation: &CutoverOperation, + ) -> Result { + match candidate_path_exists(self.prepared.current_root()) { + Ok(false) => {} + Ok(true) | Err(_) => return Ok(ProxifyreCutoverCandidateObservation::Unknown), + } + match operation { + CutoverOperation::CreateCurrentCandidateRoot => { + let observed = self.observe_root_object()?; + if matches!(observed, ProxifyreCutoverCandidateObservation::Expected(_)) + && !self.candidate_directory_matches_retained_leases() + { + Ok(ProxifyreCutoverCandidateObservation::Unknown) + } else { + Ok(observed) + } + } + _ => { + let file = candidate_file_for_operation(&self.prepared, operation) + .ok_or(ProxifyreNativeHostError)? + .clone(); + match self.observe_root_object()? { + ProxifyreCutoverCandidateObservation::Absent => { + Ok(ProxifyreCutoverCandidateObservation::Absent) + } + ProxifyreCutoverCandidateObservation::Unknown => { + Ok(ProxifyreCutoverCandidateObservation::Unknown) + } + ProxifyreCutoverCandidateObservation::Expected(_) => { + let observed = self.observe_file_object(&file)?; + if matches!(observed, ProxifyreCutoverCandidateObservation::Expected(_)) + && !self.candidate_directory_matches_retained_leases() + { + Ok(ProxifyreCutoverCandidateObservation::Unknown) + } else { + Ok(observed) + } + } + } + } + } + } + + fn observe_root_object( + &mut self, + ) -> Result { + if let Some(lease) = self.root_lease.as_ref() { + return if same_windows_path(lease.path(), self.prepared.candidate_path()) + && lease.identity().kind == safe_fs::StableObjectKind::Directory + && safe_fs::verify_identity_lease_admin_owned_user_read_only(lease).is_ok() + { + Ok(ProxifyreCutoverCandidateObservation::Expected( + sealed_path_snapshot(lease), + )) + } else { + Ok(ProxifyreCutoverCandidateObservation::Unknown) + }; + } + match candidate_path_exists(self.prepared.candidate_path()) { + Ok(false) => return Ok(ProxifyreCutoverCandidateObservation::Absent), + Ok(true) => {} + Err(_) => return Ok(ProxifyreCutoverCandidateObservation::Unknown), + } + let lease = match safe_fs::open_path_identity_lease( + self.prepared.candidate_path(), + safe_fs::StableObjectKind::Directory, + ) { + Ok(lease) + if safe_fs::verify_identity_lease_admin_owned_user_read_only(&lease).is_ok() => + { + lease + } + Ok(_) | Err(_) => return Ok(ProxifyreCutoverCandidateObservation::Unknown), + }; + let observed = sealed_path_snapshot(&lease); + self.root_lease = Some(lease); + Ok(ProxifyreCutoverCandidateObservation::Expected(observed)) + } + + fn observe_file_object( + &mut self, + spec: &CurrentCandidateFile, + ) -> Result { + let expected_path = self.prepared.candidate_path.join(&spec.relative_path); + if let Some(lease) = self.entry_leases.get(&spec.relative_path) { + return if same_windows_path(lease.path(), &expected_path) + && verify_candidate_file_lease_policy(lease, spec.role).is_ok() + && verify_candidate_file_lease(lease, spec).is_ok() + { + Ok(ProxifyreCutoverCandidateObservation::Expected( + sealed_path_snapshot(lease), + )) + } else { + Ok(ProxifyreCutoverCandidateObservation::Unknown) + }; + } + match candidate_path_exists(&expected_path) { + Ok(false) => return Ok(ProxifyreCutoverCandidateObservation::Absent), + Ok(true) => {} + Err(_) => return Ok(ProxifyreCutoverCandidateObservation::Unknown), + } + let lease = match safe_fs::open_path_identity_lease( + &expected_path, + safe_fs::StableObjectKind::RegularFile, + ) { + Ok(lease) + if verify_candidate_file_lease_policy(&lease, spec.role).is_ok() + && verify_candidate_file_lease(&lease, spec).is_ok() => + { + lease + } + Ok(_) | Err(_) => return Ok(ProxifyreCutoverCandidateObservation::Unknown), + }; + let observed = sealed_path_snapshot(&lease); + self.entry_leases.insert(spec.relative_path.clone(), lease); + Ok(ProxifyreCutoverCandidateObservation::Expected(observed)) + } + + fn candidate_directory_matches_retained_leases(&self) -> bool { + let Some(root) = self.root_lease.as_ref() else { + return false; + }; + let Ok(entries) = fs::read_dir(root.path()) else { + return false; + }; + let mut found = HashSet::new(); + for entry in entries { + let Ok(entry) = entry else { + return false; + }; + let relative_path = PathBuf::from(entry.file_name()); + let (Some(spec), Some(lease)) = ( + self.prepared.file_spec(&relative_path), + self.entry_leases.get(&relative_path), + ) else { + return false; + }; + if !found.insert(relative_path.clone()) + || !same_windows_path(lease.path(), &root.path().join(&relative_path)) + || verify_candidate_file_lease_policy(lease, spec.role).is_err() + || verify_candidate_file_lease(lease, spec).is_err() + { + return false; + } + } + found.len() == self.entry_leases.len() + } + + /// Transfers the same live handles to core's filesystem delegate, which + /// becomes the sole owner of promotion and rollback. + pub fn into_complete_candidate_leases( + mut self, + ) -> Result< + ( + safe_fs::PathIdentityLease, + Vec<(PathBuf, safe_fs::PathIdentityLease)>, + ), + ProxifyreNativeHostError, + > { + if !self.has_complete_candidate() || candidate_path_exists(self.prepared.current_root())? { + return Err(ProxifyreNativeHostError); + } + verify_materialized_candidate_at( + &self.prepared, + &self.entry_leases, + self.prepared.candidate_path(), + true, + )?; + let root = self.root_lease.take().ok_or(ProxifyreNativeHostError)?; + if !same_windows_path(root.path(), self.prepared.candidate_path()) { + return Err(ProxifyreNativeHostError); + } + Ok((root, self.entry_leases.into_iter().collect())) + } + + /// Transfers an exact partial candidate to core for rollback. Only the root + /// and entries with a unique durable forward `ExpectedEffect` in the sealed + /// journal are accepted; pending, no-effect, or unjournaled objects fail + /// closed. + pub fn into_partial_candidate_leases_for_rollback( + mut self, + journal: &CutoverJournal, + ) -> Result< + ( + safe_fs::PathIdentityLease, + Vec<(PathBuf, safe_fs::PathIdentityLease)>, + ), + ProxifyreNativeHostError, + > { + if journal.execution_mode != CutoverExecutionMode::RollingBack + || journal.plan.current_candidate_path != self.prepared.candidate_path + || journal.plan.current_root != self.prepared.current_root + || journal.before_state.current_candidate != self.prepared.snapshot + || candidate_path_exists(self.prepared.current_root())? + { + return Err(ProxifyreNativeHostError); + } + + let root_operation = CutoverOperation::CreateCurrentCandidateRoot; + let expected_root = + unique_forward_expected_effect_identity(&journal.mutations, &root_operation)? + .ok_or(ProxifyreNativeHostError)?; + let root = self.root_lease.as_ref().ok_or(ProxifyreNativeHostError)?; + if root.identity() != expected_root + || !same_windows_path(root.path(), self.prepared.candidate_path()) + { + return Err(ProxifyreNativeHostError); + } + + let mut expected_entries = 0usize; + for file in &self.prepared.snapshot.files { + let operation = cutover_candidate_write_operation(file); + let expected = unique_forward_expected_effect_identity(&journal.mutations, &operation)?; + let lease = self.entry_leases.get(&file.relative_path); + match (expected, lease) { + (Some(identity), Some(lease)) + if identity == lease.identity() + && same_windows_path( + lease.path(), + &self.prepared.candidate_path.join(&file.relative_path), + ) => + { + expected_entries += 1; + } + (None, None) => {} + _ => return Err(ProxifyreNativeHostError), + } + } + if expected_entries != self.entry_leases.len() { + return Err(ProxifyreNativeHostError); + } + verify_materialized_candidate_at( + &self.prepared, + &self.entry_leases, + self.prepared.candidate_path(), + false, + )?; + + let root = self.root_lease.take().ok_or(ProxifyreNativeHostError)?; + Ok((root, self.entry_leases.into_iter().collect())) + } + + fn open(prepared: PreparedProxifyreCutoverCandidate) -> Result { + validate_system_candidate_destination(&prepared)?; + let candidate_parent = prepared + .candidate_path + .parent() + .ok_or(ProxifyreNativeHostError)?; + safe_fs::verify_directory_admin_system_only(candidate_parent) + .map_err(|_| ProxifyreNativeHostError)?; + Ok(Self { + prepared, + root_lease: None, + entry_leases: BTreeMap::new(), + }) + } + + fn create_root_once(&mut self) -> Result<(), ProxifyreNativeHostError> { + if self.root_lease.is_some() + || !self.entry_leases.is_empty() + || candidate_path_exists(self.prepared.candidate_path())? + || candidate_path_exists(self.prepared.current_root())? + { + return Err(ProxifyreNativeHostError); + } + let candidate_parent = self + .prepared + .candidate_path() + .parent() + .ok_or(ProxifyreNativeHostError)?; + safe_fs::verify_directory_admin_system_only(candidate_parent) + .map_err(|_| ProxifyreNativeHostError)?; + safe_fs::create_new_admin_owned_user_read_only_directory(self.prepared.candidate_path()) + .map_err(|_| ProxifyreNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(self.prepared.candidate_path()) + .map_err(|_| ProxifyreNativeHostError)?; + let lease = safe_fs::open_path_identity_lease( + self.prepared.candidate_path(), + safe_fs::StableObjectKind::Directory, + ) + .map_err(|_| ProxifyreNativeHostError)?; + self.root_lease = Some(lease); + Ok(()) + } + + fn write_file_once( + &mut self, + relative_path: &Path, + expected_role: CurrentCandidateFileRole, + ) -> Result<(), ProxifyreNativeHostError> { + if self.entry_leases.contains_key(relative_path) + || candidate_path_exists(self.prepared.current_root())? + { + return Err(ProxifyreNativeHostError); + } + let root = self.root_lease.as_ref().ok_or(ProxifyreNativeHostError)?; + if !same_windows_path(root.path(), self.prepared.candidate_path()) { + return Err(ProxifyreNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(root.path()) + .map_err(|_| ProxifyreNativeHostError)?; + let spec = self + .prepared + .file_spec(relative_path) + .filter(|spec| spec.role == expected_role) + .ok_or(ProxifyreNativeHostError)? + .clone(); + let bytes = self + .prepared + .file_bytes(relative_path) + .ok_or(ProxifyreNativeHostError)? + .to_vec(); + let destination = root.path().join(relative_path); + if candidate_path_exists(&destination)? { + return Err(ProxifyreNativeHostError); + } + + let mut file = match expected_role { + CurrentCandidateFileRole::Config => { + safe_fs::create_new_admin_system_only_file(&destination) + } + CurrentCandidateFileRole::Package + | CurrentCandidateFileRole::Marker + | CurrentCandidateFileRole::Receipt => { + safe_fs::create_new_admin_owned_user_read_only_file(&destination) + } + } + .map_err(|_| ProxifyreNativeHostError)?; + file.write_all(&bytes) + .and_then(|()| file.sync_all()) + .and_then(|()| file.seek(SeekFrom::Start(0)).map(|_| ())) + .map_err(|_| ProxifyreNativeHostError)?; + let mut read_back = Vec::with_capacity(bytes.len()); + Read::by_ref(&mut file) + .take(spec.size.saturating_add(1)) + .read_to_end(&mut read_back) + .map_err(|_| ProxifyreNativeHostError)?; + if read_back != bytes { + return Err(ProxifyreNativeHostError); + } + drop(file); + + verify_candidate_file_policy(&destination, expected_role)?; + let lease = + safe_fs::open_path_identity_lease(&destination, safe_fs::StableObjectKind::RegularFile) + .map_err(|_| ProxifyreNativeHostError)?; + verify_candidate_file_lease(&lease, &spec)?; + self.entry_leases.insert(relative_path.to_path_buf(), lease); + Ok(()) + } + + fn has_complete_candidate(&self) -> bool { + self.entry_leases.len() == self.prepared.snapshot.files.len() + && self + .prepared + .snapshot + .files + .iter() + .all(|file| self.entry_leases.contains_key(&file.relative_path)) + } + + fn reacquire_root_at(&mut self, root_path: &Path) -> Result<(), ProxifyreNativeHostError> { + if self.root_lease.is_some() + || !self.entry_leases.is_empty() + || !same_windows_path(root_path, self.prepared.candidate_path()) + { + return Err(ProxifyreNativeHostError); + } + if !matches!( + self.observe_root_object()?, + ProxifyreCutoverCandidateObservation::Expected(_) + ) { + return Err(ProxifyreNativeHostError); + } + let mut found = HashSet::new(); + for entry in fs::read_dir(root_path).map_err(|_| ProxifyreNativeHostError)? { + let entry = entry.map_err(|_| ProxifyreNativeHostError)?; + let relative_path = PathBuf::from(entry.file_name()); + let spec = self + .prepared + .file_spec(&relative_path) + .ok_or(ProxifyreNativeHostError)? + .clone(); + if !found.insert(relative_path) + || !matches!( + self.observe_file_object(&spec)?, + ProxifyreCutoverCandidateObservation::Expected(_) + ) + { + return Err(ProxifyreNativeHostError); + } + } + if found.len() != self.entry_leases.len() + || !matches!( + self.observe_root_object()?, + ProxifyreCutoverCandidateObservation::Expected(_) + ) + { + return Err(ProxifyreNativeHostError); + } + Ok(()) + } +} + +impl ProxifyreCutoverCandidateWriter for SystemProxifyreCutoverCandidateWriter { + fn observe_candidate( + &mut self, + operation: &CutoverOperation, + ) -> Result { + self.observe_operation(operation) + } + + fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.create_root_once() + } + + fn write_candidate_package_entry( + &mut self, + relative_path: &Path, + ) -> Result<(), ProxifyreNativeHostError> { + self.write_file_once(relative_path, CurrentCandidateFileRole::Package) + } + + fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.write_file_once( + Path::new("app-config.json"), + CurrentCandidateFileRole::Config, + ) + } + + fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.write_file_once( + Path::new(PROXIFYRE_MARKER_FILE), + CurrentCandidateFileRole::Marker, + ) + } + + fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.write_file_once( + Path::new(INSTALL_RECEIPT_FILENAME), + CurrentCandidateFileRole::Receipt, + ) + } +} + +/// Routes exactly one candidate operation from the durable journal. All other +/// operations are left to the SCM/prerequisite/legacy-filesystem delegates. +pub fn mutate_proxifyre_cutover_candidate( + writer: &mut dyn ProxifyreCutoverCandidateWriter, + operation: &crate::component_cutover::CutoverOperation, +) -> Result { + use crate::component_cutover::CutoverOperation; + + match operation { + CutoverOperation::CreateCurrentCandidateRoot => writer.create_candidate_root()?, + CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path) => { + writer.write_candidate_package_entry(relative_path)? + } + CutoverOperation::WriteCurrentCandidateConfig => writer.write_candidate_config()?, + CutoverOperation::WriteCurrentCandidateMarker => writer.write_candidate_marker()?, + CutoverOperation::WriteCurrentCandidateReceipt => writer.write_candidate_receipt()?, + _ => return Ok(false), + } + Ok(true) +} + +fn sealed_path_snapshot(lease: &safe_fs::PathIdentityLease) -> SealedPathSnapshot { + SealedPathSnapshot { + identity: lease.identity().clone(), + security: lease.security().clone(), + } +} + +fn candidate_file_for_operation<'a>( + prepared: &'a PreparedProxifyreCutoverCandidate, + operation: &CutoverOperation, +) -> Option<&'a CurrentCandidateFile> { + match operation { + CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path) => prepared + .file_spec(relative_path) + .filter(|file| file.role == CurrentCandidateFileRole::Package), + CutoverOperation::WriteCurrentCandidateConfig => prepared + .file_spec(Path::new("app-config.json")) + .filter(|file| file.role == CurrentCandidateFileRole::Config), + CutoverOperation::WriteCurrentCandidateMarker => prepared + .snapshot + .files + .iter() + .find(|file| file.role == CurrentCandidateFileRole::Marker), + CutoverOperation::WriteCurrentCandidateReceipt => prepared + .snapshot + .files + .iter() + .find(|file| file.role == CurrentCandidateFileRole::Receipt), + _ => None, + } +} + +fn validate_system_candidate_destination( + prepared: &PreparedProxifyreCutoverCandidate, +) -> Result<(), ProxifyreNativeHostError> { + validate_prepared_candidate(prepared)?; + let current_exe = std::env::current_exe().map_err(|_| ProxifyreNativeHostError)?; + let canonical = + CanonicalComponentRoot::from_current_exe(¤t_exe, ManagedComponent::Proxifyre) + .map_err(|_| ProxifyreNativeHostError)?; + if !same_windows_path(canonical.path(), prepared.current_root()) { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +fn validate_prepared_candidate( + prepared: &PreparedProxifyreCutoverCandidate, +) -> Result<(), ProxifyreNativeHostError> { + if current_candidate_manifest_fingerprint(&prepared.snapshot.files) + .map_err(|_| ProxifyreNativeHostError)? + != prepared.snapshot.manifest_fingerprint + { + return Err(ProxifyreNativeHostError); + } + for spec in &prepared.snapshot.files { + let bytes = prepared + .file_bytes(&spec.relative_path) + .ok_or(ProxifyreNativeHostError)?; + if bytes.len() as u64 != spec.size || format!("{:x}", Sha256::digest(bytes)) != spec.sha256 + { + return Err(ProxifyreNativeHostError); + } + } + Ok(()) +} + +fn candidate_path_exists(path: &Path) -> Result { + safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| ProxifyreNativeHostError)?; + path.try_exists().map_err(|_| ProxifyreNativeHostError) +} + +fn verify_candidate_file_policy( + path: &Path, + role: CurrentCandidateFileRole, +) -> Result<(), ProxifyreNativeHostError> { + match role { + CurrentCandidateFileRole::Config => safe_fs::verify_admin_system_only_file(path), + CurrentCandidateFileRole::Package + | CurrentCandidateFileRole::Marker + | CurrentCandidateFileRole::Receipt => { + safe_fs::verify_path_admin_owned_user_read_only(path) + } + } + .map_err(|_| ProxifyreNativeHostError) +} + +fn verify_candidate_file_lease_policy( + lease: &safe_fs::PathIdentityLease, + role: CurrentCandidateFileRole, +) -> Result<(), ProxifyreNativeHostError> { + match role { + CurrentCandidateFileRole::Config => safe_fs::verify_identity_lease_admin_system_only(lease), + CurrentCandidateFileRole::Package + | CurrentCandidateFileRole::Marker + | CurrentCandidateFileRole::Receipt => { + safe_fs::verify_identity_lease_admin_owned_user_read_only(lease) + } + } + .map_err(|_| ProxifyreNativeHostError) +} + +fn verify_candidate_file_lease( + lease: &safe_fs::PathIdentityLease, + spec: &CurrentCandidateFile, +) -> Result<(), ProxifyreNativeHostError> { + if lease.identity().kind != safe_fs::StableObjectKind::RegularFile + || lease.identity().size != spec.size + { + return Err(ProxifyreNativeHostError); + } + let mut reader = lease + .file() + .try_clone() + .map_err(|_| ProxifyreNativeHostError)?; + reader + .seek(SeekFrom::Start(0)) + .map_err(|_| ProxifyreNativeHostError)?; + let mut digest = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = Read::by_ref(&mut reader) + .take(spec.size.saturating_add(1).saturating_sub(size)) + .read(&mut buffer) + .map_err(|_| ProxifyreNativeHostError)?; + if count == 0 { + break; + } + size = size + .checked_add(count as u64) + .ok_or(ProxifyreNativeHostError)?; + digest.update(&buffer[..count]); + if size > spec.size { + return Err(ProxifyreNativeHostError); + } + } + if size != spec.size || format!("{:x}", digest.finalize()) != spec.sha256 { + return Err(ProxifyreNativeHostError); + } + Ok(()) +} + +fn verify_materialized_candidate_at( + prepared: &PreparedProxifyreCutoverCandidate, + entry_leases: &BTreeMap, + root: &Path, + require_complete: bool, +) -> Result<(), ProxifyreNativeHostError> { + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError)?; + let mut found = HashSet::new(); + for entry in fs::read_dir(root).map_err(|_| ProxifyreNativeHostError)? { + let entry = entry.map_err(|_| ProxifyreNativeHostError)?; + let relative_path = PathBuf::from(entry.file_name()); + let spec = prepared + .file_spec(&relative_path) + .ok_or(ProxifyreNativeHostError)?; + if !found.insert(relative_path.clone()) { + return Err(ProxifyreNativeHostError); + } + let path = entry.path(); + verify_candidate_file_policy(&path, spec.role)?; + let lease = entry_leases + .get(&relative_path) + .ok_or(ProxifyreNativeHostError)?; + if !same_windows_path(lease.path(), &root.join(&relative_path)) { + return Err(ProxifyreNativeHostError); + } + verify_candidate_file_lease(lease, spec)?; + } + if found.len() != entry_leases.len() + || (require_complete && found.len() != prepared.snapshot.files.len()) + { + return Err(ProxifyreNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| ProxifyreNativeHostError) +} + +fn cutover_candidate_write_operation(file: &CurrentCandidateFile) -> CutoverOperation { + match file.role { + CurrentCandidateFileRole::Package => { + CutoverOperation::WriteCurrentCandidatePackageEntry(file.relative_path.clone()) + } + CurrentCandidateFileRole::Config => CutoverOperation::WriteCurrentCandidateConfig, + CurrentCandidateFileRole::Marker => CutoverOperation::WriteCurrentCandidateMarker, + CurrentCandidateFileRole::Receipt => CutoverOperation::WriteCurrentCandidateReceipt, + } +} + +fn unique_forward_expected_effect_identity<'a>( + mutations: &'a [crate::component_cutover::MutationRecord], + operation: &CutoverOperation, +) -> Result, ProxifyreNativeHostError> { + let mut matching = mutations.iter().filter(|record| { + record.direction == MutationDirection::Forward + && &record.operation == operation + && record + .effect + .as_ref() + .is_some_and(|effect| effect.disposition == EffectDisposition::ExpectedEffect) + }); + let Some(record) = matching.next() else { + return Ok(None); + }; + if matching.next().is_some() { + return Err(ProxifyreNativeHostError); + } + record + .effect + .as_ref() + .and_then(|effect| effect.object_identity.as_ref()) + .map(Some) + .ok_or(ProxifyreNativeHostError) +} + +pub fn install_proxifyre_native( + host: &mut H, + proxifyre: &PrivilegedPackageLease, + packet_filter: &PrivilegedPackageLease, + vc_runtime: &PrivilegedPackageLease, +) -> Result { + install_proxifyre_native_core( + host, + proxifyre.into(), + packet_filter.into(), + vc_runtime.into(), + ) +} + +pub fn update_proxifyre_native( + host: &mut H, + proxifyre: &PrivilegedPackageLease, +) -> Result { + update_proxifyre_native_core(host, proxifyre.into()) +} + +/// Must be called immediately before the separate AS/SYSTEM-only config +/// promotion. The start call repeats the ownership/state check after promotion +/// to close the service-state race; callers must not promote on +/// `AlreadyRunning`. +pub fn preflight_start_proxifyre_native( + host: &mut H, +) -> Result { + let current = inspect(host)?; + validate_managed(¤t)?; + match current.service_status { + ProxifyreNativeServiceStatus::Running => { + Ok(ProxifyreNativeStartDisposition::AlreadyRunning) + } + ProxifyreNativeServiceStatus::Stopped => { + Ok(ProxifyreNativeStartDisposition::ReadyForConfigPromotion) + } + _ => Err(ProxifyreNativeError::ServiceNotStopped), + } +} + +/// Starts only from an already promoted, admin/SYSTEM-sealed app-config.json. +/// Call `preflight_start_proxifyre_native` before performing that promotion. +pub fn start_proxifyre_native( + host: &mut H, + promoted_runtime_config: &Path, + expected_sha256: &str, +) -> Result { + start_proxifyre_native_observed(host, promoted_runtime_config, expected_sha256, || {}) +} + +pub(crate) fn start_proxifyre_native_observed( + host: &mut H, + promoted_runtime_config: &Path, + expected_sha256: &str, + observe: impl FnOnce(), +) -> Result { + if !promoted_runtime_config.is_absolute() + || !valid_sha256(expected_sha256) + || promoted_runtime_config + .file_name() + .and_then(|name| name.to_str()) + != Some("app-config.json") + { + return Err(ProxifyreNativeError::RuntimeConfigInvalid); + } + + let current = inspect(host)?; + validate_managed(¤t)?; + match current.service_status { + ProxifyreNativeServiceStatus::Running => return Ok(ProxifyreNativeOutcome::UNCHANGED), + ProxifyreNativeServiceStatus::Stopped => {} + _ => return Err(ProxifyreNativeError::ServiceNotStopped), + } + match host.verify_runtime_config(promoted_runtime_config, expected_sha256) { + Ok(RuntimeConfigVerification::Match) => {} + Ok(RuntimeConfigVerification::Mismatch) => { + return Err(ProxifyreNativeError::RuntimeConfigInvalid); + } + Err(_) => { + return Err(ProxifyreNativeError::HostStep( + ProxifyreNativeStep::VerifyRuntimeConfig, + )); + } + } + host.control_service(ProxifyreNativeServiceAction::Start) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::ServiceControl))?; + host.wait_service(ProxifyreNativeServiceStatus::Running) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::ServiceWait))?; + let final_state = inspect(host)?; + validate_managed(&final_state)?; + if final_state.service_status != ProxifyreNativeServiceStatus::Running { + return Err(ProxifyreNativeError::ServiceContract); + } + observe(); + Ok(ProxifyreNativeOutcome { + changed: true, + reboot_required: false, + }) +} + +pub fn stop_proxifyre_native( + host: &mut H, +) -> Result { + let current = inspect(host)?; + validate_managed(¤t)?; + match current.service_status { + ProxifyreNativeServiceStatus::Stopped => return Ok(ProxifyreNativeOutcome::UNCHANGED), + ProxifyreNativeServiceStatus::Running => {} + _ => return Err(ProxifyreNativeError::ServiceContract), + } + host.control_service(ProxifyreNativeServiceAction::Stop) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::ServiceControl))?; + host.wait_service(ProxifyreNativeServiceStatus::Stopped) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::ServiceWait))?; + let final_state = inspect(host)?; + validate_managed(&final_state)?; + if final_state.service_status != ProxifyreNativeServiceStatus::Stopped { + return Err(ProxifyreNativeError::ServiceContract); + } + Ok(ProxifyreNativeOutcome { + changed: true, + reboot_required: false, + }) +} + +pub fn configure_proxifyre_firewall_native( + host: &mut H, +) -> Result { + let current = inspect(host)?; + validate_managed(¤t)?; + let inbound = host + .configure_firewall(ProxifyreFirewallDirection::Inbound) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Firewall))?; + let outbound = host + .configure_firewall(ProxifyreFirewallDirection::Outbound) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Firewall))?; + Ok(ProxifyreNativeOutcome { + changed: inbound || outbound, + reboot_required: false, + }) +} + +pub fn uninstall_proxifyre_native( + host: &mut H, +) -> Result { + uninstall_proxifyre_native_core(host) +} + +fn install_proxifyre_native_core( + host: &mut H, + proxifyre: PackageLeaseView<'_>, + packet_filter: PackageLeaseView<'_>, + vc_runtime: PackageLeaseView<'_>, +) -> Result { + validate_package(proxifyre, ComponentId::Proxifyre, PackageSource::Bundled)?; + validate_package( + packet_filter, + ComponentId::WindowsPacketFilter, + PackageSource::Bundled, + )?; + validate_package(vc_runtime, ComponentId::VcRuntime, PackageSource::Bundled)?; + let files = read_proxifyre_archive(proxifyre)?; + let installed_files = installed_file_inventory(&files); + + let current = inspect(host)?; + match current.ownership { + ProxifyreNativeOwnership::Managed => { + validate_managed(¤t)?; + return Ok(ProxifyreNativeOutcome::UNCHANGED); + } + ProxifyreNativeOwnership::Foreign => { + return Err(ProxifyreNativeError::ServiceCollision); + } + ProxifyreNativeOwnership::Incomplete => { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + ProxifyreNativeOwnership::Missing => {} + } + if current.service_status != ProxifyreNativeServiceStatus::Missing { + return Err(ProxifyreNativeError::ServiceCollision); + } + + if host + .stage_runtime( + ProxifyreNativeMode::Install, + &proxifyre.proof.version, + &files, + ) + .is_err() + { + return Err(match cleanup_native_host(host) { + Ok(()) => ProxifyreNativeError::HostStep(ProxifyreNativeStep::Stage), + Err(error) => error, + }); + } + + let mut reboot_required = false; + if !current.vc_runtime_present { + let effect = match run_installer( + host, + ProxifyreInstallerCommand::InstallVcRuntime(vc_runtime.path), + ) { + Ok(effect) => effect, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + false, + error, + )) + } + }; + reboot_required |= effect.reboot_required; + } + + let mut owns_packet_filter = false; + if !current.packet_filter_present { + let effect = match run_installer( + host, + ProxifyreInstallerCommand::InstallPacketFilter(packet_filter.path), + ) { + Ok(effect) => effect, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + false, + error, + )) + } + }; + owns_packet_filter = effect.changed; + reboot_required |= effect.reboot_required; + } + + let prerequisites = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + }; + if prerequisites.ownership != ProxifyreNativeOwnership::Missing + || prerequisites.service_status != ProxifyreNativeServiceStatus::Missing + { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + ProxifyreNativeError::ServiceCollision, + )); + } + if !prerequisites.vc_runtime_present || !prerequisites.packet_filter_present { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + ProxifyreNativeError::ServiceContract, + )); + } + + if host.promote_runtime(ProxifyreNativeMode::Install).is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::Promote), + )); + } + let service_effect = match run_installer(host, ProxifyreInstallerCommand::InstallService) { + Ok(effect) => effect, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + }; + reboot_required |= service_effect.reboot_required; + + if host.enforce_service_contract().is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::EnforceServiceContract), + )); + } + + let service = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + }; + if let Err(error) = validate_runtime_service( + &service, + ProxifyreNativeServiceStatus::Stopped, + &proxifyre.proof.version, + ) { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + + let metadata = ProxifyreNativeMetadata::Install(Box::new(ProxifyreNativeInstallMetadata { + runtime: proxifyre.proof.clone(), + packet_filter: packet_filter.proof.clone(), + vc_runtime: vc_runtime.proof.clone(), + installed_files, + packet_filter_owned_by_proxywarden: owns_packet_filter, + })); + if host.commit_metadata(&metadata).is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::CommitMetadata), + )); + } + let final_state = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + }; + if let Err(error) = validate_managed_version( + &final_state, + ProxifyreNativeServiceStatus::Stopped, + &proxifyre.proof.version, + ) { + return Err(rollback_after( + host, + ProxifyreNativeMode::Install, + owns_packet_filter, + error, + )); + } + cleanup_native_host(host)?; + Ok(ProxifyreNativeOutcome { + changed: true, + reboot_required, + }) +} + +fn update_proxifyre_native_core( + host: &mut H, + proxifyre: PackageLeaseView<'_>, +) -> Result { + validate_package(proxifyre, ComponentId::Proxifyre, PackageSource::Cache)?; + let files = read_proxifyre_archive(proxifyre)?; + let installed_files = installed_file_inventory(&files); + let current = inspect(host)?; + validate_managed(¤t)?; + if current.service_status == ProxifyreNativeServiceStatus::Running { + return Err(ProxifyreNativeError::ServiceRunning); + } + if current.service_status != ProxifyreNativeServiceStatus::Stopped { + return Err(ProxifyreNativeError::ServiceNotStopped); + } + let installed_version = current + .installed_version + .as_deref() + .and_then(parse_numeric_version) + .ok_or(ProxifyreNativeError::InvalidReceipt)?; + let candidate_version = parse_numeric_version(&proxifyre.proof.version) + .ok_or(ProxifyreNativeError::InvalidPackage)?; + match candidate_version.cmp(&installed_version) { + std::cmp::Ordering::Equal => return Ok(ProxifyreNativeOutcome::UNCHANGED), + std::cmp::Ordering::Less => return Err(ProxifyreNativeError::DowngradeRejected), + std::cmp::Ordering::Greater => {} + } + + if host + .stage_runtime( + ProxifyreNativeMode::Update, + &proxifyre.proof.version, + &files, + ) + .is_err() + { + return Err(match cleanup_native_host(host) { + Ok(()) => ProxifyreNativeError::HostStep(ProxifyreNativeStep::Stage), + Err(error) => error, + }); + } + if host.promote_runtime(ProxifyreNativeMode::Update).is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::Promote), + )); + } + let promoted = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + error, + )); + } + }; + if let Err(error) = validate_runtime_service( + &promoted, + ProxifyreNativeServiceStatus::Stopped, + &proxifyre.proof.version, + ) { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + error, + )); + } + + let metadata = ProxifyreNativeMetadata::Update { + runtime: Box::new(proxifyre.proof.clone()), + installed_files, + }; + if host.commit_metadata(&metadata).is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::CommitMetadata), + )); + } + let final_state = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + error, + )); + } + }; + if let Err(error) = validate_managed_version( + &final_state, + ProxifyreNativeServiceStatus::Stopped, + &proxifyre.proof.version, + ) { + return Err(rollback_after( + host, + ProxifyreNativeMode::Update, + false, + error, + )); + } + cleanup_native_host(host)?; + Ok(ProxifyreNativeOutcome { + changed: true, + reboot_required: false, + }) +} + +fn uninstall_proxifyre_native_core( + host: &mut H, +) -> Result { + let current = inspect(host)?; + if current.ownership == ProxifyreNativeOwnership::Missing + && current.service_status == ProxifyreNativeServiceStatus::Missing + { + return Ok(ProxifyreNativeOutcome::UNCHANGED); + } + validate_managed(¤t)?; + if current.service_status == ProxifyreNativeServiceStatus::Running { + return Err(ProxifyreNativeError::ServiceRunning); + } + if current.service_status != ProxifyreNativeServiceStatus::Stopped { + return Err(ProxifyreNativeError::ServiceNotStopped); + } + let owns_packet_filter = current.packet_filter_owned_by_proxywarden; + let mut reboot_required = false; + let service_effect = match run_installer(host, ProxifyreInstallerCommand::UninstallService) { + Ok(effect) => effect, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + error, + )); + } + }; + reboot_required |= service_effect.reboot_required; + let service_removed = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + error, + )); + } + }; + if service_removed.service_status != ProxifyreNativeServiceStatus::Missing { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + ProxifyreNativeError::ServiceContract, + )); + } + + for direction in [ + ProxifyreFirewallDirection::Inbound, + ProxifyreFirewallDirection::Outbound, + ] { + if host.remove_firewall(direction).is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::Firewall), + )); + } + } + + if host.remove_managed_files().is_err() { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::RemoveManagedFiles), + )); + } + let final_state = match inspect(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + error, + )); + } + }; + if final_state.ownership != ProxifyreNativeOwnership::Missing + || final_state.service_status != ProxifyreNativeServiceStatus::Missing + { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + ProxifyreNativeError::OwnershipMismatch, + )); + } + // Shared prerequisites are never touched. An owned packet filter is the + // final fallible component step, so no later file failure can leave the + // machine without a driver that rollback cannot reinstall without a lease. + if owns_packet_filter { + let effect = + match run_installer(host, ProxifyreInstallerCommand::UninstallOwnedPacketFilter) { + Ok(effect) => effect, + Err(error) => { + return Err(rollback_after( + host, + ProxifyreNativeMode::Uninstall, + false, + error, + )); + } + }; + reboot_required |= effect.reboot_required; + } + cleanup_native_host(host)?; + Ok(ProxifyreNativeOutcome { + changed: true, + reboot_required, + }) +} + +fn inspect( + host: &mut H, +) -> Result { + host.inspect() + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Inspect)) +} + +fn validate_managed(snapshot: &ProxifyreNativeSnapshot) -> Result<(), ProxifyreNativeError> { + match snapshot.ownership { + ProxifyreNativeOwnership::Managed => {} + ProxifyreNativeOwnership::Foreign => { + return Err(ProxifyreNativeError::ServiceCollision); + } + ProxifyreNativeOwnership::Missing | ProxifyreNativeOwnership::Incomplete => { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + } + if !snapshot.receipt_valid || !snapshot.receipt_files_match { + return Err(ProxifyreNativeError::InvalidReceipt); + } + let version = snapshot + .installed_version + .as_deref() + .ok_or(ProxifyreNativeError::InvalidReceipt)?; + if parse_numeric_version(version).is_none() { + return Err(ProxifyreNativeError::InvalidReceipt); + } + if !matches!( + snapshot.service_status, + ProxifyreNativeServiceStatus::Stopped | ProxifyreNativeServiceStatus::Running + ) { + return Err(ProxifyreNativeError::ServiceContract); + } + validate_runtime_service(snapshot, snapshot.service_status, version) +} + +fn validate_managed_version( + snapshot: &ProxifyreNativeSnapshot, + status: ProxifyreNativeServiceStatus, + version: &str, +) -> Result<(), ProxifyreNativeError> { + validate_managed(snapshot)?; + if snapshot.service_status != status || snapshot.installed_version.as_deref() != Some(version) { + return Err(ProxifyreNativeError::ServiceContract); + } + Ok(()) +} + +fn validate_runtime_service( + snapshot: &ProxifyreNativeSnapshot, + status: ProxifyreNativeServiceStatus, + version: &str, +) -> Result<(), ProxifyreNativeError> { + if !snapshot.install_root_trusted || !snapshot.install_root_reparse_free { + return Err(ProxifyreNativeError::OwnershipMismatch); + } + if !snapshot.service_path_matches + || !snapshot.demand_start + || !snapshot.failure_recovery_disabled + || !snapshot.builtin_users_start_denied + || !snapshot.vc_runtime_present + || !snapshot.packet_filter_present + || snapshot.service_status != status + || snapshot.installed_version.as_deref() != Some(version) + { + return Err(ProxifyreNativeError::ServiceContract); + } + Ok(()) +} + +fn rollback_after( + host: &mut H, + mode: ProxifyreNativeMode, + remove_owned_packet_filter: bool, + original: ProxifyreNativeError, +) -> ProxifyreNativeError { + if host.rollback(mode, remove_owned_packet_filter).is_err() { + return ProxifyreNativeError::RollbackFailed; + } + match cleanup_native_host(host) { + Ok(()) => original, + Err(error) => error, + } +} + +fn cleanup_native_host(host: &mut H) -> Result<(), ProxifyreNativeError> { + host.discard_staging() + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Cleanup)) +} + +pub fn map_proxifyre_installer_exit( + kind: ProxifyreInstallerKind, + code: i32, +) -> Result { + let effect = match (kind, code) { + ( + ProxifyreInstallerKind::VcRuntimeInstall | ProxifyreInstallerKind::PacketFilterInstall, + 0, + ) => ProxifyreInstallerEffect { + changed: true, + reboot_required: false, + }, + ( + ProxifyreInstallerKind::VcRuntimeInstall | ProxifyreInstallerKind::PacketFilterInstall, + 3010, + ) => ProxifyreInstallerEffect { + changed: true, + reboot_required: true, + }, + ( + ProxifyreInstallerKind::VcRuntimeInstall | ProxifyreInstallerKind::PacketFilterInstall, + 1638, + ) => ProxifyreInstallerEffect { + changed: false, + reboot_required: false, + }, + (ProxifyreInstallerKind::PacketFilterUninstall, 0) => ProxifyreInstallerEffect { + changed: true, + reboot_required: false, + }, + (ProxifyreInstallerKind::PacketFilterUninstall, 3010) => ProxifyreInstallerEffect { + changed: true, + reboot_required: true, + }, + (ProxifyreInstallerKind::PacketFilterUninstall, 1605) => ProxifyreInstallerEffect { + changed: false, + reboot_required: false, + }, + (ProxifyreInstallerKind::ServiceInstall | ProxifyreInstallerKind::ServiceUninstall, 0) => { + ProxifyreInstallerEffect { + changed: true, + reboot_required: false, + } + } + _ => return Err(ProxifyreNativeError::InstallerExit { kind, code }), + }; + Ok(effect) +} + +/// A cutover may own the packet filter only when the MSI reports that this +/// transaction changed the machine. An already-installed result contradicts +/// the sealed absent-before state and must remain an ambiguous pending intent. +pub fn map_cutover_packet_filter_install_exit( + code: i32, +) -> Result { + let effect = map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterInstall, code)?; + if effect.changed { + Ok(effect) + } else { + Err(ProxifyreNativeError::OwnershipMismatch) + } +} + +fn run_installer( + host: &mut H, + command: ProxifyreInstallerCommand<'_>, +) -> Result { + let kind = command.kind(); + let code = host + .run_installer(command) + .map_err(|_| ProxifyreNativeError::HostStep(ProxifyreNativeStep::Installer))?; + map_proxifyre_installer_exit(kind, code) +} + +fn validate_package( + lease: PackageLeaseView<'_>, + component_id: ComponentId, + source: PackageSource, +) -> Result<(), ProxifyreNativeError> { + let proof = lease.proof; + if proof.component_id != component_id + || proof.source != source + || proof.size == 0 + || proof.size > MAX_NATIVE_PACKAGE_BYTES + || parse_numeric_version(&proof.version).is_none() + || !valid_sha256(&proof.sha256) + || !lease.path.is_absolute() + || lease.path.file_name().and_then(|name| name.to_str()) != Some(&proof.asset_name) + || !valid_asset_name(component_id, &proof.version, &proof.asset_name) + { + return Err(ProxifyreNativeError::InvalidPackage); + } + match source { + PackageSource::Bundled if proof.independent_proof.is_some() => { + return Err(ProxifyreNativeError::InvalidPackage); + } + PackageSource::Cache => { + let independent = proof + .independent_proof + .as_ref() + .ok_or(ProxifyreNativeError::InvalidPackage)?; + if component_id != ComponentId::Proxifyre + || independent.repository != "wiresock/proxifyre" + || independent.release_id == 0 + || independent.asset_id == 0 + || independent.stable_tag != format!("v{}", proof.version) + || independent.asset_name != proof.asset_name + || independent.size != proof.size + || independent.sha256_from_api != proof.sha256 + || independent.verified_signatures.is_empty() + { + return Err(ProxifyreNativeError::InvalidPackage); + } + } + PackageSource::Bundled => {} + } + verify_package_file(lease) +} + +fn verify_package_file(lease: PackageLeaseView<'_>) -> Result<(), ProxifyreNativeError> { + let metadata = + fs::symlink_metadata(lease.path).map_err(|_| ProxifyreNativeError::InvalidPackage)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() != lease.proof.size + { + return Err(ProxifyreNativeError::InvalidPackage); + } + let file = File::open(lease.path).map_err(|_| ProxifyreNativeError::InvalidPackage)?; + let mut reader = file.take(lease.proof.size.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut read = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| ProxifyreNativeError::InvalidPackage)?; + if count == 0 { + break; + } + read = read + .checked_add(count as u64) + .ok_or(ProxifyreNativeError::InvalidPackage)?; + hasher.update(&buffer[..count]); + } + if read != lease.proof.size || format!("{:x}", hasher.finalize()) != lease.proof.sha256 { + return Err(ProxifyreNativeError::InvalidPackage); + } + Ok(()) +} + +fn read_proxifyre_archive( + lease: PackageLeaseView<'_>, +) -> Result, ProxifyreNativeError> { + let file = File::open(lease.path).map_err(|_| ProxifyreNativeError::InvalidArchive)?; + let mut archive = + zip::ZipArchive::new(file).map_err(|_| ProxifyreNativeError::InvalidArchive)?; + if archive.len() != CURRENT_PROXIFYRE_PACKAGE_FILES.len() + || archive.len() > MAX_PROXIFYRE_ARCHIVE_ENTRIES + { + return Err(ProxifyreNativeError::InvalidArchive); + } + let mut remaining: HashSet<&str> = CURRENT_PROXIFYRE_PACKAGE_FILES.into_iter().collect(); + let mut case_folded = HashSet::new(); + let mut total = 0_u64; + let mut files = Vec::with_capacity(archive.len()); + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|_| ProxifyreNativeError::InvalidArchive)?; + let name = entry.name().to_owned(); + let folded = name.to_ascii_lowercase(); + if entry.is_dir() + || name.contains(['/', '\\', ':']) + || name.starts_with('.') + || !remaining.remove(name.as_str()) + || !case_folded.insert(folded) + || entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + || entry.size() == 0 + || entry.size() > MAX_PROXIFYRE_FILE_BYTES + { + return Err(ProxifyreNativeError::InvalidArchive); + } + total = total + .checked_add(entry.size()) + .filter(|value| *value <= MAX_PROXIFYRE_EXPANDED_BYTES) + .ok_or(ProxifyreNativeError::InvalidArchive)?; + let expected_size = entry.size(); + let capacity = + usize::try_from(expected_size).map_err(|_| ProxifyreNativeError::InvalidArchive)?; + let mut bytes = Vec::with_capacity(capacity); + entry + .take(expected_size.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| ProxifyreNativeError::InvalidArchive)?; + if bytes.len() as u64 != expected_size { + return Err(ProxifyreNativeError::InvalidArchive); + } + if name == "NLog.config" { + bytes = PROXIFYRE_NLOG_CONFIG.to_vec(); + } + let staged_size = bytes.len() as u64; + files.push(ProxifyreStagedFile { + relative_path: name, + sha256: format!("{:x}", Sha256::digest(&bytes)), + size: staged_size, + bytes, + }); + } + if !remaining.is_empty() { + return Err(ProxifyreNativeError::InvalidArchive); + } + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(files) +} + +fn installed_file_inventory(files: &[ProxifyreStagedFile]) -> Vec { + files + .iter() + .map(|file| ProxifyreInstalledFile { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect() +} + +fn valid_asset_name(component_id: ComponentId, version: &str, asset_name: &str) -> bool { + match component_id { + ComponentId::Proxifyre => asset_name == format!("ProxiFyre-v{version}-x64-signed.zip"), + ComponentId::WindowsPacketFilter => asset_name + .strip_prefix("Windows.Packet.Filter.") + .and_then(|value| value.strip_suffix(".x64.msi")) + .is_some_and(|asset_version| { + parse_numeric_version(asset_version).is_some() + && (asset_version == version + || asset_version + .strip_prefix(version) + .is_some_and(|suffix| suffix.starts_with('.'))) + }), + ComponentId::VcRuntime => asset_name == "VC_redist.x64.exe", + ComponentId::SingBox | ComponentId::Winsw => false, + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn parse_numeric_version(value: &str) -> Option> { + let parts: Vec<_> = value.split('.').collect(); + if parts.is_empty() || parts.len() > 4 { + return None; + } + parts + .into_iter() + .map(|part| { + if part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) { + None + } else { + part.parse().ok() + } + }) + .collect() +} + +#[cfg(test)] +#[path = "proxifyre_runtime/native_tests.rs"] +mod native_tests; + +#[cfg(test)] +#[path = "proxifyre_runtime/cutover_scm_tests.rs"] +mod cutover_scm_tests; + +pub fn run_proxifyre_lifecycle_entrypoint( + inventory: &ComponentInventory, + action: InventoryAction, + runner: impl FnOnce(Option<&ComponentCandidate>) -> Result, +) -> Result { + run_authorized_component_action(inventory, action, runner).map_err(authorized_action_error) } fn app_install_dir(app: &tauri::AppHandle) -> Result { @@ -558,47 +4547,15 @@ pub(crate) fn singbox_install_dir_for_app(app: &tauri::AppHandle) -> Result Result { - let Some(detected) = detect_proxyfier_install() else { - let component = proxyfier_component_from_detection(None); - return Ok(ComponentStatusDto::from(&component)); - }; +fn inventory_command_error(issue: InventoryIssue) -> CommandError { + CommandError::new(issue.code, issue.message) +} - let expected_install_dir = proxifyre_install_dir_for_app(app)?; - let ownership = verify_managed_proxifyre_install( - &detected.install_dir, - &detected.executable_path, - &expected_install_dir, - ) - .map_err(|reason| { - CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - format!("Удаление ProxiFyre заблокировано: {reason}"), - ) - })?; - let script = uninstall_proxifyre_script(Some(&detected), &ownership); - - let artifact_dir = default_config_root().join("state"); - run_elevated_package_script(ProxiFyrePackageAction::Uninstall, script, &artifact_dir)?; - - let refreshed = detect_proxyfier_install(); - if refreshed.is_some() { - return Err(CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - "Удаление ProxiFyre завершилось, но приложение все еще найдено на компьютере.", - )); +fn authorized_action_error(error: AuthorizedActionError) -> CommandError { + match error { + AuthorizedActionError::Denied(issue) => inventory_command_error(issue), + AuthorizedActionError::Runner(error) => error, } - if ownership.remove_packet_filter && detect_windows_packet_filter().is_some() { - return Err(CommandError::new( - ProxiFyrePackageAction::Uninstall.error_code(), - "Удаление ProxiFyre завершилось, но Windows Packet Filter все еще найден на компьютере.", - )); - } - - let component = proxyfier_component_from_detection(None); - Ok(ComponentStatusDto::from(&component)) } pub(crate) fn build_proxifyre_setup_status_for_install_dir( @@ -632,7 +4589,7 @@ pub(crate) fn build_proxifyre_setup_status_with_detection( id: "proxifyre".to_string(), name: "ProxiFyre".to_string(), installed: true, - version: Some(proxifyre_service_setup_version(detected)), + version: detected.version.clone(), details: detected.install_dir.display().to_string(), }, None => ProxiFyreSetupItemDto { @@ -657,115 +4614,6 @@ pub(crate) fn build_proxifyre_setup_status_with_detection( } } -fn proxifyre_service_setup_version(detected: &DetectedProxyfier) -> String { - match detected.service_status.as_deref() { - Some(status) if status.eq_ignore_ascii_case("running") => "служба запущена".to_string(), - Some(_) => "служба остановлена".to_string(), - None => "служба не установлена".to_string(), - } -} - -fn proxifyre_progress_path(state_dir: &Path) -> PathBuf { - state_dir.join("proxifyre-setup-progress.json") -} - -fn idle_proxifyre_setup_progress() -> ProxiFyreSetupProgressDto { - ProxiFyreSetupProgressDto { - operation: "idle".to_string(), - status: "idle".to_string(), - active_step: None, - percent: 0, - message: "Ожидаю действия пользователя.".to_string(), - updated_at: None, - } -} - -pub(crate) fn read_proxifyre_setup_progress( - storage: &JsonStorage, -) -> Result { - let path = proxifyre_progress_path(&storage.paths().state_dir); - if !path.exists() { - return Ok(idle_proxifyre_setup_progress()); - } - - let contents = fs::read_to_string(&path).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_read_failed", - format!( - "Не удалось прочитать прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - })?; - - serde_json::from_str(&contents).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_parse_failed", - format!( - "Не удалось разобрать прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - }) -} - -fn write_proxifyre_setup_progress( - path: &Path, - operation: &str, - active_step: Option<&str>, - status: &str, - percent: u8, - message: &str, -) -> Result<(), CommandError> { - let progress = ProxiFyreSetupProgressDto { - operation: operation.to_string(), - status: status.to_string(), - active_step: active_step.map(str::to_string), - percent: percent.min(100), - message: message.to_string(), - updated_at: Some(SystemClock.now()), - }; - let bytes = serde_json::to_vec_pretty(&progress).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!("Не удалось подготовить прогресс установки ProxiFyre: {error}"), - ) - })?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось создать папку прогресса установки ProxiFyre '{}': {error}", - parent.display() - ), - ) - })?; - } - - let temp_path = safe_fs::temp_path(path); - fs::write(&temp_path, bytes).map_err(|error| { - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось записать прогресс установки ProxiFyre '{}': {error}", - temp_path.display() - ), - ) - })?; - fs::rename(&temp_path, path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - CommandError::new( - "proxifyre_setup_progress_write_failed", - format!( - "Не удалось обновить прогресс установки ProxiFyre '{}': {error}", - path.display() - ), - ) - }) -} - fn setup_item_from_program( id: &str, name: &str, @@ -796,82 +4644,84 @@ struct InstalledProgram { display_version: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "PascalCase")] -struct InstalledProgramJson { - display_name: Option, - display_version: Option, -} - +#[cfg(windows)] fn detect_vc_runtime() -> Option { - installed_program(&vc_runtime_registry_pattern()) + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; + use winreg::RegKey; + + let architectures: &[&str] = if cfg!(target_arch = "aarch64") { + &["arm64", "x64"] + } else if cfg!(target_arch = "x86") { + &["x86"] + } else { + &["x64"] + }; + let local_machine = RegKey::predef(HKEY_LOCAL_MACHINE); + for architecture in architectures { + let path = format!(r"SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\{architecture}"); + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + let Ok(key) = local_machine.open_subkey_with_flags(&path, KEY_READ | view) else { + continue; + }; + if key.get_value::("Installed").ok() != Some(1) { + continue; + } + return Some(InstalledProgram { + display_name: format!( + "Microsoft Visual C++ Redistributable ({})", + architecture.to_ascii_uppercase() + ), + display_version: registry_string(&key, "Version"), + }); + } + } + None } +#[cfg(not(windows))] +fn detect_vc_runtime() -> Option { + None +} + +#[cfg(windows)] fn detect_windows_packet_filter() -> Option { - installed_program("Windows Packet Filter|WinpkFilter|NDISAPI") -} + use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; + use winreg::RegKey; -fn installed_program(pattern: &str) -> Option { - let script = format!( - r#" -$paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -) -$program = Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object {{ $_.DisplayName -match '{}' }} | - Select-Object -First 1 DisplayName, DisplayVersion -if ($null -ne $program) {{ - $program | ConvertTo-Json -Compress -}} -"#, - escape_powershell_single(pattern) + let path = format!( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{}", + WINDOWS_PACKET_FILTER_PRODUCT_CODE ); - - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .ok()?; - if !output.status.success() { - return None; + let local_machine = RegKey::predef(HKEY_LOCAL_MACHINE); + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + let Ok(key) = local_machine.open_subkey_with_flags(&path, KEY_READ | view) else { + continue; + }; + let Some(display_name) = registry_string(&key, "DisplayName") else { + continue; + }; + if !display_name.eq_ignore_ascii_case("Windows Packet Filter x64") { + continue; + } + return Some(InstalledProgram { + display_name, + display_version: registry_string(&key, "DisplayVersion"), + }); } - - let stdout = String::from_utf8_lossy(&output.stdout); - let payload = stdout.trim(); - if payload.is_empty() || payload.eq_ignore_ascii_case("null") { - return None; - } - - let parsed: InstalledProgramJson = serde_json::from_str(payload).ok()?; - let display_name = parsed.display_name?.trim().to_string(); - if display_name.is_empty() { - return None; - } - - Some(InstalledProgram { - display_name, - display_version: parsed - .display_version - .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()), - }) + None } -fn vc_runtime_registry_pattern() -> String { - let arch = runtime_arch_label(); - if arch == "ARM64" { - return r"Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)".to_string(); - } +#[cfg(not(windows))] +fn detect_windows_packet_filter() -> Option { + None +} - format!(r"Microsoft Visual C\+\+.*Redistributable.*\({arch}\)") +#[cfg(windows)] +fn registry_string(key: &winreg::RegKey, name: &str) -> Option { + key.get_value::(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } fn runtime_arch_label() -> &'static str { @@ -883,306 +4733,3 @@ fn runtime_arch_label() -> &'static str { "x64" } } - -fn run_elevated_package_script( - action: ProxiFyrePackageAction, - body: String, - artifact_dir: &Path, -) -> Result<(), CommandError> { - fs::create_dir_all(artifact_dir).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось создать папку для временных файлов ProxiFyre '{}': {error}", - artifact_dir.display() - ), - ) - })?; - let prefix = format!("proxywarden-proxifyre-{}", action.file_label()); - let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); - let result_path = - elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); - let progress_path = proxifyre_progress_path(artifact_dir); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "running", - 1, - action.start_message(), - ); - let script = - wrap_elevated_package_script_for_action(&body, &result_path, Some(&progress_path), action); - - write_powershell_script(&script_path, &script).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить временный скрипт, чтобы {} ProxiFyre '{}': {error}", - action.label(), - script_path.display() - ), - ) - })?; - - let launch_script = format!( - r#" -$ErrorActionPreference = 'Stop' -$resultPath = '{}' -try {{ - $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') - if ($null -eq $p) {{ - Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 - exit 1 - }} - exit $p.ExitCode -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} -"#, - escape_powershell_single(&result_path.display().to_string()), - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => { - let _ = fs::remove_file(&result_path); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "succeeded", - 100, - action.success_message(), - ); - Ok(()) - } - Ok(output) => { - let details = package_failure_details(&result_path, &output); - let _ = fs::remove_file(&result_path); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "failed", - 100, - &details, - ); - Err(CommandError::new( - action.error_code(), - format!( - "Не удалось {} ProxiFyre. Код elevated-команды: {}. {details}", - action.label(), - output.status.code().unwrap_or(-1), - ), - )) - } - Err(error) => { - let message = format!( - "Не удалось запросить права администратора, чтобы {} ProxiFyre: {error}", - action.label() - ); - let _ = write_proxifyre_setup_progress( - &progress_path, - action.operation(), - None, - "failed", - 100, - &message, - ); - Err(CommandError::new(action.error_code(), message)) - } - } -} - -pub fn wrap_elevated_package_script(body: &str, result_path: &Path) -> String { - wrap_elevated_package_script_for_action( - body, - result_path, - None, - ProxiFyrePackageAction::Install, - ) -} - -fn wrap_elevated_package_script_for_action( - body: &str, - result_path: &Path, - progress_path: Option<&Path>, - action: ProxiFyrePackageAction, -) -> String { - let mut script = String::new(); - script.push_str("$ErrorActionPreference = 'Stop'\n"); - script.push_str(&format!( - "$resultPath = '{}'\n", - escape_powershell_single(&result_path.display().to_string()) - )); - script.push_str(&format!( - "$script:progressOperation = '{}'\n", - escape_powershell_single(action.operation()) - )); - script.push_str("$script:progressActiveStep = $null\n"); - if let Some(progress_path) = progress_path { - script.push_str(&format!( - "$progressPath = '{}'\n", - escape_powershell_single(&progress_path.display().to_string()) - )); - script.push_str( - r#" -function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) { - $script:progressOperation = $operation - $script:progressActiveStep = if ([string]::IsNullOrWhiteSpace($activeStep)) { $null } else { $activeStep } - $payload = [ordered]@{ - operation = $operation - status = $status - activeStep = $script:progressActiveStep - percent = [Math]::Max(0, [Math]::Min(100, $percent)) - message = $message - updatedAt = (Get-Date).ToUniversalTime().ToString('o') - } | ConvertTo-Json -Compress - $progressTempPath = "$progressPath.tmp" - Set-Content -LiteralPath $progressTempPath -Value $payload -Encoding UTF8 - Move-Item -LiteralPath $progressTempPath -Destination $progressPath -Force -} -"#, - ); - } else { - script.push_str( - r#" -function Write-ProxyWardenProgress([string]$operation, [string]$activeStep, [string]$status, [int]$percent, [string]$message) {} -"#, - ); - } - script.push_str("try {\n"); - script.push_str(body); - script.push_str( - r#" - Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 - exit 0 -} catch { - $message = ($_ | Out-String) - Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'failed' 100 $message - Set-Content -LiteralPath $resultPath -Value $message -Encoding UTF8 - exit 1 -} -"#, - ); - - script -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ServiceControlAction { - Start, - Stop, -} - -impl ServiceControlAction { - fn error_code(self) -> &'static str { - match self { - ServiceControlAction::Start => "proxifyre_service_start_failed", - ServiceControlAction::Stop => "proxifyre_service_stop_failed", - } - } - - fn label(self) -> &'static str { - match self { - ServiceControlAction::Start => "запустить", - ServiceControlAction::Stop => "остановить", - } - } -} - -fn parse_service_command_output(stdout: &[u8]) -> Option { - let stdout = String::from_utf8_lossy(stdout); - let payload = stdout - .lines() - .rev() - .map(str::trim) - .find(|line| line.starts_with('{') && line.ends_with('}'))?; - - serde_json::from_str(payload).ok() -} - -fn service_script_failed_message(action: ServiceControlAction, exit_code: Option) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода PowerShell: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу ProxiFyre: команда управления службой не вернула корректный результат.{exit_code}", - action.label() - ) -} - -fn service_command_failed_message( - action: ServiceControlAction, - result: &ServiceCommandOutput, -) -> String { - let service_name = result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxiFyre"); - let status = result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); - - match result.code.as_str() { - "service_not_found" => "Служба ProxiFyre не найдена.".to_string(), - "start_failed" => format!( - "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." - ), - "stop_failed" => format!( - "Не удалось остановить службу {service_name} даже после принудительной попытки. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." - ), - _ => format!( - "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", - action.label() - ), - } -} - -fn elevated_service_failed_message( - action: ServiceControlAction, - direct_result: &ServiceCommandOutput, - exit_code: Option, -) -> String { - let service_name = direct_result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxiFyre"); - let status = direct_result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = direct_result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); - let exit_code = exit_code - .map(|code| format!(" Код elevated-команды: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу {service_name} даже после запроса прав администратора. До запроса UAC статус был: {status}{pid}.{exit_code} Если появлялось окно UAC, проверь, что оно было подтверждено.", - action.label() - ) -} diff --git a/src-tauri/src/proxifyre_runtime/cutover_scm_tests.rs b/src-tauri/src/proxifyre_runtime/cutover_scm_tests.rs new file mode 100644 index 0000000..a4cb9c1 --- /dev/null +++ b/src-tauri/src/proxifyre_runtime/cutover_scm_tests.rs @@ -0,0 +1,921 @@ +use super::*; +use crate::component_cutover::{ + CutoverOperation, EffectDisposition, LegacyServiceState, MutationDirection, MutationEffect, + MutationRecord, StateFingerprint, +}; +use crate::process::{ + FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceSecuritySnapshot, ServiceStableState, + SERVICE_CONFIG2_KINDS, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Call { + CaptureLegacy, + QueryLegacy, + QueryCurrent, + QueryComplete, + QueryLegacyPolicy(ServiceConfig2Kind), + QueryCurrentPolicy(ServiceConfig2Kind), + QueryCurrentSecurity, + StopLegacy, + DeleteLegacy, + CreateCurrent, + SetCurrentPolicy(ServiceConfig2Kind), + SetCurrentSecurity, + StartCurrent, + StopCurrent, + DeleteCurrent, + CreateLegacy, + RestoreLegacyPolicy(ServiceConfig2Kind), + RestoreLegacySecurity, + StartLegacy, +} + +struct FakeScm { + calls: Vec, + fail_on: Option, + before: ServiceRestoreSnapshot, + complete: CompleteServiceObservation, + current_base: ServiceBaseConfigSnapshot, +} + +impl FakeScm { + fn new() -> Self { + let current_base = expected_current_proxifyre_service_base( + &std::env::temp_dir().join("ProxyWarden-current-ProxiFyre.exe"), + ) + .expect("current base fixture"); + Self { + calls: Vec::new(), + fail_on: None, + before: before_state(), + complete: CompleteServiceObservation::Missing, + current_base, + } + } + + fn record(&mut self, call: Call) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(call.clone()); + if self.fail_on.as_ref() == Some(&call) { + Err(ProxifyreNativeHostError) + } else { + Ok(()) + } + } + + fn missing_policy() -> ServicePolicySnapshot { + ServicePolicySnapshot { + service: crate::process::ServiceSnapshot { + exists: false, + state: None, + path_name: None, + process_id: None, + }, + path_matches: false, + demand_start: false, + failure_recovery_disabled: false, + dacl_matches: false, + } + } +} + +impl ProxifyreCutoverScm for FakeScm { + fn capture_legacy_service( + &mut self, + ) -> Result { + self.record(Call::CaptureLegacy)?; + Ok(self.before.clone()) + } + + fn query_legacy_service(&mut self) -> Result { + self.record(Call::QueryLegacy)?; + Ok(Self::missing_policy()) + } + + fn query_current_service(&mut self) -> Result { + self.record(Call::QueryCurrent)?; + Ok(Self::missing_policy()) + } + + fn query_complete_service( + &mut self, + ) -> Result { + self.record(Call::QueryComplete)?; + Ok(self.complete.clone()) + } + + fn expected_current_service_base( + &self, + ) -> Result { + Ok(self.current_base.clone()) + } + + fn query_legacy_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError> { + self.record(Call::QueryLegacyPolicy(kind))?; + Ok(self.before.config2(kind).cloned()) + } + + fn query_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result, ProxifyreNativeHostError> { + self.record(Call::QueryCurrentPolicy(kind))?; + Ok(None) + } + + fn current_service_security_matches(&mut self) -> Result { + self.record(Call::QueryCurrentSecurity)?; + Ok(false) + } + + fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::StopLegacy) + } + + fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::DeleteLegacy) + } + + fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::CreateCurrent) + } + + fn set_current_service_policy( + &mut self, + kind: ServiceConfig2Kind, + ) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::SetCurrentPolicy(kind)) + } + + fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::SetCurrentSecurity) + } + + fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::StartCurrent) + } + + fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::StopCurrent) + } + + fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::DeleteCurrent) + } + + fn create_legacy_service( + &mut self, + _before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::CreateLegacy) + } + + fn restore_legacy_service_policy( + &mut self, + snapshot: &ServiceConfig2Snapshot, + ) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::RestoreLegacyPolicy(snapshot.kind())) + } + + fn restore_legacy_service_security( + &mut self, + _before: &ServiceRestoreSnapshot, + ) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::RestoreLegacySecurity) + } + + fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(Call::StartLegacy) + } +} + +fn before_state() -> ServiceRestoreSnapshot { + FullServiceSnapshot { + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + base: ServiceBaseConfigSnapshot { + service_type: 0x10, + start_type: 2, + error_control: 1, + binary_path_name: concat!( + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" "#, + r#"-displayname "ProxiFyre Service" -servicename "ProxiFyreService""# + ) + .to_owned(), + load_order_group: None, + tag_id: 0, + dependencies: Vec::new(), + service_start_name: "LocalSystem".to_owned(), + display_name: "ProxiFyre Service".to_owned(), + }, + config2: SERVICE_CONFIG2_KINDS + .iter() + .copied() + .map(expected_current_proxifyre_service_policy) + .collect(), + security: ServiceSecuritySnapshot { + self_relative_descriptor: vec![1, 2, 3], + untrusted_mutation_rights: false, + }, + original_state: ServiceStableState::Running, + } +} + +#[test] +fn create_current_is_exactly_one_call_and_never_starts() { + let mut host = FakeScm::new(); + let before = host.before.clone(); + + assert!(mutate_proxifyre_cutover_scm( + &mut host, + &CutoverOperation::CreateCurrentService, + &before, + ) + .expect("SCM mutation dispatch")); + assert_eq!(host.calls, vec![Call::CreateCurrent]); +} + +#[test] +fn collision_or_failure_stops_after_the_single_selected_mutation() { + let mut host = FakeScm::new(); + host.fail_on = Some(Call::DeleteLegacy); + let before = host.before.clone(); + + mutate_proxifyre_cutover_scm(&mut host, &CutoverOperation::DeleteLegacyService, &before) + .expect_err("collision/failure must surface"); + assert_eq!(host.calls, vec![Call::DeleteLegacy]); +} + +#[test] +fn restore_policy_selects_only_the_requested_captured_record() { + let mut host = FakeScm::new(); + let before = host.before.clone(); + + assert!(mutate_proxifyre_cutover_scm( + &mut host, + &CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers), + &before, + ) + .expect("restore dispatch")); + assert_eq!( + host.calls, + vec![Call::RestoreLegacyPolicy(ServiceConfig2Kind::Triggers)] + ); +} + +#[test] +fn read_only_missing_policy_is_typed_absence_and_never_mutates() { + let mut host = FakeScm::new(); + assert_eq!( + host.query_current_service_policy(ServiceConfig2Kind::Description) + .expect("read-only query"), + None + ); + assert_eq!( + host.calls, + vec![Call::QueryCurrentPolicy(ServiceConfig2Kind::Description)] + ); +} + +#[test] +fn non_scm_operation_is_not_claimed_or_mutated() { + let mut host = FakeScm::new(); + let before = host.before.clone(); + assert!(!mutate_proxifyre_cutover_scm( + &mut host, + &CutoverOperation::HardenLegacyRootSecurity, + &before, + ) + .expect("non-SCM dispatch")); + assert!(host.calls.is_empty()); +} + +#[test] +fn expected_current_policy_covers_every_config2_kind() { + for kind in SERVICE_CONFIG2_KINDS { + assert_eq!(expected_current_proxifyre_service_policy(kind).kind(), kind); + } + assert!(matches!( + expected_current_proxifyre_service_policy(ServiceConfig2Kind::Triggers), + ServiceConfig2Snapshot::Triggers(ref triggers) if triggers.is_empty() + )); +} + +#[test] +fn scm_observer_matches_typed_expected_fingerprint_for_every_scm_operation() { + let before = before_state(); + let operations = vec![ + CutoverOperation::StopLegacyService, + CutoverOperation::DeleteLegacyService, + CutoverOperation::CreateCurrentService, + CutoverOperation::SetCurrentServicePolicy(ServiceConfig2Kind::Description), + CutoverOperation::SetCurrentServiceSecurity, + CutoverOperation::StartCurrentService, + CutoverOperation::StopCurrentService, + CutoverOperation::DeleteCurrentService, + CutoverOperation::CreateLegacyService, + CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers), + CutoverOperation::RestoreLegacyServiceSecurity, + CutoverOperation::StartLegacyService, + ]; + + for operation in operations { + let mut host = FakeScm::new(); + host.complete = satisfying_scm_observation(&operation, &before, &host.current_base); + assert_eq!( + observe_proxifyre_cutover_scm_state(&mut host, &operation, &before) + .expect("typed complete SCM observation"), + expected_proxifyre_cutover_scm_effect(&operation).expect("typed expected SCM effect"), + "operation {operation:?}" + ); + assert_eq!(host.calls, vec![Call::QueryComplete]); + } +} + +#[test] +fn scm_unexpected_fingerprint_preserves_complete_drift_instead_of_boolean_bucket() { + let before = before_state(); + let operation = CutoverOperation::CreateCurrentService; + let mut first = FakeScm::new(); + let mut first_snapshot = before.clone(); + first_snapshot.base.display_name = "foreign-one".to_owned(); + first.complete = complete_service(first_snapshot, false); + let first_fingerprint = observe_proxifyre_cutover_scm_state(&mut first, &operation, &before) + .expect("first exact unexpected state"); + + let mut repeated = FakeScm::new(); + let mut repeated_snapshot = before.clone(); + repeated_snapshot.base.display_name = "foreign-one".to_owned(); + repeated.complete = complete_service(repeated_snapshot, false); + let repeated_fingerprint = + observe_proxifyre_cutover_scm_state(&mut repeated, &operation, &before) + .expect("repeated exact unexpected state"); + + let mut second = FakeScm::new(); + let mut second_snapshot = before.clone(); + second_snapshot.base.display_name = "foreign-two".to_owned(); + second.complete = complete_service(second_snapshot, false); + let second_fingerprint = observe_proxifyre_cutover_scm_state(&mut second, &operation, &before) + .expect("second exact unexpected state"); + + assert_eq!(first_fingerprint, repeated_fingerprint); + assert_ne!(first_fingerprint, second_fingerprint); + assert_ne!( + first_fingerprint, + expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect") + ); +} + +#[test] +fn scm_expected_effect_rejects_untrusted_mutation_rights() { + let before = before_state(); + let operation = CutoverOperation::CreateCurrentService; + let mut host = FakeScm::new(); + let mut live = satisfying_scm_observation(&operation, &before, &host.current_base); + let CompleteServiceObservation::Present { snapshot, .. } = &mut live else { + panic!("current service fixture must be present"); + }; + snapshot.security.untrusted_mutation_rights = true; + host.complete = live; + + assert_ne!( + observe_proxifyre_cutover_scm_state(&mut host, &operation, &before) + .expect("exact unsafe SCM observation"), + expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect") + ); +} + +#[test] +fn create_current_effect_requires_the_complete_fresh_service_default_profile() { + let before = before_state(); + let operation = CutoverOperation::CreateCurrentService; + let mut exact = FakeScm::new(); + exact.complete = satisfying_scm_observation(&operation, &before, &exact.current_base); + assert_eq!( + observe_proxifyre_cutover_scm_state(&mut exact, &operation, &before) + .expect("complete fresh-service defaults"), + expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect") + ); + + let mut drifted = FakeScm::new(); + let mut live = satisfying_scm_observation(&operation, &before, &drifted.current_base); + let CompleteServiceObservation::Present { snapshot, .. } = &mut live else { + panic!("current service fixture must be present"); + }; + let description = snapshot + .config2 + .iter_mut() + .find(|value| value.kind() == ServiceConfig2Kind::Description) + .expect("complete default profile"); + *description = ServiceConfig2Snapshot::Description(Some("drift".to_owned())); + drifted.complete = live; + assert_ne!( + observe_proxifyre_cutover_scm_state(&mut drifted, &operation, &before) + .expect("drifted fresh-service defaults"), + expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect") + ); +} + +fn complete_service( + snapshot: ServiceRestoreSnapshot, + current_dacl_matches: bool, +) -> CompleteServiceObservation { + CompleteServiceObservation::Present { + snapshot: Box::new(snapshot), + current_dacl_matches, + } +} + +fn satisfying_scm_observation( + operation: &CutoverOperation, + before: &ServiceRestoreSnapshot, + current_base: &ServiceBaseConfigSnapshot, +) -> CompleteServiceObservation { + if matches!( + operation, + CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService + ) { + return CompleteServiceObservation::Missing; + } + + let current = matches!( + operation, + CutoverOperation::CreateCurrentService + | CutoverOperation::SetCurrentServicePolicy(_) + | CutoverOperation::SetCurrentServiceSecurity + | CutoverOperation::StartCurrentService + | CutoverOperation::StopCurrentService + ); + let mut snapshot = before.clone(); + let mut current_dacl_matches = false; + if current { + snapshot.base = current_base.clone(); + snapshot.config2 = SERVICE_CONFIG2_KINDS + .iter() + .copied() + .map(expected_current_proxifyre_service_policy) + .collect(); + current_dacl_matches = matches!( + operation, + CutoverOperation::SetCurrentServiceSecurity + | CutoverOperation::StartCurrentService + | CutoverOperation::StopCurrentService + ); + } + snapshot.original_state = if matches!( + operation, + CutoverOperation::StartCurrentService | CutoverOperation::StartLegacyService + ) { + ServiceStableState::Running + } else { + ServiceStableState::Stopped + }; + complete_service(snapshot, current_dacl_matches) +} + +#[cfg(windows)] +#[test] +fn missing_primary_service_config2_probe_is_live_and_read_only() { + let service = crate::process::query_known_service(KnownWindowsService::Proxifyre) + .expect("read-only SCM probe"); + if service.exists { + eprintln!("skipping missing-service assertion because ProxiFyreService exists"); + return; + } + let executable = std::env::current_exe().expect("current test executable"); + assert_eq!( + query_service_config2_exact( + PROXIFYRE_MANAGED_SERVICE_NAME, + &executable, + ServiceConfig2Kind::Description, + ) + .expect("missing service query"), + None + ); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CandidateCall { + CreateRoot, + WritePackage(PathBuf), + WriteConfig, + WriteMarker, + WriteReceipt, +} + +struct FakeCandidateWriter { + calls: Vec, + fail_on: Option, + fail_after_effect: Option, + observation: ProxifyreCutoverCandidateObservation, +} + +impl Default for FakeCandidateWriter { + fn default() -> Self { + Self { + calls: Vec::new(), + fail_on: None, + fail_after_effect: None, + observation: ProxifyreCutoverCandidateObservation::Absent, + } + } +} + +impl FakeCandidateWriter { + fn record(&mut self, call: CandidateCall) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(call.clone()); + if self.fail_on.as_ref() == Some(&call) { + Err(ProxifyreNativeHostError) + } else if self.fail_after_effect.as_ref() == Some(&call) { + self.observation = expected_candidate_observation(); + Err(ProxifyreNativeHostError) + } else { + Ok(()) + } + } +} + +impl ProxifyreCutoverCandidateWriter for FakeCandidateWriter { + fn observe_candidate( + &mut self, + operation: &CutoverOperation, + ) -> Result { + if !matches!( + operation, + CutoverOperation::CreateCurrentCandidateRoot + | CutoverOperation::WriteCurrentCandidatePackageEntry(_) + | CutoverOperation::WriteCurrentCandidateConfig + | CutoverOperation::WriteCurrentCandidateMarker + | CutoverOperation::WriteCurrentCandidateReceipt + ) { + return Err(ProxifyreNativeHostError); + } + Ok(self.observation.clone()) + } + + fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(CandidateCall::CreateRoot) + } + + fn write_candidate_package_entry( + &mut self, + relative_path: &Path, + ) -> Result<(), ProxifyreNativeHostError> { + self.record(CandidateCall::WritePackage(relative_path.to_path_buf())) + } + + fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(CandidateCall::WriteConfig) + } + + fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(CandidateCall::WriteMarker) + } + + fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.record(CandidateCall::WriteReceipt) + } +} + +fn expected_candidate_observation() -> ProxifyreCutoverCandidateObservation { + let snapshot: SealedPathSnapshot = serde_json::from_value(serde_json::json!({ + "identity": { + "volumeSerialNumber": 7, + "fileId": 11, + "kind": "regular_file", + "size": 3 + }, + "security": { + "selfRelative": [1, 2, 3], + "sacl": "present" + } + })) + .expect("sealed candidate fixture"); + ProxifyreCutoverCandidateObservation::Expected(snapshot) +} + +#[test] +fn candidate_dispatch_selects_exactly_one_create_new_mutation() { + let mut writer = FakeCandidateWriter::default(); + let relative_path = PathBuf::from("ProxiFyre.exe"); + + assert!(mutate_proxifyre_cutover_candidate( + &mut writer, + &CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path.clone()), + ) + .expect("candidate mutation dispatch")); + assert_eq!( + writer.calls, + vec![CandidateCall::WritePackage(relative_path)] + ); +} + +#[test] +fn candidate_collision_or_write_failure_is_not_hidden() { + let mut writer = FakeCandidateWriter { + fail_on: Some(CandidateCall::WriteReceipt), + ..FakeCandidateWriter::default() + }; + + mutate_proxifyre_cutover_candidate( + &mut writer, + &CutoverOperation::WriteCurrentCandidateReceipt, + ) + .expect_err("collision/failure must surface"); + assert_eq!(writer.calls, vec![CandidateCall::WriteReceipt]); +} + +#[test] +fn candidate_failed_create_or_write_distinguishes_no_effect_from_reacquired_exact_effect() { + for (operation, call) in [ + ( + CutoverOperation::CreateCurrentCandidateRoot, + CandidateCall::CreateRoot, + ), + ( + CutoverOperation::WriteCurrentCandidateReceipt, + CandidateCall::WriteReceipt, + ), + ] { + let mut before_effect = FakeCandidateWriter { + fail_on: Some(call.clone()), + ..FakeCandidateWriter::default() + }; + mutate_proxifyre_cutover_candidate(&mut before_effect, &operation) + .expect_err("failure before external effect"); + assert_eq!( + before_effect + .observe_candidate(&operation) + .expect("observe absent target"), + ProxifyreCutoverCandidateObservation::Absent + ); + + let mut after_effect = FakeCandidateWriter { + fail_after_effect: Some(call), + ..FakeCandidateWriter::default() + }; + mutate_proxifyre_cutover_candidate(&mut after_effect, &operation) + .expect_err("failure after external effect"); + let observed = after_effect + .observe_candidate(&operation) + .expect("reacquire exact target"); + assert!(matches!( + observed, + ProxifyreCutoverCandidateObservation::Expected(SealedPathSnapshot { + identity: safe_fs::StableObjectIdentity { + volume_serial_number: 7, + file_id: 11, + .. + }, + .. + }) + )); + } +} + +#[test] +fn candidate_observer_keeps_unknown_distinct_from_absent_and_expected() { + let operation = CutoverOperation::CreateCurrentCandidateRoot; + let mut writer = FakeCandidateWriter { + observation: ProxifyreCutoverCandidateObservation::Unknown, + ..FakeCandidateWriter::default() + }; + assert_eq!( + writer + .observe_candidate(&operation) + .expect("typed unknown observation"), + ProxifyreCutoverCandidateObservation::Unknown + ); + assert_ne!( + ProxifyreCutoverCandidateObservation::Unknown, + ProxifyreCutoverCandidateObservation::Absent + ); + assert_ne!( + ProxifyreCutoverCandidateObservation::Unknown, + expected_candidate_observation() + ); +} + +#[test] +fn candidate_dispatch_does_not_claim_scm_or_legacy_filesystem_operations() { + let mut writer = FakeCandidateWriter::default(); + + assert!(!mutate_proxifyre_cutover_candidate( + &mut writer, + &CutoverOperation::CreateCurrentService, + ) + .expect("non-candidate operation")); + assert!(writer.calls.is_empty()); +} + +#[test] +fn partial_candidate_handoff_accepts_only_unique_durable_forward_identity() { + let operation = CutoverOperation::CreateCurrentCandidateRoot; + let identity = safe_fs::StableObjectIdentity { + volume_serial_number: 7, + file_id: 11, + kind: safe_fs::StableObjectKind::Directory, + size: 0, + }; + let fingerprint = StateFingerprint::digest("candidate-handoff-test", b"state"); + let durable = MutationRecord { + sequence: 0, + direction: MutationDirection::Forward, + operation: operation.clone(), + before_state: fingerprint.clone(), + expected_effect: fingerprint.clone(), + intent_written_at_epoch_seconds: 1, + authority_evidence: None, + effect: Some(MutationEffect { + disposition: EffectDisposition::ExpectedEffect, + observed: fingerprint, + object_identity: Some(identity.clone()), + observed_at_epoch_seconds: 2, + }), + }; + + assert_eq!( + unique_forward_expected_effect_identity(std::slice::from_ref(&durable), &operation) + .expect("unique durable identity"), + Some(&identity) + ); + + let mut pending = durable.clone(); + pending.effect = None; + assert_eq!( + unique_forward_expected_effect_identity(&[pending], &operation) + .expect("pending intent is not durable effect"), + None + ); + + let mut missing_identity = durable.clone(); + missing_identity.effect.as_mut().unwrap().object_identity = None; + assert!(unique_forward_expected_effect_identity(&[missing_identity], &operation).is_err()); + assert!( + unique_forward_expected_effect_identity(&[durable.clone(), durable], &operation).is_err() + ); +} + +#[test] +fn prepared_candidate_freezes_complete_sorted_final_metadata() { + let (plan, runtime, config, config_sha256) = candidate_inputs(); + let prepared = prepare_proxifyre_cutover_candidate( + &plan, + runtime, + &config, + &config_sha256, + false, + 1_700_000_000, + ) + .expect("prepare complete cutover candidate"); + + assert_eq!( + prepared.snapshot().files.len(), + CURRENT_PROXIFYRE_PACKAGE_FILES.len() + 3 + ); + assert!(valid_sha256(&prepared.snapshot().manifest_fingerprint)); + assert!(prepared.snapshot().files.windows(2).all(|pair| { + candidate_relative_label(&pair[0].relative_path) + < candidate_relative_label(&pair[1].relative_path) + })); + let config_spec = prepared + .file_spec(Path::new("app-config.json")) + .expect("config spec"); + assert_eq!(config_spec.role, CurrentCandidateFileRole::Config); + assert_eq!(config_spec.sha256, config_sha256); + + let marker: SystemProxifyreMarker = serde_json::from_slice( + prepared + .file_bytes(Path::new(PROXIFYRE_MARKER_FILE)) + .expect("marker bytes"), + ) + .expect("marker JSON"); + assert!(marker.packet_filter_installed_by_proxy_warden); + let receipt: InstallReceipt = serde_json::from_slice( + prepared + .file_bytes(Path::new(INSTALL_RECEIPT_FILENAME)) + .expect("receipt bytes"), + ) + .expect("receipt JSON"); + assert_eq!(receipt.installed_at, 1_700_000_000); + assert!(receipt + .windows_packet_filter + .as_ref() + .is_some_and(|ownership| ownership.installed_by_proxy_warden)); + + let (_, repeated_runtime, _, _) = candidate_inputs_with_plan(&plan); + let repeated = prepare_proxifyre_cutover_candidate( + &plan, + repeated_runtime, + &config, + &config_sha256, + false, + 1_700_000_000, + ) + .expect("repeat identical candidate"); + assert_eq!( + prepared.snapshot().manifest_fingerprint, + repeated.snapshot().manifest_fingerprint + ); +} + +#[test] +fn captured_timestamp_and_preexisting_packet_filter_change_final_manifest() { + let (plan, runtime, config, config_sha256) = candidate_inputs(); + let first = prepare_proxifyre_cutover_candidate( + &plan, + runtime, + &config, + &config_sha256, + false, + 1_700_000_000, + ) + .expect("first candidate"); + let (_, runtime, _, _) = candidate_inputs_with_plan(&plan); + let second = prepare_proxifyre_cutover_candidate( + &plan, + runtime, + &config, + &config_sha256, + true, + 1_700_000_001, + ) + .expect("second candidate"); + + assert_ne!( + first.snapshot().manifest_fingerprint, + second.snapshot().manifest_fingerprint + ); + let receipt: InstallReceipt = serde_json::from_slice( + second + .file_bytes(Path::new(INSTALL_RECEIPT_FILENAME)) + .expect("receipt bytes"), + ) + .expect("receipt JSON"); + assert!(receipt.windows_packet_filter.is_none()); +} + +fn candidate_inputs() -> ( + ProxifyreCutoverPlan, + PreparedProxifyreRuntime, + Vec, + String, +) { + let app_root = std::env::temp_dir().join("proxywarden-cutover-contract"); + let config = br#"{"proxies":[],"applications":[]}"#.to_vec(); + let config_sha256 = format!("{:x}", Sha256::digest(&config)); + let package_sha256 = "a".repeat(64); + let plan = ProxifyreCutoverPlan::new( + &app_root, + PathBuf::from(r"C:\Tools\ProxiFyre"), + LegacyServiceState::Stopped, + "2.2.1".to_owned(), + package_sha256, + config_sha256.clone(), + "b".repeat(64), + uuid::Uuid::new_v4().hyphenated().to_string(), + ); + let (_, runtime, _, _) = candidate_inputs_with_plan(&plan); + (plan, runtime, config, config_sha256) +} + +fn candidate_inputs_with_plan( + plan: &ProxifyreCutoverPlan, +) -> ( + ProxifyreCutoverPlan, + PreparedProxifyreRuntime, + Vec, + String, +) { + let files: Vec<_> = CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .enumerate() + .map(|(index, name)| { + let bytes = vec![u8::try_from(index + 1).expect("small fixture index")]; + ProxifyreStagedFile { + relative_path: (*name).to_owned(), + sha256: format!("{:x}", Sha256::digest(&bytes)), + size: bytes.len() as u64, + bytes, + } + }) + .collect(); + let runtime = PreparedProxifyreRuntime { + proof: PrivilegedPackageProof { + component_id: ComponentId::Proxifyre, + version: plan.bundled_version.clone(), + asset_name: "proxifyre.zip".to_owned(), + sha256: plan.package_fingerprint.clone(), + size: 123, + source: PackageSource::Bundled, + independent_proof: None, + }, + installed_files: installed_file_inventory(&files), + files, + }; + let config = br#"{"proxies":[],"applications":[]}"#.to_vec(); + let config_sha256 = format!("{:x}", Sha256::digest(&config)); + (plan.clone(), runtime, config, config_sha256) +} diff --git a/src-tauri/src/proxifyre_runtime/native_tests.rs b/src-tauri/src/proxifyre_runtime/native_tests.rs new file mode 100644 index 0000000..d81b165 --- /dev/null +++ b/src-tauri/src/proxifyre_runtime/native_tests.rs @@ -0,0 +1,1324 @@ +use super::*; +use crate::component_cutover::{ + CurrentCandidateSnapshot, CurrentComponentsParentBeforeState, CutoverJournal, CutoverPhase, + LegacyBeforeState, LegacyFilesystemSnapshot, LegacyServiceState, MutationAuthorityEvidence, + MutationEffect, MutationRecord, PrerequisiteBeforeState, ProxifyreLegacyIdentity, + SealedPathSnapshot, CUTOVER_JOURNAL_SCHEMA_VERSION, +}; +use crate::component_packages::{GithubReleaseDigestProof, VerifiedSignatureEvidence}; +use crate::process::{ + FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceSecuritySnapshot, ServiceStableState, +}; +use crate::safe_fs::{SecurityDescriptorSnapshot, StableObjectIdentity, StableObjectKind}; +use std::path::PathBuf; + +fn packet_filter_rollback_journal() -> CutoverJournal { + let app_root = std::env::current_dir() + .expect("current directory") + .join("packet-filter-cutover-fixture"); + let plan = ProxifyreCutoverPlan::new( + &app_root, + PathBuf::from(r"C:\Tools\ProxiFyre"), + LegacyServiceState::Stopped, + "2.2.1".to_owned(), + "a".repeat(64), + "b".repeat(64), + "c".repeat(64), + "fixture-startup".to_owned(), + ); + let security: SecurityDescriptorSnapshot = serde_json::from_value(serde_json::json!({ + "selfRelative": [1, 2, 3], + "sacl": "absent" + })) + .expect("security fixture"); + let root_identity = StableObjectIdentity { + volume_serial_number: 1, + file_id: 1, + kind: StableObjectKind::Directory, + size: 0, + }; + let config_identity = StableObjectIdentity { + volume_serial_number: 1, + file_id: 2, + kind: StableObjectKind::RegularFile, + size: 1, + }; + let fingerprint = StateFingerprint::digest("packet-filter-cutover-fixture", b"state"); + let installed = MutationRecord { + sequence: 0, + direction: MutationDirection::Forward, + operation: CutoverOperation::InstallWindowsPacketFilterPrerequisite, + before_state: fingerprint.clone(), + expected_effect: fingerprint.clone(), + intent_written_at_epoch_seconds: 1, + authority_evidence: Some( + MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction, + ), + effect: Some(MutationEffect { + disposition: EffectDisposition::ExpectedEffect, + observed: fingerprint.clone(), + object_identity: None, + observed_at_epoch_seconds: 2, + }), + }; + let uninstall_intent = MutationRecord { + sequence: 1, + direction: MutationDirection::Rollback, + operation: CutoverOperation::UninstallTransactionWindowsPacketFilter, + before_state: fingerprint.clone(), + expected_effect: fingerprint, + intent_written_at_epoch_seconds: 3, + authority_evidence: None, + effect: None, + }; + + CutoverJournal { + schema_version: CUTOVER_JOURNAL_SCHEMA_VERSION, + plan, + phase: CutoverPhase::RecoveryRequired, + execution_mode: CutoverExecutionMode::RollingBack, + before_state: LegacyBeforeState { + identity: ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService, + identity_fingerprint: "d".repeat(64), + service: FullServiceSnapshot { + service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(), + base: ServiceBaseConfigSnapshot { + service_type: 0x10, + start_type: 2, + error_control: 1, + binary_path_name: r"C:\Tools\ProxiFyre\ProxiFyre.exe".to_owned(), + load_order_group: None, + tag_id: 0, + dependencies: Vec::new(), + service_start_name: "LocalSystem".to_owned(), + display_name: "ProxiFyre Service".to_owned(), + }, + config2: Vec::new(), + security: ServiceSecuritySnapshot { + self_relative_descriptor: vec![1, 2, 3], + untrusted_mutation_rights: false, + }, + original_state: ServiceStableState::Stopped, + }, + filesystem: LegacyFilesystemSnapshot { + root: SealedPathSnapshot { + identity: root_identity, + security: security.clone(), + }, + config: SealedPathSnapshot { + identity: config_identity, + security, + }, + config_relative_path: PathBuf::from("app-config.json"), + entries: Vec::new(), + }, + current_components_parent: CurrentComponentsParentBeforeState::Absent, + current_candidate: CurrentCandidateSnapshot { + manifest_fingerprint: "e".repeat(64), + files: Vec::new(), + }, + prerequisites: PrerequisiteBeforeState { + windows_packet_filter_present: false, + vc_runtime_present: false, + }, + package_fingerprint: "a".repeat(64), + config_fingerprint: "b".repeat(64), + additional_matching_service: false, + }, + mutations: vec![installed, uninstall_intent], + verified_current_fingerprint: None, + next_start: None, + route_smoke: None, + recovery_code: Some("fixture".to_owned()), + updated_at_epoch_seconds: 3, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Call { + Inspect, + Stage(ProxifyreNativeMode), + Promote(ProxifyreNativeMode), + Discard, + Installer(ProxifyreInstallerKind), + EnforceServiceContract, + Control(ProxifyreNativeServiceAction), + Wait(ProxifyreNativeServiceStatus), + VerifyConfig, + Commit, + Rollback(ProxifyreNativeMode, bool), + Firewall(ProxifyreFirewallDirection), + RemoveFirewall(ProxifyreFirewallDirection), + RemoveFiles, +} + +struct FakeHost { + snapshot: ProxifyreNativeSnapshot, + saved_snapshot: Option, + calls: Vec, + next_version: String, + fail_commit: bool, + fail_cleanup: bool, + fail_rollback: bool, + config_verification: Result, + installer_exit: Option<(ProxifyreInstallerKind, i32)>, +} + +impl FakeHost { + fn new(snapshot: ProxifyreNativeSnapshot, next_version: &str) -> Self { + Self { + snapshot, + saved_snapshot: None, + calls: Vec::new(), + next_version: next_version.to_owned(), + fail_commit: false, + fail_cleanup: false, + fail_rollback: false, + config_verification: Ok(RuntimeConfigVerification::Match), + installer_exit: None, + } + } + + fn remember_state(&mut self) { + if self.saved_snapshot.is_none() { + self.saved_snapshot = Some(self.snapshot.clone()); + } + } + + fn mutation_calls(&self) -> usize { + self.calls + .iter() + .filter(|call| !matches!(call, Call::Inspect | Call::VerifyConfig | Call::Discard)) + .count() + } + + fn installer_calls(&self) -> Vec { + self.calls + .iter() + .filter_map(|call| match call { + Call::Installer(kind) => Some(*kind), + _ => None, + }) + .collect() + } +} + +impl ProxifyreNativeHost for FakeHost { + fn inspect(&mut self) -> Result { + self.calls.push(Call::Inspect); + Ok(self.snapshot.clone()) + } + + fn stage_runtime( + &mut self, + mode: ProxifyreNativeMode, + version: &str, + files: &[ProxifyreStagedFile], + ) -> Result<(), ProxifyreNativeHostError> { + assert_eq!(files.len(), CURRENT_PROXIFYRE_PACKAGE_FILES.len()); + assert!(files.iter().all(|file| !file.bytes.is_empty())); + let nlog = files + .iter() + .find(|file| file.relative_path == "NLog.config") + .expect("frozen NLog config"); + assert_eq!(nlog.bytes, PROXIFYRE_NLOG_CONFIG); + assert_eq!(nlog.size, PROXIFYRE_NLOG_CONFIG.len() as u64); + assert_eq!( + nlog.sha256, + format!("{:x}", Sha256::digest(PROXIFYRE_NLOG_CONFIG)) + ); + assert_eq!(version, self.next_version); + self.remember_state(); + self.calls.push(Call::Stage(mode)); + Ok(()) + } + + fn promote_runtime( + &mut self, + mode: ProxifyreNativeMode, + ) -> Result<(), ProxifyreNativeHostError> { + self.remember_state(); + self.calls.push(Call::Promote(mode)); + self.snapshot.install_root_trusted = true; + self.snapshot.install_root_reparse_free = true; + self.snapshot.installed_version = Some(self.next_version.clone()); + Ok(()) + } + + fn discard_staging(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(Call::Discard); + if self.fail_cleanup { + Err(ProxifyreNativeHostError) + } else { + Ok(()) + } + } + + fn run_installer( + &mut self, + command: ProxifyreInstallerCommand<'_>, + ) -> Result { + self.remember_state(); + let kind = command.kind(); + self.calls.push(Call::Installer(kind)); + let code = self + .installer_exit + .filter(|(expected, _)| *expected == kind) + .map(|(_, code)| code) + .unwrap_or(0); + if matches!(code, 0 | 3010 | 1638) { + match kind { + ProxifyreInstallerKind::VcRuntimeInstall => { + self.snapshot.vc_runtime_present = true; + } + ProxifyreInstallerKind::PacketFilterInstall => { + self.snapshot.packet_filter_present = true; + } + ProxifyreInstallerKind::ServiceInstall => { + self.snapshot.ownership = ProxifyreNativeOwnership::Incomplete; + self.snapshot.service_status = ProxifyreNativeServiceStatus::Stopped; + self.snapshot.install_root_trusted = true; + self.snapshot.install_root_reparse_free = true; + self.snapshot.service_path_matches = true; + self.snapshot.installed_version = Some(self.next_version.clone()); + } + ProxifyreInstallerKind::ServiceUninstall => { + self.snapshot.service_status = ProxifyreNativeServiceStatus::Missing; + } + ProxifyreInstallerKind::PacketFilterUninstall => { + self.snapshot.packet_filter_present = false; + } + } + } + Ok(code) + } + + fn enforce_service_contract(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(Call::EnforceServiceContract); + self.snapshot.demand_start = true; + self.snapshot.failure_recovery_disabled = true; + self.snapshot.builtin_users_start_denied = true; + Ok(()) + } + + fn control_service( + &mut self, + action: ProxifyreNativeServiceAction, + ) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(Call::Control(action)); + self.snapshot.service_status = match action { + ProxifyreNativeServiceAction::Start => ProxifyreNativeServiceStatus::Running, + ProxifyreNativeServiceAction::Stop => ProxifyreNativeServiceStatus::Stopped, + }; + Ok(()) + } + + fn wait_service( + &mut self, + status: ProxifyreNativeServiceStatus, + ) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(Call::Wait(status)); + if self.snapshot.service_status == status { + Ok(()) + } else { + Err(ProxifyreNativeHostError) + } + } + + fn verify_runtime_config( + &mut self, + _path: &Path, + _expected_sha256: &str, + ) -> Result { + self.calls.push(Call::VerifyConfig); + self.config_verification + } + + fn commit_metadata( + &mut self, + metadata: &ProxifyreNativeMetadata, + ) -> Result<(), ProxifyreNativeHostError> { + self.calls.push(Call::Commit); + if self.fail_commit { + return Err(ProxifyreNativeHostError); + } + let (runtime, owns_filter) = match metadata { + ProxifyreNativeMetadata::Install(metadata) => { + assert_frozen_nlog_receipt(&metadata.installed_files); + ( + &metadata.runtime, + Some(metadata.packet_filter_owned_by_proxywarden), + ) + } + ProxifyreNativeMetadata::Update { + runtime, + installed_files, + } => { + assert_frozen_nlog_receipt(installed_files); + (runtime.as_ref(), None) + } + }; + self.snapshot.ownership = ProxifyreNativeOwnership::Managed; + self.snapshot.receipt_valid = true; + self.snapshot.receipt_files_match = true; + self.snapshot.installed_version = Some(runtime.version.clone()); + if let Some(owns_filter) = owns_filter { + self.snapshot.packet_filter_owned_by_proxywarden = owns_filter; + } + Ok(()) + } + + fn rollback( + &mut self, + mode: ProxifyreNativeMode, + remove_owned_packet_filter: bool, + ) -> Result<(), ProxifyreNativeHostError> { + self.calls + .push(Call::Rollback(mode, remove_owned_packet_filter)); + if self.fail_rollback { + return Err(ProxifyreNativeHostError); + } + if let Some(snapshot) = self.saved_snapshot.take() { + self.snapshot = snapshot; + } + Ok(()) + } + + fn configure_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result { + self.calls.push(Call::Firewall(direction)); + Ok(true) + } + + fn remove_firewall( + &mut self, + direction: ProxifyreFirewallDirection, + ) -> Result { + self.calls.push(Call::RemoveFirewall(direction)); + Ok(true) + } + + fn remove_managed_files(&mut self) -> Result<(), ProxifyreNativeHostError> { + self.remember_state(); + self.calls.push(Call::RemoveFiles); + self.snapshot.ownership = ProxifyreNativeOwnership::Missing; + self.snapshot.service_status = ProxifyreNativeServiceStatus::Missing; + self.snapshot.installed_version = None; + self.snapshot.install_root_trusted = false; + self.snapshot.install_root_reparse_free = false; + self.snapshot.receipt_valid = false; + self.snapshot.receipt_files_match = false; + self.snapshot.service_path_matches = false; + Ok(()) + } +} + +fn assert_frozen_nlog_receipt(files: &[ProxifyreInstalledFile]) { + let nlog = files + .iter() + .find(|file| file.relative_path == "NLog.config") + .expect("NLog receipt entry"); + assert_eq!(nlog.size, PROXIFYRE_NLOG_CONFIG.len() as u64); + assert_eq!( + nlog.sha256, + format!("{:x}", Sha256::digest(PROXIFYRE_NLOG_CONFIG)) + ); +} + +fn missing_snapshot() -> ProxifyreNativeSnapshot { + ProxifyreNativeSnapshot { + ownership: ProxifyreNativeOwnership::Missing, + service_status: ProxifyreNativeServiceStatus::Missing, + installed_version: None, + install_root_trusted: false, + install_root_reparse_free: false, + receipt_valid: false, + receipt_files_match: false, + service_path_matches: false, + demand_start: false, + failure_recovery_disabled: false, + builtin_users_start_denied: false, + vc_runtime_present: false, + packet_filter_present: false, + packet_filter_owned_by_proxywarden: false, + } +} + +fn managed_snapshot( + version: &str, + status: ProxifyreNativeServiceStatus, +) -> ProxifyreNativeSnapshot { + ProxifyreNativeSnapshot { + ownership: ProxifyreNativeOwnership::Managed, + service_status: status, + installed_version: Some(version.to_owned()), + install_root_trusted: true, + install_root_reparse_free: true, + receipt_valid: true, + receipt_files_match: true, + service_path_matches: true, + demand_start: true, + failure_recovery_disabled: true, + builtin_users_start_denied: true, + vc_runtime_present: true, + packet_filter_present: true, + packet_filter_owned_by_proxywarden: false, + } +} + +fn bundled_asset(relative: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components") + .join(relative) +} + +fn package( + component_id: ComponentId, + version: &str, + relative: &str, + source: PackageSource, +) -> (PrivilegedPackageProof, PathBuf) { + let path = bundled_asset(relative); + let bytes = fs::read(&path).expect("bundled test asset must be readable"); + let asset_name = path + .file_name() + .and_then(|name| name.to_str()) + .expect("bundled asset name must be UTF-8") + .to_owned(); + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + let independent_proof = (source == PackageSource::Cache).then(|| GithubReleaseDigestProof { + repository: "wiresock/proxifyre".to_owned(), + release_id: 24, + asset_id: 240, + stable_tag: format!("v{version}"), + asset_name: asset_name.clone(), + size: bytes.len() as u64, + sha256_from_api: sha256.clone(), + verified_signatures: vec![VerifiedSignatureEvidence { + target_name: "ProxiFyre.exe".to_owned(), + common_name: "The Anti-Cloud Corporation".to_owned(), + organization: "The Anti-Cloud Corporation".to_owned(), + }], + }); + ( + PrivilegedPackageProof { + component_id, + version: version.to_owned(), + asset_name, + sha256, + size: bytes.len() as u64, + source, + independent_proof, + }, + path, + ) +} + +fn install_packages() -> ( + (PrivilegedPackageProof, PathBuf), + (PrivilegedPackageProof, PathBuf), + (PrivilegedPackageProof, PathBuf), +) { + ( + package( + ComponentId::Proxifyre, + "2.4.0", + "proxifyre/ProxiFyre-v2.4.0-x64-signed.zip", + PackageSource::Bundled, + ), + package( + ComponentId::WindowsPacketFilter, + "3.6.2", + "windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi", + PackageSource::Bundled, + ), + package( + ComponentId::VcRuntime, + "14.51.36247.0", + "vc-runtime/VC_redist.x64.exe", + PackageSource::Bundled, + ), + ) +} + +fn update_package() -> (PrivilegedPackageProof, PathBuf) { + package( + ComponentId::Proxifyre, + "2.4.0", + "proxifyre/ProxiFyre-v2.4.0-x64-signed.zip", + PackageSource::Cache, + ) +} + +#[test] +fn invalid_lease_reaches_no_host_call() { + let ((mut proxifyre, proxifyre_path), (packet, packet_path), (vc, vc_path)) = + install_packages(); + proxifyre.sha256 = "0".repeat(64); + let mut host = FakeHost::new(missing_snapshot(), "2.4.0"); + + let error = install_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proxifyre, + path: &proxifyre_path, + }, + PackageLeaseView { + proof: &packet, + path: &packet_path, + }, + PackageLeaseView { + proof: &vc, + path: &vc_path, + }, + ) + .expect_err("invalid lease must fail closed"); + + assert_eq!(error, ProxifyreNativeError::InvalidPackage); + assert!(host.calls.is_empty()); +} + +#[test] +fn archive_staging_replaces_nlog_with_frozen_null_target() { + let (proof, path) = package( + ComponentId::Proxifyre, + "2.4.0", + "proxifyre/ProxiFyre-v2.4.0-x64-signed.zip", + PackageSource::Bundled, + ); + let files = read_proxifyre_archive(PackageLeaseView { + proof: &proof, + path: &path, + }) + .expect("archive must stage"); + let nlog = files + .iter() + .find(|file| file.relative_path == "NLog.config") + .expect("NLog config"); + assert_eq!(nlog.bytes, PROXIFYRE_NLOG_CONFIG); + assert_eq!(nlog.size, PROXIFYRE_NLOG_CONFIG.len() as u64); + assert_eq!( + nlog.sha256, + format!("{:x}", Sha256::digest(PROXIFYRE_NLOG_CONFIG)) + ); + let config = String::from_utf8_lossy(PROXIFYRE_NLOG_CONFIG); + assert!(config.contains("xsi:type=\"Null\"")); + assert!(!config.contains("fileName=")); + assert!(!files + .iter() + .any(|file| { file.relative_path.contains('/') || file.relative_path.contains('\\') })); +} + +#[test] +fn foreign_running_and_invalid_receipt_updates_make_no_mutation() { + let (proof, path) = update_package(); + let lease = PackageLeaseView { + proof: &proof, + path: &path, + }; + let cases = [ + ( + ProxifyreNativeSnapshot { + ownership: ProxifyreNativeOwnership::Foreign, + ..missing_snapshot() + }, + ProxifyreNativeError::ServiceCollision, + ), + ( + managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Running), + ProxifyreNativeError::ServiceRunning, + ), + ( + ProxifyreNativeSnapshot { + receipt_valid: false, + ..managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Stopped) + }, + ProxifyreNativeError::InvalidReceipt, + ), + ]; + + for (snapshot, expected) in cases { + let mut host = FakeHost::new(snapshot, "2.4.0"); + assert_eq!( + update_proxifyre_native_core(&mut host, lease).expect_err("must reject"), + expected + ); + assert_eq!(host.mutation_calls(), 0); + assert_eq!(host.calls, vec![Call::Inspect]); + } +} + +#[test] +fn fresh_install_uses_all_leases_and_never_starts_service() { + let ((proxifyre, proxifyre_path), (packet, packet_path), (vc, vc_path)) = install_packages(); + let mut host = FakeHost::new(missing_snapshot(), "2.4.0"); + + let outcome = install_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proxifyre, + path: &proxifyre_path, + }, + PackageLeaseView { + proof: &packet, + path: &packet_path, + }, + PackageLeaseView { + proof: &vc, + path: &vc_path, + }, + ) + .expect("fresh install must succeed"); + + assert!(outcome.changed); + assert_eq!( + host.installer_calls(), + vec![ + ProxifyreInstallerKind::VcRuntimeInstall, + ProxifyreInstallerKind::PacketFilterInstall, + ProxifyreInstallerKind::ServiceInstall, + ] + ); + assert!(!host + .calls + .iter() + .any(|call| matches!(call, Call::Control(ProxifyreNativeServiceAction::Start)))); + assert_eq!( + host.snapshot.service_status, + ProxifyreNativeServiceStatus::Stopped + ); + assert!(host.snapshot.demand_start); + assert!(host.snapshot.failure_recovery_disabled); + assert!(host.snapshot.builtin_users_start_denied); + assert!(host.snapshot.service_path_matches); + assert!(host.calls.contains(&Call::EnforceServiceContract)); +} + +#[test] +fn managed_install_is_idempotent() { + let ((proxifyre, proxifyre_path), (packet, packet_path), (vc, vc_path)) = install_packages(); + let mut host = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Running), + "2.4.0", + ); + + let outcome = install_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proxifyre, + path: &proxifyre_path, + }, + PackageLeaseView { + proof: &packet, + path: &packet_path, + }, + PackageLeaseView { + proof: &vc, + path: &vc_path, + }, + ) + .expect("managed install must be idempotent"); + + assert_eq!(outcome, ProxifyreNativeOutcome::UNCHANGED); + assert_eq!(host.mutation_calls(), 0); +} + +#[test] +fn stopped_update_stays_stopped_and_never_controls_service() { + let (proof, path) = update_package(); + let mut host = FakeHost::new( + managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + + let outcome = update_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proof, + path: &path, + }, + ) + .expect("stopped update must succeed"); + + assert!(outcome.changed); + assert_eq!( + host.snapshot.service_status, + ProxifyreNativeServiceStatus::Stopped + ); + assert_eq!(host.snapshot.installed_version.as_deref(), Some("2.4.0")); + assert!(!host + .calls + .iter() + .any(|call| matches!(call, Call::Control(_)))); +} + +#[test] +fn failed_update_commit_rolls_back_installed_version() { + let (proof, path) = update_package(); + let mut host = FakeHost::new( + managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + host.fail_commit = true; + + let error = update_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proof, + path: &path, + }, + ) + .expect_err("failed commit must roll back"); + + assert_eq!( + error, + ProxifyreNativeError::HostStep(ProxifyreNativeStep::CommitMetadata) + ); + assert_eq!(host.snapshot.installed_version.as_deref(), Some("2.3.0")); + assert_eq!( + host.snapshot.service_status, + ProxifyreNativeServiceStatus::Stopped + ); + assert!(host + .calls + .contains(&Call::Rollback(ProxifyreNativeMode::Update, false))); + let rollback = host + .calls + .iter() + .position(|call| *call == Call::Rollback(ProxifyreNativeMode::Update, false)) + .expect("rollback call"); + let discard = host + .calls + .iter() + .position(|call| *call == Call::Discard) + .expect("discard call"); + assert!(rollback < discard); +} + +#[test] +fn rollback_failure_preserves_backup_and_skips_cleanup() { + let (proof, path) = update_package(); + let mut host = FakeHost::new( + managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + host.fail_commit = true; + host.fail_rollback = true; + + assert_eq!( + update_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proof, + path: &path, + }, + ) + .expect_err("rollback failure must surface"), + ProxifyreNativeError::RollbackFailed + ); + assert!(host + .calls + .contains(&Call::Rollback(ProxifyreNativeMode::Update, false))); + assert!(!host.calls.contains(&Call::Discard)); +} + +#[test] +fn successful_update_surfaces_backup_cleanup_failure() { + let (proof, path) = update_package(); + let mut host = FakeHost::new( + managed_snapshot("2.3.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + host.fail_cleanup = true; + + assert_eq!( + update_proxifyre_native_core( + &mut host, + PackageLeaseView { + proof: &proof, + path: &path, + }, + ) + .expect_err("cleanup failure must surface"), + ProxifyreNativeError::HostStep(ProxifyreNativeStep::Cleanup) + ); + assert_eq!(host.snapshot.installed_version.as_deref(), Some("2.4.0")); + assert!(!host + .calls + .iter() + .any(|call| matches!(call, Call::Rollback(_, _)))); +} + +#[test] +fn installer_exit_codes_are_explicit() { + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::VcRuntimeInstall, 0), + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: false + }) + ); + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterInstall, 3010), + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: true + }) + ); + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterInstall, 1638), + Ok(ProxifyreInstallerEffect { + changed: false, + reboot_required: false + }) + ); + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterInstall, 1605), + Err(ProxifyreNativeError::InstallerExit { + kind: ProxifyreInstallerKind::PacketFilterInstall, + code: 1605 + }) + ); + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterUninstall, 1605), + Ok(ProxifyreInstallerEffect { + changed: false, + reboot_required: false + }) + ); + assert_eq!( + map_proxifyre_installer_exit(ProxifyreInstallerKind::PacketFilterUninstall, 1638), + Err(ProxifyreNativeError::InstallerExit { + kind: ProxifyreInstallerKind::PacketFilterUninstall, + code: 1638 + }) + ); +} + +#[test] +fn cutover_packet_filter_install_never_adopts_an_already_installed_result() { + assert_eq!( + map_cutover_packet_filter_install_exit(0), + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: false, + }) + ); + assert_eq!( + map_cutover_packet_filter_install_exit(3010), + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: true, + }) + ); + assert_eq!( + map_cutover_packet_filter_install_exit(1638), + Err(ProxifyreNativeError::OwnershipMismatch) + ); +} + +#[test] +fn cutover_packet_filter_rollback_validates_authority_before_calling_runner() { + let mut journal = packet_filter_rollback_journal(); + journal + .before_state + .prerequisites + .windows_packet_filter_present = true; + let mut runner_calls = 0; + + assert_eq!( + uninstall_cutover_transaction_packet_filter_with_runner(&journal, || { + runner_calls += 1; + Ok(0) + }), + Err(ProxifyreNativeError::OwnershipMismatch) + ); + assert_eq!(runner_calls, 0); + + let mut journal = packet_filter_rollback_journal(); + journal.mutations[0].authority_evidence = None; + assert_eq!( + uninstall_cutover_transaction_packet_filter_with_runner(&journal, || { + runner_calls += 1; + Ok(0) + }), + Err(ProxifyreNativeError::OwnershipMismatch) + ); + assert_eq!(runner_calls, 0); + + let mut journal = packet_filter_rollback_journal(); + journal.mutations.insert(1, journal.mutations[0].clone()); + assert_eq!( + uninstall_cutover_transaction_packet_filter_with_runner(&journal, || { + runner_calls += 1; + Ok(0) + }), + Err(ProxifyreNativeError::OwnershipMismatch) + ); + assert_eq!(runner_calls, 0); +} + +#[test] +fn cutover_packet_filter_rollback_runs_once_and_maps_fixed_uninstall_exits() { + for (code, expected) in [ + ( + 0, + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: false, + }), + ), + ( + 3010, + Ok(ProxifyreInstallerEffect { + changed: true, + reboot_required: true, + }), + ), + ( + 1605, + Ok(ProxifyreInstallerEffect { + changed: false, + reboot_required: false, + }), + ), + ( + 1638, + Err(ProxifyreNativeError::InstallerExit { + kind: ProxifyreInstallerKind::PacketFilterUninstall, + code: 1638, + }), + ), + ] { + let journal = packet_filter_rollback_journal(); + let mut runner_calls = 0; + let actual = uninstall_cutover_transaction_packet_filter_with_runner(&journal, || { + runner_calls += 1; + Ok(code) + }); + + assert_eq!(actual, expected); + assert_eq!(runner_calls, 1); + } +} + +#[test] +fn uninstall_rejects_foreign_running_and_invalid_receipt_without_mutation() { + let cases = [ + ( + ProxifyreNativeSnapshot { + ownership: ProxifyreNativeOwnership::Foreign, + service_status: ProxifyreNativeServiceStatus::Stopped, + ..missing_snapshot() + }, + ProxifyreNativeError::ServiceCollision, + ), + ( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Running), + ProxifyreNativeError::ServiceRunning, + ), + ( + ProxifyreNativeSnapshot { + receipt_files_match: false, + ..managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped) + }, + ProxifyreNativeError::InvalidReceipt, + ), + ]; + + for (snapshot, expected) in cases { + let mut host = FakeHost::new(snapshot, "2.4.0"); + assert_eq!( + uninstall_proxifyre_native_core(&mut host).expect_err("must reject"), + expected + ); + assert_eq!(host.mutation_calls(), 0); + assert_eq!(host.calls, vec![Call::Inspect]); + } +} + +#[test] +fn uninstall_keeps_shared_packet_filter_and_removes_owned_one() { + for owns_filter in [false, true] { + let mut snapshot = managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped); + snapshot.packet_filter_owned_by_proxywarden = owns_filter; + let mut host = FakeHost::new(snapshot, "2.4.0"); + + uninstall_proxifyre_native_core(&mut host).expect("uninstall must succeed"); + + assert_eq!( + host.installer_calls() + .contains(&ProxifyreInstallerKind::PacketFilterUninstall), + owns_filter + ); + assert!(!host + .installer_calls() + .contains(&ProxifyreInstallerKind::VcRuntimeInstall)); + assert_eq!( + host.calls + .iter() + .filter_map(|call| match call { + Call::RemoveFirewall(direction) => Some(*direction), + _ => None, + }) + .collect::>(), + vec![ + ProxifyreFirewallDirection::Inbound, + ProxifyreFirewallDirection::Outbound, + ] + ); + if owns_filter { + let remove = host + .calls + .iter() + .position(|call| *call == Call::RemoveFiles) + .expect("managed files must be removed"); + let filter = host + .calls + .iter() + .position(|call| { + *call == Call::Installer(ProxifyreInstallerKind::PacketFilterUninstall) + }) + .expect("owned filter must be removed"); + assert!(remove < filter); + assert!(host.calls[filter + 1..] + .iter() + .all(|call| *call == Call::Discard)); + } + } +} + +#[test] +fn system_invocations_have_only_fixed_arguments() { + let root = std::env::current_dir() + .expect("current directory") + .join("components") + .join("proxifyre"); + let msiexec = std::env::current_dir() + .expect("current directory") + .join("system32") + .join("msiexec.exe"); + let vc = std::env::current_dir() + .expect("current directory") + .join("leases") + .join("VC_redist.x64.exe"); + let packet = std::env::current_dir() + .expect("current directory") + .join("leases") + .join("Windows.Packet.Filter.3.6.2.1.x64.msi"); + + let vc_install = system_installer_invocation( + ProxifyreInstallerCommand::InstallVcRuntime(&vc), + &root, + &msiexec, + ) + .expect("fixed VC invocation"); + assert_eq!(vc_install.program, vc); + assert_eq!( + vc_install.arguments, + ["/install", "/quiet", "/norestart"].map(OsString::from) + ); + + let packet_install = system_installer_invocation( + ProxifyreInstallerCommand::InstallPacketFilter(&packet), + &root, + &msiexec, + ) + .expect("fixed packet-filter invocation"); + assert_eq!(packet_install.program, msiexec); + assert_eq!( + packet_install.arguments, + vec![ + OsString::from("/i"), + packet.as_os_str().to_owned(), + OsString::from("/qn"), + OsString::from("/norestart"), + ] + ); + + let service = + system_installer_invocation(ProxifyreInstallerCommand::InstallService, &root, &msiexec) + .expect("fixed service invocation"); + assert_eq!(service.program, root.join("ProxiFyre.exe")); + assert_eq!(service.arguments, vec![OsString::from("install")]); + assert_eq!(service.working_directory.as_deref(), Some(root.as_path())); + + let uninstall = system_installer_invocation( + ProxifyreInstallerCommand::UninstallOwnedPacketFilter, + &root, + &msiexec, + ) + .expect("fixed uninstall invocation"); + assert_eq!( + uninstall.arguments, + [ + "/x", + WINDOWS_PACKET_FILTER_PRODUCT_CODE, + "/qn", + "/norestart" + ] + .map(OsString::from) + ); +} + +#[test] +fn firewall_arguments_are_direction_allowlisted() { + let executable = std::env::current_dir() + .expect("current directory") + .join("ProxiFyre.exe"); + for (direction, expected) in [ + (ProxifyreFirewallDirection::Inbound, "dir=in"), + (ProxifyreFirewallDirection::Outbound, "dir=out"), + ] { + let add = fixed_firewall_arguments(ProxifyreFirewallMutation::Add, direction, &executable) + .expect("fixed firewall add arguments"); + let delete = + fixed_firewall_arguments(ProxifyreFirewallMutation::Delete, direction, &executable) + .expect("fixed firewall delete arguments"); + assert_eq!(add[0], "advfirewall"); + assert_eq!(add[2], "add"); + assert_eq!(add[3], "rule"); + assert!(add.iter().any(|argument| argument == expected)); + assert!(add.iter().any(|argument| argument == "action=allow")); + assert!(add.iter().any(|argument| { + argument == "name=ProxyWarden.ProxiFyre.Inbound" + || argument == "name=ProxyWarden.ProxiFyre.Outbound" + })); + assert_eq!(add.len(), 10); + assert_eq!(delete[2], "delete"); + assert_eq!(delete.len(), 7); + assert!(!delete.iter().any(|argument| argument == "action=allow")); + } +} + +#[test] +fn firewall_uses_only_fixed_directions() { + let mut host = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + configure_proxifyre_firewall_native(&mut host).expect("firewall must succeed"); + let calls: Vec<_> = host + .calls + .iter() + .filter_map(|call| match call { + Call::Firewall(direction) => Some(*direction), + _ => None, + }) + .collect(); + assert_eq!( + calls, + vec![ + ProxifyreFirewallDirection::Inbound, + ProxifyreFirewallDirection::Outbound + ] + ); +} + +#[test] +fn start_requires_exact_promoted_config_before_service_call() { + let mut host = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + assert_eq!( + start_proxifyre_native(&mut host, Path::new("app-config.json"), &"a".repeat(64)) + .expect_err("relative config must be rejected"), + ProxifyreNativeError::RuntimeConfigInvalid + ); + assert!(host.calls.is_empty()); + + let config = std::env::current_dir() + .expect("current dir") + .join("app-config.json"); + start_proxifyre_native(&mut host, &config, &"a".repeat(64)).expect("start must succeed"); + let verify = host + .calls + .iter() + .position(|call| *call == Call::VerifyConfig) + .expect("config verification call"); + let start = host + .calls + .iter() + .position(|call| *call == Call::Control(ProxifyreNativeServiceAction::Start)) + .expect("start call"); + assert!(verify < start); +} + +#[test] +fn start_distinguishes_proven_config_mismatch_from_observation_failure() { + let config = std::env::current_dir() + .expect("current dir") + .join("app-config.json"); + + let mut mismatch = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + mismatch.config_verification = Ok(RuntimeConfigVerification::Mismatch); + assert_eq!( + start_proxifyre_native(&mut mismatch, &config, &"a".repeat(64)) + .expect_err("proven config mismatch must be rejected"), + ProxifyreNativeError::RuntimeConfigInvalid + ); + assert!(!mismatch + .calls + .iter() + .any(|call| matches!(call, Call::Control(ProxifyreNativeServiceAction::Start)))); + + let mut observation_failure = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Stopped), + "2.4.0", + ); + observation_failure.config_verification = Err(ProxifyreNativeHostError); + assert_eq!( + start_proxifyre_native(&mut observation_failure, &config, &"a".repeat(64)) + .expect_err("ambiguous config observation must remain a host error"), + ProxifyreNativeError::HostStep(ProxifyreNativeStep::VerifyRuntimeConfig) + ); + assert!(!observation_failure + .calls + .iter() + .any(|call| matches!(call, Call::Control(ProxifyreNativeServiceAction::Start)))); +} + +#[test] +fn existing_root_verification_errors_never_become_a_false_snapshot_field() { + let unreadable_acl = verify_existing_native_root( + true, + || Ok(()), + || Err(ProxifyreNativeHostError), + || -> Result<&'static str, ProxifyreNativeHostError> { + panic!("receipt read must not follow an ambiguous ACL failure") + }, + ); + assert_eq!(unreadable_acl, Err(ProxifyreNativeHostError)); + + let unreadable_receipt: Result, ProxifyreNativeHostError> = + verify_existing_native_root(true, || Ok(()), || Ok(()), || Err(ProxifyreNativeHostError)); + assert_eq!(unreadable_receipt, Err(ProxifyreNativeHostError)); + + let verified = + verify_existing_native_root(true, || Ok(()), || Ok(()), || Ok("verified receipt")) + .expect("successful verification") + .expect("existing root payload"); + assert_eq!(verified, "verified receipt"); +} + +#[test] +fn start_preflight_prevents_config_promotion_when_already_running() { + let mut host = FakeHost::new( + managed_snapshot("2.4.0", ProxifyreNativeServiceStatus::Running), + "2.4.0", + ); + + assert_eq!( + preflight_start_proxifyre_native(&mut host).expect("preflight must succeed"), + ProxifyreNativeStartDisposition::AlreadyRunning + ); + assert_eq!(host.calls, vec![Call::Inspect]); + assert_eq!(host.mutation_calls(), 0); +} diff --git a/src-tauri/src/proxifyre_scripts.rs b/src-tauri/src/proxifyre_scripts.rs deleted file mode 100644 index 0ded1b4..0000000 --- a/src-tauri/src/proxifyre_scripts.rs +++ /dev/null @@ -1,687 +0,0 @@ -//! Static-template PowerShell generation for explicit ProxiFyre package actions. - -use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier}; -use crate::powershell::escape_single as escape_powershell_single; -use crate::proxifyre_ownership::ManagedProxiFyreOwnership; -use std::path::Path; - -const PROXIFYRE_RELEASE_API_URL: &str = - "https://api.github.com/repos/wiresock/proxifyre/releases/latest"; -const NDISAPI_RELEASE_API_URL: &str = - "https://api.github.com/repos/wiresock/ndisapi/releases/latest"; -const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1"; -const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2"; -const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1"; -const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe"; -const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe"; -pub const PROXIFYRE_FIREWALL_INBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Inbound"; -pub const PROXIFYRE_FIREWALL_OUTBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Outbound"; - -pub fn install_proxifyre_script(generated_config_path: &Path) -> String { - install_proxifyre_script_with_bundle(generated_config_path, None) -} - -pub fn install_proxifyre_script_with_bundle( - generated_config_path: &Path, - bundled_asset_dir: Option<&Path>, -) -> String { - install_proxifyre_script_for_target( - generated_config_path, - bundled_asset_dir, - &default_proxifyre_install_dir(), - ) -} - -pub fn install_proxifyre_script_for_target( - generated_config_path: &Path, - bundled_asset_dir: Option<&Path>, - target_dir: &Path, -) -> String { - let mut script = String::new(); - script.push_str(&format!( - "$targetDir = '{}'\n", - escape_powershell_single(&target_dir.display().to_string()) - )); - script.push_str(&format!( - "$generatedConfigPath = '{}'\n", - escape_powershell_single(&generated_config_path.display().to_string()) - )); - script.push_str(&format!( - "$bundledAssetDir = '{}'\n", - escape_powershell_single( - &bundled_asset_dir - .map(|path| path.display().to_string()) - .unwrap_or_default() - ) - )); - script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n"); - script.push_str(&format!( - "$proxifyreReleaseApi = '{}'\n", - escape_powershell_single(PROXIFYRE_RELEASE_API_URL) - )); - script.push_str(&format!( - "$ndisapiReleaseApi = '{}'\n", - escape_powershell_single(NDISAPI_RELEASE_API_URL) - )); - script.push_str(&format!( - "$proxifyrePinnedReleaseTag = '{}'\n", - escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG) - )); - script.push_str(&format!( - "$ndisapiPinnedReleaseTag = '{}'\n", - escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG) - )); - script.push_str(&format!( - "$ndisapiPinnedInstallerVersion = '{}'\n", - escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION) - )); - script.push_str(&format!( - "$vcRedistX64Url = '{}'\n", - escape_powershell_single(VC_REDIST_X64_URL) - )); - script.push_str(&format!( - "$vcRedistX86Url = '{}'\n", - escape_powershell_single(VC_REDIST_X86_URL) - )); - script.push_str( - r#" - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - - function Get-NativeArchitecture { - $processor = Get-CimInstance Win32_Processor | Select-Object -First 1 - if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' } - if ([Environment]::Is64BitOperatingSystem) { return 'x64' } - return 'x86' - } - - function Get-SafeUriForLog([string]$uri) { - try { - $parsed = [Uri]$uri - $port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" } - return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)" - } catch { - return '' - } - } - - function Invoke-ReleaseApi([string]$uri, [string]$label) { - $safeUri = Get-SafeUriForLog $uri - $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' } - $lastError = $null - - foreach ($attempt in 1..3) { - try { - return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10 - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt 3) { - Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) - } - } - } - - throw "Не удалось получить metadata для $label ($safeUri): $lastError" - } - - function New-ReleaseAsset([string]$name, [string]$url) { - [PSCustomObject]@{ - name = $name - browser_download_url = $url - digest = $null - } - } - - function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) { - try { - $release = Invoke-ReleaseApi $apiUri $label - return Select-Asset $release.assets $pattern $label - } catch { - $fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url - Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri" - return $fallbackAsset - } - } - - function Get-PinnedProxiFyreAsset([string]$arch) { - $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } - $name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip" - $url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name" - return New-ReleaseAsset $name $url - } - - function Get-PinnedWindowsPacketFilterAsset([string]$arch) { - $archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' } - $name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi" - $url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name" - return New-ReleaseAsset $name $url - } - - function Complete-Download([string]$partialPath, [string]$path, [string]$label) { - if (-not (Test-Path -LiteralPath $partialPath)) { - throw "${label}: файл не был создан." - } - - $item = Get-Item -LiteralPath $partialPath - if ($item.Length -le 0) { - throw "${label}: скачанный файл пустой." - } - - Move-Item -LiteralPath $partialPath -Destination $path -Force - } - - function Invoke-WebClientDownload([string]$uri, [string]$partialPath) { - $client = New-Object System.Net.WebClient - try { - $client.Headers.Add('User-Agent', 'proxywarden') - $client.Headers.Add('Accept', 'application/octet-stream,*/*') - $client.DownloadFile($uri, $partialPath) - } finally { - $client.Dispose() - } - } - - function Invoke-CurlDownload([string]$uri, [string]$partialPath) { - $curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue - if ($null -eq $curl) { - throw 'curl.exe не найден.' - } - - $curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1 - if ($LASTEXITCODE -ne 0) { - $curlMessage = ($curlOutput | Out-String).Trim() - if ([string]::IsNullOrWhiteSpace($curlMessage)) { - throw "curl.exe завершился с кодом $LASTEXITCODE." - } - - throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage" - } - } - - function Invoke-Download([string]$uri, [string]$path, [string]$label) { - $safeUri = Get-SafeUriForLog $uri - $partialPath = "$path.part" - $headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' } - $webRequestError = $null - $webClientError = $null - $curlError = $null - - foreach ($attempt in 1..3) { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - try { - Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10 - Complete-Download $partialPath $path $label - return - } catch { - $webRequestError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - if ($attempt -lt 3) { - Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2)) - } - } - } - - try { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Invoke-WebClientDownload $uri $partialPath - Complete-Download $partialPath $path $label - return - } catch { - $webClientError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - } - - try { - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Invoke-CurlDownload $uri $partialPath - Complete-Download $partialPath $path $label - return - } catch { - $curlError = $_.Exception.Message - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - } - - $errors = @() - if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" } - if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" } - if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" } - $details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' } - - throw "Не удалось скачать $label ($safeUri): $details" - } - - function Select-Asset($assets, [string]$pattern, [string]$label) { - $asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1 - if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." } - return $asset - } - - function Verify-AssetHash([string]$path, $asset) { - if ($asset.digest -match '^sha256:(.+)$') { - $expected = $Matches[1].ToLowerInvariant() - $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected) { - throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual." - } - } - } - - function Assert-ExitCode($process, [string]$label) { - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { - throw "$label завершился с кодом $($process.ExitCode)." - } - } - - function Get-InstalledProgram([string]$pattern) { - $paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' - ) - return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match $pattern } | - Select-Object -First 1 - } - - function Test-VcRuntime([string]$arch) { - $pattern = if ($arch -eq 'ARM64') { - 'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)' - } else { - "Microsoft Visual C\+\+.*Redistributable.*\($arch\)" - } - - return $null -ne (Get-InstalledProgram $pattern) - } - - function Test-WindowsPacketFilter { - return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') - } - - function Get-LogTail([string]$path) { - if (-not (Test-Path -LiteralPath $path)) { return '' } - return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' - } - - function Get-BundledAssetDir { - $dir = [string]$script:bundledAssetDir - if ([string]::IsNullOrWhiteSpace($dir)) { return $null } - if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null } - return $dir - } - - function Get-BundledAssetManifest { - $assetDir = Get-BundledAssetDir - if ($null -eq $assetDir) { return $null } - $manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json') - if (-not (Test-Path -LiteralPath $manifestPath)) { return $null } - - try { - return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json - } catch { - throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)" - } - } - - $script:bundledAssetManifest = Get-BundledAssetManifest - - function Get-BundledAssetHash([string]$name) { - if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) { - return $null - } - - $entry = $script:bundledAssetManifest.files | - Where-Object { $_.name -eq $name } | - Select-Object -First 1 - if ($null -eq $entry) { return $null } - return [string]$entry.sha256 - } - - function Verify-BundledAssetHash([string]$path, [string]$label) { - $name = [IO.Path]::GetFileName($path) - $expected = Get-BundledAssetHash $name - if ([string]::IsNullOrWhiteSpace($expected)) { - throw "Во встроенном manifest нет SHA256 для $label ($name)." - } - - $actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actual -ne $expected.ToLowerInvariant()) { - throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual." - } - } - - function Get-BundledAsset([string]$pattern, [string]$label) { - $assetDir = Get-BundledAssetDir - if ($null -eq $assetDir) { return $null } - - $asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match $pattern } | - Select-Object -First 1 - if ($null -eq $asset) { return $null } - - Verify-BundledAssetHash $asset.FullName $label - return $asset.FullName - } - - function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) { - Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force - $item = Get-Item -LiteralPath $targetPath - if ($item.Length -le 0) { - throw "${label}: встроенный файл пустой." - } - } - - $arch = Get-NativeArchitecture - $workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install' - $extractDir = Join-Path $workDir 'proxifyre' - Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue - New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null - - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.' - $packetFilterAlreadyInstalled = Test-WindowsPacketFilter - if (-not $packetFilterAlreadyInstalled) { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.' - $ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' } - $bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter' - if ($null -ne $bundledNdisPath) { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.' - $ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath)) - Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter' - } else { - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.' - $ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16 - $ndisPath = Join-Path $workDir $ndisAsset.name - Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter' - Verify-AssetHash $ndisPath $ndisAsset - } - $ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log' - Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.' - $ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden - if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) { - $ndisLogTail = Get-LogTail $ndisLogPath - throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail" - } - } - Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.' - - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.' - if (-not (Test-VcRuntime $arch)) { - $vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' } - $vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url } - $bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime' - $vcRedistPath = Join-Path $workDir 'vc_redist.exe' - if ($null -ne $bundledVcPath) { - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.' - Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime' - } else { - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.' - Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime' - } - Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.' - $vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden - if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) { - throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)." - } - } - Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.' - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.' - $proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' } - $bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre' - if ($null -ne $bundledProxiFyrePath) { - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.' - $proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath)) - Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre' - } else { - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.' - $proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68 - $proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name - Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre' - Verify-AssetHash $proxifyreZipPath $proxifyreAsset - } - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.' - Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force - $proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1 - if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' } - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.' - Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force - - $configTarget = Join-Path $targetDir 'app-config.json' - if (Test-Path -LiteralPath $generatedConfigPath) { - Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force - } elseif (-not (Test-Path -LiteralPath $configTarget)) { - $emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}' - Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8 - } - - $markerPath = Join-Path $targetDir 'proxywarden-component.json' - $markerJson = [ordered]@{ - manager = 'ProxyWarden' - component = 'proxifyre' - serviceName = 'ProxiFyreService' - installedAt = (Get-Date).ToString('o') - installRoot = $targetDir - packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled) - } | ConvertTo-Json -Depth 4 - [IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false)) - - Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.' - Push-Location $targetDir - try { - & .\ProxiFyre.exe stop | Out-Null - & .\ProxiFyre.exe uninstall | Out-Null - & .\ProxiFyre.exe install - if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." } - & .\ProxiFyre.exe start - if ($LASTEXITCODE -ne 0) { - Start-Service -Name 'ProxiFyreService' -ErrorAction Stop - } - } finally { - Pop-Location - } - Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.' -"#, - ); - - script -} - -pub fn configure_proxifyre_firewall_script(executable_path: &Path) -> String { - let executable_path = escape_powershell_single(&executable_path.display().to_string()); - format!( - r#" -$exePath = '{executable_path}' -if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {{ - throw "ProxiFyre.exe не найден по подтвержденному пути: $exePath" -}} - -$ruleSpecs = @( - @{{ Name = '{PROXIFYRE_FIREWALL_INBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (входящие)'; Direction = 'Inbound' }}, - @{{ Name = '{PROXIFYRE_FIREWALL_OUTBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (исходящие)'; Direction = 'Outbound' }} -) - -foreach ($rule in $ruleSpecs) {{ - Get-NetFirewallRule -Name $rule.Name -ErrorAction SilentlyContinue | - Remove-NetFirewallRule -ErrorAction Stop - New-NetFirewallRule ` - -Name $rule.Name ` - -DisplayName $rule.DisplayName ` - -Group 'ProxyWarden' ` - -Program $exePath ` - -Direction $rule.Direction ` - -Action Allow ` - -Profile Any ` - -Enabled True ` - -ErrorAction Stop | Out-Null -}} -"#, - ) -} - -pub fn uninstall_proxifyre_script( - detected: Option<&DetectedProxyfier>, - ownership: &ManagedProxiFyreOwnership, -) -> String { - let mut script = String::new(); - let install_dir = detected - .map(|detected| detected.install_dir.display().to_string()) - .unwrap_or_default(); - let executable_path = detected - .map(|detected| detected.executable_path.display().to_string()) - .unwrap_or_default(); - script.push_str(&format!( - "$installDir = '{}'\n", - escape_powershell_single(&install_dir) - )); - script.push_str(&format!( - "$exePath = '{}'\n", - escape_powershell_single(&executable_path) - )); - script.push_str(&format!( - "$serviceName = '{}'\n", - escape_powershell_single(&ownership.service_name) - )); - script.push_str(&format!( - "$removePacketFilter = ${}\n", - if ownership.remove_packet_filter { - "true" - } else { - "false" - } - )); - script.push_str( - r#" - function Get-InstalledProgram([string]$pattern) { - $paths = @( - 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*', - 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' - ) - return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue | - Where-Object { $_.DisplayName -match $pattern } | - Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString - } - - function Test-WindowsPacketFilter { - return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI') - } - - function Get-LogTail([string]$path) { - if (-not (Test-Path -LiteralPath $path)) { return '' } - return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' ' - } - - function Resolve-MsiProductCode($program, [string]$label) { - if ($null -eq $program) { return $null } - if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') { - return $program.PSChildName - } - foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) { - if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') { - return $Matches[0] - } - } - throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString." - } - - function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) { - $productCode = Resolve-MsiProductCode $program $label - if ([string]::IsNullOrWhiteSpace($productCode)) { return } - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden - if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) { - $logTail = Get-LogTail $logPath - throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail" - } - } - - function Get-ServiceBinaryPath([string]$pathName) { - if ([string]::IsNullOrWhiteSpace($pathName)) { return $null } - $pathName = $pathName.Trim() - if ($pathName.StartsWith('"')) { - $closingQuote = $pathName.IndexOf('"', 1) - if ($closingQuote -lt 2) { return $null } - return $pathName.Substring(1, $closingQuote - 1) - } - return ($pathName -split '\s+', 2)[0] - } - - function Find-ManagedProxiFyreService { - $escapedName = $serviceName.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) { return $null } - $binaryPath = Get-ServiceBinaryPath $record.PathName - if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null } - return Get-Service -Name $serviceName -ErrorAction SilentlyContinue - } - - function Get-ServiceProcessId([string]$name) { - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) { return 0 } - return [int]$record.ProcessId - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.' - $service = Find-ManagedProxiFyreService - if ($null -ne $service -and $service.Status -ne 'Stopped') { - try { - if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue } - $service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue - if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) } - } catch {} - } - - $service = Find-ManagedProxiFyreService - if ($null -ne $service -and $service.Status -ne 'Stopped') { - $processId = Get-ServiceProcessId $service.Name - if ($processId -gt 0) { - taskkill.exe /PID $processId /F | Out-Null - Start-Sleep -Milliseconds 700 - } - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.' - if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) { - Push-Location (Split-Path -Parent $exePath) - try { - & $exePath uninstall | Out-Null - } finally { - Pop-Location - } - } - - $service = Find-ManagedProxiFyreService - if ($null -ne $service) { - sc.exe delete $service.Name | Out-Null - } - - foreach ($firewallRuleName in @('ProxyWarden.ProxiFyre.Inbound', 'ProxyWarden.ProxiFyre.Outbound')) { - Get-NetFirewallRule -Name $firewallRuleName -ErrorAction SilentlyContinue | - Remove-NetFirewallRule -ErrorAction Stop - } - - if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) { - Remove-Item -LiteralPath $installDir -Recurse -Force - } - - Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.' - - if ($removePacketFilter) { - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.' - $packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI' - if ($null -ne $packetFilter) { - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.' - $driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log' - Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath - } - if (Test-WindowsPacketFilter) { - throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.' - } - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.' - } else { - Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.' - } -"#, - ); - - script -} diff --git a/src-tauri/src/proxy_apply.rs b/src-tauri/src/proxy_apply.rs index 1b4feab..c47fe0e 100644 --- a/src-tauri/src/proxy_apply.rs +++ b/src-tauri/src/proxy_apply.rs @@ -10,10 +10,15 @@ use crate::adapters::proxy_router::{ use crate::clock::Clock; use crate::command_dto::{ActivityEntryDto, CommandError}; use crate::component_detection::{ - detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install, - DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost, + detect_proxyfier_install, detect_singbox_install, inventory_proxyfier_with_host, + inventory_proxyfier_with_host_and_current_root, DetectedProxyfier, DetectedSingBox, + ProxyfierDetectionHost, SystemProxyfierDetectionHost, }; -use crate::component_status::components_or_defaults_with_detection; +use crate::component_inventory::{ + run_authorized_component_action, AuthorizedActionError, ComponentClassification, + InventoryAction, +}; +use crate::component_status::components_with_detection; use crate::models::{ActivityEntry, ActivityLevel}; use crate::safe_fs; use crate::storage::JsonStorage; @@ -58,6 +63,7 @@ pub trait ProxyApplyHelper { pub struct DetectedProxyApplyHelper { host: H, + current_root: Option, } impl DetectedProxyApplyHelper { @@ -68,7 +74,19 @@ impl DetectedProxyApplyHelper { impl From for DetectedProxyApplyHelper { fn from(host: H) -> Self { - Self { host } + Self { + host, + current_root: None, + } + } +} + +impl DetectedProxyApplyHelper { + pub fn with_current_root(host: H, current_root: std::path::PathBuf) -> Self { + Self { + host, + current_root: Some(current_root), + } } } @@ -80,11 +98,36 @@ where &self, request: HelperApplyRequest<'_>, ) -> Result { - let Some(detected) = detect_proxyfier_install_with_host(&self.host) else { + let inventory = self.current_root.as_deref().map_or_else( + || inventory_proxyfier_with_host(&self.host), + |current_root| inventory_proxyfier_with_host_and_current_root(&self.host, current_root), + ); + if inventory.classification() == ComponentClassification::Missing { return staged_apply_result(request); - }; + } + if inventory.classification() == ComponentClassification::ManagedLegacy { + return Err(CommandError::new( + "legacy_cutover_required", + "Старая установка ProxiFyre не изменена. Сначала выполните явный перенос компонента.", + )); + } + run_authorized_component_action(&inventory, InventoryAction::Apply, |_| { + if inventory.classification() == ComponentClassification::ManagedCurrent { + return staged_managed_current_result(request); + } + Err(CommandError::new( + "ownership_mismatch", + "Найденный ProxiFyre не прошел ownership-проверку.", + )) + }) + .map_err(authorized_action_error) + } +} - apply_to_detected_proxyfier(request, &detected) +fn authorized_action_error(error: AuthorizedActionError) -> CommandError { + match error { + AuthorizedActionError::Denied(issue) => CommandError::new(issue.code, issue.message), + AuthorizedActionError::Runner(error) => error, } } @@ -112,10 +155,12 @@ pub fn apply_profiles_with_services_and_detection( detected_proxyfier: Option, detected_singbox: Option, ) -> Result { + let transaction = + crate::configuration_transaction::ConfigurationTransaction::begin(storage, None) + .map_err(storage_error)?; let profiles = storage.read_profiles().map_err(storage_error)?; let targets = storage.read_targets().map_err(storage_error)?; - let components = - components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?; + let components = components_with_detection(detected_proxyfier, detected_singbox); let generated = match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) { Ok(generated) => generated, @@ -139,10 +184,18 @@ pub fn apply_profiles_with_services_and_detection( config_contents: generated.contents.as_str(), })?; + crate::route_state::record_prepared_locked( + storage, + crate::privileged_jobs::ManagedComponent::Proxifyre, + ) + .map_err(storage_error)?; + if helper_result.success { + transaction.commit().map_err(storage_error)?; + } else { + drop(transaction); + } let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result); - storage - .append_activity(activity.clone()) - .map_err(storage_error)?; + let _ = storage.append_activity(activity.clone()); Ok(ApplyProfilesResponse { success: helper_result.success, @@ -158,38 +211,7 @@ pub fn apply_profiles_with_services_and_detection( } fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { - safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) -} - -fn apply_to_detected_proxyfier( - request: HelperApplyRequest<'_>, - detected: &DetectedProxyfier, -) -> Result { - let Some(config_path) = &detected.config_path else { - return staged_apply_result(request); - }; - - safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err( - |error| { - CommandError::new( - "proxyfier_apply_failed", - format!( - "Не удалось безопасно записать конфиг ProxiFyre '{}': {error}", - config_path.display() - ), - ) - }, - )?; - - Ok(HelperApplyResult { - success: true, - changed: true, - action: "proxifyre.apply-detected-config".to_string(), - message: format!( - "Сгенерированный конфиг записан в найденную установку ProxiFyre: {}", - config_path.display() - ), - }) + safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error) } fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result { @@ -204,6 +226,20 @@ fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result, +) -> Result { + Ok(HelperApplyResult { + success: true, + changed: true, + action: format!("{}.stage-managed-config", request.adapter_id), + message: format!( + "Сгенерированный конфиг подготовлен в {}; служба получит его при следующем явном запуске", + request.config_path.display() + ), + }) +} + fn activity_for_apply( clock: &impl Clock, generated: &ProxyRouterGeneratedConfig, diff --git a/src-tauri/src/route_state.rs b/src-tauri/src/route_state.rs new file mode 100644 index 0000000..06b024b --- /dev/null +++ b/src-tauri/src/route_state.rs @@ -0,0 +1,274 @@ +//! Source/prepared/activation are separate facts. This module never controls services. +use crate::{ + configuration_transaction, + privileged_jobs::{ManagedComponent, PrivilegedJobStore}, + process::{self, KnownWindowsService, ServiceState}, + safe_fs, + storage::JsonStorage, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs, io, path::PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PreparedArtifact { + source_fingerprint: String, + config_sha256: String, +} +#[derive(Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PreparedState { + proxifyre: Option, + singbox: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ActivationState { + Unknown, + Stopped, + RestartRequired, + Confirmed, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArtifactStatus { + pub component: String, + pub source_matches_prepared: bool, + pub generated_exists: bool, + pub activation: ActivationState, +} + +pub fn prepared_path(storage: &JsonStorage) -> PathBuf { + storage + .paths() + .state_dir + .join("prepared-configuration.json") +} +fn generated_path(storage: &JsonStorage, component: ManagedComponent) -> PathBuf { + storage.paths().generated_dir.join(match component { + ManagedComponent::Proxifyre => "proxifyre-app-config.json", + ManagedComponent::SingBox => "sing-box-config.json", + }) +} +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn source_fingerprint(storage: &JsonStorage, component: ManagedComponent) -> io::Result { + let bytes = match component { + ManagedComponent::Proxifyre => { + serde_json::to_vec(&(storage.read_profiles()?, storage.read_targets()?))? + } + ManagedComponent::SingBox => serde_json::to_vec(&( + storage.read_local_singbox_config()?, + storage.read_singbox_subscription_cache()?, + ))?, + }; + Ok(hash(&bytes)) +} +fn read_prepared(storage: &JsonStorage) -> PreparedState { + // Missing, old, or invalid derived metadata is unknown, never reconstructed from source. + let path = prepared_path(storage); + if safe_fs::ensure_no_reparse_ancestors(&path).is_err() { + return PreparedState::default(); + } + fs::read(path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +/// Must run inside ConfigurationTransaction after all source and generated writes. +pub fn record_prepared_locked( + storage: &JsonStorage, + component: ManagedComponent, +) -> io::Result<()> { + let path = generated_path(storage, component); + safe_fs::ensure_no_reparse_ancestors(&path)?; + let artifact = PreparedArtifact { + source_fingerprint: source_fingerprint(storage, component)?, + config_sha256: hash(&fs::read(path)?), + }; + let mut state = read_prepared(storage); + match component { + ManagedComponent::Proxifyre => state.proxifyre = Some(artifact), + ManagedComponent::SingBox => state.singbox = Some(artifact), + }; + safe_fs::write_restricted_atomic(&prepared_path(storage), &serde_json::to_vec(&state)?) +} + +pub fn read_status_locked(storage: &JsonStorage) -> io::Result> { + let prepared = read_prepared(storage); + let store = PrivilegedJobStore::production().ok(); + [ManagedComponent::Proxifyre, ManagedComponent::SingBox] + .into_iter() + .map(|component| { + let path = generated_path(storage, component); + safe_fs::ensure_no_reparse_ancestors(&path)?; + let generated = fs::read(path).ok().map(|bytes| hash(&bytes)); + let artifact = match component { + ManagedComponent::Proxifyre => &prepared.proxifyre, + ManagedComponent::SingBox => &prepared.singbox, + }; + let source = source_fingerprint(storage, component)?; + let source_matches_prepared = artifact.as_ref().is_some_and(|record| { + record.source_fingerprint == source + && generated.as_ref() == Some(&record.config_sha256) + }); + let service = match component { + ManagedComponent::Proxifyre => KnownWindowsService::Proxifyre, + ManagedComponent::SingBox => KnownWindowsService::SingBox, + }; + let ack = store + .as_ref() + .and_then(|store| store.read_activation(component).ok().flatten()); + let current = process::running_service_instance(service).ok(); + let managed = match component { + ManagedComponent::Proxifyre => crate::component_detection::inventory_proxyfier(), + ManagedComponent::SingBox => crate::component_detection::inventory_singbox(), + } + .classification() + == crate::component_inventory::ComponentClassification::ManagedCurrent; + let stopped = process::query_known_service(service) + .ok() + .is_some_and(|state| !state.exists || state.state == Some(ServiceState::Stopped)); + let activation = classify_activation( + source_matches_prepared, + generated.as_deref(), + ack.as_ref(), + current, + managed, + stopped, + ); + Ok(ArtifactStatus { + component: match component { + ManagedComponent::Proxifyre => "proxyfier", + ManagedComponent::SingBox => "singbox", + } + .into(), + source_matches_prepared, + generated_exists: generated.is_some(), + activation, + }) + }) + .collect() +} + +pub fn read_status(storage: &JsonStorage) -> io::Result> { + let _guard = configuration_transaction::read_guard(storage)?; + read_status_locked(storage) +} + +fn classify_activation( + prepared: bool, + generated: Option<&str>, + ack: Option<&crate::privileged_jobs::ActivationAcknowledgement>, + current: Option, + managed: bool, + stopped: bool, +) -> ActivationState { + if stopped { + return ActivationState::Stopped; + } + match (ack, current) { + (Some(ack), Some(current)) if managed && ack.instance == current => { + if prepared && generated == Some(ack.config_sha256.as_str()) { + ActivationState::Confirmed + } else { + ActivationState::RestartRequired + } + } + _ => ActivationState::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn late_activation_never_confirms_new_preparation_or_a_reused_pid() { + let instance = process::ServiceInstance { + process_id: 42, + created_at_filetime: 100, + }; + let ack = crate::privileged_jobs::ActivationAcknowledgement { + component: ManagedComponent::Proxifyre, + config_sha256: "a".repeat(64), + instance, + }; + assert_eq!( + classify_activation( + true, + Some(&ack.config_sha256), + Some(&ack), + Some(instance), + true, + false + ), + ActivationState::Confirmed + ); + assert_eq!( + classify_activation( + true, + Some(&"b".repeat(64)), + Some(&ack), + Some(instance), + true, + false + ), + ActivationState::RestartRequired + ); + assert_eq!( + classify_activation( + false, + Some(&ack.config_sha256), + Some(&ack), + Some(instance), + true, + false + ), + ActivationState::RestartRequired + ); + assert_eq!( + classify_activation( + true, + Some(&ack.config_sha256), + Some(&ack), + Some(process::ServiceInstance { + created_at_filetime: 101, + ..instance + }), + true, + false + ), + ActivationState::Unknown + ); + assert_eq!( + classify_activation( + true, + Some(&ack.config_sha256), + Some(&ack), + Some(instance), + false, + false + ), + ActivationState::Unknown + ); + assert_eq!( + classify_activation( + true, + Some(&ack.config_sha256), + None, + Some(instance), + true, + false + ), + ActivationState::Unknown + ); + assert_eq!( + classify_activation(true, Some(&ack.config_sha256), Some(&ack), None, true, true), + ActivationState::Stopped + ); + } +} diff --git a/src-tauri/src/safe_fs.rs b/src-tauri/src/safe_fs.rs index fec407b..8002da8 100644 --- a/src-tauri/src/safe_fs.rs +++ b/src-tauri/src/safe_fs.rs @@ -1,7 +1,90 @@ -use std::fs; -use std::io; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StableObjectKind { + RegularFile, + Directory, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StableObjectIdentity { + pub volume_serial_number: u64, + pub file_id: u64, + pub kind: StableObjectKind, + pub size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapturedSacl { + Absent, + Present, +} + +/// Exact self-relative Windows security descriptor captured from an open handle. +/// It is rollback material, not a display DTO, and must only be persisted in a +/// sealed internal journal. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SecurityDescriptorSnapshot { + self_relative: Vec, + pub sacl: CapturedSacl, +} + +impl SecurityDescriptorSnapshot { + pub fn as_bytes(&self) -> &[u8] { + &self.self_relative + } +} + +/// Stable regular-file/directory lease. The Windows handle denies share-write +/// and share-delete for its lifetime and carries the exact identity and security +/// descriptor observed through that same handle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathIdentityLeaseMode { + /// A privileged cutover handle with the rights required for exact ACL, + /// rename, and delete mutations. + Mutation, + /// A read/query-only handle compatible with normal runtime readers while + /// still denying concurrent write and delete sharing. + RuntimeRead, +} + +#[derive(Debug)] +pub struct PathIdentityLease { + file: File, + path: PathBuf, + identity: StableObjectIdentity, + security: SecurityDescriptorSnapshot, + mode: PathIdentityLeaseMode, +} + +impl PathIdentityLease { + pub fn file(&self) -> &File { + &self.file + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn identity(&self) -> &StableObjectIdentity { + &self.identity + } + + pub fn security(&self) -> &SecurityDescriptorSnapshot { + &self.security + } + + pub fn mode(&self) -> PathIdentityLeaseMode { + self.mode + } +} + pub fn backup_path(path: &Path) -> PathBuf { sibling_with_suffix(path, "bak") } @@ -17,37 +100,4646 @@ pub fn temp_path(path: &Path) -> PathBuf { sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated())) } +/// Rejects a path when it or any existing ancestor is a reparse point. +/// +/// Missing path segments are allowed so callers can validate a fresh target. +/// Metadata errors other than `NotFound` fail closed. +pub fn ensure_no_reparse_ancestors(path: &Path) -> io::Result<()> { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + + for ancestor in absolute.ancestors() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) if is_reparse_point(&metadata) => { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem path contains a reparse point", + )); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + + Ok(()) +} + +/// Applies a protected DACL granting full control only to the object owner, +/// local Administrators, and SYSTEM. On non-Windows platforms this is a no-op. +pub fn protect_path_for_owner_admin_system(path: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(path)?; + protect_path(path) +} + +/// Verifies without mutation that a path is owned by the current user, +/// Administrators, or SYSTEM and has only the protected ProxyWarden DACL. +pub fn verify_path_protected_for_owner_admin_system(path: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(path)?; + verify_path_protection(path) +} + +/// Re-owns an existing file or directory to local Administrators and applies a +/// protected DACL where only Administrators and SYSTEM can write. Builtin Users +/// receive read/execute access. This boundary is intentionally Windows-only and +/// requires an elevated process. +pub fn protect_path_admin_owned_user_read_only(path: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(path)?; + protect_path_admin_owned(path) +} + +/// Verifies, without mutation, the ACL installed by +/// `protect_path_admin_owned_user_read_only`. +pub fn verify_path_admin_owned_user_read_only(path: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(path)?; + verify_path_admin_owned(path) +} + +/// Creates a new regular file whose owner is Administrators and whose protected +/// DACL grants full control only to Administrators and SYSTEM. +/// +/// The Windows security descriptor is supplied to `CreateFileW`, so no handle +/// or file contents are exposed under inherited permissions. Existing paths are +/// never opened or modified. +pub fn create_new_admin_system_only_file(path: &Path) -> io::Result { + create_new_admin_system_only_file_impl(path) +} + +/// Verifies without mutation that a regular, non-reparse file is owned by +/// Administrators or SYSTEM and has exactly two full-control ACEs for those +/// principals in a protected DACL. +pub fn verify_admin_system_only_file(path: &Path) -> io::Result<()> { + verify_admin_system_only_file_impl(path) +} + +/// Promotes an Administrators/SYSTEM-only staging file in the same directory. +/// +/// An existing destination must already satisfy the exact confidential-file +/// policy and is replaced with `ReplaceFileW`. A missing destination is created +/// with the final policy before any bytes are copied into it. +pub fn promote_admin_system_only_file_same_parent( + staging: &Path, + destination: &Path, +) -> io::Result<()> { + promote_admin_system_only_file_same_parent_impl(staging, destination) +} + +/// Creates an Administrators-owned directory with a protected DACL granting +/// Administrators/SYSTEM full control and Builtin Users read/execute. +/// +/// Existing directories are only verified; this function never repairs or +/// widens an existing ACL. +pub fn create_directory_admin_owned_user_read_only(path: &Path) -> io::Result<()> { + create_directory_admin_owned_user_read_only_impl(path) +} + +/// Creates exactly one fresh Administrators-owned directory with the final +/// protected Users read/execute policy. Existing paths are rejected rather than +/// adopted, repaired, or overwritten. +pub fn create_new_admin_owned_user_read_only_directory(path: &Path) -> io::Result<()> { + create_new_admin_owned_user_read_only_directory_impl(path) +} + +/// Creates a new regular file atomically with the admin-owned, users-read-only +/// policy and returns its exclusive handle before any contents are written. +/// Existing paths are never opened or modified. +pub fn create_new_admin_owned_user_read_only_file(path: &Path) -> io::Result { + create_new_admin_owned_user_read_only_file_impl(path) +} + +/// Atomically writes user-readable administrative metadata without ever +/// creating an owner/current-user-writable staging file. +pub fn write_admin_owned_user_read_only_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + write_admin_owned_user_read_only_atomic_impl(path, contents) +} + +/// Creates an Administrators/SYSTEM-only directory without ever inheriting a +/// user-writable DACL. Existing paths are verified and never repaired. +pub fn create_directory_admin_system_only(path: &Path) -> io::Result<()> { + create_directory_admin_system_only_impl(path) +} + +/// Creates a fresh Administrators/SYSTEM-only directory. Existing paths are +/// rejected so the caller can safely track transaction ownership. +pub fn create_new_directory_admin_system_only(path: &Path) -> io::Result<()> { + create_new_directory_admin_system_only_impl(path) +} + +pub fn verify_directory_admin_system_only(path: &Path) -> io::Result<()> { + verify_directory_admin_system_only_impl(path) +} + +/// Atomically writes a sealed Administrators/SYSTEM-only file in an already +/// sealed parent directory. +pub fn write_admin_system_only_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + write_admin_system_only_atomic_impl(path, contents) +} + +/// Opens a sealed journal file while denying concurrent write/delete sharing. +pub fn open_admin_system_only_file_read_lease(path: &Path) -> io::Result { + open_admin_system_only_file_read_lease_impl(path) +} + +/// Opens an exact regular file or directory, captures its stable identity and +/// full owner/group/DACL/SACL descriptor through the same handle, and denies +/// share-write/delete until the lease is dropped. +pub fn open_path_identity_lease( + path: &Path, + expected_kind: StableObjectKind, +) -> io::Result { + open_path_identity_lease_impl(path, expected_kind, PathIdentityLeaseMode::Mutation) +} + +/// Opens an exact regular file or directory with read/query rights only. +/// +/// The retained Windows handle remains compatible with ordinary readers such +/// as the running component service, while its share mode still blocks write, +/// delete, and rename attempts. Mutation primitives reject this lease mode. +pub fn open_path_runtime_read_identity_lease( + path: &Path, + expected_kind: StableObjectKind, +) -> io::Result { + open_path_identity_lease_impl(path, expected_kind, PathIdentityLeaseMode::RuntimeRead) +} + +/// Restores the exact captured descriptor through the still-open identity +/// handle and verifies the byte-for-byte descriptor afterwards. +pub fn restore_identity_lease_security( + lease: &mut PathIdentityLease, + snapshot: &SecurityDescriptorSnapshot, +) -> io::Result<()> { + restore_identity_lease_security_impl(lease, snapshot) +} + +/// Captures the current full owner/group/DACL/SACL descriptor through the +/// retained identity handle without reopening the path. +pub fn current_identity_lease_security( + lease: &PathIdentityLease, +) -> io::Result { + current_identity_lease_security_impl(lease) +} + +/// Verifies through the retained handle that the leased object has the exact +/// reversible Administrators/SYSTEM-only cutover policy. +pub fn verify_identity_lease_admin_system_only(lease: &PathIdentityLease) -> io::Result<()> { + verify_identity_lease_admin_system_only_impl(lease) +} + +/// Verifies through the retained handle that a leased object has the exact +/// Administrators-owned, SYSTEM-full-control, Builtin-Users-read-only policy. +pub fn verify_identity_lease_admin_owned_user_read_only( + lease: &PathIdentityLease, +) -> io::Result<()> { + verify_identity_lease_admin_owned_user_read_only_impl(lease) +} + +/// Applies the reversible Administrators/SYSTEM-only policy through the leased +/// handle. Callers must retain `lease.security()` as rollback material. +pub fn harden_identity_lease_admin_system_only(lease: &mut PathIdentityLease) -> io::Result<()> { + harden_identity_lease_admin_system_only_impl(lease) +} + +/// Renames a leased object into an existing leased directory on the same +/// volume. No path-based copy/delete fallback is permitted. +pub fn rename_identity_lease_same_volume( + lease: &mut PathIdentityLease, + destination_parent: &PathIdentityLease, + destination_name: &std::ffi::OsStr, +) -> io::Result<()> { + rename_identity_lease_same_volume_impl(lease, destination_parent, destination_name) +} + +/// Deletes exactly the leased regular file or empty directory through its +/// stable handle. The lease is consumed so the delete-on-close disposition is +/// committed before this function returns. No recursive or path-based fallback +/// is permitted. +pub fn delete_identity_lease(lease: PathIdentityLease) -> io::Result<()> { + delete_identity_lease_impl(lease) +} + +fn require_mutation_identity_lease(lease: &PathIdentityLease) -> io::Result<()> { + if lease.mode == PathIdentityLeaseMode::Mutation { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "read-only runtime identity lease cannot perform filesystem mutations", + )) + } +} + +/// Verifies that an existing path is a strict descendant of the registered +/// 64-bit Program Files directory and that every descendant object in the path +/// is a non-reparse, installer-owned, read-only object. +pub fn verify_path_under_trusted_program_files(path: &Path) -> io::Result<()> { + verify_path_under_trusted_program_files_impl(path) +} + +/// Verifies a standard Program Files object without rewriting its installer +/// ACL. The owner must be Administrators, SYSTEM, or TrustedInstaller, and no +/// non-trusted allow ACE may grant write, delete, or DACL/owner mutation. +pub fn verify_path_installed_read_only(path: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(path)?; + verify_path_installed_acl(path) +} + +/// Creates a new file, immediately protects its ACL, writes all bytes, and +/// flushes the file before returning. Existing destinations are never opened. +pub fn write_new_restricted(path: &Path, contents: &[u8]) -> io::Result<()> { + prepare_parent(path)?; + let mut file = create_new_restricted_file(path)?; + let result = file.write_all(contents).and_then(|()| file.sync_all()); + drop(file); + + if let Err(error) = result { + let _ = fs::remove_file(path); + return Err(error); + } + + Ok(()) +} + +/// Copies a regular file into a newly-created, restricted destination. +pub fn copy_new_restricted(source: &Path, destination: &Path) -> io::Result<()> { + ensure_no_reparse_ancestors(source)?; + let mut source_file = File::open(source)?; + if !source_file.metadata()?.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "copy source is not a regular file", + )); + } + ensure_no_reparse_ancestors(source)?; + + prepare_parent(destination)?; + let mut destination_file = create_new_restricted_file(destination)?; + let result = + io::copy(&mut source_file, &mut destination_file).and_then(|_| destination_file.sync_all()); + drop(destination_file); + + if let Err(error) = result { + let _ = fs::remove_file(destination); + return Err(error); + } + + Ok(()) +} + +/// Atomically installs restricted contents without keeping a permanent backup. +pub fn write_restricted_atomic(path: &Path, contents: &[u8]) -> io::Result<()> { + write_atomic(path, contents, None, true) +} + +/// Atomically installs restricted contents and keeps the previous file as +/// `.bak` when a destination existed. +pub fn write_restricted_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> { + let backup = backup_path(path); + write_atomic(path, contents, Some(&backup), true) +} + +/// Opens an existing restricted file for a stable read while denying concurrent +/// writes, replacement, and deletion for the lifetime of the returned handle. +/// +/// The caller must provide an absolute path. On Windows the leaf is opened as a +/// reparse point, then the regular-file type and exact restricted owner/DACL are +/// verified through that same handle. Non-Windows platforms fail closed. +pub fn open_restricted_file_read_lease(path: &Path) -> io::Result { + open_restricted_file_read_lease_impl(path) +} + pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; + let backup = backup_path(path); + write_atomic(path, contents, Some(&backup), false) +} + +/// Atomically installs contents without creating or updating a backup file. +pub fn write_atomic_without_backup(path: &Path, contents: &[u8]) -> io::Result<()> { + write_atomic(path, contents, None, false) +} + +fn write_atomic( + path: &Path, + contents: &[u8], + backup: Option<&Path>, + restricted: bool, +) -> io::Result<()> { + prepare_parent(path)?; + ensure_no_reparse_ancestors(path)?; + + if restricted && path.try_exists()? { + protect_path_for_owner_admin_system(path)?; } let temp_path = temp_path(path); - fs::write(&temp_path, contents)?; + if restricted { + write_new_restricted(&temp_path, contents) + } else { + write_new_flushed(&temp_path, contents) + }?; - let backup_path = backup_path(path); - if path.exists() { - fs::copy(path, &backup_path)?; - fs::remove_file(path)?; + let install_result = atomic_install(&temp_path, path, backup); + if let Err(error) = install_result { + let _ = fs::remove_file(&temp_path); + return Err(error); } - match fs::rename(&temp_path, path) { - Ok(()) => Ok(()), - Err(error) => { - let _ = fs::remove_file(&temp_path); - if !path.exists() && backup_path.exists() { - let _ = fs::copy(&backup_path, path); + if restricted { + protect_path_for_owner_admin_system(path)?; + if let Some(backup) = backup { + if backup.try_exists()? { + protect_path_for_owner_admin_system(backup)?; } - Err(error) } } + + Ok(()) +} + +fn prepare_parent(path: &Path) -> io::Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + ensure_no_reparse_ancestors(parent)?; + fs::create_dir_all(parent)?; + ensure_no_reparse_ancestors(parent)?; + } + Ok(()) +} + +fn create_new_restricted_file(path: &Path) -> io::Result { + ensure_no_reparse_ancestors(path)?; + let file = create_new_file(path)?; + if let Err(error) = protect_path_for_owner_admin_system(path) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) +} + +fn write_new_flushed(path: &Path, contents: &[u8]) -> io::Result<()> { + let mut file = create_new_file(path)?; + let result = file.write_all(contents).and_then(|()| file.sync_all()); + drop(file); + if let Err(error) = result { + let _ = fs::remove_file(path); + return Err(error); + } + Ok(()) +} + +fn create_new_file(path: &Path) -> io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + +fn atomic_install(temp: &Path, destination: &Path, backup: Option<&Path>) -> io::Result<()> { + ensure_no_reparse_ancestors(temp)?; + ensure_no_reparse_ancestors(destination)?; + + if destination.try_exists()? { + if let Some(backup) = backup { + ensure_no_reparse_ancestors(backup)?; + match fs::remove_file(backup) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + replace_existing(temp, destination, backup) + } else { + fs::rename(temp, destination) + } } fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf { - let file_name = path + let mut file_name = path .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("proxywarden-file"); + .unwrap_or_else(|| std::ffi::OsStr::new("proxywarden-file")) + .to_os_string(); + file_name.push("."); + file_name.push(suffix); - path.with_file_name(format!("{file_name}.{suffix}")) + path.with_file_name(file_name) +} + +#[cfg(windows)] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(not(windows))] +fn is_reparse_point(metadata: &fs::Metadata) -> bool { + metadata.file_type().is_symlink() +} + +#[cfg(windows)] +const RESTRICTED_DIRECTORY_DACL_SDDL: &str = "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)"; +#[cfg(windows)] +const RESTRICTED_FILE_DACL_SDDL: &str = "D:P(A;;FA;;;OW)(A;;FA;;;BA)(A;;FA;;;SY)"; +#[cfg(windows)] +const ADMIN_OWNED_DIRECTORY_DACL_SDDL: &str = + "D:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;GRGX;;;BU)"; +#[cfg(windows)] +const ADMIN_OWNED_FILE_DACL_SDDL: &str = "D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)"; +#[cfg(windows)] +const ADMIN_SYSTEM_ONLY_FILE_SECURITY_SDDL: &str = "O:BAD:P(A;;FA;;;BA)(A;;FA;;;SY)"; +#[cfg(windows)] +const ADMIN_SYSTEM_ONLY_DIRECTORY_SECURITY_SDDL: &str = "O:BAD:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)"; +#[cfg(windows)] +const ADMIN_OWNED_USER_READ_ONLY_DIRECTORY_SECURITY_SDDL: &str = + "O:BAD:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;GRGX;;;BU)"; +#[cfg(windows)] +const ADMIN_OWNED_USER_READ_ONLY_FILE_SECURITY_SDDL: &str = + "O:BAD:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)"; + +#[cfg(windows)] +struct LocalSecurityDescriptor(windows_sys::Win32::Security::PSECURITY_DESCRIPTOR); + +#[cfg(windows)] +impl Drop for LocalSecurityDescriptor { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the descriptor was allocated by a matching Windows local-allocation API. + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.0); + } + } + } +} + +#[cfg(windows)] +struct OwnedHandle(windows_sys::Win32::Foundation::HANDLE); + +#[cfg(windows)] +impl Drop for OwnedHandle { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: this handle came from OpenProcessToken and is closed once here. + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.0); + } + } + } +} + +#[cfg(windows)] +struct TrustedOwnerSids { + token_user: Vec, + owner_rights: Vec, + administrators: Vec, + system: Vec, + builtin_users: Vec, + trusted_installer: LocalSecurityDescriptor, +} + +#[cfg(windows)] +impl TrustedOwnerSids { + fn contains(&self, owner: windows_sys::Win32::Security::PSID) -> bool { + use windows_sys::Win32::Security::{EqualSid, PSID, TOKEN_USER}; + + if owner.is_null() { + return false; + } + // SAFETY: each SID points into an aligned buffer owned by self for this comparison. + unsafe { + let current_user = (*(self.token_user.as_ptr().cast::())).User.Sid; + let administrators = self.administrators.as_ptr() as PSID; + let system = self.system.as_ptr() as PSID; + !current_user.is_null() + && (EqualSid(owner, current_user) != 0 + || EqualSid(owner, administrators) != 0 + || EqualSid(owner, system) != 0) + } + } + + fn expected_dacl_sid_bit(&self, sid: windows_sys::Win32::Security::PSID) -> Option { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return None; + } + // SAFETY: every compared SID points into a buffer owned by self or a validated ACE. + unsafe { + let owner_rights = self.owner_rights.as_ptr() as PSID; + let administrators = self.administrators.as_ptr() as PSID; + let system = self.system.as_ptr() as PSID; + if EqualSid(sid, owner_rights) != 0 { + Some(0b001) + } else if EqualSid(sid, administrators) != 0 { + Some(0b010) + } else if EqualSid(sid, system) != 0 { + Some(0b100) + } else { + None + } + } + } + + fn is_administrator_or_system(&self, sid: windows_sys::Win32::Security::PSID) -> bool { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return false; + } + // SAFETY: each compared SID points into a buffer owned by self. + unsafe { + EqualSid(sid, self.administrators.as_ptr() as PSID) != 0 + || EqualSid(sid, self.system.as_ptr() as PSID) != 0 + } + } + + fn is_trusted_install_owner(&self, sid: windows_sys::Win32::Security::PSID) -> bool { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return false; + } + // SAFETY: each compared SID points into a buffer owned by self. + unsafe { + self.is_administrator_or_system(sid) + || EqualSid(sid, self.trusted_installer.0 as PSID) != 0 + } + } + + fn is_trusted_install_writer(&self, sid: windows_sys::Win32::Security::PSID) -> bool { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return false; + } + // OWNER RIGHTS is safe here because the object owner was independently + // restricted to an installer-trusted principal. + unsafe { + self.is_trusted_install_owner(sid) + || EqualSid(sid, self.owner_rights.as_ptr() as PSID) != 0 + } + } + + fn administrators_sid(&self) -> windows_sys::Win32::Security::PSID { + self.administrators.as_ptr() as windows_sys::Win32::Security::PSID + } + + fn expected_admin_owned_sid_bit(&self, sid: windows_sys::Win32::Security::PSID) -> Option { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return None; + } + // SAFETY: every compared SID points into a buffer owned by self or a validated ACE. + unsafe { + if EqualSid(sid, self.administrators.as_ptr() as PSID) != 0 { + Some(0b001) + } else if EqualSid(sid, self.system.as_ptr() as PSID) != 0 { + Some(0b010) + } else if EqualSid(sid, self.builtin_users.as_ptr() as PSID) != 0 { + Some(0b100) + } else { + None + } + } + } + + fn expected_admin_system_only_sid_bit( + &self, + sid: windows_sys::Win32::Security::PSID, + ) -> Option { + use windows_sys::Win32::Security::{EqualSid, PSID}; + + if sid.is_null() { + return None; + } + // SAFETY: every compared SID points into a buffer owned by self or a validated ACE. + unsafe { + if EqualSid(sid, self.administrators.as_ptr() as PSID) != 0 { + Some(0b01) + } else if EqualSid(sid, self.system.as_ptr() as PSID) != 0 { + Some(0b10) + } else { + None + } + } + } +} + +#[cfg(windows)] +struct PathSecurityDescriptor { + descriptor: LocalSecurityDescriptor, + owner: windows_sys::Win32::Security::PSID, + dacl: *mut windows_sys::Win32::Security::ACL, +} + +#[cfg(windows)] +fn create_new_admin_system_only_file_impl(path: &Path) -> io::Result { + let file = create_new_file_with_security(path, ADMIN_SYSTEM_ONLY_FILE_SECURITY_SDDL)?; + if let Err(error) = verify_admin_system_only_file_handle(&file) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) +} + +#[cfg(not(windows))] +fn create_new_admin_system_only_file_impl(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only file creation is unavailable", + )) +} + +#[cfg(windows)] +fn verify_admin_system_only_file_impl(path: &Path) -> io::Result<()> { + ensure_absolute_regular_non_reparse_file(path)?; + let actual = read_path_security(path)?; + verify_admin_system_only_security(&actual) +} + +#[cfg(not(windows))] +fn verify_admin_system_only_file_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only file verification is unavailable", + )) +} + +#[cfg(windows)] +fn promote_admin_system_only_file_same_parent_impl( + staging: &Path, + destination: &Path, +) -> io::Result<()> { + ensure_same_canonical_parent(staging, destination)?; + let mut staging_file = open_existing_file_exclusive(staging)?; + verify_admin_system_only_file_handle(&staging_file)?; + + match fs::symlink_metadata(destination) { + Ok(_) => { + let destination_file = open_existing_file_exclusive(destination)?; + verify_admin_system_only_file_handle(&destination_file)?; + drop(destination_file); + drop(staging_file); + + replace_existing(staging, destination, None)?; + verify_admin_system_only_file_impl(destination) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let source_len = staging_file.metadata()?.len(); + let mut destination_file = create_new_admin_system_only_file_impl(destination)?; + let copy_result = io::copy(&mut staging_file, &mut destination_file) + .and_then(|copied| { + if copied == source_len { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "confidential staging copy length changed", + )) + } + }) + .and_then(|()| destination_file.sync_all()) + .and_then(|()| verify_admin_system_only_file_handle(&destination_file)); + drop(destination_file); + drop(staging_file); + + if let Err(error) = copy_result { + let _ = fs::remove_file(destination); + return Err(error); + } + if let Err(error) = verify_admin_system_only_file_impl(destination) { + let _ = fs::remove_file(destination); + return Err(error); + } + fs::remove_file(staging) + } + Err(error) => Err(error), + } +} + +#[cfg(not(windows))] +fn promote_admin_system_only_file_same_parent_impl( + _staging: &Path, + _destination: &Path, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only file promotion is unavailable", + )) +} + +#[cfg(windows)] +fn create_directory_admin_owned_user_read_only_impl(path: &Path) -> io::Result<()> { + use std::mem::size_of; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + ensure_absolute_path(path)?; + match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure_directory_metadata(&metadata, "admin-owned directory")?; + return verify_path_admin_owned_user_read_only(path); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + ensure_elevated_process("admin-owned directory creation")?; + ensure_existing_parent_without_reparse(path)?; + + let descriptor = + security_descriptor_from_sddl(ADMIN_OWNED_USER_READ_ONLY_DIRECTORY_SECURITY_SDDL)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated and attributes references a live descriptor. + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } == 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::AlreadyExists { + let metadata = fs::symlink_metadata(path)?; + ensure_directory_metadata(&metadata, "admin-owned directory")?; + return verify_path_admin_owned_user_read_only(path); + } + return Err(error); + } + + if let Err(error) = verify_path_admin_owned_user_read_only(path) { + let _ = fs::remove_dir(path); + return Err(error); + } + Ok(()) +} + +#[cfg(windows)] +fn create_new_admin_owned_user_read_only_directory_impl(path: &Path) -> io::Result<()> { + use std::mem::size_of; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + ensure_absolute_path(path)?; + match fs::symlink_metadata(path) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "candidate directory already exists", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + ensure_elevated_process("candidate directory creation")?; + ensure_existing_parent_without_reparse(path)?; + let descriptor = + security_descriptor_from_sddl(ADMIN_OWNED_USER_READ_ONLY_DIRECTORY_SECURITY_SDDL)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated and attributes references a live descriptor. + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } == 0 { + return Err(io::Error::last_os_error()); + } + if let Err(error) = verify_path_admin_owned_user_read_only(path) { + let _ = fs::remove_dir(path); + return Err(error); + } + Ok(()) +} + +#[cfg(not(windows))] +fn create_new_admin_owned_user_read_only_directory_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "fresh Administrators-owned directory creation is unavailable", + )) +} + +#[cfg(windows)] +fn create_directory_admin_system_only_impl(path: &Path) -> io::Result<()> { + use std::mem::size_of; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + ensure_absolute_path(path)?; + match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure_directory_metadata(&metadata, "sealed directory")?; + return verify_directory_admin_system_only_impl(path); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + ensure_elevated_process("sealed directory creation")?; + ensure_existing_parent_without_reparse(path)?; + + let descriptor = security_descriptor_from_sddl(ADMIN_SYSTEM_ONLY_DIRECTORY_SECURITY_SDDL)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated and attributes references a live descriptor. + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } == 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::AlreadyExists { + return verify_directory_admin_system_only_impl(path); + } + return Err(error); + } + + if let Err(error) = verify_directory_admin_system_only_impl(path) { + let _ = fs::remove_dir(path); + return Err(error); + } + Ok(()) +} + +#[cfg(windows)] +fn create_new_directory_admin_system_only_impl(path: &Path) -> io::Result<()> { + use std::mem::size_of; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + ensure_absolute_path(path)?; + match fs::symlink_metadata(path) { + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "sealed directory already exists", + )); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + ensure_elevated_process("sealed directory creation")?; + ensure_existing_parent_without_reparse(path)?; + + let descriptor = security_descriptor_from_sddl(ADMIN_SYSTEM_ONLY_DIRECTORY_SECURITY_SDDL)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated and attributes references a live descriptor. + if unsafe { CreateDirectoryW(path_wide.as_ptr(), &attributes) } == 0 { + return Err(io::Error::last_os_error()); + } + if let Err(error) = verify_directory_admin_system_only_impl(path) { + let _ = fs::remove_dir(path); + return Err(error); + } + Ok(()) +} + +#[cfg(not(windows))] +fn create_directory_admin_system_only_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only directory creation is unavailable", + )) +} + +#[cfg(not(windows))] +fn create_new_directory_admin_system_only_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "fresh Administrators/SYSTEM-only directory creation is unavailable", + )) +} + +#[cfg(windows)] +fn verify_directory_admin_system_only_impl(path: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + ensure_directory_metadata(&fs::symlink_metadata(path)?, "sealed directory")?; + let actual = read_path_security(path)?; + verify_admin_system_only_security_with_flags( + &actual, + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8, + ) +} + +#[cfg(not(windows))] +fn verify_directory_admin_system_only_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only directory verification is unavailable", + )) +} + +#[cfg(not(windows))] +fn create_directory_admin_owned_user_read_only_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "admin-owned Windows directory creation is unavailable", + )) +} + +#[cfg(windows)] +fn create_new_admin_owned_user_read_only_file_impl(path: &Path) -> io::Result { + let file = create_new_file_with_security(path, ADMIN_OWNED_USER_READ_ONLY_FILE_SECURITY_SDDL)?; + if let Err(error) = verify_admin_owned_user_read_only_file_handle(&file) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + Ok(file) +} + +#[cfg(not(windows))] +fn create_new_admin_owned_user_read_only_file_impl(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "admin-owned Windows file creation is unavailable", + )) +} + +#[cfg(windows)] +fn write_admin_owned_user_read_only_atomic_impl(path: &Path, contents: &[u8]) -> io::Result<()> { + ensure_absolute_path(path)?; + let parent = ensure_existing_parent_without_reparse(path)?; + verify_path_admin_owned_user_read_only(parent)?; + + let destination_exists = match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure_regular_non_reparse_metadata(&metadata, "admin-owned metadata file")?; + verify_path_admin_owned_user_read_only(path)?; + true + } + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => return Err(error), + }; + + let staging = temp_path(path); + let mut staging_file = create_new_admin_owned_user_read_only_file_impl(&staging)?; + let write_result = staging_file + .write_all(contents) + .and_then(|()| staging_file.sync_all()); + drop(staging_file); + if let Err(error) = write_result { + let _ = fs::remove_file(&staging); + return Err(error); + } + + let install_result = if destination_exists { + replace_existing(&staging, path, None) + } else { + fs::rename(&staging, path) + }; + if let Err(error) = install_result { + let _ = fs::remove_file(&staging); + return Err(error); + } + verify_path_admin_owned_user_read_only(path) +} + +#[cfg(windows)] +fn write_admin_system_only_atomic_impl(path: &Path, contents: &[u8]) -> io::Result<()> { + ensure_absolute_path(path)?; + let parent = ensure_existing_parent_without_reparse(path)?; + verify_directory_admin_system_only_impl(parent)?; + + let destination_exists = match fs::symlink_metadata(path) { + Ok(metadata) => { + ensure_regular_non_reparse_metadata(&metadata, "sealed journal file")?; + verify_admin_system_only_file_impl(path)?; + true + } + Err(error) if error.kind() == io::ErrorKind::NotFound => false, + Err(error) => return Err(error), + }; + + let staging = temp_path(path); + let mut staging_file = create_new_admin_system_only_file_impl(&staging)?; + let write_result = staging_file + .write_all(contents) + .and_then(|()| staging_file.sync_all()); + drop(staging_file); + if let Err(error) = write_result { + let _ = fs::remove_file(&staging); + return Err(error); + } + + let install_result = if destination_exists { + replace_existing(&staging, path, None) + } else { + fs::rename(&staging, path) + }; + if let Err(error) = install_result { + let _ = fs::remove_file(&staging); + return Err(error); + } + verify_admin_system_only_file_impl(path) +} + +#[cfg(not(windows))] +fn write_admin_system_only_atomic_impl(_path: &Path, _contents: &[u8]) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only atomic writes are unavailable", + )) +} + +#[cfg(not(windows))] +fn write_admin_owned_user_read_only_atomic_impl(_path: &Path, _contents: &[u8]) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "admin-owned Windows metadata writes are unavailable", + )) +} + +#[cfg(windows)] +fn open_admin_system_only_file_read_lease_impl(path: &Path) -> io::Result { + use std::os::windows::io::FromRawHandle; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + OPEN_EXISTING, + }; + + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let path_wide = path_to_wide(path)?; + // SAFETY: the path is NUL-terminated and File assumes sole ownership of a valid handle. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + GENERIC_READ, + FILE_SHARE_READ, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a unique owned handle. + let file = unsafe { File::from_raw_handle(handle) }; + verify_admin_system_only_file_handle(&file)?; + ensure_no_reparse_ancestors(path)?; + Ok(file) +} + +#[cfg(not(windows))] +fn open_admin_system_only_file_read_lease_impl(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Administrators/SYSTEM-only file leases are unavailable", + )) +} + +#[cfg(windows)] +fn verify_path_under_trusted_program_files_impl(path: &Path) -> io::Result<()> { + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let program_files = registered_program_files_dir()?; + ensure_no_reparse_ancestors(&program_files)?; + + let canonical_root = fs::canonicalize(program_files)?; + let canonical_path = fs::canonicalize(path)?; + if canonical_path == canonical_root || !canonical_path.starts_with(&canonical_root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "path is not a strict descendant of registered Program Files", + )); + } + + let mut current = canonical_path.as_path(); + loop { + verify_path_installed_read_only(current)?; + if current == canonical_root { + break; + } + current = current.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "trusted Program Files path escaped its registered root", + ) + })?; + if !current.starts_with(&canonical_root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "trusted Program Files path escaped its registered root", + )); + } + } + Ok(()) +} + +#[cfg(not(windows))] +fn verify_path_under_trusted_program_files_impl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "trusted Windows Program Files verification is unavailable", + )) +} + +#[cfg(windows)] +fn registered_program_files_dir() -> io::Result { + use winreg::{ + enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}, + RegKey, + }; + + let machine = RegKey::predef(HKEY_LOCAL_MACHINE); + let key = machine + .open_subkey_with_flags( + r"SOFTWARE\Microsoft\Windows\CurrentVersion", + KEY_READ | KEY_WOW64_64KEY, + ) + .map_err(|error| io::Error::new(error.kind(), "cannot read Program Files registry key"))?; + let value: String = key.get_value("ProgramFilesDir").map_err(|error| { + io::Error::new(error.kind(), "cannot read registered Program Files path") + })?; + let path = PathBuf::from(value); + ensure_absolute_path(&path)?; + Ok(path) +} + +#[cfg(windows)] +fn create_new_file_with_security(path: &Path, sddl: &str) -> io::Result { + use std::mem::size_of; + use std::os::windows::io::FromRawHandle; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, CREATE_NEW, DELETE, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, + }; + + ensure_absolute_path(path)?; + ensure_elevated_process("secured file creation")?; + ensure_existing_parent_without_reparse(path)?; + let descriptor = security_descriptor_from_sddl(sddl)?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: 0, + }; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated; attributes references a live descriptor; CREATE_NEW + // prevents opening an existing path, and File takes sole ownership of a successful handle. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE | DELETE, + 0, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a unique owned handle and it is transferred to File once. + Ok(unsafe { File::from_raw_handle(handle) }) +} + +#[cfg(windows)] +fn open_existing_file_exclusive(path: &Path) -> io::Result { + use std::os::windows::io::FromRawHandle; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, OPEN_EXISTING, + }; + + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated; null security/template pointers are permitted, and + // File takes sole ownership of a successful handle. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + GENERIC_READ, + 0, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a unique owned handle and it is transferred to File once. + Ok(unsafe { File::from_raw_handle(handle) }) +} + +#[cfg(windows)] +fn open_restricted_file_read_lease_impl(path: &Path) -> io::Result { + use std::os::windows::io::FromRawHandle; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + OPEN_EXISTING, + }; + + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated; null security/template pointers are permitted, and + // File takes sole ownership of a successful handle. FILE_SHARE_READ intentionally excludes + // write/delete sharing until the returned lease is dropped. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + GENERIC_READ, + FILE_SHARE_READ, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a unique owned handle and it is transferred to File once. + let file = unsafe { File::from_raw_handle(handle) }; + verify_restricted_file_handle(&file)?; + ensure_no_reparse_ancestors(path)?; + Ok(file) +} + +#[cfg(not(windows))] +fn open_restricted_file_read_lease_impl(_path: &Path) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "restricted Windows read leases are unavailable", + )) +} + +#[cfg(windows)] +struct SecurityPrivilegeGuard { + token: OwnedHandle, + previous: windows_sys::Win32::Security::TOKEN_PRIVILEGES, + restore_previous: bool, +} + +#[cfg(windows)] +impl SecurityPrivilegeGuard { + fn enable() -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::{GetLastError, SetLastError, ERROR_NOT_ALL_ASSIGNED}; + use windows_sys::Win32::Security::{ + AdjustTokenPrivileges, LookupPrivilegeValueW, LUID_AND_ATTRIBUTES, + SE_PRIVILEGE_ENABLED, TOKEN_ADJUST_PRIVILEGES, TOKEN_PRIVILEGES, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut raw_token = null_mut(); + // SAFETY: raw_token is a valid out pointer and the pseudo process handle is valid. + if unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, + &mut raw_token, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + let token = OwnedHandle(raw_token); + let mut luid = windows_sys::Win32::Foundation::LUID::default(); + let privilege: Vec = "SeSecurityPrivilege\0".encode_utf16().collect(); + // SAFETY: privilege is NUL-terminated and luid is a valid out pointer. + if unsafe { LookupPrivilegeValueW(null_mut(), privilege.as_ptr(), &mut luid) } == 0 { + return Err(io::Error::last_os_error()); + } + + let requested = TOKEN_PRIVILEGES { + PrivilegeCount: 1, + Privileges: [LUID_AND_ATTRIBUTES { + Luid: luid, + Attributes: SE_PRIVILEGE_ENABLED, + }], + }; + let mut previous = TOKEN_PRIVILEGES::default(); + let mut returned = 0; + // SAFETY: both TOKEN_PRIVILEGES records and the returned-size pointer are valid. + unsafe { SetLastError(0) }; + if unsafe { + AdjustTokenPrivileges( + token.0, + 0, + &requested, + size_of::() as u32, + &mut previous, + &mut returned, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetLastError is the documented way to detect an unassigned privilege. + if unsafe { GetLastError() } == ERROR_NOT_ALL_ASSIGNED { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "process token cannot enable SeSecurityPrivilege", + )); + } + + Ok(Self { + token, + previous, + restore_previous: returned != 0 && previous.PrivilegeCount != 0, + }) + } +} + +#[cfg(windows)] +impl Drop for SecurityPrivilegeGuard { + fn drop(&mut self) { + if !self.restore_previous { + return; + } + use windows_sys::Win32::Security::AdjustTokenPrivileges; + // SAFETY: token and previous remain valid for the lifetime of this guard. + unsafe { + AdjustTokenPrivileges( + self.token.0, + 0, + &self.previous, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ); + } + } +} + +#[cfg(windows)] +fn open_path_identity_lease_impl( + path: &Path, + expected_kind: StableObjectKind, + mode: PathIdentityLeaseMode, +) -> io::Result { + use std::os::windows::io::FromRawHandle; + use std::ptr::{null, null_mut}; + use windows_sys::Win32::Foundation::{GENERIC_READ, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, DELETE, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_READ, OPEN_EXISTING, + READ_CONTROL, WRITE_DAC, WRITE_OWNER, + }; + use windows_sys::Win32::System::SystemServices::MAXIMUM_ALLOWED; + + const ACCESS_SYSTEM_SECURITY: u32 = 0x0100_0000; + + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let _privilege = SecurityPrivilegeGuard::enable()?; + let flags = FILE_ATTRIBUTE_NORMAL + | FILE_FLAG_OPEN_REPARSE_POINT + | if expected_kind == StableObjectKind::Directory { + FILE_FLAG_BACKUP_SEMANTICS + } else { + 0 + }; + let read_contents = if expected_kind == StableObjectKind::RegularFile { + GENERIC_READ + } else { + 0 + }; + let path_wide = path_to_wide(path)?; + // MAXIMUM_ALLOWED is intentional for mutation leases in addition to the exact mandatory + // rights below: SetSecurityInfo suppresses directory ACE propagation for a handle opened + // with this bit, preserving the one-object-per-journal-operation cutover contract. Runtime + // leases deliberately request only read/query rights so normal component readers can share + // the object while the retained handle still denies write/delete sharing. + let desired_access = match mode { + PathIdentityLeaseMode::Mutation => { + read_contents + | MAXIMUM_ALLOWED + | FILE_READ_ATTRIBUTES + | READ_CONTROL + | WRITE_DAC + | WRITE_OWNER + | DELETE + | ACCESS_SYSTEM_SECURITY + } + PathIdentityLeaseMode::RuntimeRead => { + read_contents | FILE_READ_ATTRIBUTES | READ_CONTROL | ACCESS_SYSTEM_SECURITY + } + }; + // SAFETY: the path is NUL-terminated and File takes sole ownership of a valid handle. + let handle = unsafe { + CreateFileW( + path_wide.as_ptr(), + desired_access, + FILE_SHARE_READ, + null(), + OPEN_EXISTING, + flags, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: CreateFileW returned a unique owned handle. + let file = unsafe { File::from_raw_handle(handle) }; + let identity = stable_identity_from_handle(&file)?; + if identity.kind != expected_kind { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "leased filesystem object has an unexpected type", + )); + } + let security = full_security_snapshot_from_handle(&file)?; + ensure_no_reparse_ancestors(path)?; + Ok(PathIdentityLease { + file, + path: path.to_path_buf(), + identity, + security, + mode, + }) +} + +#[cfg(not(windows))] +fn open_path_identity_lease_impl( + _path: &Path, + _expected_kind: StableObjectKind, + _mode: PathIdentityLeaseMode, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "stable Windows filesystem leases are unavailable", + )) +} + +#[cfg(windows)] +fn stable_identity_from_handle(file: &File) -> io::Result { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, + FILE_ATTRIBUTE_REPARSE_POINT, + }; + + let mut information = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: file owns a valid handle and information is a valid writable record. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 { + return Err(io::Error::last_os_error()); + } + if information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "leased filesystem object is a reparse point", + )); + } + let kind = if information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 { + StableObjectKind::Directory + } else { + StableObjectKind::RegularFile + }; + Ok(StableObjectIdentity { + volume_serial_number: u64::from(information.dwVolumeSerialNumber), + file_id: (u64::from(information.nFileIndexHigh) << 32) + | u64::from(information.nFileIndexLow), + kind, + size: (u64::from(information.nFileSizeHigh) << 32) | u64::from(information.nFileSizeLow), + }) +} + +#[cfg(windows)] +fn full_security_snapshot_from_handle(file: &File) -> io::Result { + use std::os::windows::io::AsRawHandle; + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + GetSecurityDescriptorLength, GetSecurityDescriptorSacl, ACL, DACL_SECURITY_INFORMATION, + GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + SACL_SECURITY_INFORMATION, + }; + + let _privilege = SecurityPrivilegeGuard::enable()?; + let mut owner: PSID = null_mut(); + let mut group: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut sacl: *mut ACL = null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + // SAFETY: all output pointers are valid and the lease handle remains open. + let status = unsafe { + GetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | SACL_SECURITY_INFORMATION, + &mut owner, + &mut group, + &mut dacl, + &mut sacl, + &mut descriptor, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + if descriptor.is_null() || owner.is_null() || group.is_null() || dacl.is_null() { + if !descriptor.is_null() { + // SAFETY: GetSecurityInfo allocated descriptor with LocalAlloc. + unsafe { windows_sys::Win32::Foundation::LocalFree(descriptor) }; + } + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "filesystem security descriptor is incomplete", + )); + } + let descriptor = LocalSecurityDescriptor(descriptor); + // SAFETY: descriptor is valid and owned for this scope. + let length = unsafe { GetSecurityDescriptorLength(descriptor.0) } as usize; + if length == 0 || length > 256 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "filesystem security descriptor size is invalid", + )); + } + // SAFETY: GetSecurityDescriptorLength reports the readable self-relative extent. + let bytes = unsafe { std::slice::from_raw_parts(descriptor.0.cast::(), length) }.to_vec(); + let mut captured_sacl: *mut ACL = null_mut(); + let mut sacl_present = 0; + let mut sacl_defaulted = 0; + // SAFETY: descriptor and all output pointers remain valid for this call. + if unsafe { + GetSecurityDescriptorSacl( + descriptor.0, + &mut sacl_present, + &mut captured_sacl, + &mut sacl_defaulted, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(SecurityDescriptorSnapshot { + self_relative: bytes, + sacl: if sacl_present != 0 { + CapturedSacl::Present + } else { + CapturedSacl::Absent + }, + }) +} + +#[cfg(windows)] +fn restore_identity_lease_security_impl( + lease: &mut PathIdentityLease, + snapshot: &SecurityDescriptorSnapshot, +) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{SetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + GetSecurityDescriptorControl, GetSecurityDescriptorDacl, GetSecurityDescriptorGroup, + GetSecurityDescriptorOwner, GetSecurityDescriptorSacl, IsValidSecurityDescriptor, ACL, + DACL_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PROTECTED_SACL_SECURITY_INFORMATION, + SACL_SECURITY_INFORMATION, SE_DACL_PROTECTED, SE_SACL_PROTECTED, + UNPROTECTED_DACL_SECURITY_INFORMATION, UNPROTECTED_SACL_SECURITY_INFORMATION, + }; + + require_mutation_identity_lease(lease)?; + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem lease identity changed before security restore", + )); + } + if snapshot.self_relative.is_empty() || snapshot.self_relative.len() > 256 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security snapshot size is invalid", + )); + } + let descriptor = snapshot.self_relative.as_ptr() as *mut core::ffi::c_void; + // SAFETY: descriptor points to the complete captured byte buffer. + if unsafe { IsValidSecurityDescriptor(descriptor) } == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security snapshot is not a valid self-relative descriptor", + )); + } + let mut control = 0; + let mut revision = 0; + // SAFETY: descriptor is valid and both output pointers are writable. + if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 { + return Err(io::Error::last_os_error()); + } + let dacl_protection = if control & SE_DACL_PROTECTED != 0 { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + let sacl_protection = if control & SE_SACL_PROTECTED != 0 { + PROTECTED_SACL_SECURITY_INFORMATION + } else { + UNPROTECTED_SACL_SECURITY_INFORMATION + }; + let mut owner = null_mut(); + let mut owner_defaulted = 0; + let mut group = null_mut(); + let mut group_defaulted = 0; + let mut dacl: *mut ACL = null_mut(); + let mut dacl_present = 0; + let mut dacl_defaulted = 0; + let mut sacl: *mut ACL = null_mut(); + let mut sacl_present = 0; + let mut sacl_defaulted = 0; + // SAFETY: descriptor is valid and every output pointer remains writable. + if unsafe { + GetSecurityDescriptorOwner(descriptor, &mut owner, &mut owner_defaulted) == 0 + || GetSecurityDescriptorGroup(descriptor, &mut group, &mut group_defaulted) == 0 + || GetSecurityDescriptorDacl( + descriptor, + &mut dacl_present, + &mut dacl, + &mut dacl_defaulted, + ) == 0 + || GetSecurityDescriptorSacl( + descriptor, + &mut sacl_present, + &mut sacl, + &mut sacl_defaulted, + ) == 0 + } { + return Err(io::Error::last_os_error()); + } + if owner.is_null() + || group.is_null() + || dacl_present == 0 + || matches!(snapshot.sacl, CapturedSacl::Present) != (sacl_present != 0) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security snapshot components are inconsistent", + )); + } + let _privilege = SecurityPrivilegeGuard::enable()?; + // SAFETY: the lease handle and all descriptor components are valid for this call. The + // lease is opened with MAXIMUM_ALLOWED, which makes SetSecurityInfo suppress automatic + // propagation to child objects; every entry therefore remains a separate journal mutation. + let status = unsafe { + SetSecurityInfo( + lease.file.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | GROUP_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | SACL_SECURITY_INFORMATION + | dacl_protection + | sacl_protection, + owner, + group, + dacl, + sacl, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + let restored = full_security_snapshot_from_handle(&lease.file)?; + if restored != *snapshot { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "filesystem security descriptor did not restore exactly", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn current_identity_lease_security_impl( + lease: &PathIdentityLease, +) -> io::Result { + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem identity changed before security observation", + )); + } + full_security_snapshot_from_handle(&lease.file) +} + +#[cfg(not(windows))] +fn current_identity_lease_security_impl( + _lease: &PathIdentityLease, +) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows security observation is unavailable", + )) +} + +#[cfg(windows)] +fn verify_identity_lease_admin_system_only_impl(lease: &PathIdentityLease) -> io::Result<()> { + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem identity changed before security verification", + )); + } + let actual = read_handle_security(&lease.file)?; + let expected_flags = if lease.identity.kind == StableObjectKind::Directory { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8 + } else { + 0 + }; + verify_admin_system_only_security_with_flags(&actual, expected_flags) +} + +#[cfg(not(windows))] +fn verify_identity_lease_admin_system_only_impl(_lease: &PathIdentityLease) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows security verification is unavailable", + )) +} + +#[cfg(windows)] +fn verify_identity_lease_admin_owned_user_read_only_impl( + lease: &PathIdentityLease, +) -> io::Result<()> { + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem identity changed before final ACL verification", + )); + } + let actual = read_handle_security(&lease.file)?; + let expected_flags = if lease.identity.kind == StableObjectKind::Directory { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8 + } else { + 0 + }; + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.is_administrator_or_system(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "final component owner is not Administrators or SYSTEM", + )); + } + ensure_security_descriptor_has_protected_dacl(&actual, "final component")?; + if !dacl_has_exact_admin_owned_rules(actual.dacl, expected_flags, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "final component DACL is not exact", + )); + } + Ok(()) +} + +#[cfg(not(windows))] +fn verify_identity_lease_admin_owned_user_read_only_impl( + _lease: &PathIdentityLease, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows final component security verification is unavailable", + )) +} + +#[cfg(not(windows))] +fn restore_identity_lease_security_impl( + _lease: &mut PathIdentityLease, + _snapshot: &SecurityDescriptorSnapshot, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows security descriptor restore is unavailable", + )) +} + +#[cfg(windows)] +fn harden_identity_lease_admin_system_only_impl(lease: &mut PathIdentityLease) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{SetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + }; + + require_mutation_identity_lease(lease)?; + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem lease identity changed before ACL hardening", + )); + } + let sddl = match lease.identity.kind { + StableObjectKind::RegularFile => ADMIN_SYSTEM_ONLY_FILE_SECURITY_SDDL, + StableObjectKind::Directory => ADMIN_SYSTEM_ONLY_DIRECTORY_SECURITY_SDDL, + }; + let descriptor = security_descriptor_from_sddl(sddl)?; + let owner = trusted_owner_sids()?.administrators_sid(); + let dacl = descriptor_dacl(&descriptor)?; + // SAFETY: the lease, owner SID, and DACL are valid for the duration of this call. + let status = unsafe { + SetSecurityInfo( + lease.file.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + owner, + null_mut(), + dacl, + null_mut(), + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + let actual = read_handle_security(&lease.file)?; + let expected_flags = if lease.identity.kind == StableObjectKind::Directory { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8 + } else { + 0 + }; + verify_admin_system_only_security_with_flags(&actual, expected_flags) +} + +#[cfg(not(windows))] +fn harden_identity_lease_admin_system_only_impl(_lease: &mut PathIdentityLease) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows ACL hardening is unavailable", + )) +} + +#[cfg(windows)] +fn rename_identity_lease_same_volume_impl( + lease: &mut PathIdentityLease, + destination_parent: &PathIdentityLease, + destination_name: &std::ffi::OsStr, +) -> io::Result<()> { + use std::mem::{offset_of, size_of}; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileRenameInfoEx, SetFileInformationByHandle, FILE_RENAME_INFO, + }; + + require_mutation_identity_lease(lease)?; + require_mutation_identity_lease(destination_parent)?; + if destination_parent.identity.kind != StableObjectKind::Directory + || lease.identity.volume_serial_number != destination_parent.identity.volume_serial_number + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "handle-based rename requires a leased directory on the same volume", + )); + } + let leaf = Path::new(destination_name); + if leaf.components().count() != 1 + || !matches!( + leaf.components().next(), + Some(std::path::Component::Normal(_)) + ) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "rename destination must be one normal path component", + )); + } + let destination = destination_parent.path.join(destination_name); + ensure_no_reparse_ancestors(&destination)?; + if destination.try_exists()? { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "rename destination already exists", + )); + } + if stable_identity_from_handle(&lease.file)? != lease.identity + || stable_identity_from_handle(&destination_parent.file)? != destination_parent.identity + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem identity changed before handle-based rename", + )); + } + + let name: Vec = destination_name.encode_wide().collect(); + if name.is_empty() || name.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "rename destination name is invalid", + )); + } + let name_bytes = name + .len() + .checked_mul(size_of::()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename name is too long"))?; + let total = offset_of!(FILE_RENAME_INFO, FileName) + .checked_add(name_bytes) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename buffer is too large"))?; + let mut storage = vec![0usize; total.div_ceil(size_of::())]; + let info = storage.as_mut_ptr().cast::(); + // SAFETY: storage is aligned and large enough for the header plus name bytes. + unsafe { + (*info).Anonymous.Flags = 0; + (*info).RootDirectory = destination_parent.file.as_raw_handle(); + (*info).FileNameLength = name_bytes as u32; + std::ptr::copy_nonoverlapping( + name.as_ptr(), + std::ptr::addr_of_mut!((*info).FileName).cast::(), + name.len(), + ); + } + // SAFETY: info points to an initialized variable-length FILE_RENAME_INFO buffer. + if unsafe { + SetFileInformationByHandle( + lease.file.as_raw_handle(), + FileRenameInfoEx, + info.cast(), + total as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "filesystem identity changed during handle-based rename", + )); + } + lease.path = destination; + Ok(()) +} + +#[cfg(windows)] +fn delete_identity_lease_impl(lease: PathIdentityLease) -> io::Result<()> { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfoEx, SetFileInformationByHandle, FILE_DISPOSITION_FLAG_DELETE, + FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, FILE_DISPOSITION_FLAG_POSIX_SEMANTICS, + FILE_DISPOSITION_INFO_EX, + }; + + require_mutation_identity_lease(&lease)?; + if stable_identity_from_handle(&lease.file)? != lease.identity { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "filesystem identity changed before handle-based delete", + )); + } + let disposition = FILE_DISPOSITION_INFO_EX { + Flags: FILE_DISPOSITION_FLAG_DELETE + | FILE_DISPOSITION_FLAG_POSIX_SEMANTICS + | FILE_DISPOSITION_FLAG_IGNORE_READONLY_ATTRIBUTE, + }; + // SAFETY: the lease owns a handle opened with DELETE access and disposition + // points to a complete FILE_DISPOSITION_INFO_EX record for this call. + if unsafe { + SetFileInformationByHandle( + lease.file.as_raw_handle(), + FileDispositionInfoEx, + (&disposition as *const FILE_DISPOSITION_INFO_EX).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + drop(lease); + Ok(()) +} + +#[cfg(not(windows))] +fn delete_identity_lease_impl(_lease: PathIdentityLease) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "handle-based Windows delete is unavailable", + )) +} + +#[cfg(not(windows))] +fn rename_identity_lease_same_volume_impl( + _lease: &mut PathIdentityLease, + _destination_parent: &PathIdentityLease, + _destination_name: &std::ffi::OsStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "handle-based Windows rename is unavailable", + )) +} + +#[cfg(windows)] +fn ensure_elevated_process(action: &str) -> io::Result<()> { + if current_process_is_elevated()? { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{action} requires an elevated process"), + )) + } +} + +#[cfg(windows)] +fn ensure_absolute_path(path: &Path) -> io::Result<()> { + if path.is_absolute() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "secured filesystem path must be absolute", + )) + } +} + +#[cfg(windows)] +fn ensure_existing_parent_without_reparse(path: &Path) -> io::Result<&Path> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "secured filesystem path has no parent", + ) + })?; + ensure_no_reparse_ancestors(parent)?; + let metadata = fs::symlink_metadata(parent)?; + ensure_directory_metadata(&metadata, "secured filesystem parent")?; + Ok(parent) +} + +#[cfg(windows)] +fn ensure_same_canonical_parent(first: &Path, second: &Path) -> io::Result<()> { + ensure_absolute_path(first)?; + ensure_absolute_path(second)?; + if first == second { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "staging and destination paths must differ", + )); + } + let first_parent = ensure_existing_parent_without_reparse(first)?; + let second_parent = ensure_existing_parent_without_reparse(second)?; + if fs::canonicalize(first_parent)? != fs::canonicalize(second_parent)? { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "staging and destination must share one canonical parent", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn ensure_absolute_regular_non_reparse_file(path: &Path) -> io::Result<()> { + ensure_absolute_path(path)?; + ensure_no_reparse_ancestors(path)?; + let metadata = fs::symlink_metadata(path)?; + ensure_regular_non_reparse_metadata(&metadata, "confidential runtime config") +} + +#[cfg(windows)] +fn ensure_regular_non_reparse_metadata(metadata: &fs::Metadata, label: &str) -> io::Result<()> { + if metadata.file_type().is_file() && !is_reparse_point(metadata) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{label} is not a regular non-reparse file"), + )) + } +} + +#[cfg(windows)] +fn ensure_directory_metadata(metadata: &fs::Metadata, label: &str) -> io::Result<()> { + if metadata.file_type().is_dir() && !is_reparse_point(metadata) { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{label} is not a regular non-reparse directory"), + )) + } +} + +#[cfg(windows)] +fn protect_path(path: &Path) -> io::Result<()> { + let current = read_path_security(path)?; + if !trusted_owner_sids()?.contains(current.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "restricted path has an untrusted owner", + )); + } + + let (expected_sddl, _) = restricted_dacl_policy(path)?; + let expected = security_descriptor_from_sddl(expected_sddl)?; + let expected_dacl = descriptor_dacl(&expected)?; + set_path_dacl(path, expected_dacl, true)?; + verify_path_protected_for_owner_admin_system(path) +} + +#[cfg(not(windows))] +fn protect_path(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(windows)] +fn protect_path_admin_owned(path: &Path) -> io::Result<()> { + if !current_process_is_elevated()? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path protection requires an elevated process", + )); + } + + let current = read_path_security(path)?; + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.contains(current.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path has an untrusted current owner", + )); + } + + let (expected_sddl, _) = admin_owned_dacl_policy(path)?; + let expected = security_descriptor_from_sddl(expected_sddl)?; + let expected_dacl = descriptor_dacl(&expected)?; + set_path_owner_and_dacl(path, trusted_sids.administrators_sid(), expected_dacl)?; + verify_path_admin_owned_user_read_only(path) +} + +#[cfg(not(windows))] +fn protect_path_admin_owned(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "admin-owned Windows DACL protection is unavailable", + )) +} + +#[cfg(windows)] +fn verify_path_protection(path: &Path) -> io::Result<()> { + let actual = read_path_security(path)?; + let (_, expected_flags) = restricted_dacl_policy(path)?; + verify_restricted_security(&actual, expected_flags) +} + +#[cfg(windows)] +fn verify_path_admin_owned(path: &Path) -> io::Result<()> { + use windows_sys::Win32::Security::{GetSecurityDescriptorControl, SE_DACL_PROTECTED}; + + let actual = read_path_security(path)?; + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.is_administrator_or_system(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path owner is not Administrators or SYSTEM", + )); + } + if actual.dacl.is_null() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path has no DACL", + )); + } + + let mut control = 0; + let mut revision = 0; + // SAFETY: actual owns the security descriptor for this entire check. + if unsafe { GetSecurityDescriptorControl(actual.descriptor.0, &mut control, &mut revision) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if control & SE_DACL_PROTECTED == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path DACL is not protected", + )); + } + + let (_, expected_flags) = admin_owned_dacl_policy(path)?; + if !dacl_has_exact_admin_owned_rules(actual.dacl, expected_flags, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned path DACL contains unexpected access rules", + )); + } + + Ok(()) +} + +#[cfg(windows)] +fn verify_path_installed_acl(path: &Path) -> io::Result<()> { + let actual = read_path_security(path)?; + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.is_trusted_install_owner(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "installed path owner is not Administrators, SYSTEM, or TrustedInstaller", + )); + } + if actual.dacl.is_null() || !dacl_has_no_untrusted_writers(actual.dacl, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "installed path grants mutation rights to a non-installer principal", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn restricted_dacl_policy(path: &Path) -> io::Result<(&'static str, u8)> { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_dir() { + Ok(( + RESTRICTED_DIRECTORY_DACL_SDDL, + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8, + )) + } else if metadata.file_type().is_file() { + Ok((RESTRICTED_FILE_DACL_SDDL, 0)) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "restricted path is not a regular file or directory", + )) + } +} + +#[cfg(windows)] +fn admin_owned_dacl_policy(path: &Path) -> io::Result<(&'static str, u8)> { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_dir() { + Ok(( + ADMIN_OWNED_DIRECTORY_DACL_SDDL, + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8, + )) + } else if metadata.file_type().is_file() { + Ok((ADMIN_OWNED_FILE_DACL_SDDL, 0)) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidInput, + "admin-owned path is not a regular file or directory", + )) + } +} + +#[cfg(not(windows))] +fn verify_path_protection(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "protected Windows DACL verification is unavailable", + )) +} + +#[cfg(not(windows))] +fn verify_path_admin_owned(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "admin-owned Windows DACL verification is unavailable", + )) +} + +#[cfg(not(windows))] +fn verify_path_installed_acl(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "installed Windows ACL verification is unavailable", + )) +} + +#[cfg(windows)] +fn current_process_is_elevated() -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Security::{ + GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token_handle: HANDLE = null_mut(); + // SAFETY: GetCurrentProcess returns a pseudo-handle and token_handle is a valid out pointer. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) } == 0 { + return Err(io::Error::last_os_error()); + } + let token_handle = OwnedHandle(token_handle); + let mut elevation = TOKEN_ELEVATION::default(); + let mut returned = 0; + // SAFETY: elevation is a correctly-sized output buffer and the token is valid here. + if unsafe { + GetTokenInformation( + token_handle.0, + TokenElevation, + (&mut elevation as *mut TOKEN_ELEVATION).cast(), + size_of::() as u32, + &mut returned, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if returned != size_of::() as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process token returned an invalid elevation record", + )); + } + Ok(elevation.TokenIsElevated != 0) +} + +#[cfg(windows)] +fn trusted_owner_sids() -> io::Result { + use std::mem::size_of; + use std::ptr::null_mut; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Security::{ + CreateWellKnownSid, GetTokenInformation, TokenUser, WinBuiltinAdministratorsSid, + WinBuiltinUsersSid, WinLocalSystemSid, SECURITY_MAX_SID_SIZE, TOKEN_QUERY, TOKEN_USER, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token_handle: HANDLE = null_mut(); + // SAFETY: GetCurrentProcess returns a pseudo-handle and token_handle is a valid out pointer. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle) } == 0 { + return Err(io::Error::last_os_error()); + } + let token_handle = OwnedHandle(token_handle); + + let mut token_bytes = 0; + // SAFETY: the first call intentionally supplies no buffer to obtain its required size. + unsafe { + GetTokenInformation(token_handle.0, TokenUser, null_mut(), 0, &mut token_bytes); + } + if token_bytes < size_of::() as u32 { + return Err(io::Error::last_os_error()); + } + let word_size = size_of::(); + let mut token_user = vec![0usize; (token_bytes as usize).div_ceil(word_size)]; + // SAFETY: token_user is aligned and has at least token_bytes writable bytes. + if unsafe { + GetTokenInformation( + token_handle.0, + TokenUser, + token_user.as_mut_ptr().cast(), + token_bytes, + &mut token_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: GetTokenInformation populated TOKEN_USER at the aligned buffer start. + if unsafe { (*(token_user.as_ptr().cast::())).User.Sid }.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "current process token has no user SID", + )); + } + + let sid_words = (SECURITY_MAX_SID_SIZE as usize).div_ceil(word_size); + let mut owner_rights = vec![0usize; sid_words]; + let mut owner_rights_bytes = SECURITY_MAX_SID_SIZE; + // SAFETY: the destination is aligned and sized to SECURITY_MAX_SID_SIZE. + if unsafe { + CreateWellKnownSid( + windows_sys::Win32::Security::WinCreatorOwnerRightsSid, + null_mut(), + owner_rights.as_mut_ptr().cast(), + &mut owner_rights_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let mut administrators = vec![0usize; sid_words]; + let mut administrators_bytes = SECURITY_MAX_SID_SIZE; + // SAFETY: the destination is aligned and sized to SECURITY_MAX_SID_SIZE. + if unsafe { + CreateWellKnownSid( + WinBuiltinAdministratorsSid, + null_mut(), + administrators.as_mut_ptr().cast(), + &mut administrators_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let mut system = vec![0usize; sid_words]; + let mut system_bytes = SECURITY_MAX_SID_SIZE; + // SAFETY: the destination is aligned and sized to SECURITY_MAX_SID_SIZE. + if unsafe { + CreateWellKnownSid( + WinLocalSystemSid, + null_mut(), + system.as_mut_ptr().cast(), + &mut system_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let mut builtin_users = vec![0usize; sid_words]; + let mut builtin_users_bytes = SECURITY_MAX_SID_SIZE; + // SAFETY: the destination is aligned and sized to SECURITY_MAX_SID_SIZE. + if unsafe { + CreateWellKnownSid( + WinBuiltinUsersSid, + null_mut(), + builtin_users.as_mut_ptr().cast(), + &mut builtin_users_bytes, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + + let mut trusted_installer_sid = std::ptr::null_mut(); + let mut trusted_installer_text: Vec = + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464" + .encode_utf16() + .collect(); + trusted_installer_text.push(0); + // SAFETY: the SID string is NUL-terminated and the output pointer is valid. + if unsafe { + windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW( + trusted_installer_text.as_ptr(), + &mut trusted_installer_sid, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if trusted_installer_sid.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "TrustedInstaller SID conversion returned no SID", + )); + } + + Ok(TrustedOwnerSids { + token_user, + owner_rights, + administrators, + system, + builtin_users, + trusted_installer: LocalSecurityDescriptor(trusted_installer_sid), + }) +} + +#[cfg(windows)] +fn read_path_security(path: &Path) -> io::Result { + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + }; + + let path_wide = path_to_wide(path)?; + let mut owner: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + // SAFETY: every output pointer is valid and path_wide is NUL-terminated. + let status = unsafe { + GetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut descriptor, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + if descriptor.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "path security descriptor is unavailable", + )); + } + + Ok(PathSecurityDescriptor { + descriptor: LocalSecurityDescriptor(descriptor), + owner, + dacl, + }) +} + +#[cfg(windows)] +fn read_handle_security(file: &File) -> io::Result { + use std::os::windows::io::AsRawHandle; + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + ACL, DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, + }; + + let mut owner: PSID = null_mut(); + let mut dacl: *mut ACL = null_mut(); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + // SAFETY: the file owns a valid handle and every requested output pointer is valid. + let status = unsafe { + GetSecurityInfo( + file.as_raw_handle(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + &mut dacl, + null_mut(), + &mut descriptor, + ) + }; + if status != 0 { + return Err(io::Error::from_raw_os_error(status as i32)); + } + if descriptor.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "file handle security descriptor is unavailable", + )); + } + Ok(PathSecurityDescriptor { + descriptor: LocalSecurityDescriptor(descriptor), + owner, + dacl, + }) +} + +#[cfg(windows)] +fn verify_admin_system_only_file_handle(file: &File) -> io::Result<()> { + let metadata = file.metadata()?; + ensure_regular_non_reparse_metadata(&metadata, "confidential runtime config")?; + let actual = read_handle_security(file)?; + verify_admin_system_only_security(&actual) +} + +#[cfg(windows)] +fn verify_restricted_file_handle(file: &File) -> io::Result<()> { + let metadata = file.metadata()?; + ensure_regular_non_reparse_metadata(&metadata, "restricted read lease")?; + let actual = read_handle_security(file)?; + verify_restricted_security(&actual, 0) +} + +#[cfg(windows)] +fn verify_restricted_security( + actual: &PathSecurityDescriptor, + expected_flags: u8, +) -> io::Result<()> { + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.contains(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "restricted path has an untrusted owner", + )); + } + ensure_security_descriptor_has_protected_dacl(actual, "restricted path")?; + if !dacl_has_exact_rules(actual.dacl, expected_flags, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "restricted path DACL contains unexpected access rules", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn verify_admin_owned_user_read_only_file_handle(file: &File) -> io::Result<()> { + let metadata = file.metadata()?; + ensure_regular_non_reparse_metadata(&metadata, "admin-owned metadata file")?; + let actual = read_handle_security(file)?; + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.is_administrator_or_system(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned metadata owner is not Administrators or SYSTEM", + )); + } + ensure_security_descriptor_has_protected_dacl(&actual, "admin-owned metadata")?; + if !dacl_has_exact_admin_owned_rules(actual.dacl, 0, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "admin-owned metadata DACL is not exact", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn verify_admin_system_only_security(actual: &PathSecurityDescriptor) -> io::Result<()> { + verify_admin_system_only_security_with_flags(actual, 0) +} + +#[cfg(windows)] +fn verify_admin_system_only_security_with_flags( + actual: &PathSecurityDescriptor, + expected_flags: u8, +) -> io::Result<()> { + let trusted_sids = trusted_owner_sids()?; + if !trusted_sids.is_administrator_or_system(actual.owner) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "confidential file owner is not Administrators or SYSTEM", + )); + } + ensure_security_descriptor_has_protected_dacl(actual, "confidential file")?; + if !dacl_has_exact_admin_system_only_rules(actual.dacl, expected_flags, &trusted_sids)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "confidential file DACL is not exact Administrators/SYSTEM-only", + )); + } + Ok(()) +} + +#[cfg(windows)] +fn ensure_security_descriptor_has_protected_dacl( + actual: &PathSecurityDescriptor, + label: &str, +) -> io::Result<()> { + use windows_sys::Win32::Security::{GetSecurityDescriptorControl, SE_DACL_PROTECTED}; + + if actual.dacl.is_null() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{label} has no DACL"), + )); + } + let mut control = 0; + let mut revision = 0; + // SAFETY: actual owns the security descriptor for this entire check. + if unsafe { GetSecurityDescriptorControl(actual.descriptor.0, &mut control, &mut revision) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if control & SE_DACL_PROTECTED == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{label} DACL is not protected"), + )); + } + Ok(()) +} + +#[cfg(windows)] +fn security_descriptor_from_sddl(sddl: &str) -> io::Result { + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; + + let mut sddl_wide: Vec = sddl.encode_utf16().collect(); + sddl_wide.push(0); + let mut descriptor: PSECURITY_DESCRIPTOR = null_mut(); + // SAFETY: sddl_wide is NUL-terminated and descriptor is a valid out pointer. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl_wide.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if descriptor.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security descriptor conversion returned no descriptor", + )); + } + Ok(LocalSecurityDescriptor(descriptor)) +} + +#[cfg(windows)] +fn descriptor_dacl( + descriptor: &LocalSecurityDescriptor, +) -> io::Result<*mut windows_sys::Win32::Security::ACL> { + use std::ptr::null_mut; + use windows_sys::Win32::Security::{GetSecurityDescriptorDacl, ACL}; + + let mut present = 0; + let mut defaulted = 0; + let mut dacl: *mut ACL = null_mut(); + // SAFETY: descriptor is valid for this scope and every output pointer is valid. + if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if present == 0 || dacl.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security descriptor has no DACL", + )); + } + Ok(dacl) +} + +#[cfg(windows)] +fn set_path_dacl( + path: &Path, + dacl: *mut windows_sys::Win32::Security::ACL, + protected: bool, +) -> io::Result<()> { + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{SetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + UNPROTECTED_DACL_SECURITY_INFORMATION, + }; + + let path_wide = path_to_wide(path)?; + let protection = if protected { + PROTECTED_DACL_SECURITY_INFORMATION + } else { + UNPROTECTED_DACL_SECURITY_INFORMATION + }; + // SAFETY: path_wide is NUL-terminated and dacl remains owned by its descriptor. + let status = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | protection, + null_mut(), + null_mut(), + dacl, + null_mut(), + ) + }; + if status == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } +} + +#[cfg(windows)] +fn set_path_owner_and_dacl( + path: &Path, + owner: windows_sys::Win32::Security::PSID, + dacl: *mut windows_sys::Win32::Security::ACL, +) -> io::Result<()> { + use std::ptr::null_mut; + use windows_sys::Win32::Security::Authorization::{SetNamedSecurityInfoW, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + }; + + if owner.is_null() || dacl.is_null() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "admin-owned path security descriptor is incomplete", + )); + } + let path_wide = path_to_wide(path)?; + // SAFETY: path_wide is NUL-terminated; owner and dacl remain backed by live SID/descriptor + // buffers for the duration of this call. + let status = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION, + owner, + null_mut(), + dacl, + null_mut(), + ) + }; + if status == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } +} + +#[cfg(windows)] +fn dacl_has_exact_rules( + acl: *mut windows_sys::Win32::Security::ACL, + expected_flags: u8, + trusted_sids: &TrustedOwnerSids, +) -> io::Result { + dacl_has_exact_rule_set( + acl, + expected_flags, + trusted_sids, + ExactDaclRuleSet::OwnerAdminSystemFull, + ) +} + +#[cfg(windows)] +fn dacl_has_exact_admin_owned_rules( + acl: *mut windows_sys::Win32::Security::ACL, + expected_flags: u8, + trusted_sids: &TrustedOwnerSids, +) -> io::Result { + dacl_has_exact_rule_set( + acl, + expected_flags, + trusted_sids, + ExactDaclRuleSet::AdminSystemFullUsersReadExecute, + ) +} + +#[cfg(windows)] +fn dacl_has_exact_admin_system_only_rules( + acl: *mut windows_sys::Win32::Security::ACL, + expected_flags: u8, + trusted_sids: &TrustedOwnerSids, +) -> io::Result { + dacl_has_exact_rule_set( + acl, + expected_flags, + trusted_sids, + ExactDaclRuleSet::AdminSystemFullOnly, + ) +} + +#[cfg(windows)] +#[derive(Clone, Copy)] +enum ExactDaclRuleSet { + OwnerAdminSystemFull, + AdminSystemFullUsersReadExecute, + AdminSystemFullOnly, +} + +#[cfg(windows)] +fn dacl_has_exact_rule_set( + acl: *mut windows_sys::Win32::Security::ACL, + expected_flags: u8, + trusted_sids: &TrustedOwnerSids, + rule_set: ExactDaclRuleSet, +) -> io::Result { + use std::mem::size_of; + use windows_sys::Win32::Foundation::{GENERIC_EXECUTE, GENERIC_READ}; + use windows_sys::Win32::Security::{ + GetAce, GetLengthSid, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, PSID, + }; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + let info = acl_size_information(acl)?; + let expected_ace_count = match rule_set { + ExactDaclRuleSet::AdminSystemFullOnly => 2, + ExactDaclRuleSet::OwnerAdminSystemFull + | ExactDaclRuleSet::AdminSystemFullUsersReadExecute => 3, + }; + if info.AceCount != expected_ace_count { + return Ok(false); + } + + let acl_start = acl as usize; + let Some(acl_end) = acl_start.checked_add(info.AclBytesInUse as usize) else { + return Ok(false); + }; + const SID_OFFSET: usize = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + let mut seen_sids = 0_u8; + for index in 0..info.AceCount { + let mut raw_ace = std::ptr::null_mut(); + // SAFETY: acl is valid, index is below AceCount, and raw_ace is a valid out pointer. + if unsafe { GetAce(acl, index, &mut raw_ace) } == 0 || raw_ace.is_null() { + return Err(io::Error::last_os_error()); + } + let ace_start = raw_ace as usize; + let Some(header_end) = ace_start.checked_add(size_of::()) else { + return Ok(false); + }; + if ace_start < acl_start || header_end > acl_end { + return Ok(false); + } + // SAFETY: the header bounds were checked against the live ACL allocation above. + let header = unsafe { std::ptr::read_unaligned(raw_ace.cast::()) }; + let ace_size = usize::from(header.AceSize); + let Some(ace_end) = ace_start.checked_add(ace_size) else { + return Ok(false); + }; + if header.AceType != ACCESS_ALLOWED_ACE_TYPE + || ace_size < SID_OFFSET + 8 + || ace_end > acl_end + { + return Ok(false); + } + // SAFETY: ACCESS_ALLOWED_ACE's fixed fields end before SID_OFFSET, whose complete + // minimum SID and enclosing ACE bounds were validated above. + let ace = unsafe { std::ptr::read_unaligned(raw_ace.cast::()) }; + if ace.Header.AceFlags != expected_flags { + return Ok(false); + } + + // SAFETY: SID_OFFSET is inside the already-validated ACE bounds. + let sid: PSID = unsafe { raw_ace.cast::().add(SID_OFFSET).cast() }; + let sid_available = ace_size - SID_OFFSET; + // SAFETY: GetAce and IsValidAcl guarantee that the complete ACE lies inside the ACL. + let sid_bytes = unsafe { std::slice::from_raw_parts(sid.cast::(), sid_available) }; + let encoded_sid_len = 8 + usize::from(sid_bytes[1]) * size_of::(); + if encoded_sid_len != sid_available { + return Ok(false); + } + // SAFETY: sid points to the variable-length SID stored inside this validated ACL. + if unsafe { IsValidSid(sid) } == 0 { + return Ok(false); + } + // SAFETY: IsValidSid accepted sid, so GetLengthSid can inspect it. + let sid_len = unsafe { GetLengthSid(sid) } as usize; + if sid_len != encoded_sid_len { + return Ok(false); + } + + let expected = match rule_set { + ExactDaclRuleSet::OwnerAdminSystemFull => trusted_sids + .expected_dacl_sid_bit(sid) + .map(|bit| (bit, FILE_ALL_ACCESS)), + ExactDaclRuleSet::AdminSystemFullUsersReadExecute => { + trusted_sids.expected_admin_owned_sid_bit(sid).map(|bit| { + let mask = if bit == 0b100 { + GENERIC_READ | GENERIC_EXECUTE + } else { + FILE_ALL_ACCESS + }; + (bit, mask) + }) + } + ExactDaclRuleSet::AdminSystemFullOnly => trusted_sids + .expected_admin_system_only_sid_bit(sid) + .map(|bit| (bit, FILE_ALL_ACCESS)), + }; + let Some((bit, expected_mask)) = expected else { + return Ok(false); + }; + let users_read_execute_matches = + matches!(rule_set, ExactDaclRuleSet::AdminSystemFullUsersReadExecute) + && bit == 0b100 + && (ace.Mask == expected_mask + || ace.Mask == FILE_GENERIC_READ | FILE_GENERIC_EXECUTE); + if ace.Mask != expected_mask && !users_read_execute_matches { + return Ok(false); + } + if seen_sids & bit != 0 { + return Ok(false); + } + seen_sids |= bit; + } + + let expected_sids = match rule_set { + ExactDaclRuleSet::AdminSystemFullOnly => 0b11, + ExactDaclRuleSet::OwnerAdminSystemFull + | ExactDaclRuleSet::AdminSystemFullUsersReadExecute => 0b111, + }; + Ok(seen_sids == expected_sids) +} + +#[cfg(windows)] +fn dacl_has_no_untrusted_writers( + acl: *mut windows_sys::Win32::Security::ACL, + trusted_sids: &TrustedOwnerSids, +) -> io::Result { + use std::mem::size_of; + use windows_sys::Win32::Foundation::{GENERIC_ALL, GENERIC_WRITE}; + use windows_sys::Win32::Security::{ + GetAce, GetLengthSid, IsValidSid, ACCESS_ALLOWED_ACE, ACE_HEADER, INHERIT_ONLY_ACE, PSID, + }; + use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_APPEND_DATA, FILE_DELETE_CHILD, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, + FILE_WRITE_EA, WRITE_DAC, WRITE_OWNER, + }; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + const ACCESS_DENIED_ACE_TYPE: u8 = 1; + const SID_OFFSET: usize = std::mem::offset_of!(ACCESS_ALLOWED_ACE, SidStart); + const MUTATION_RIGHTS: u32 = GENERIC_ALL + | GENERIC_WRITE + | DELETE + | WRITE_DAC + | WRITE_OWNER + | FILE_WRITE_DATA + | FILE_APPEND_DATA + | FILE_WRITE_EA + | FILE_WRITE_ATTRIBUTES + | FILE_DELETE_CHILD; + + let info = acl_size_information(acl)?; + let acl_start = acl as usize; + let Some(acl_end) = acl_start.checked_add(info.AclBytesInUse as usize) else { + return Ok(false); + }; + for index in 0..info.AceCount { + let mut raw_ace = std::ptr::null_mut(); + // SAFETY: acl is valid, index is below AceCount, and raw_ace is a valid out pointer. + if unsafe { GetAce(acl, index, &mut raw_ace) } == 0 || raw_ace.is_null() { + return Err(io::Error::last_os_error()); + } + let ace_start = raw_ace as usize; + let Some(header_end) = ace_start.checked_add(size_of::()) else { + return Ok(false); + }; + if ace_start < acl_start || header_end > acl_end { + return Ok(false); + } + // SAFETY: the header bounds were checked against the live ACL allocation above. + let header = unsafe { std::ptr::read_unaligned(raw_ace.cast::()) }; + let ace_size = usize::from(header.AceSize); + let Some(ace_end) = ace_start.checked_add(ace_size) else { + return Ok(false); + }; + if ace_end > acl_end { + return Ok(false); + } + if header.AceType == ACCESS_DENIED_ACE_TYPE { + continue; + } + if header.AceType != ACCESS_ALLOWED_ACE_TYPE || ace_size < SID_OFFSET + 8 { + return Ok(false); + } + // SAFETY: the fixed fields and complete minimum SID fit in the validated ACE. + let ace = unsafe { std::ptr::read_unaligned(raw_ace.cast::()) }; + if u32::from(ace.Header.AceFlags) & INHERIT_ONLY_ACE != 0 { + continue; + } + + // SAFETY: SID_OFFSET is inside the already-validated ACE bounds. + let sid: PSID = unsafe { raw_ace.cast::().add(SID_OFFSET).cast() }; + let sid_available = ace_size - SID_OFFSET; + // SAFETY: GetAce and IsValidAcl guarantee that the complete ACE lies inside the ACL. + let sid_bytes = unsafe { std::slice::from_raw_parts(sid.cast::(), sid_available) }; + let encoded_sid_len = 8 + usize::from(sid_bytes[1]) * size_of::(); + if encoded_sid_len != sid_available || unsafe { IsValidSid(sid) } == 0 { + return Ok(false); + } + // SAFETY: IsValidSid accepted sid, so GetLengthSid can inspect it. + if unsafe { GetLengthSid(sid) } as usize != encoded_sid_len { + return Ok(false); + } + if ace.Mask & MUTATION_RIGHTS != 0 && !trusted_sids.is_trusted_install_writer(sid) { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(windows)] +fn acl_size_information( + acl: *const windows_sys::Win32::Security::ACL, +) -> io::Result { + use std::mem::size_of; + use windows_sys::Win32::Security::{ + AclSizeInformation, GetAclInformation, IsValidAcl, ACL_SIZE_INFORMATION, + }; + + if acl.is_null() || unsafe { IsValidAcl(acl) } == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security descriptor contains an invalid DACL", + )); + } + let mut info = ACL_SIZE_INFORMATION::default(); + // SAFETY: acl is valid and info is a correctly-sized writable output buffer. + if unsafe { + GetAclInformation( + acl, + (&mut info as *mut ACL_SIZE_INFORMATION).cast(), + size_of::() as u32, + AclSizeInformation, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + if info.AclBytesInUse < size_of::() as u32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "security descriptor contains a truncated DACL", + )); + } + + Ok(info) +} + +#[cfg(all(windows, test))] +fn acl_bytes(acl: *const windows_sys::Win32::Security::ACL) -> io::Result> { + let info = acl_size_information(acl)?; + // SAFETY: GetAclInformation reports the initialized byte extent of this valid ACL. + Ok( + unsafe { std::slice::from_raw_parts(acl.cast::(), info.AclBytesInUse as usize) } + .to_vec(), + ) +} + +#[cfg(windows)] +fn replace_existing(temp: &Path, destination: &Path, backup: Option<&Path>) -> io::Result<()> { + use std::ptr::null; + use windows_sys::Win32::Storage::FileSystem::{ReplaceFileW, REPLACEFILE_WRITE_THROUGH}; + + let temp_wide = path_to_wide(temp)?; + let destination_wide = path_to_wide(destination)?; + let backup_wide = backup.map(path_to_wide).transpose()?; + let backup_ptr = backup_wide.as_ref().map_or(null(), |value| value.as_ptr()); + + // SAFETY: every non-null path buffer is NUL-terminated and remains alive + // for the duration of the call; the reserved arguments must be null. + let replaced = unsafe { + ReplaceFileW( + destination_wide.as_ptr(), + temp_wide.as_ptr(), + backup_ptr, + REPLACEFILE_WRITE_THROUGH, + null(), + null(), + ) + }; + if replaced == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn replace_existing(temp: &Path, destination: &Path, backup: Option<&Path>) -> io::Result<()> { + if let Some(backup) = backup { + fs::copy(destination, backup)?; + } + fs::rename(temp, destination) +} + +#[cfg(windows)] +fn path_to_wide(path: &Path) -> io::Result> { + use std::os::windows::ffi::OsStrExt; + + let mut wide: Vec = path.as_os_str().encode_wide().collect(); + if wide.contains(&0) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "filesystem path contains a NUL character", + )); + } + wide.push(0); + Ok(wide) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "proxywarden-safe-fs-{label}-{}", + uuid::Uuid::new_v4().hyphenated() + )) + } + + #[cfg(windows)] + fn sid_bytes(sid: windows_sys::Win32::Security::PSID) -> Vec { + use windows_sys::Win32::Security::{GetLengthSid, IsValidSid}; + + // SAFETY: callers pass a SID backed by a live security descriptor. + assert_ne!(unsafe { IsValidSid(sid) }, 0); + // SAFETY: IsValidSid accepted sid, so GetLengthSid can inspect it. + let length = unsafe { GetLengthSid(sid) } as usize; + // SAFETY: the valid SID has exactly length readable bytes. + unsafe { std::slice::from_raw_parts(sid.cast::(), length) }.to_vec() + } + + #[cfg(windows)] + fn sid_sddl(sid: windows_sys::Win32::Security::PSID) -> String { + let bytes = sid_bytes(sid); + let revision = bytes[0]; + let subauthority_count = usize::from(bytes[1]); + let authority = bytes[2..8] + .iter() + .fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)); + let mut result = format!("S-{revision}-{authority}"); + for index in 0..subauthority_count { + let offset = 8 + index * 4; + let subauthority = u32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("validated SID has complete subauthority"), + ); + result.push_str(&format!("-{subauthority}")); + } + result + } + + #[cfg(windows)] + fn path_security_bytes(path: &Path) -> (Vec, Vec) { + let security = read_path_security(path).expect("read path security"); + ( + sid_bytes(security.owner), + acl_bytes(security.dacl).expect("copy path DACL"), + ) + } + + #[cfg(windows)] + fn file_sha256(path: &Path) -> Vec { + use sha2::{Digest, Sha256}; + + Sha256::digest(fs::read(path).expect("read hashed file")).to_vec() + } + + #[test] + fn write_with_backup_replaces_without_removing_destination_first() { + let root = test_root("backup"); + let path = root.join("state.json"); + + write_with_backup(&path, b"one").expect("first write"); + write_with_backup(&path, b"two").expect("replacement"); + assert_eq!(fs::read(&path).unwrap(), b"two"); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"one"); + + write_with_backup(&path, b"three").expect("second replacement"); + assert_eq!(fs::read(&path).unwrap(), b"three"); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"two"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn atomic_write_without_backup_does_not_create_nested_backups() { + let root = test_root("without-backup"); + let path = root.join("state.json.bak"); + + write_atomic_without_backup(&path, b"one").expect("first write"); + write_atomic_without_backup(&path, b"two").expect("replacement"); + assert_eq!(fs::read(&path).unwrap(), b"two"); + assert!(!backup_path(&path).exists()); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn restricted_writes_keep_expected_backup_contract() { + let root = test_root("restricted"); + let path = root.join("journal.json"); + + write_new_restricted(&path, b"one").expect("new restricted file"); + let error = write_new_restricted(&path, b"duplicate").unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + + write_restricted_with_backup(&path, b"two").expect("restricted replacement"); + assert_eq!(fs::read(&path).unwrap(), b"two"); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"one"); + #[cfg(windows)] + { + verify_path_protected_for_owner_admin_system(&path) + .expect("live file keeps restricted ACL"); + verify_path_protected_for_owner_admin_system(&backup_path(&path)) + .expect("backup keeps restricted ACL"); + } + + write_restricted_atomic(&path, b"three").expect("journal replacement"); + assert_eq!(fs::read(&path).unwrap(), b"three"); + assert_eq!(fs::read(backup_path(&path)).unwrap(), b"one"); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn restricted_read_lease_verifies_handle_and_denies_write_and_delete() { + use std::io::Read; + + let root = test_root("restricted-read-lease"); + let path = root.join("generated.json"); + write_restricted_with_backup(&path, b"sealed-config").expect("write restricted config"); + + let mut lease = open_restricted_file_read_lease(&path).expect("open verified read lease"); + let mut contents = Vec::new(); + lease.read_to_end(&mut contents).expect("read leased bytes"); + assert_eq!(contents, b"sealed-config"); + assert!( + OpenOptions::new().write(true).open(&path).is_err(), + "read lease must deny a concurrent writer" + ); + assert!( + fs::remove_file(&path).is_err(), + "read lease must deny concurrent deletion" + ); + + drop(lease); + fs::remove_file(&path).expect("delete succeeds after lease release"); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn restricted_read_lease_rejects_relative_weak_and_reparse_paths() { + use std::os::windows::fs::symlink_file; + + assert_eq!( + open_restricted_file_read_lease(Path::new("generated.json")) + .expect_err("relative path must fail") + .kind(), + io::ErrorKind::InvalidInput + ); + + let root = test_root("restricted-read-lease-rejections"); + fs::create_dir_all(&root).unwrap(); + let weak = root.join("weak.json"); + fs::write(&weak, b"weak").unwrap(); + assert_eq!( + open_restricted_file_read_lease(&weak) + .expect_err("weak ACL must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let target = root.join("target.json"); + let link = root.join("linked.json"); + write_restricted_with_backup(&target, b"sealed").expect("write symlink target"); + match symlink_file(&target, &link) { + Ok(()) => assert_eq!( + open_restricted_file_read_lease(&link) + .expect_err("reparse leaf must fail") + .kind(), + io::ErrorKind::PermissionDenied + ), + Err(error) if error.raw_os_error() == Some(1314) => { + eprintln!("skipping read-lease reparse probe without symlink privilege"); + } + Err(error) => panic!("test symlink creation failed: {error}"), + } + let _ = fs::remove_dir_all(root); + } + + #[test] + fn copy_new_restricted_never_overwrites_destination() { + let root = test_root("copy"); + fs::create_dir_all(&root).unwrap(); + let source = root.join("source.json"); + let destination = root.join("snapshot.json"); + fs::write(&source, b"snapshot").unwrap(); + + copy_new_restricted(&source, &destination).expect("restricted copy"); + assert_eq!(fs::read(&destination).unwrap(), b"snapshot"); + assert_eq!( + copy_new_restricted(&source, &destination) + .unwrap_err() + .kind(), + io::ErrorKind::AlreadyExists + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn protect_path_accepts_files_and_directories() { + let root = test_root("protect"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("lock"); + fs::write(&path, b"").unwrap(); + + protect_path_for_owner_admin_system(&root).expect("protect directory"); + protect_path_for_owner_admin_system(&path).expect("protect file"); + verify_path_protected_for_owner_admin_system(&root).expect("verify protected directory"); + + let before = { + let security = read_path_security(&path).expect("read DACL before verification"); + acl_bytes(security.dacl).expect("copy DACL before verification") + }; + verify_path_protected_for_owner_admin_system(&path).expect("verify protected file"); + let security = read_path_security(&path).expect("read DACL after verification"); + assert_eq!( + acl_bytes(security.dacl).expect("copy DACL after verification"), + before, + "verification must not mutate the DACL" + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_owned_policy_accepts_the_windows_mapped_users_read_execute_mask() { + use windows_sys::Win32::Security::{CONTAINER_INHERIT_ACE, OBJECT_INHERIT_ACE}; + use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ}; + + let trusted_sids = trusted_owner_sids().expect("load trusted SIDs"); + let mapped_mask = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + for (flags, sddl) in [ + ( + 0, + format!("D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;0x{mapped_mask:x};;;BU)"), + ), + ( + (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) as u8, + format!("D:P(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;0x{mapped_mask:x};;;BU)"), + ), + ] { + let descriptor = + security_descriptor_from_sddl(&sddl).expect("build mapped admin-owned DACL"); + assert!(dacl_has_exact_admin_owned_rules( + descriptor_dacl(&descriptor).expect("read mapped admin-owned DACL"), + flags, + &trusted_sids, + ) + .expect("validate mapped admin-owned DACL")); + } + } + + #[cfg(windows)] + #[test] + fn admin_owned_policy_is_exact_and_non_elevated_setter_does_not_mutate() { + let root = test_root("admin-owned"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("package.zip"); + fs::write(&path, b"package").unwrap(); + + let expected = security_descriptor_from_sddl(ADMIN_OWNED_FILE_DACL_SDDL) + .expect("build admin-owned file descriptor"); + assert!(dacl_has_exact_admin_owned_rules( + descriptor_dacl(&expected).expect("read expected DACL"), + 0, + &trusted_owner_sids().expect("load trusted SIDs") + ) + .expect("validate exact admin-owned DACL")); + + let (before_owner, before_dacl) = { + let security = read_path_security(&path).expect("read security before protection"); + ( + sid_bytes(security.owner), + acl_bytes(security.dacl).expect("copy DACL before protection"), + ) + }; + let result = protect_path_admin_owned_user_read_only(&path); + if current_process_is_elevated().expect("read elevation state") { + result.expect("elevated process seals the file"); + verify_path_admin_owned_user_read_only(&path).expect("verify sealed file"); + } else { + assert_eq!( + result + .expect_err("non-elevated process must not seal the file") + .kind(), + io::ErrorKind::PermissionDenied + ); + let security = read_path_security(&path).expect("read security after rejection"); + assert_eq!(sid_bytes(security.owner), before_owner); + assert_eq!( + acl_bytes(security.dacl).expect("copy DACL after rejection"), + before_dacl, + "rejected protection must not mutate the DACL" + ); + } + + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_descriptor_has_exactly_two_trusted_aces() { + use std::ptr::null_mut; + use windows_sys::Win32::Security::{GetSecurityDescriptorOwner, PSID}; + + let trusted_sids = trusted_owner_sids().expect("load trusted SIDs"); + let descriptor = security_descriptor_from_sddl(ADMIN_SYSTEM_ONLY_FILE_SECURITY_SDDL) + .expect("build confidential descriptor"); + let dacl = descriptor_dacl(&descriptor).expect("read confidential DACL"); + assert!( + dacl_has_exact_admin_system_only_rules(dacl, 0, &trusted_sids) + .expect("validate confidential DACL") + ); + + let mut owner: PSID = null_mut(); + let mut defaulted = 0; + // SAFETY: descriptor is live and both output pointers are valid. + assert_ne!( + unsafe { GetSecurityDescriptorOwner(descriptor.0, &mut owner, &mut defaulted) }, + 0 + ); + assert!(trusted_sids.is_administrator_or_system(owner)); + + // SAFETY: trusted_sids owns the aligned TOKEN_USER buffer for this test scope. + let current_user = unsafe { + (*(trusted_sids + .token_user + .as_ptr() + .cast::())) + .User + .Sid + }; + let current_user_rule = format!( + "D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;{})", + sid_sddl(current_user) + ); + for widened in [ + "D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)".to_string(), + "D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;FA;;;OW)".to_string(), + current_user_rule, + ] { + let widened = security_descriptor_from_sddl(&widened) + .expect("build descriptor with forbidden extra ACE"); + assert!(!dacl_has_exact_admin_system_only_rules( + descriptor_dacl(&widened).expect("read widened DACL"), + 0, + &trusted_sids, + ) + .expect("reject widened DACL")); + } + } + + #[cfg(windows)] + #[test] + fn admin_system_only_verifier_rejects_weak_file_without_mutation() { + let root = test_root("confidential-verifier"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("config.json"); + fs::write(&path, b"secret").unwrap(); + let before = path_security_bytes(&path); + + assert_eq!( + verify_admin_system_only_file(&path) + .expect_err("ordinary user-owned file must be rejected") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!(path_security_bytes(&path), before); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_create_seals_before_returning_exclusive_handle() { + let root = test_root("confidential-create"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("config.json"); + + if !current_process_is_elevated().expect("read elevation state") { + assert_eq!( + create_new_admin_system_only_file(&path) + .expect_err("non-elevated creation must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!(!path.exists()); + let _ = fs::remove_dir_all(root); + return; + } + + let mut file = create_new_admin_system_only_file(&path).expect("create confidential file"); + assert!( + OpenOptions::new().read(true).open(&path).is_err(), + "exclusive CREATE_NEW handle must prevent a second readable handle" + ); + verify_admin_system_only_file_handle(&file).expect("verify create handle ACL"); + file.write_all(b"secret").unwrap(); + file.sync_all().unwrap(); + drop(file); + + let before = path_security_bytes(&path); + verify_admin_system_only_file(&path).expect("verify created confidential file"); + assert_eq!( + path_security_bytes(&path), + before, + "verifier must not mutate" + ); + assert_eq!(fs::read(&path).unwrap(), b"secret"); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_create_handle_carries_exact_delete_authority() { + use std::mem::size_of; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("confidential-create-delete"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("lifecycle.lock"); + let file = create_new_admin_system_only_file(&path).expect("create sealed lock"); + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + // SAFETY: file is live and disposition is initialized for the stated size. + assert_ne!( + unsafe { + SetFileInformationByHandle( + file.as_raw_handle(), + FileDispositionInfo, + (&disposition as *const FILE_DISPOSITION_INFO).cast(), + size_of::() as u32, + ) + }, + 0, + "fresh sealed handle must support exact delete-on-close" + ); + drop(file); + assert!(!path.exists()); + fs::remove_dir(root).unwrap(); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_replace_preserves_policy_and_staging_hash() { + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("confidential-replace"); + fs::create_dir_all(&root).unwrap(); + let staging = root.join("config.next.json"); + let destination = root.join("config.json"); + + let mut old = create_new_admin_system_only_file(&destination).unwrap(); + old.write_all(b"old-config").unwrap(); + old.sync_all().unwrap(); + drop(old); + let mut next = create_new_admin_system_only_file(&staging).unwrap(); + next.write_all(b"new-config").unwrap(); + next.sync_all().unwrap(); + drop(next); + let expected_hash = file_sha256(&staging); + + promote_admin_system_only_file_same_parent(&staging, &destination) + .expect("replace confidential snapshot"); + assert_eq!(file_sha256(&destination), expected_hash); + verify_admin_system_only_file(&destination).expect("replacement keeps exact ACL"); + assert!(!staging.exists()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_first_promotion_creates_secured_destination() { + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("confidential-first"); + fs::create_dir_all(&root).unwrap(); + let staging = root.join("config.next.json"); + let destination = root.join("config.json"); + let mut next = create_new_admin_system_only_file(&staging).unwrap(); + next.write_all(b"first-config").unwrap(); + next.sync_all().unwrap(); + drop(next); + let expected_hash = file_sha256(&staging); + + promote_admin_system_only_file_same_parent(&staging, &destination) + .expect("promote first confidential snapshot"); + assert_eq!(file_sha256(&destination), expected_hash); + verify_admin_system_only_file(&destination).expect("first snapshot keeps exact ACL"); + assert!(!staging.exists()); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_preflight_failure_preserves_old_snapshot() { + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("confidential-preflight"); + fs::create_dir_all(&root).unwrap(); + let staging = root.join("weak.next.json"); + let destination = root.join("config.json"); + let mut old = create_new_admin_system_only_file(&destination).unwrap(); + old.write_all(b"old-config").unwrap(); + old.sync_all().unwrap(); + drop(old); + fs::write(&staging, b"untrusted-config").unwrap(); + let old_hash = file_sha256(&destination); + let old_security = path_security_bytes(&destination); + + assert!(promote_admin_system_only_file_same_parent(&staging, &destination).is_err()); + assert_eq!(file_sha256(&destination), old_hash); + assert_eq!(path_security_bytes(&destination), old_security); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_owned_directory_create_never_repairs_foreign_path() { + let root = test_root("admin-directory"); + fs::create_dir_all(&root).unwrap(); + let foreign = root.join("system"); + fs::create_dir(&foreign).unwrap(); + let before = path_security_bytes(&foreign); + + assert!(create_directory_admin_owned_user_read_only(&foreign).is_err()); + assert_eq!(path_security_bytes(&foreign), before); + + if current_process_is_elevated().expect("read elevation state") { + let secured = root.join("secured-system"); + create_directory_admin_owned_user_read_only(&secured) + .expect("atomically create admin-owned directory"); + verify_path_admin_owned_user_read_only(&secured).expect("verify secured directory"); + create_directory_admin_owned_user_read_only(&secured) + .expect("existing exact directory is accepted"); + } + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_owned_create_new_file_is_exact_before_write_and_preserves_collision() { + let root = test_root("admin-create-new-file"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("claim.json"); + + if !current_process_is_elevated().expect("read elevation state") { + assert_eq!( + create_new_admin_owned_user_read_only_file(&path) + .expect_err("non-elevated creation must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!(!path.exists()); + let _ = fs::remove_dir_all(root); + return; + } + + let mut file = create_new_admin_owned_user_read_only_file(&path) + .expect("atomically reserve admin-owned file"); + assert_eq!(file.metadata().unwrap().len(), 0); + verify_admin_owned_user_read_only_file_handle(&file) + .expect("returned handle has exact ACL before first write"); + file.write_all(b"claim").unwrap(); + file.sync_all().unwrap(); + drop(file); + let before_bytes = fs::read(&path).unwrap(); + let before_security = path_security_bytes(&path); + + assert_eq!( + create_new_admin_owned_user_read_only_file(&path) + .expect_err("existing reservation must not be replaced") + .kind(), + io::ErrorKind::AlreadyExists + ); + assert_eq!(fs::read(&path).unwrap(), before_bytes); + assert_eq!(path_security_bytes(&path), before_security); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_owned_atomic_metadata_writer_preserves_exact_policy() { + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("admin-metadata"); + fs::create_dir_all(&root).unwrap(); + let system = root.join("system"); + create_directory_admin_owned_user_read_only(&system).unwrap(); + let receipt = system.join("receipt.json"); + + write_admin_owned_user_read_only_atomic(&receipt, b"one").expect("first receipt write"); + verify_path_admin_owned_user_read_only(&receipt).expect("verify first receipt ACL"); + write_admin_owned_user_read_only_atomic(&receipt, b"two") + .expect("atomic receipt replacement"); + verify_path_admin_owned_user_read_only(&receipt).expect("verify replacement receipt ACL"); + assert_eq!(fs::read(&receipt).unwrap(), b"two"); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn admin_system_only_verifier_rejects_reparse_file_when_available() { + use std::os::windows::fs::symlink_file; + + let root = test_root("confidential-reparse"); + fs::create_dir_all(&root).unwrap(); + let target = root.join("target.json"); + let link = root.join("linked.json"); + fs::write(&target, b"secret").unwrap(); + if let Err(error) = symlink_file(&target, &link) { + let _ = fs::remove_dir_all(&root); + if error.raw_os_error() == Some(1314) { + eprintln!("skipping reparse probe because this process lacks symlink privilege"); + return; + } + panic!("test symlink creation failed: {error}"); + } + + assert_eq!( + verify_admin_system_only_file(&link) + .expect_err("reparse config must be rejected") + .kind(), + io::ErrorKind::PermissionDenied + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn trusted_program_files_verifier_rejects_outside_path_and_root_itself() { + use std::os::windows::fs::symlink_file; + + let root = test_root("outside-program-files"); + fs::create_dir_all(&root).unwrap(); + let outside = root.join("proxywarden.exe"); + fs::write(&outside, b"not-installed").unwrap(); + assert_eq!( + verify_path_under_trusted_program_files(&outside) + .expect_err("outside path must be rejected") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let program_files = registered_program_files_dir().expect("read Program Files registry"); + assert_eq!( + verify_path_under_trusted_program_files(&program_files) + .expect_err("Program Files root is not a strict descendant") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let link = root.join("linked-proxywarden.exe"); + match symlink_file(&outside, &link) { + Ok(()) => { + assert_eq!( + verify_path_under_trusted_program_files(&link) + .expect_err("reparse path must be rejected") + .kind(), + io::ErrorKind::PermissionDenied + ); + } + Err(error) if error.raw_os_error() == Some(1314) => { + eprintln!("skipping Program Files reparse probe without symlink privilege"); + } + Err(error) => panic!("test symlink creation failed: {error}"), + } + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn installed_acl_policy_allows_trusted_writers_and_rejects_world_write() { + let trusted_sids = trusted_owner_sids().expect("load trusted SIDs"); + let safe = security_descriptor_from_sddl( + "D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGX;;;BU)(A;;GRGX;;;AC)", + ) + .expect("build standard read-only install DACL"); + assert!(dacl_has_no_untrusted_writers( + descriptor_dacl(&safe).expect("read safe DACL"), + &trusted_sids + ) + .expect("check safe DACL")); + + let writable = security_descriptor_from_sddl("D:P(A;;FA;;;BA)(A;;FA;;;SY)(A;;GRGXGW;;;BU)") + .expect("build writable install DACL"); + assert!(!dacl_has_no_untrusted_writers( + descriptor_dacl(&writable).expect("read writable DACL"), + &trusted_sids + ) + .expect("check writable DACL")); + + let root = test_root("installed-read-only-owner"); + fs::create_dir_all(&root).unwrap(); + assert_eq!( + verify_path_installed_read_only(&root) + .expect_err("ordinary user-owned directory must not be trusted") + .kind(), + io::ErrorKind::PermissionDenied + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn verify_path_rejects_unprotected_or_extra_access_rules() { + const EXTRA_WRITE_DACL: &str = + "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;BA)(A;OICI;FA;;;SY)(A;OICI;FW;;;WD)"; + + let root = test_root("verify-protection"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("package.json"); + fs::write(&path, b"{}").unwrap(); + protect_path_for_owner_admin_system(&root).expect("protect test root"); + protect_path_for_owner_admin_system(&path).expect("protect test file"); + + let expected = security_descriptor_from_sddl(RESTRICTED_FILE_DACL_SDDL) + .expect("build expected descriptor"); + set_path_dacl( + &path, + descriptor_dacl(&expected).expect("read expected DACL"), + false, + ) + .expect("remove DACL protection for test"); + assert_eq!( + verify_path_protected_for_owner_admin_system(&path) + .expect_err("unprotected DACL must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let extra = security_descriptor_from_sddl(EXTRA_WRITE_DACL) + .expect("build descriptor with an extra write ACE"); + set_path_dacl( + &path, + descriptor_dacl(&extra).expect("read widened DACL"), + true, + ) + .expect("install widened protected DACL for test"); + assert_eq!( + verify_path_protected_for_owner_admin_system(&path) + .expect_err("extra write ACE must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn trusted_owner_check_rejects_an_unrelated_sid() { + use windows_sys::Win32::Security::{ + CreateWellKnownSid, WinWorldSid, SECURITY_MAX_SID_SIZE, + }; + + let mut world = vec![0usize; (SECURITY_MAX_SID_SIZE as usize).div_ceil(size_of::())]; + let mut world_bytes = SECURITY_MAX_SID_SIZE; + // SAFETY: world is aligned and sized to SECURITY_MAX_SID_SIZE. + assert_ne!( + unsafe { + CreateWellKnownSid( + WinWorldSid, + std::ptr::null_mut(), + world.as_mut_ptr().cast(), + &mut world_bytes, + ) + }, + 0 + ); + + assert!(!trusted_owner_sids() + .expect("load trusted owner SIDs") + .contains(world.as_mut_ptr().cast())); + } + + #[cfg(windows)] + #[test] + fn exact_dacl_check_rejects_short_or_trailing_ace_data() { + use windows_sys::Win32::Security::{ACE_HEADER, ACL, ACL_REVISION}; + + const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; + + let trusted_sids = trusted_owner_sids().expect("load trusted owner SIDs"); + + let short_acl_len = size_of::() + 3 * size_of::(); + let mut short_acl = vec![0usize; short_acl_len.div_ceil(size_of::())]; + let short_acl_ptr = short_acl.as_mut_ptr().cast::(); + // SAFETY: short_acl is aligned and large enough for the ACL plus three ACE headers. + unsafe { + (*short_acl_ptr).AclRevision = ACL_REVISION as u8; + (*short_acl_ptr).AclSize = short_acl_len as u16; + (*short_acl_ptr).AceCount = 3; + for index in 0..3 { + let header = short_acl_ptr + .cast::() + .add(size_of::() + index * size_of::()) + .cast::(); + (*header).AceType = ACCESS_ALLOWED_ACE_TYPE; + (*header).AceSize = size_of::() as u16; + } + } + assert!(!matches!( + dacl_has_exact_rules(short_acl_ptr, 0, &trusted_sids), + Ok(true) + )); + + let descriptor = security_descriptor_from_sddl(RESTRICTED_FILE_DACL_SDDL) + .expect("build exact file descriptor"); + let original = acl_bytes(descriptor_dacl(&descriptor).expect("read exact file DACL")) + .expect("copy exact file DACL"); + let first_header = unsafe { + std::ptr::read_unaligned(original.as_ptr().add(size_of::()).cast::()) + }; + let first_end = size_of::() + usize::from(first_header.AceSize); + let widened_len = original.len() + size_of::(); + let mut widened = vec![0usize; widened_len.div_ceil(size_of::())]; + // SAFETY: widened is aligned and its byte view covers widened_len initialized bytes. + let widened_bytes = unsafe { + std::slice::from_raw_parts_mut(widened.as_mut_ptr().cast::(), widened_len) + }; + widened_bytes[..first_end].copy_from_slice(&original[..first_end]); + widened_bytes[first_end + size_of::()..].copy_from_slice(&original[first_end..]); + let widened_acl = widened.as_mut_ptr().cast::(); + // SAFETY: the copied ACL/header are aligned and within widened's initialized byte view. + unsafe { + (*widened_acl).AclSize = widened_len as u16; + let widened_first = widened_acl + .cast::() + .add(size_of::()) + .cast::(); + (*widened_first).AceSize += size_of::() as u16; + } + assert!(!matches!( + dacl_has_exact_rules(widened_acl, 0, &trusted_sids), + Ok(true) + )); + } + + #[cfg(windows)] + #[test] + fn identity_lease_retains_exact_file_and_blocks_writable_root_swap() { + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping identity lease probe because it requires an elevated token"); + return; + } + let root = test_root("identity-lease-swap"); + fs::create_dir_all(&root).expect("create lease test root"); + let path = root.join("legacy.bin"); + let renamed = root.join("attacker-renamed.bin"); + fs::write(&path, b"frozen legacy bytes").expect("write leased fixture"); + + let lease = open_path_identity_lease(&path, StableObjectKind::RegularFile) + .expect("lease exact regular file"); + let identity = lease.identity().clone(); + assert_eq!(identity.size, b"frozen legacy bytes".len() as u64); + assert_eq!(stable_identity_from_handle(lease.file()).unwrap(), identity); + assert!(OpenOptions::new().write(true).open(&path).is_err()); + assert!(fs::rename(&path, &renamed).is_err()); + assert!(fs::remove_file(&path).is_err()); + assert_eq!(fs::read(&path).unwrap(), b"frozen legacy bytes"); + + drop(lease); + fs::rename(&path, &renamed).expect("rename is possible after lease release"); + assert_eq!(fs::read(&renamed).unwrap(), b"frozen legacy bytes"); + fs::remove_dir_all(root).expect("remove lease test root"); + } + + #[cfg(windows)] + #[test] + fn runtime_read_identity_lease_coexists_with_service_readers_and_blocks_mutation() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping runtime read lease probe because it requires an elevated token"); + return; + } + let root = test_root("runtime-read-lease"); + fs::create_dir_all(&root).expect("create runtime lease test root"); + let path = root.join("app-config.json"); + let renamed = root.join("renamed.json"); + fs::write(&path, br#"{"route":"proxy"}"#).expect("write runtime fixture"); + + // FileShare.Read matches the narrow sharing used by common .NET read helpers. + let service_reader = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&path) + .expect("open simulated service reader"); + let mut lease = open_path_runtime_read_identity_lease(&path, StableObjectKind::RegularFile) + .expect("runtime lease must coexist with a service reader"); + assert_eq!(lease.mode(), PathIdentityLeaseMode::RuntimeRead); + let security_before = lease.security().clone(); + assert_eq!(fs::read(&path).unwrap(), br#"{"route":"proxy"}"#); + assert!(OpenOptions::new().write(true).open(&path).is_err()); + assert!(fs::rename(&path, &renamed).is_err()); + assert!(fs::remove_file(&path).is_err()); + assert_eq!( + harden_identity_lease_admin_system_only(&mut lease) + .expect_err("runtime lease must reject ACL hardening") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!( + restore_identity_lease_security(&mut lease, &security_before) + .expect_err("runtime lease must reject ACL restore") + .kind(), + io::ErrorKind::PermissionDenied + ); + let parent = open_path_identity_lease(&root, StableObjectKind::Directory) + .expect("open mutation parent lease"); + assert_eq!( + rename_identity_lease_same_volume( + &mut lease, + &parent, + std::ffi::OsStr::new("renamed.json"), + ) + .expect_err("runtime lease must reject handle rename") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!( + current_identity_lease_security(&lease).unwrap(), + security_before + ); + + drop(lease); + drop(parent); + let delete_lease = + open_path_runtime_read_identity_lease(&path, StableObjectKind::RegularFile) + .expect("reopen runtime lease for delete rejection"); + assert_eq!( + delete_identity_lease(delete_lease) + .expect_err("runtime lease must reject handle delete") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!(path.is_file()); + assert!( + open_path_identity_lease(&path, StableObjectKind::RegularFile).is_err(), + "mutation upgrade must wait until the service reader is closed" + ); + drop(service_reader); + let mutation = open_path_identity_lease(&path, StableObjectKind::RegularFile) + .expect("mutation lease opens after all runtime readers close"); + assert_eq!(mutation.mode(), PathIdentityLeaseMode::Mutation); + drop(mutation); + fs::remove_dir_all(root).expect("remove runtime lease fixture"); + } + + #[cfg(windows)] + #[test] + fn root_only_hardening_keeps_child_reader_live_and_does_not_propagate() { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping root-only hardening probe because it requires an elevated token"); + return; + } + let fixture = test_root("root-only-hardening"); + let legacy = fixture.join("legacy"); + let child_path = legacy.join("ProxiFyre.exe"); + fs::create_dir_all(&legacy).expect("create root-only hardening fixture"); + fs::write(&child_path, b"legacy package").expect("write legacy package fixture"); + + let service_reader = OpenOptions::new() + .read(true) + .share_mode(FILE_SHARE_READ) + .open(&child_path) + .expect("open simulated running-service reader"); + let child = + open_path_runtime_read_identity_lease(&child_path, StableObjectKind::RegularFile) + .expect("retain child runtime lease"); + let child_security = current_identity_lease_security(&child).unwrap(); + let root_read = open_path_runtime_read_identity_lease(&legacy, StableObjectKind::Directory) + .expect("capture root with runtime lease"); + let root_security = root_read.security().clone(); + let root_identity = root_read.identity().clone(); + drop(root_read); + + let mut root_mutation = open_path_identity_lease(&legacy, StableObjectKind::Directory) + .expect("upgrade only root while child reader remains live"); + assert_eq!(root_mutation.identity(), &root_identity); + harden_identity_lease_admin_system_only(&mut root_mutation) + .expect("harden exact root without child propagation"); + verify_identity_lease_admin_system_only(&root_mutation).expect("verify protected root"); + assert_eq!( + current_identity_lease_security(&child).unwrap(), + child_security, + "root ACL mutation must not alter a child descriptor" + ); + assert_eq!(fs::read(&child_path).unwrap(), b"legacy package"); + + drop(root_mutation); + let root_runtime = + open_path_runtime_read_identity_lease(&legacy, StableObjectKind::Directory) + .expect("return hardened root to runtime-compatible lease mode"); + assert_eq!(root_runtime.mode(), PathIdentityLeaseMode::RuntimeRead); + assert_eq!(fs::read(&child_path).unwrap(), b"legacy package"); + + drop(root_runtime); + drop(child); + drop(service_reader); + let mut restore = open_path_identity_lease(&legacy, StableObjectKind::Directory) + .expect("reopen root for exact rollback restore"); + restore_identity_lease_security(&mut restore, &root_security) + .expect("restore exact original root descriptor"); + assert_eq!( + current_identity_lease_security(&restore).unwrap(), + root_security + ); + drop(restore); + fs::remove_dir_all(fixture).expect("remove root-only hardening fixture"); + } + + #[cfg(windows)] + #[test] + fn identity_lease_hardening_restores_full_file_and_directory_descriptors_exactly() { + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping ACL rollback probe because it requires an elevated token"); + return; + } + let root = test_root("identity-lease-security"); + let directory = root.join("legacy"); + let file = directory.join("app-config.json"); + fs::create_dir_all(&directory).expect("create ACL fixture directory"); + fs::write(&file, b"{}").expect("create ACL fixture file"); + + let mut file_lease = open_path_identity_lease(&file, StableObjectKind::RegularFile) + .expect("lease ACL fixture file"); + let mut directory_lease = open_path_identity_lease(&directory, StableObjectKind::Directory) + .expect("lease ACL fixture directory"); + let file_identity = file_lease.identity().clone(); + let directory_identity = directory_lease.identity().clone(); + let file_before = file_lease.security().clone(); + let directory_before = directory_lease.security().clone(); + + harden_identity_lease_admin_system_only(&mut directory_lease) + .expect("harden exact directory handle"); + assert_eq!( + current_identity_lease_security(&file_lease).unwrap(), + file_before, + "one directory ACL operation must not mutate a child entry" + ); + harden_identity_lease_admin_system_only(&mut file_lease).expect("harden exact file handle"); + verify_identity_lease_admin_system_only(&file_lease).expect("verify hardened file"); + verify_identity_lease_admin_system_only(&directory_lease) + .expect("verify hardened directory"); + assert_ne!( + current_identity_lease_security(&file_lease).unwrap(), + file_before + ); + assert_ne!( + current_identity_lease_security(&directory_lease).unwrap(), + directory_before + ); + + restore_identity_lease_security(&mut file_lease, &file_before) + .expect("restore exact file descriptor"); + restore_identity_lease_security(&mut directory_lease, &directory_before) + .expect("restore exact directory descriptor"); + assert_eq!( + current_identity_lease_security(&file_lease).unwrap(), + file_before + ); + assert_eq!( + current_identity_lease_security(&directory_lease).unwrap(), + directory_before + ); + assert_eq!( + stable_identity_from_handle(file_lease.file()).unwrap(), + file_identity + ); + assert_eq!( + stable_identity_from_handle(directory_lease.file()).unwrap(), + directory_identity + ); + + drop(file_lease); + drop(directory_lease); + fs::remove_dir_all(root).expect("remove ACL fixture"); + } + + #[cfg(windows)] + #[test] + fn identity_lease_directory_rename_preserves_identity_and_rejects_collision() { + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping handle rename probe because it requires an elevated token"); + return; + } + let root = test_root("identity-lease-rename"); + let source = root.join("source"); + let destination = root.join("destination"); + let collision_source = root.join("collision-source"); + let occupied = root.join("occupied"); + fs::create_dir_all(&source).expect("create rename source"); + fs::write(source.join("payload.bin"), b"payload").expect("write rename payload"); + fs::create_dir(&collision_source).expect("create collision source"); + fs::create_dir(&occupied).expect("create occupied destination"); + + let parent = open_path_identity_lease(&root, StableObjectKind::Directory) + .expect("lease rename parent"); + let mut source_lease = open_path_identity_lease(&source, StableObjectKind::Directory) + .expect("lease rename source"); + let child_lease = + open_path_identity_lease(&source.join("payload.bin"), StableObjectKind::RegularFile) + .expect("lease rename child"); + let source_identity = source_lease.identity().clone(); + let child_identity = child_lease.identity().clone(); + assert!( + rename_identity_lease_same_volume( + &mut source_lease, + &parent, + std::ffi::OsStr::new("destination"), + ) + .is_err(), + "Windows must not be asked to rename a nonempty root with retained child handles" + ); + drop(child_lease); + rename_identity_lease_same_volume( + &mut source_lease, + &parent, + std::ffi::OsStr::new("destination"), + ) + .expect("rename leased directory by handle"); + assert!(!source.exists()); + assert_eq!(source_lease.path(), destination); + assert_eq!(source_lease.identity(), &source_identity); + assert_eq!( + fs::read(destination.join("payload.bin")).unwrap(), + b"payload" + ); + let reopened_child = open_path_runtime_read_identity_lease( + &destination.join("payload.bin"), + StableObjectKind::RegularFile, + ) + .expect("exact-reopen renamed child with runtime lease"); + assert_eq!(reopened_child.identity(), &child_identity); + drop(reopened_child); + + let mut collision_lease = + open_path_identity_lease(&collision_source, StableObjectKind::Directory) + .expect("lease collision source"); + assert_eq!( + rename_identity_lease_same_volume( + &mut collision_lease, + &parent, + std::ffi::OsStr::new("occupied"), + ) + .expect_err("occupied destination must not be replaced") + .kind(), + io::ErrorKind::AlreadyExists + ); + assert!(collision_source.is_dir()); + assert!(occupied.is_dir()); + + drop(collision_lease); + drop(source_lease); + drop(parent); + let reopened = open_path_identity_lease(&destination, StableObjectKind::Directory) + .expect("reopen renamed identity"); + assert_eq!(reopened.identity(), &source_identity); + drop(reopened); + fs::remove_dir_all(root).expect("remove rename fixture"); + } + + #[cfg(windows)] + #[test] + fn identity_lease_delete_removes_exact_file_and_empty_directory_but_not_nonempty() { + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping handle delete probe because it requires an elevated token"); + return; + } + let root = test_root("identity-lease-delete"); + let file = root.join("entry.bin"); + let empty = root.join("empty"); + let nonempty = root.join("nonempty"); + fs::create_dir_all(&nonempty).expect("create nonempty directory"); + fs::create_dir(&empty).expect("create empty directory"); + fs::write(&file, b"entry").expect("create delete file"); + fs::write(nonempty.join("child.bin"), b"child").expect("create retained child"); + + let file_lease = open_path_identity_lease(&file, StableObjectKind::RegularFile) + .expect("lease delete file"); + delete_identity_lease(file_lease).expect("delete exact leased file"); + assert!(!file.exists()); + + let empty_lease = open_path_identity_lease(&empty, StableObjectKind::Directory) + .expect("lease empty directory"); + delete_identity_lease(empty_lease).expect("delete exact empty directory"); + assert!(!empty.exists()); + + let nonempty_lease = open_path_identity_lease(&nonempty, StableObjectKind::Directory) + .expect("lease nonempty directory"); + assert!(delete_identity_lease(nonempty_lease).is_err()); + assert!(nonempty.is_dir()); + assert_eq!(fs::read(nonempty.join("child.bin")).unwrap(), b"child"); + + fs::remove_dir_all(root).expect("remove delete fixture"); + } + + #[cfg(windows)] + #[test] + fn identity_lease_rejects_reparse_file_and_directory_when_symlinks_are_available() { + use std::os::windows::fs::{symlink_dir, symlink_file}; + + if !current_process_is_elevated().expect("read elevation state") { + eprintln!("skipping reparse lease probe because it requires an elevated token"); + return; + } + let root = test_root("identity-lease-reparse"); + let target_file = root.join("target.bin"); + let target_directory = root.join("target-dir"); + let file_link = root.join("file-link.bin"); + let directory_link = root.join("directory-link"); + fs::create_dir_all(&target_directory).expect("create reparse target directory"); + fs::write(&target_file, b"target").expect("create reparse target file"); + if let Err(error) = symlink_file(&target_file, &file_link) { + let _ = fs::remove_dir_all(&root); + if error.raw_os_error() == Some(1314) { + eprintln!("skipping reparse lease probe without symlink privilege"); + return; + } + panic!("file symlink creation failed: {error}"); + } + symlink_dir(&target_directory, &directory_link) + .unwrap_or_else(|error| panic!("directory symlink creation failed: {error}")); + + assert_eq!( + open_path_identity_lease(&file_link, StableObjectKind::RegularFile) + .expect_err("reparse file lease must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + assert_eq!( + open_path_identity_lease(&directory_link, StableObjectKind::Directory) + .expect_err("reparse directory lease must fail") + .kind(), + io::ErrorKind::PermissionDenied + ); + fs::remove_dir_all(root).expect("remove reparse fixture"); + } + + #[cfg(not(windows))] + #[test] + fn verify_path_protection_fails_closed_off_windows() { + let root = test_root("verify-unsupported"); + fs::create_dir_all(&root).unwrap(); + let file = root.join("generated.json"); + fs::write(&file, b"{}").unwrap(); + assert_eq!( + verify_path_protected_for_owner_admin_system(&root) + .expect_err("non-Windows ACL verification must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + open_restricted_file_read_lease(&file) + .expect_err("non-Windows read lease must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(not(windows))] + #[test] + fn admin_owned_path_protection_fails_closed_off_windows() { + let root = test_root("admin-owned-unsupported"); + fs::create_dir_all(&root).unwrap(); + assert_eq!( + protect_path_admin_owned_user_read_only(&root) + .expect_err("non-Windows sealing must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + verify_path_admin_owned_user_read_only(&root) + .expect_err("non-Windows verification must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + verify_path_installed_read_only(&root) + .expect_err("non-Windows install ACL verification must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + let file = root.join("config.json"); + assert_eq!( + create_new_admin_system_only_file(&file) + .expect_err("non-Windows confidential create must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + verify_admin_system_only_file(&file) + .expect_err("non-Windows confidential verify must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + promote_admin_system_only_file_same_parent(&file, &root.join("active.json")) + .expect_err("non-Windows confidential promotion must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + create_directory_admin_owned_user_read_only(&root.join("system")) + .expect_err("non-Windows admin directory create must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + create_new_admin_owned_user_read_only_directory(&root.join("candidate")) + .expect_err("non-Windows fresh admin directory create must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + create_new_directory_admin_system_only(&root.join("sealed")) + .expect_err("non-Windows fresh sealed directory create must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + create_new_admin_owned_user_read_only_file(&file) + .expect_err("non-Windows admin file create must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + write_admin_owned_user_read_only_atomic(&file, b"{}") + .expect_err("non-Windows admin metadata write must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + assert_eq!( + verify_path_under_trusted_program_files(&file) + .expect_err("non-Windows Program Files verify must fail closed") + .kind(), + io::ErrorKind::Unsupported + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn fresh_admin_owned_directory_rejects_an_existing_path_without_adopting_it() { + let root = test_root("fresh-admin-directory-existing"); + fs::create_dir_all(&root).expect("create existing test path"); + assert_eq!( + create_new_admin_owned_user_read_only_directory(&root) + .expect_err("fresh create must reject an existing path") + .kind(), + io::ErrorKind::AlreadyExists + ); + assert_eq!( + create_new_directory_admin_system_only(&root) + .expect_err("fresh sealed create must reject an existing path") + .kind(), + io::ErrorKind::AlreadyExists + ); + let _ = fs::remove_dir_all(root); + } + + #[cfg(windows)] + #[test] + fn fresh_admin_system_directory_is_exact_and_never_adopts_itself() { + if !current_process_is_elevated().expect("read elevation state") { + return; + } + let root = test_root("fresh-admin-system-directory"); + create_new_directory_admin_system_only(&root).expect("create fresh sealed directory"); + verify_directory_admin_system_only(&root).expect("verify fresh sealed directory"); + assert_eq!( + create_new_directory_admin_system_only(&root) + .expect_err("fresh sealed create must never adopt an existing directory") + .kind(), + io::ErrorKind::AlreadyExists + ); + let lease = open_path_identity_lease(&root, StableObjectKind::Directory) + .expect("lease fresh sealed directory for cleanup"); + delete_identity_lease(lease).expect("delete fresh sealed directory"); + } + + #[cfg(windows)] + #[test] + fn protect_path_accepts_an_exclusively_open_lock_file() { + use std::os::windows::fs::OpenOptionsExt; + + let root = test_root("protect-open-lock"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("storage-migration.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .share_mode(0) + .open(&path) + .unwrap(); + + protect_path_for_owner_admin_system(&path).expect("protect open lock file"); + drop(file); + let _ = fs::remove_dir_all(root); + } } diff --git a/src-tauri/src/singbox_config.rs b/src-tauri/src/singbox_config.rs index 3f83188..5e752f5 100644 --- a/src-tauri/src/singbox_config.rs +++ b/src-tauri/src/singbox_config.rs @@ -6,12 +6,12 @@ use crate::adapters::singbox::{ }; use crate::clock::Clock; use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse}; +use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction}; use crate::models::{ ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target, TargetKind, }; use crate::safe_fs; -use crate::singbox_subscription::read_required_singbox_cache; use crate::storage::JsonStorage; use std::path::Path; @@ -25,8 +25,19 @@ pub fn generate_singbox_config_with_services( where C: SingBoxConfigChecker, { + let guard = read_guard(storage).map_err(storage_error)?; let config = storage.read_local_singbox_config().map_err(storage_error)?; - let cache = read_required_singbox_cache(storage)?; + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)? + .ok_or_else(|| { + CommandError::new( + "singbox_subscription_cache_missing", + "Сначала загрузите подписку.", + ) + })?; + let revision = revision_locked(storage).map_err(storage_error)?; + drop(guard); let generated = adapter .generate_config( SingBoxGenerationRequest::new(&config, &cache, binary_path), @@ -38,13 +49,19 @@ where .generated_dir .join(generated.output_file_name.as_str()); + let transaction = + ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?; write_generated_config(&generated_path, &generated.contents)?; ensure_local_singbox_target(storage, &config)?; + crate::route_state::record_prepared_locked( + storage, + crate::privileged_jobs::ManagedComponent::SingBox, + ) + .map_err(storage_error)?; + transaction.commit().map_err(storage_error)?; let activity = activity_for_singbox_generate(clock, &generated, &generated_path); - storage - .append_activity(activity.clone()) - .map_err(storage_error)?; + let _ = storage.append_activity(activity.clone()); Ok(GenerateSingBoxConfigResponse { success: true, @@ -117,7 +134,7 @@ fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError { } fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> { - safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error) + safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error) } fn storage_error(error: std::io::Error) -> CommandError { diff --git a/src-tauri/src/singbox_runtime.rs b/src-tauri/src/singbox_runtime.rs index 81a89f2..27d4024 100644 --- a/src-tauri/src/singbox_runtime.rs +++ b/src-tauri/src/singbox_runtime.rs @@ -3,530 +3,1361 @@ //! These operations may request UAC elevation. Apply configuration never calls //! this module; install/start/stop/uninstall remain separate user actions. -use crate::command_dto::{CommandError, ComponentStatusDto}; -use crate::component_detection::{detect_singbox_install, singbox_component_from_detection}; -use crate::elevated_scripts; -use crate::powershell::{ - escape_single as escape_powershell_single, is_elevated as is_running_elevated, - package_failure_details, run_command as run_powershell_command, - run_file as run_powershell_file, write_script as write_powershell_script, +use crate::command_dto::CommandError; +use crate::component_catalog::ComponentId; +use crate::component_inventory::{ + run_authorized_component_action, AuthorizedActionError, ComponentCandidate, + ComponentClassification, ComponentInventory, InventoryAction, InventoryIssue, }; -use crate::process::command_no_window; +use crate::component_packages::{PackageSource, PrivilegedPackageLease, PrivilegedPackageProof}; use crate::singbox_service::{ - ensure_safe_singbox_install_dir, - parse_service_command_output as parse_singbox_service_command_output, service_control_script, - ServiceCommandOutput as SingBoxServiceCommandOutput, SingBoxServiceAction, + singbox_service_xml, SingBoxNativeServiceSnapshot, SingBoxNativeServiceState, + SingBoxServiceInstallSpec, SINGBOX_CRONET_FILE, SINGBOX_LICENSE_FILE, + SINGBOX_OWNERSHIP_MARKER_FILE, SINGBOX_RUNTIME_CONFIG_FILE, SINGBOX_RUNTIME_FILE, + WINSW_SERVICE_XML_FILE, WINSW_WRAPPER_FILE, }; -use crate::storage::{default_config_root, JsonStorage}; -use std::fs; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::Read; use std::path::{Path, PathBuf}; +use thiserror::Error; -pub(crate) fn control_singbox_service( - action: SingBoxServiceAction, - config_source: Option<&Path>, -) -> Result { - let Some(detected) = detect_singbox_install() else { - return Err(CommandError::new( - "singbox_not_found", - "Local sing-box не найден на компьютере.", - )); - }; +mod system; +pub use system::{SystemSingBoxNativeHost, SystemSingBoxRuntimeConfigLease}; - let config_target = config_source.map(|_| detected.install_dir.join("config.json")); - let script = service_control_script( - action, - &detected.service_name, - config_source, - config_target.as_deref(), - ); - let output = command_no_window("powershell") - .args([ - "-NoProfile", - "-NonInteractive", - "-ExecutionPolicy", - "Bypass", - "-Command", - script.as_str(), - ]) - .output() - .map_err(|error| { - CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось {} службу Local sing-box: {error}", - action.label() - ), - ) - })?; - let result = parse_singbox_service_command_output(&output.stdout).ok_or_else(|| { - CommandError::new( - singbox_service_error_code(action), - singbox_service_script_failed_message(action, output.status.code()), - ) - })?; +const SINGBOX_OWNERSHIP_MARKER_SCHEMA_VERSION: u32 = 1; +const MAX_SINGBOX_PACKAGE_BYTES: u64 = 128 * 1024 * 1024; +const MAX_SINGBOX_ARCHIVE_ENTRIES: usize = 3; +const MAX_SINGBOX_ARCHIVE_FILE_BYTES: u64 = 64 * 1024 * 1024; +const MAX_SINGBOX_EXPANDED_BYTES: u64 = 80 * 1024 * 1024; +const MAX_WINSW_PACKAGE_BYTES: u64 = 8 * 1024 * 1024; +const SINGBOX_MANAGED_RUNTIME_FILES: [&str; 5] = [ + SINGBOX_LICENSE_FILE, + WINSW_WRAPPER_FILE, + WINSW_SERVICE_XML_FILE, + SINGBOX_CRONET_FILE, + SINGBOX_RUNTIME_FILE, +]; - if result.success { - let refreshed = detect_singbox_install(); - let component = singbox_component_from_detection(refreshed.as_ref()); - return Ok(ComponentStatusDto::from(&component)); - } - - if matches!( - result.code.as_str(), - "start_failed" | "stop_failed" | "config_sync_failed" - ) { - run_elevated_singbox_service_command( - action, - &detected.service_name, - config_source, - config_target.as_deref(), - &result, - )?; - let refreshed = detect_singbox_install(); - let component = singbox_component_from_detection(refreshed.as_ref()); - return Ok(ComponentStatusDto::from(&component)); - } - - Err(CommandError::new( - singbox_service_error_code(action), - singbox_service_command_failed_message(action, &result), - )) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxNativeOwnership { + Missing, + Managed, + Foreign, + Incomplete, } -fn run_elevated_singbox_service_command( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, - direct_result: &SingBoxServiceCommandOutput, -) -> Result<(), CommandError> { - let script_path = - write_elevated_singbox_service_script(action, service_name, config_source, config_target)?; - let launch_script = format!( - "$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}'); exit $p.ExitCode", - escape_powershell_single(&script_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&script_path) - } else { - run_powershell_command(&launch_script) - }; - - let _ = fs::remove_file(&script_path); - - match output { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => Err(CommandError::new( - singbox_service_error_code(action), - elevated_singbox_service_failed_message(action, direct_result, output.status.code()), - )), - Err(error) => Err(CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось запросить права администратора, чтобы {} службу Local sing-box: {error}", - action.label() - ), - )), - } -} - -fn write_elevated_singbox_service_script( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, -) -> Result { - let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service"); - let script = - elevated_singbox_service_script(action, service_name, config_source, config_target); - - write_powershell_script(&script_path, &script).map_err(|error| { - CommandError::new( - singbox_service_error_code(action), - format!( - "Не удалось подготовить временный скрипт для управления Local sing-box '{}': {error}", - script_path.display() - ), - ) - })?; - - Ok(script_path) -} - -fn elevated_singbox_service_script( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, -) -> String { - let action_name = action.action_name(); - let escaped_service_name = escape_powershell_single(service_name); - let escaped_config_source = config_source - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - let escaped_config_target = config_target - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - - format!( - r#" -$ErrorActionPreference = 'SilentlyContinue' -$serviceName = '{escaped_service_name}' -$action = '{action_name}' -$configSource = '{escaped_config_source}' -$configTarget = '{escaped_config_target}' - -if ($action -eq 'start') {{ - if (-not [string]::IsNullOrWhiteSpace($configSource)) {{ - if (-not (Test-Path -LiteralPath $configSource)) {{ exit 5 }} - if (-not [string]::IsNullOrWhiteSpace($configTarget)) {{ - try {{ - Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop - }} catch {{ - exit 6 - }} - }} - }} - - $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue - if ($null -eq $service) {{ exit 2 }} - if ($service.Status -eq 'Running') {{ exit 0 }} - - Start-Service -Name $serviceName -ErrorAction SilentlyContinue - $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue - if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Running') {{ exit 0 }} - }} - - exit 3 -}} - -$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue -if ($null -eq $service) {{ exit 2 }} -if ($service.Status -eq 'Stopped') {{ exit 0 }} - -Stop-Service -Name $serviceName -Force -ErrorAction SilentlyContinue -$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue -if ($null -ne $service) {{ - try {{ $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) }} catch {{}} - if ($service.Status -eq 'Stopped') {{ exit 0 }} -}} - -exit 4 -"# - ) -} - -pub(crate) fn install_singbox_component( - storage: &JsonStorage, - install_dir: &Path, -) -> Result { - let generated_config_path = storage.paths().generated_dir.join("sing-box-config.json"); - run_elevated_singbox_package_script( - SingBoxPackageAction::Install, - include_str!("../../scripts/install-singbox.ps1"), - vec![ - "-InstallRoot".to_string(), - install_dir.display().to_string(), - "-ConfigSource".to_string(), - generated_config_path.display().to_string(), - ], - &storage.paths().state_dir, - )?; - - let refreshed = detect_singbox_install(); - let Some(detected) = refreshed.as_ref() else { - return Err(CommandError::new( - SingBoxPackageAction::Install.error_code(), - "Установка Local sing-box завершилась, но приложение не найдено после проверки.", - )); - }; - - Ok(ComponentStatusDto::from(&singbox_component_from_detection( - Some(detected), - ))) -} - -pub(crate) fn uninstall_singbox_component() -> Result { - let Some(detected) = detect_singbox_install() else { - let component = singbox_component_from_detection(None); - return Ok(ComponentStatusDto::from(&component)); - }; - - ensure_safe_singbox_install_dir(&detected.install_dir).map_err(|message| { - CommandError::new(SingBoxPackageAction::Uninstall.error_code(), message) - })?; - let artifact_dir = default_config_root().join("state"); - run_elevated_singbox_package_script( - SingBoxPackageAction::Uninstall, - include_str!("../../scripts/install-singbox.ps1"), - vec![ - "-InstallRoot".to_string(), - detected.install_dir.display().to_string(), - "-ServiceName".to_string(), - detected.service_name, - "-Uninstall".to_string(), - ], - &artifact_dir, - )?; - - let refreshed = detect_singbox_install(); - if refreshed.is_some() { - return Err(CommandError::new( - SingBoxPackageAction::Uninstall.error_code(), - "Удаление Local sing-box завершилось, но приложение все еще найдено на компьютере.", - )); - } - - let component = singbox_component_from_detection(None); - Ok(ComponentStatusDto::from(&component)) +/// A fresh handle-backed inventory. The privileged host must derive it from +/// the live root, receipt, ownership marker, fixed XML, file hashes, and SCM; +/// cached UI detection is not trusted here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxNativeSnapshot { + pub ownership: SingBoxNativeOwnership, + pub installed_version: Option, + pub wrapper_version: Option, + pub install_root_trusted: bool, + pub install_root_reparse_free: bool, + pub promoted_files_match: bool, + pub receipt_valid: bool, + pub receipt_files_match: bool, + pub marker_valid: bool, + pub marker_files_match: bool, + pub service_xml_matches: bool, + pub marker: Option, + pub service: SingBoxNativeServiceSnapshot, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SingBoxPackageAction { +pub enum SingBoxNativeMode { Install, + Update, Uninstall, } -impl SingBoxPackageAction { - fn error_code(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "singbox_install_failed", - SingBoxPackageAction::Uninstall => "singbox_uninstall_failed", - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxNativeServiceAction { + Start, + Stop, +} - fn label(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "установить", - SingBoxPackageAction::Uninstall => "удалить", - } - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxNativeOutcome { + Installed, + AlreadyInstalled, + Updated, + AlreadyCurrent, + Started, + AlreadyRunning, + Stopped, + AlreadyStopped, + Uninstalled, + AlreadyUninstalled, +} - fn file_label(self) -> &'static str { - match self { - SingBoxPackageAction::Install => "install", - SingBoxPackageAction::Uninstall => "uninstall", +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxStartPreflight { + Ready, + AlreadyRunning, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxNativeStep { + Inspect, + Stage, + Promote, + InstallService, + DeleteService, + CommitMetadata, + LeaseRuntimeConfig, + ServiceControl, + ServiceWait, + RemoveManagedFiles, + Finalize, +} + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("native sing-box host operation failed")] +pub struct SingBoxNativeHostError; + +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SingBoxNativeFinalizeError { + #[error("sing-box cleanup did not start and the operation can be rolled back")] + Reversible, + #[error("sing-box cleanup started and its fixed tombstone must be retried")] + CleanupPending, +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum SingBoxNativeError { + #[error("the privileged sing-box package lease is invalid")] + InvalidPackage, + #[error("the sing-box archive layout is invalid")] + InvalidArchive, + #[error("a foreign or colliding sing-box install exists")] + ServiceCollision, + #[error("the managed sing-box receipt or ownership marker is invalid")] + InvalidReceipt, + #[error("the sing-box install is not owned by ProxyWarden")] + OwnershipMismatch, + #[error("sing-box must be stopped for this operation")] + ServiceRunning, + #[error("the sing-box service is not in the required stable state")] + ServiceNotStopped, + #[error("the sing-box service policy does not match the managed contract")] + ServiceContract, + #[error("the selected sing-box update is not newer than the installed version")] + DowngradeRejected, + #[error("the promoted sing-box runtime config is invalid")] + RuntimeConfigInvalid, + #[error("native sing-box host step {0:?} failed")] + HostStep(SingBoxNativeStep), + #[error("sing-box cleanup is incomplete and must be retried")] + CleanupPending, + #[error("the operation failed and rollback could not restore the prior state")] + RollbackFailed, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct SingBoxStagedFile { + pub relative_path: String, + pub bytes: Vec, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SingBoxInstalledFile { + pub relative_path: String, + pub sha256: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SingBoxMarkerPackage { + pub version: String, + pub asset_name: String, + pub sha256: String, + pub size: u64, + pub source: PackageSource, +} + +impl From<&PrivilegedPackageProof> for SingBoxMarkerPackage { + fn from(proof: &PrivilegedPackageProof) -> Self { + Self { + version: proof.version.clone(), + asset_name: proof.asset_name.clone(), + sha256: proof.sha256.clone(), + size: proof.size, + source: proof.source, } } } -fn run_elevated_singbox_package_script( - action: SingBoxPackageAction, - installer_body: &str, - installer_args: Vec, - artifact_dir: &Path, -) -> Result<(), CommandError> { - fs::create_dir_all(artifact_dir).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось создать папку для временных файлов Local sing-box '{}': {error}", - artifact_dir.display() - ), - ) - })?; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SingBoxOwnershipMarker { + pub schema_version: u32, + pub service_name: String, + pub runtime: SingBoxMarkerPackage, + pub wrapper: SingBoxMarkerPackage, + pub files: Vec, +} - let prefix = format!("proxywarden-singbox-{}", action.file_label()); - let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1"); - let runner_path = - elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1"); - let result_path = - elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log"); +/// Canonical data from which the host atomically writes both the ownership +/// marker and shared install receipt. `receipt_files` contains the marker hash +/// in addition to all fixed runtime/XML hashes; the receipt itself is never +/// self-referential. +#[derive(Clone, PartialEq, Eq)] +pub struct SingBoxNativeMetadata { + pub runtime: PrivilegedPackageProof, + pub marker: SingBoxOwnershipMarker, + pub marker_bytes: Vec, + pub marker_sha256: String, + pub receipt_files: Vec, +} - write_powershell_script(&installer_path, installer_body).map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить установщик Local sing-box '{}': {error}", - installer_path.display() - ), - ) - })?; - write_powershell_script( - &runner_path, - &singbox_installer_runner_script(&installer_path, &result_path, &installer_args), - ) - .map_err(|error| { - CommandError::new( - action.error_code(), - format!( - "Не удалось подготовить runner Local sing-box '{}': {error}", - runner_path.display() - ), - ) - })?; +/// Narrow privileged OS boundary for native sing-box lifecycle work. +/// +/// There is deliberately no network or shell/process runner here. A production +/// host may only map service methods to fixed SCM calls. `stage_runtime` must +/// use a fresh sealed staging root and `promote_runtime`/`rollback` must be +/// atomic. `lease_runtime_config` must open the exact AS/SY-only regular +/// `config.json` deny-share-write/delete, hash that handle, and keep it alive +/// through `wait_service` and the final Running query. Removal is limited to a +/// freshly revalidated sealed receipt/marker inventory; it is never recursive. +/// `finalize` distinguishes failures before cleanup (safe to roll back) from a +/// retryable fixed tombstone after deletion starts. `retry_cleanup` is called +/// only from explicit install/update/uninstall actions. +pub trait SingBoxNativeHost { + type RuntimeConfigLease; - let launch_script = format!( - r#" -$ErrorActionPreference = 'Stop' -$resultPath = '{}' -try {{ - $p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}') - if ($null -eq $p) {{ - Set-Content -LiteralPath $resultPath -Value 'Elevated PowerShell не был запущен.' -Encoding UTF8 - exit 1 - }} - exit $p.ExitCode -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} -"#, - escape_powershell_single(&result_path.display().to_string()), - escape_powershell_single(&runner_path.display().to_string()) - ); - let output = if is_running_elevated() { - run_powershell_file(&runner_path) - } else { - run_powershell_command(&launch_script) - }; + fn install_root(&self) -> &Path; + fn inspect(&mut self) -> Result; + fn stage_runtime( + &mut self, + mode: SingBoxNativeMode, + files: &[SingBoxStagedFile], + runtime_version: &str, + wrapper_version: &str, + ) -> Result<(), SingBoxNativeHostError>; + fn promote_runtime(&mut self, mode: SingBoxNativeMode) -> Result<(), SingBoxNativeHostError>; + fn discard_staging(&mut self); + fn retry_cleanup(&mut self) -> Result<(), SingBoxNativeHostError>; + fn finalize(&mut self, mode: SingBoxNativeMode) -> Result<(), SingBoxNativeFinalizeError>; + fn commit_metadata( + &mut self, + mode: SingBoxNativeMode, + metadata: &SingBoxNativeMetadata, + ) -> Result<(), SingBoxNativeHostError>; + fn install_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError>; + fn delete_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError>; + fn lease_runtime_config( + &mut self, + path: &Path, + expected_sha256: &str, + ) -> Result; + fn start_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + config: &Self::RuntimeConfigLease, + ) -> Result<(), SingBoxNativeHostError>; + fn stop_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError>; + fn wait_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + state: SingBoxNativeServiceState, + config: Option<&Self::RuntimeConfigLease>, + ) -> Result<(), SingBoxNativeHostError>; + fn remove_managed_files(&mut self) -> Result<(), SingBoxNativeHostError>; + fn rollback( + &mut self, + mode: SingBoxNativeMode, + restore_or_remove_service: bool, + ) -> Result<(), SingBoxNativeHostError>; +} - let _ = fs::remove_file(&installer_path); - let _ = fs::remove_file(&runner_path); +pub enum SingBoxNativeAction<'a> { + Install { + runtime: &'a PrivilegedPackageLease, + wrapper: &'a PrivilegedPackageLease, + }, + Update { + runtime: &'a PrivilegedPackageLease, + }, + Start { + runtime_config: &'a Path, + expected_sha256: &'a str, + }, + Stop, + Uninstall, +} - match output { - Ok(output) if output.status.success() => { - let _ = fs::remove_file(&result_path); - Ok(()) +#[derive(Clone, Copy)] +struct PackageLeaseView<'a> { + proof: &'a PrivilegedPackageProof, + path: &'a Path, +} + +impl<'a> From<&'a PrivilegedPackageLease> for PackageLeaseView<'a> { + fn from(lease: &'a PrivilegedPackageLease) -> Self { + Self { + proof: lease.proof(), + path: lease.asset_path(), } - Ok(output) => { - let details = package_failure_details(&result_path, &output); - let _ = fs::remove_file(&result_path); - Err(CommandError::new( - action.error_code(), - format!( - "Не удалось {} Local sing-box. Код elevated-команды: {}. {details}", - action.label(), - output.status.code().unwrap_or(-1), - ), - )) - } - Err(error) => Err(CommandError::new( - action.error_code(), - format!( - "Не удалось запросить права администратора, чтобы {} Local sing-box: {error}", - action.label() - ), - )), } } -pub fn singbox_installer_runner_script( - installer_path: &Path, - result_path: &Path, - installer_args: &[String], -) -> String { - let args = installer_args - .iter() - .map(|arg| format!("'{}'", escape_powershell_single(arg))) - .collect::>() - .join(", "); - - format!( - r#" -$ErrorActionPreference = 'Stop' -$installerPath = '{}' -$resultPath = '{}' -$stdoutPath = "$resultPath.stdout.log" -$stderrPath = "$resultPath.stderr.log" -$installerArgs = @({args}) -try {{ - $output = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs 2>&1 - $exitCode = $LASTEXITCODE - Set-Content -LiteralPath $stdoutPath -Value ($output | Out-String) -Encoding UTF8 - if ($exitCode -ne 0) {{ - $stdout = if (Test-Path -LiteralPath $stdoutPath) {{ Get-Content -LiteralPath $stdoutPath -Raw }} else {{ '' }} - $stderr = if (Test-Path -LiteralPath $stderrPath) {{ Get-Content -LiteralPath $stderrPath -Raw }} else {{ '' }} - throw "install-singbox.ps1 завершился с кодом $exitCode. stdout: $stdout stderr: $stderr" - }} - Set-Content -LiteralPath $resultPath -Value 'ok' -Encoding UTF8 - exit 0 -}} catch {{ - Set-Content -LiteralPath $resultPath -Value ($_ | Out-String) -Encoding UTF8 - exit 1 -}} finally {{ - Remove-Item -LiteralPath $stdoutPath, $stderrPath -Force -ErrorAction SilentlyContinue -}} -"#, - escape_powershell_single(&installer_path.display().to_string()), - escape_powershell_single(&result_path.display().to_string()) - ) -} - -fn singbox_service_error_code(action: SingBoxServiceAction) -> &'static str { +pub fn run_singbox_native_action( + host: &mut H, + action: SingBoxNativeAction<'_>, +) -> Result { match action { - SingBoxServiceAction::Start => "singbox_service_start_failed", - SingBoxServiceAction::Stop => "singbox_service_stop_failed", + SingBoxNativeAction::Install { runtime, wrapper } => { + install_singbox_native(host, runtime, wrapper) + } + SingBoxNativeAction::Update { runtime } => update_singbox_native(host, runtime), + SingBoxNativeAction::Start { + runtime_config, + expected_sha256, + } => start_singbox_native(host, runtime_config, expected_sha256), + SingBoxNativeAction::Stop => stop_singbox_native(host), + SingBoxNativeAction::Uninstall => uninstall_singbox_native(host), } } -fn singbox_service_script_failed_message( - action: SingBoxServiceAction, - exit_code: Option, -) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода PowerShell: {code}.")) - .unwrap_or_default(); - - format!( - "Не удалось {} службу Local sing-box: команда управления службой не вернула корректный результат.{exit_code}", - action.label() - ) +pub fn install_singbox_native( + host: &mut H, + runtime: &PrivilegedPackageLease, + wrapper: &PrivilegedPackageLease, +) -> Result { + install_singbox_native_core(host, runtime.into(), wrapper.into()) } -fn singbox_service_command_failed_message( - action: SingBoxServiceAction, - result: &SingBoxServiceCommandOutput, -) -> String { - let service_name = result - .service_name - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("ProxyWardenSingBox"); - let status = result - .status - .as_deref() - .filter(|value| !value.trim().is_empty()) - .unwrap_or("неизвестен"); - let pid = result - .process_id - .filter(|value| *value > 0) - .map(|value| format!(", PID: {value}")) - .unwrap_or_default(); +pub fn update_singbox_native( + host: &mut H, + runtime: &PrivilegedPackageLease, +) -> Result { + update_singbox_native_core(host, runtime.into()) +} - match result.code.as_str() { - "service_not_found" => "Служба Local sing-box не найдена.".to_string(), - "config_source_missing" => { - "Сгенерированный конфиг Local sing-box не найден перед запуском службы.".to_string() +/// Read-only gate used before promoting a new sealed `config.json`. A caller +/// must skip promotion when this returns `AlreadyRunning`. `start_singbox_native` +/// intentionally repeats the live ownership/service check after promotion to +/// close the race between these two explicit phases. +pub fn preflight_start_singbox_native( + host: &mut H, +) -> Result { + let spec = service_spec(host)?; + let current = inspect_native(host)?; + validate_managed(¤t, &spec)?; + match current.service.state { + SingBoxNativeServiceState::Stopped => Ok(SingBoxStartPreflight::Ready), + SingBoxNativeServiceState::Running => Ok(SingBoxStartPreflight::AlreadyRunning), + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + Err(SingBoxNativeError::ServiceNotStopped) } - "config_sync_failed" => { - "Не удалось обновить config.json службы Local sing-box перед запуском. Попробуй запустить приложение от имени администратора.".to_string() - } - "start_failed" => format!( - "Не удалось запустить службу {service_name}. Текущий статус: {status}{pid}. Попробуй запустить приложение от имени администратора." - ), - "stop_failed" => format!( - "Не удалось остановить службу {service_name}. Текущий статус: {status}{pid}. Запусти приложение от имени администратора или останови службу вручную в services.msc." - ), - _ => format!( - "Не удалось {} службу {service_name}. Текущий статус: {status}{pid}.", - action.label() - ), } } -fn elevated_singbox_service_failed_message( - action: SingBoxServiceAction, - direct_result: &SingBoxServiceCommandOutput, - exit_code: Option, -) -> String { - let exit_code = exit_code - .map(|code| format!(" Код выхода elevated PowerShell: {code}.")) - .unwrap_or_default(); - format!( - "{} Попытка с правами администратора тоже не сработала.{exit_code}", - singbox_service_command_failed_message(action, direct_result) +pub fn start_singbox_native( + host: &mut H, + runtime_config: &Path, + expected_sha256: &str, +) -> Result { + start_singbox_native_observed(host, runtime_config, expected_sha256, || {}) +} + +pub(crate) fn start_singbox_native_observed( + host: &mut H, + runtime_config: &Path, + expected_sha256: &str, + observe: impl FnOnce(), +) -> Result { + let spec = service_spec(host)?; + if !same_windows_path( + runtime_config, + &host.install_root().join(SINGBOX_RUNTIME_CONFIG_FILE), + ) || !valid_sha256(expected_sha256) + { + return Err(SingBoxNativeError::RuntimeConfigInvalid); + } + let current = inspect_native(host)?; + validate_managed(¤t, &spec)?; + let config = host + .lease_runtime_config(runtime_config, expected_sha256) + .map_err(|_| SingBoxNativeError::RuntimeConfigInvalid)?; + match current.service.state { + SingBoxNativeServiceState::Running => { + drop(config); + return Ok(SingBoxNativeOutcome::AlreadyRunning); + } + SingBoxNativeServiceState::Stopped => {} + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + drop(config); + return Err(SingBoxNativeError::ServiceNotStopped); + } + } + if host.start_service(&spec, &config).is_err() { + let error = rollback_started_service( + host, + &spec, + &config, + SingBoxNativeError::HostStep(SingBoxNativeStep::ServiceControl), + ); + drop(config); + return Err(error); + } + if host + .wait_service(&spec, SingBoxNativeServiceState::Running, Some(&config)) + .is_err() + { + let error = rollback_started_service( + host, + &spec, + &config, + SingBoxNativeError::HostStep(SingBoxNativeStep::ServiceWait), + ); + drop(config); + return Err(error); + } + let final_state = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + let error = rollback_started_service(host, &spec, &config, error); + drop(config); + return Err(error); + } + }; + let result = validate_managed(&final_state, &spec).and_then(|()| { + if final_state.service.state == SingBoxNativeServiceState::Running { + Ok(SingBoxNativeOutcome::Started) + } else { + Err(SingBoxNativeError::ServiceContract) + } + }); + if let Err(error) = result { + let error = rollback_started_service(host, &spec, &config, error); + drop(config); + return Err(error); + } + observe(); + drop(config); + result +} + +pub fn stop_singbox_native( + host: &mut H, +) -> Result { + let spec = service_spec(host)?; + let current = inspect_native(host)?; + validate_managed(¤t, &spec)?; + match current.service.state { + SingBoxNativeServiceState::Stopped => return Ok(SingBoxNativeOutcome::AlreadyStopped), + SingBoxNativeServiceState::Running => {} + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + return Err(SingBoxNativeError::ServiceContract); + } + } + host.stop_service(&spec) + .map_err(|_| SingBoxNativeError::HostStep(SingBoxNativeStep::ServiceControl))?; + host.wait_service(&spec, SingBoxNativeServiceState::Stopped, None) + .map_err(|_| SingBoxNativeError::HostStep(SingBoxNativeStep::ServiceWait))?; + let final_state = inspect_native(host)?; + validate_managed(&final_state, &spec)?; + if final_state.service.state != SingBoxNativeServiceState::Stopped { + return Err(SingBoxNativeError::ServiceContract); + } + Ok(SingBoxNativeOutcome::Stopped) +} + +pub fn uninstall_singbox_native( + host: &mut H, +) -> Result { + uninstall_singbox_native_core(host) +} + +fn install_singbox_native_core( + host: &mut H, + runtime: PackageLeaseView<'_>, + wrapper: PackageLeaseView<'_>, +) -> Result { + let spec = service_spec(host)?; + validate_package(runtime, ComponentId::SingBox, PackageSource::Bundled)?; + validate_package(wrapper, ComponentId::Winsw, PackageSource::Bundled)?; + let mut files = read_singbox_archive(runtime)?; + files.push(read_flat_package(wrapper, WINSW_WRAPPER_FILE)?); + files.push(staged_file( + WINSW_SERVICE_XML_FILE, + singbox_service_xml().as_bytes().to_vec(), + )?); + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let metadata = build_metadata( + runtime.proof, + SingBoxMarkerPackage::from(wrapper.proof), + installed_file_inventory(&files), + )?; + + retry_pending_cleanup(host)?; + let current = inspect_native(host)?; + match current.ownership { + SingBoxNativeOwnership::Managed => { + validate_managed(¤t, &spec)?; + return Ok(SingBoxNativeOutcome::AlreadyInstalled); + } + SingBoxNativeOwnership::Foreign => return Err(SingBoxNativeError::ServiceCollision), + SingBoxNativeOwnership::Incomplete => { + return Err(SingBoxNativeError::OwnershipMismatch); + } + SingBoxNativeOwnership::Missing => {} + } + if current.service.state != SingBoxNativeServiceState::Missing { + return Err(SingBoxNativeError::ServiceCollision); + } + if !current.install_root_trusted || !current.install_root_reparse_free { + return Err(SingBoxNativeError::OwnershipMismatch); + } + + host.stage_runtime( + SingBoxNativeMode::Install, + &files, + &runtime.proof.version, + &wrapper.proof.version, + ) + .map_err(|_| { + host.discard_staging(); + SingBoxNativeError::HostStep(SingBoxNativeStep::Stage) + })?; + if host.promote_runtime(SingBoxNativeMode::Install).is_err() { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + false, + SingBoxNativeError::HostStep(SingBoxNativeStep::Promote), + )); + } + if host.install_service(&spec).is_err() { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + SingBoxNativeError::HostStep(SingBoxNativeStep::InstallService), + )); + } + let installed = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + error, + )); + } + }; + if let Err(error) = validate_promoted_runtime( + &installed, + &spec, + SingBoxNativeServiceState::Stopped, + &runtime.proof.version, + &wrapper.proof.version, + ) { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + error, + )); + } + if host + .commit_metadata(SingBoxNativeMode::Install, &metadata) + .is_err() + { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + SingBoxNativeError::HostStep(SingBoxNativeStep::CommitMetadata), + )); + } + let final_state = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + error, + )); + } + }; + if let Err(error) = validate_managed_version( + &final_state, + &spec, + SingBoxNativeServiceState::Stopped, + &runtime.proof.version, + &wrapper.proof.version, + ) { + return Err(rollback_after( + host, + SingBoxNativeMode::Install, + true, + error, + )); + } + finalize_or_rollback(host, SingBoxNativeMode::Install, true)?; + Ok(SingBoxNativeOutcome::Installed) +} + +fn update_singbox_native_core( + host: &mut H, + runtime: PackageLeaseView<'_>, +) -> Result { + let spec = service_spec(host)?; + validate_package(runtime, ComponentId::SingBox, PackageSource::Cache)?; + let files = read_singbox_archive(runtime)?; + retry_pending_cleanup(host)?; + let current = inspect_native(host)?; + validate_managed(¤t, &spec)?; + match current.service.state { + SingBoxNativeServiceState::Running => return Err(SingBoxNativeError::ServiceRunning), + SingBoxNativeServiceState::Stopped => {} + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + return Err(SingBoxNativeError::ServiceNotStopped); + } + } + let installed_version = current + .installed_version + .as_deref() + .and_then(parse_numeric_version) + .ok_or(SingBoxNativeError::InvalidReceipt)?; + let candidate_version = + parse_numeric_version(&runtime.proof.version).ok_or(SingBoxNativeError::InvalidPackage)?; + match candidate_version.cmp(&installed_version) { + std::cmp::Ordering::Equal => return Ok(SingBoxNativeOutcome::AlreadyCurrent), + std::cmp::Ordering::Less => return Err(SingBoxNativeError::DowngradeRejected), + std::cmp::Ordering::Greater => {} + } + let installed_marker = current + .marker + .as_ref() + .ok_or(SingBoxNativeError::InvalidReceipt)?; + let merged_files = merge_updated_files(&installed_marker.files, &files)?; + let metadata = build_metadata( + runtime.proof, + installed_marker.wrapper.clone(), + merged_files, + )?; + + host.stage_runtime( + SingBoxNativeMode::Update, + &files, + &runtime.proof.version, + &installed_marker.wrapper.version, + ) + .map_err(|_| { + host.discard_staging(); + SingBoxNativeError::HostStep(SingBoxNativeStep::Stage) + })?; + if host.promote_runtime(SingBoxNativeMode::Update).is_err() { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + SingBoxNativeError::HostStep(SingBoxNativeStep::Promote), + )); + } + let promoted = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + error, + )); + } + }; + if let Err(error) = validate_promoted_runtime( + &promoted, + &spec, + SingBoxNativeServiceState::Stopped, + &runtime.proof.version, + &installed_marker.wrapper.version, + ) { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + error, + )); + } + if host + .commit_metadata(SingBoxNativeMode::Update, &metadata) + .is_err() + { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + SingBoxNativeError::HostStep(SingBoxNativeStep::CommitMetadata), + )); + } + let final_state = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + error, + )); + } + }; + if let Err(error) = validate_managed_version( + &final_state, + &spec, + SingBoxNativeServiceState::Stopped, + &runtime.proof.version, + &installed_marker.wrapper.version, + ) { + return Err(rollback_after( + host, + SingBoxNativeMode::Update, + false, + error, + )); + } + finalize_or_rollback(host, SingBoxNativeMode::Update, false)?; + Ok(SingBoxNativeOutcome::Updated) +} + +fn uninstall_singbox_native_core( + host: &mut H, +) -> Result { + let spec = service_spec(host)?; + retry_pending_cleanup(host)?; + let current = inspect_native(host)?; + if current.ownership == SingBoxNativeOwnership::Missing + && current.service.state == SingBoxNativeServiceState::Missing + { + return Ok(SingBoxNativeOutcome::AlreadyUninstalled); + } + validate_managed(¤t, &spec)?; + match current.service.state { + SingBoxNativeServiceState::Running => return Err(SingBoxNativeError::ServiceRunning), + SingBoxNativeServiceState::Stopped => {} + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + return Err(SingBoxNativeError::ServiceNotStopped); + } + } + if host.delete_service(&spec).is_err() { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + SingBoxNativeError::HostStep(SingBoxNativeStep::DeleteService), + )); + } + let service_removed = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + error, + )); + } + }; + if service_removed.service.state != SingBoxNativeServiceState::Missing { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + SingBoxNativeError::ServiceContract, + )); + } + if host.remove_managed_files().is_err() { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + SingBoxNativeError::HostStep(SingBoxNativeStep::RemoveManagedFiles), + )); + } + let final_state = match inspect_native(host) { + Ok(snapshot) => snapshot, + Err(error) => { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + error, + )); + } + }; + if final_state.ownership != SingBoxNativeOwnership::Missing + || final_state.service.state != SingBoxNativeServiceState::Missing + { + return Err(rollback_after( + host, + SingBoxNativeMode::Uninstall, + true, + SingBoxNativeError::OwnershipMismatch, + )); + } + finalize_or_rollback(host, SingBoxNativeMode::Uninstall, true)?; + Ok(SingBoxNativeOutcome::Uninstalled) +} + +fn service_spec( + host: &H, +) -> Result { + SingBoxServiceInstallSpec::for_install_root(host.install_root()) + .ok_or(SingBoxNativeError::OwnershipMismatch) +} + +fn inspect_native( + host: &mut H, +) -> Result { + host.inspect() + .map_err(|_| SingBoxNativeError::HostStep(SingBoxNativeStep::Inspect)) +} + +fn validate_managed( + snapshot: &SingBoxNativeSnapshot, + spec: &SingBoxServiceInstallSpec, +) -> Result<(), SingBoxNativeError> { + match snapshot.ownership { + SingBoxNativeOwnership::Managed => {} + SingBoxNativeOwnership::Foreign => return Err(SingBoxNativeError::ServiceCollision), + SingBoxNativeOwnership::Missing | SingBoxNativeOwnership::Incomplete => { + return Err(SingBoxNativeError::OwnershipMismatch); + } + } + if !snapshot.receipt_valid + || !snapshot.receipt_files_match + || !snapshot.marker_valid + || !snapshot.marker_files_match + { + return Err(SingBoxNativeError::InvalidReceipt); + } + let marker = snapshot + .marker + .as_ref() + .ok_or(SingBoxNativeError::InvalidReceipt)?; + validate_marker(marker)?; + if snapshot.installed_version.as_deref() != Some(marker.runtime.version.as_str()) + || snapshot.wrapper_version.as_deref() != Some(marker.wrapper.version.as_str()) + { + return Err(SingBoxNativeError::InvalidReceipt); + } + validate_promoted_runtime( + snapshot, + spec, + snapshot.service.state, + &marker.runtime.version, + &marker.wrapper.version, ) } + +fn validate_managed_version( + snapshot: &SingBoxNativeSnapshot, + spec: &SingBoxServiceInstallSpec, + state: SingBoxNativeServiceState, + runtime_version: &str, + wrapper_version: &str, +) -> Result<(), SingBoxNativeError> { + validate_managed(snapshot, spec)?; + if snapshot.service.state != state + || snapshot.installed_version.as_deref() != Some(runtime_version) + || snapshot.wrapper_version.as_deref() != Some(wrapper_version) + { + return Err(SingBoxNativeError::ServiceContract); + } + Ok(()) +} + +fn validate_promoted_runtime( + snapshot: &SingBoxNativeSnapshot, + spec: &SingBoxServiceInstallSpec, + state: SingBoxNativeServiceState, + runtime_version: &str, + wrapper_version: &str, +) -> Result<(), SingBoxNativeError> { + if !snapshot.install_root_trusted + || !snapshot.install_root_reparse_free + || !snapshot.promoted_files_match + || !snapshot.service_xml_matches + { + return Err(SingBoxNativeError::OwnershipMismatch); + } + if snapshot.service.state != state + || !snapshot.service.matches_managed_policy(spec) + || snapshot.installed_version.as_deref() != Some(runtime_version) + || snapshot.wrapper_version.as_deref() != Some(wrapper_version) + { + return Err(SingBoxNativeError::ServiceContract); + } + Ok(()) +} + +fn validate_marker(marker: &SingBoxOwnershipMarker) -> Result<(), SingBoxNativeError> { + if marker.schema_version != SINGBOX_OWNERSHIP_MARKER_SCHEMA_VERSION + || marker.service_name != spec_service_name() + || !valid_marker_package(&marker.runtime, ComponentId::SingBox) + || !valid_marker_package(&marker.wrapper, ComponentId::Winsw) + || marker.wrapper.source != PackageSource::Bundled + || marker.files.len() != SINGBOX_MANAGED_RUNTIME_FILES.len() + { + return Err(SingBoxNativeError::InvalidReceipt); + } + let mut paths = HashSet::new(); + for file in &marker.files { + if !SINGBOX_MANAGED_RUNTIME_FILES.contains(&file.relative_path.as_str()) + || !paths.insert(file.relative_path.to_ascii_lowercase()) + || file.size == 0 + || !valid_sha256(&file.sha256) + { + return Err(SingBoxNativeError::InvalidReceipt); + } + } + Ok(()) +} + +fn valid_marker_package(package: &SingBoxMarkerPackage, component: ComponentId) -> bool { + package.size > 0 + && parse_numeric_version(&package.version).is_some() + && valid_sha256(&package.sha256) + && valid_asset_name(component, &package.version, &package.asset_name) + && match component { + ComponentId::SingBox => matches!( + package.source, + PackageSource::Bundled | PackageSource::Cache + ), + ComponentId::Winsw => package.source == PackageSource::Bundled, + ComponentId::Proxifyre | ComponentId::WindowsPacketFilter | ComponentId::VcRuntime => { + false + } + } +} + +fn spec_service_name() -> &'static str { + crate::models::DEFAULT_LOCAL_SINGBOX_SERVICE_NAME +} + +fn rollback_after( + host: &mut H, + mode: SingBoxNativeMode, + restore_or_remove_service: bool, + original: SingBoxNativeError, +) -> SingBoxNativeError { + host.discard_staging(); + match host.rollback(mode, restore_or_remove_service) { + Ok(()) => original, + Err(_) => SingBoxNativeError::RollbackFailed, + } +} + +fn finalize_or_rollback( + host: &mut H, + mode: SingBoxNativeMode, + restore_or_remove_service: bool, +) -> Result<(), SingBoxNativeError> { + match host.finalize(mode) { + Ok(()) => Ok(()), + Err(SingBoxNativeFinalizeError::Reversible) => Err(rollback_after( + host, + mode, + restore_or_remove_service, + SingBoxNativeError::HostStep(SingBoxNativeStep::Finalize), + )), + Err(SingBoxNativeFinalizeError::CleanupPending) => Err(SingBoxNativeError::CleanupPending), + } +} + +fn retry_pending_cleanup(host: &mut H) -> Result<(), SingBoxNativeError> { + host.retry_cleanup() + .map_err(|_| SingBoxNativeError::CleanupPending) +} + +fn rollback_started_service( + host: &mut H, + spec: &SingBoxServiceInstallSpec, + config: &H::RuntimeConfigLease, + original: SingBoxNativeError, +) -> SingBoxNativeError { + if host.stop_service(spec).is_err() + || host + .wait_service(spec, SingBoxNativeServiceState::Stopped, Some(config)) + .is_err() + { + SingBoxNativeError::RollbackFailed + } else { + original + } +} + +fn validate_package( + lease: PackageLeaseView<'_>, + component: ComponentId, + source: PackageSource, +) -> Result<(), SingBoxNativeError> { + let proof = lease.proof; + let max_size = match component { + ComponentId::SingBox => MAX_SINGBOX_PACKAGE_BYTES, + ComponentId::Winsw => MAX_WINSW_PACKAGE_BYTES, + ComponentId::Proxifyre | ComponentId::WindowsPacketFilter | ComponentId::VcRuntime => { + return Err(SingBoxNativeError::InvalidPackage); + } + }; + if proof.component_id != component + || proof.source != source + || proof.size == 0 + || proof.size > max_size + || parse_numeric_version(&proof.version).is_none() + || !valid_sha256(&proof.sha256) + || !valid_asset_name(component, &proof.version, &proof.asset_name) + || !lease.path.is_absolute() + || lease.path.file_name().and_then(|name| name.to_str()) != Some(&proof.asset_name) + { + return Err(SingBoxNativeError::InvalidPackage); + } + match source { + PackageSource::Bundled if proof.independent_proof.is_some() => { + return Err(SingBoxNativeError::InvalidPackage); + } + PackageSource::Cache => { + let independent = proof + .independent_proof + .as_ref() + .ok_or(SingBoxNativeError::InvalidPackage)?; + if component != ComponentId::SingBox + || independent.repository != "SagerNet/sing-box" + || independent.release_id == 0 + || independent.asset_id == 0 + || independent.stable_tag != format!("v{}", proof.version) + || independent.asset_name != proof.asset_name + || independent.size != proof.size + || independent.sha256_from_api != proof.sha256 + || !independent.verified_signatures.is_empty() + { + return Err(SingBoxNativeError::InvalidPackage); + } + } + PackageSource::Bundled => {} + } + verify_package_file(lease) +} + +fn verify_package_file(lease: PackageLeaseView<'_>) -> Result<(), SingBoxNativeError> { + let metadata = + fs::symlink_metadata(lease.path).map_err(|_| SingBoxNativeError::InvalidPackage)?; + if !metadata.is_file() + || metadata.file_type().is_symlink() + || metadata.len() != lease.proof.size + { + return Err(SingBoxNativeError::InvalidPackage); + } + let file = File::open(lease.path).map_err(|_| SingBoxNativeError::InvalidPackage)?; + let mut reader = file.take(lease.proof.size.saturating_add(1)); + let mut hasher = Sha256::new(); + let mut read = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|_| SingBoxNativeError::InvalidPackage)?; + if count == 0 { + break; + } + read = read + .checked_add(count as u64) + .ok_or(SingBoxNativeError::InvalidPackage)?; + hasher.update(&buffer[..count]); + } + if read != lease.proof.size || format!("{:x}", hasher.finalize()) != lease.proof.sha256 { + return Err(SingBoxNativeError::InvalidPackage); + } + Ok(()) +} + +fn read_singbox_archive( + lease: PackageLeaseView<'_>, +) -> Result, SingBoxNativeError> { + let file = File::open(lease.path).map_err(|_| SingBoxNativeError::InvalidArchive)?; + let mut archive = zip::ZipArchive::new(file).map_err(|_| SingBoxNativeError::InvalidArchive)?; + if archive.len() != MAX_SINGBOX_ARCHIVE_ENTRIES { + return Err(SingBoxNativeError::InvalidArchive); + } + let prefix = format!("sing-box-{}-windows-amd64/", lease.proof.version); + let expected = [ + ( + format!("{prefix}{SINGBOX_LICENSE_FILE}"), + SINGBOX_LICENSE_FILE, + ), + ( + format!("{prefix}{SINGBOX_CRONET_FILE}"), + SINGBOX_CRONET_FILE, + ), + ( + format!("{prefix}{SINGBOX_RUNTIME_FILE}"), + SINGBOX_RUNTIME_FILE, + ), + ]; + let mut remaining: HashSet = expected.iter().map(|(path, _)| path.clone()).collect(); + let mut case_folded = HashSet::new(); + let mut total = 0_u64; + let mut files = Vec::with_capacity(expected.len()); + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|_| SingBoxNativeError::InvalidArchive)?; + let name = entry.name().to_owned(); + let folded = name.to_ascii_lowercase(); + let Some(output_name) = expected + .iter() + .find_map(|(expected_path, output)| (expected_path == &name).then_some(*output)) + else { + return Err(SingBoxNativeError::InvalidArchive); + }; + if entry.is_dir() + || !remaining.remove(&name) + || !case_folded.insert(folded) + || entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + || entry.size() == 0 + || entry.size() > MAX_SINGBOX_ARCHIVE_FILE_BYTES + { + return Err(SingBoxNativeError::InvalidArchive); + } + total = total + .checked_add(entry.size()) + .filter(|value| *value <= MAX_SINGBOX_EXPANDED_BYTES) + .ok_or(SingBoxNativeError::InvalidArchive)?; + let expected_size = entry.size(); + let capacity = + usize::try_from(expected_size).map_err(|_| SingBoxNativeError::InvalidArchive)?; + let mut bytes = Vec::with_capacity(capacity); + entry + .take(expected_size.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| SingBoxNativeError::InvalidArchive)?; + if bytes.len() as u64 != expected_size { + return Err(SingBoxNativeError::InvalidArchive); + } + files.push(staged_file(output_name, bytes)?); + } + if !remaining.is_empty() { + return Err(SingBoxNativeError::InvalidArchive); + } + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(files) +} + +fn read_flat_package( + lease: PackageLeaseView<'_>, + relative_path: &str, +) -> Result { + let capacity = + usize::try_from(lease.proof.size).map_err(|_| SingBoxNativeError::InvalidPackage)?; + let file = File::open(lease.path).map_err(|_| SingBoxNativeError::InvalidPackage)?; + let mut bytes = Vec::with_capacity(capacity); + file.take(lease.proof.size.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| SingBoxNativeError::InvalidPackage)?; + if bytes.len() as u64 != lease.proof.size + || format!("{:x}", Sha256::digest(&bytes)) != lease.proof.sha256 + { + return Err(SingBoxNativeError::InvalidPackage); + } + staged_file(relative_path, bytes) +} + +fn staged_file( + relative_path: &str, + bytes: Vec, +) -> Result { + let size = u64::try_from(bytes.len()).map_err(|_| SingBoxNativeError::InvalidArchive)?; + if size == 0 { + return Err(SingBoxNativeError::InvalidArchive); + } + Ok(SingBoxStagedFile { + relative_path: relative_path.to_string(), + sha256: format!("{:x}", Sha256::digest(&bytes)), + size, + bytes, + }) +} + +fn installed_file_inventory(files: &[SingBoxStagedFile]) -> Vec { + files + .iter() + .map(|file| SingBoxInstalledFile { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect() +} + +fn merge_updated_files( + installed: &[SingBoxInstalledFile], + updates: &[SingBoxStagedFile], +) -> Result, SingBoxNativeError> { + let mut merged = installed.to_vec(); + for update in installed_file_inventory(updates) { + let Some(existing) = merged.iter_mut().find(|file| { + file.relative_path + .eq_ignore_ascii_case(&update.relative_path) + }) else { + return Err(SingBoxNativeError::InvalidReceipt); + }; + *existing = update; + } + merged.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + if merged.len() != SINGBOX_MANAGED_RUNTIME_FILES.len() { + return Err(SingBoxNativeError::InvalidReceipt); + } + Ok(merged) +} + +fn build_metadata( + runtime: &PrivilegedPackageProof, + wrapper: SingBoxMarkerPackage, + mut files: Vec, +) -> Result { + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let marker = SingBoxOwnershipMarker { + schema_version: SINGBOX_OWNERSHIP_MARKER_SCHEMA_VERSION, + service_name: spec_service_name().to_string(), + runtime: SingBoxMarkerPackage::from(runtime), + wrapper, + files, + }; + validate_marker(&marker)?; + let marker_bytes = + serde_json::to_vec_pretty(&marker).map_err(|_| SingBoxNativeError::InvalidReceipt)?; + let marker_size = + u64::try_from(marker_bytes.len()).map_err(|_| SingBoxNativeError::InvalidReceipt)?; + let marker_sha256 = format!("{:x}", Sha256::digest(&marker_bytes)); + let mut receipt_files = marker.files.clone(); + receipt_files.push(SingBoxInstalledFile { + relative_path: SINGBOX_OWNERSHIP_MARKER_FILE.to_string(), + sha256: marker_sha256.clone(), + size: marker_size, + }); + receipt_files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(SingBoxNativeMetadata { + runtime: runtime.clone(), + marker, + marker_bytes, + marker_sha256, + receipt_files, + }) +} + +fn valid_asset_name(component: ComponentId, version: &str, asset_name: &str) -> bool { + match component { + ComponentId::SingBox => asset_name == format!("sing-box-{version}-windows-amd64.zip"), + ComponentId::Winsw => asset_name == "WinSW.NET461.exe", + ComponentId::Proxifyre | ComponentId::WindowsPacketFilter | ComponentId::VcRuntime => false, + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn parse_numeric_version(value: &str) -> Option> { + let parts: Vec<_> = value.split('.').collect(); + if parts.is_empty() || parts.len() > 4 { + return None; + } + parts + .into_iter() + .map(|part| { + if part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()) { + None + } else { + part.parse().ok() + } + }) + .collect() +} + +fn same_windows_path(left: &Path, right: &Path) -> bool { + if !left.is_absolute() || !right.is_absolute() { + return false; + } + normalize_windows_path(&left.to_string_lossy()) + == normalize_windows_path(&right.to_string_lossy()) +} + +fn normalize_windows_path(value: &str) -> String { + value + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} + +#[cfg(test)] +#[path = "singbox_runtime/native_tests.rs"] +mod native_tests; + +pub fn run_singbox_lifecycle_entrypoint( + inventory: &ComponentInventory, + action: InventoryAction, + runner: impl FnOnce(Option<&ComponentCandidate>) -> Result, +) -> Result { + run_authorized_component_action(inventory, action, runner).map_err(authorized_action_error) +} + +pub fn run_singbox_config_check_entrypoint( + inventory: &ComponentInventory, + runner: impl FnOnce(Option<&Path>) -> Result, +) -> Result { + if inventory.classification() == ComponentClassification::Missing { + return runner(None); + } + + run_singbox_lifecycle_entrypoint(inventory, InventoryAction::CheckBinary, |candidate| { + let binary_path = candidate + .and_then(|candidate| candidate.executable_path.as_deref()) + .ok_or_else(|| { + CommandError::new( + "component_incomplete", + "Управляемый sing-box.exe отсутствует; проверка конфига заблокирована.", + ) + })?; + runner(Some(binary_path)) + }) +} + +fn inventory_command_error(issue: InventoryIssue) -> CommandError { + CommandError::new(issue.code, issue.message) +} + +fn authorized_action_error(error: AuthorizedActionError) -> CommandError { + match error { + AuthorizedActionError::Denied(issue) => inventory_command_error(issue), + AuthorizedActionError::Runner(error) => error, + } +} diff --git a/src-tauri/src/singbox_runtime/native_tests.rs b/src-tauri/src/singbox_runtime/native_tests.rs new file mode 100644 index 0000000..9a0c445 --- /dev/null +++ b/src-tauri/src/singbox_runtime/native_tests.rs @@ -0,0 +1,1072 @@ +use super::*; +use crate::component_packages::{GithubReleaseDigestProof, VerifiedSignatureEvidence}; +use std::cell::Cell; +use std::collections::VecDeque; +use std::fs; +use std::io::Write; +use std::rc::Rc; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CallKind { + RetryCleanup, + Inspect, + Stage, + Promote, + CommitMetadata, + InstallService, + DeleteService, + LeaseConfig, + StartService, + StopService, + WaitService, + RemoveManagedFiles, + Finalize, + Rollback, +} + +struct FakeConfigLease { + live: Rc>, +} + +impl Drop for FakeConfigLease { + fn drop(&mut self) { + self.live.set(false); + } +} + +struct FakeHost { + root: PathBuf, + snapshots: VecDeque, + last_snapshot: SingBoxNativeSnapshot, + calls: Vec, + fail_on: Option, + cleanup_pending_after: Option, + staged: Vec<(SingBoxNativeMode, Vec, String, String)>, + metadata: Option<(SingBoxNativeMode, SingBoxNativeMetadata)>, + installed_spec: Option, + rollback: Option<(SingBoxNativeMode, bool)>, + config_ok: bool, + config_live: Rc>, + running_inspect_held_config: bool, +} + +impl FakeHost { + fn new(root: PathBuf, snapshots: Vec) -> Self { + let snapshots = VecDeque::from(snapshots); + let last_snapshot = snapshots + .back() + .cloned() + .unwrap_or_else(|| missing_snapshot(&root)); + Self { + root, + snapshots, + last_snapshot, + calls: Vec::new(), + fail_on: None, + cleanup_pending_after: None, + staged: Vec::new(), + metadata: None, + installed_spec: None, + rollback: None, + config_ok: true, + config_live: Rc::new(Cell::new(false)), + running_inspect_held_config: false, + } + } + + fn record(&mut self, call: CallKind) -> Result<(), SingBoxNativeHostError> { + self.calls.push(call); + if self.fail_on == Some(call) { + Err(SingBoxNativeHostError) + } else { + Ok(()) + } + } + + fn mutation_calls(&self) -> Vec { + self.calls + .iter() + .copied() + .filter(|call| { + matches!( + call, + CallKind::Stage + | CallKind::Promote + | CallKind::CommitMetadata + | CallKind::InstallService + | CallKind::DeleteService + | CallKind::StartService + | CallKind::StopService + | CallKind::RemoveManagedFiles + | CallKind::Finalize + | CallKind::Rollback + ) + }) + .collect() + } +} + +impl SingBoxNativeHost for FakeHost { + type RuntimeConfigLease = FakeConfigLease; + + fn install_root(&self) -> &Path { + &self.root + } + + fn inspect(&mut self) -> Result { + self.record(CallKind::Inspect)?; + let snapshot = self + .snapshots + .pop_front() + .unwrap_or_else(|| self.last_snapshot.clone()); + self.last_snapshot = snapshot.clone(); + if snapshot.service.state == SingBoxNativeServiceState::Running && self.config_live.get() { + self.running_inspect_held_config = true; + } + Ok(snapshot) + } + + fn stage_runtime( + &mut self, + mode: SingBoxNativeMode, + files: &[SingBoxStagedFile], + runtime_version: &str, + wrapper_version: &str, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::Stage)?; + self.staged.push(( + mode, + files.to_vec(), + runtime_version.to_string(), + wrapper_version.to_string(), + )); + Ok(()) + } + + fn promote_runtime(&mut self, _mode: SingBoxNativeMode) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::Promote) + } + + fn discard_staging(&mut self) {} + + fn retry_cleanup(&mut self) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::RetryCleanup) + } + + fn finalize(&mut self, _mode: SingBoxNativeMode) -> Result<(), SingBoxNativeFinalizeError> { + self.record(CallKind::Finalize) + .map_err(|_| SingBoxNativeFinalizeError::Reversible)?; + if self.cleanup_pending_after.is_some() { + Err(SingBoxNativeFinalizeError::CleanupPending) + } else { + Ok(()) + } + } + + fn commit_metadata( + &mut self, + mode: SingBoxNativeMode, + metadata: &SingBoxNativeMetadata, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::CommitMetadata)?; + self.metadata = Some((mode, metadata.clone())); + Ok(()) + } + + fn install_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::InstallService)?; + self.installed_spec = Some(spec.clone()); + Ok(()) + } + + fn delete_service( + &mut self, + _spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::DeleteService) + } + + fn lease_runtime_config( + &mut self, + _path: &Path, + _expected_sha256: &str, + ) -> Result { + self.record(CallKind::LeaseConfig)?; + if !self.config_ok { + return Err(SingBoxNativeHostError); + } + self.config_live.set(true); + Ok(FakeConfigLease { + live: Rc::clone(&self.config_live), + }) + } + + fn start_service( + &mut self, + _spec: &SingBoxServiceInstallSpec, + config: &Self::RuntimeConfigLease, + ) -> Result<(), SingBoxNativeHostError> { + assert!(config.live.get(), "config lease must cover StartService"); + self.record(CallKind::StartService) + } + + fn stop_service( + &mut self, + _spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::StopService) + } + + fn wait_service( + &mut self, + _spec: &SingBoxServiceInstallSpec, + _state: SingBoxNativeServiceState, + config: Option<&Self::RuntimeConfigLease>, + ) -> Result<(), SingBoxNativeHostError> { + if let Some(config) = config { + assert!(config.live.get(), "config lease must cover service wait"); + } + self.record(CallKind::WaitService) + } + + fn remove_managed_files(&mut self) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::RemoveManagedFiles) + } + + fn rollback( + &mut self, + mode: SingBoxNativeMode, + restore_or_remove_service: bool, + ) -> Result<(), SingBoxNativeHostError> { + self.record(CallKind::Rollback)?; + self.rollback = Some((mode, restore_or_remove_service)); + Ok(()) + } +} + +#[test] +fn fresh_install_is_offline_by_api_shape_stays_stopped_and_uses_fixed_service_contract() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (runtime_path, runtime_proof) = runtime_package(&temp, "1.13.19", PackageSource::Bundled); + let (wrapper_path, wrapper_proof) = wrapper_package(&temp); + let runtime = package_view(&runtime_path, &runtime_proof); + let wrapper = package_view(&wrapper_path, &wrapper_proof); + let files = full_installed_files(runtime, wrapper); + let metadata = build_metadata( + runtime.proof, + SingBoxMarkerPackage::from(wrapper.proof), + installed_file_inventory(&files), + ) + .expect("metadata"); + let mut host = FakeHost::new( + root.clone(), + vec![ + missing_snapshot(&root), + promoted_snapshot( + &root, + "1.13.19", + "2.12.0", + SingBoxNativeServiceState::Stopped, + ), + managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ), + ], + ); + + let outcome = install_singbox_native_core(&mut host, runtime, wrapper).expect("install"); + + assert_eq!(outcome, SingBoxNativeOutcome::Installed); + assert!(!host.calls.contains(&CallKind::StartService)); + assert!(!host.calls.contains(&CallKind::WaitService)); + let spec = host.installed_spec.expect("fixed service spec"); + assert_eq!(spec.service_name, "ProxyWardenSingBox"); + assert_eq!(spec.wrapper_path, root.join(WINSW_WRAPPER_FILE)); + assert_eq!( + spec.command_line, + format!("\"{}\"", spec.wrapper_path.display()) + ); + assert!(spec.demand_start); + assert!(spec.failure_recovery_disabled); + assert!(!spec.builtin_users_can_start); + let staged = &host.staged[0].1; + assert_eq!(host.staged[0].2, "1.13.19"); + assert_eq!(host.staged[0].3, "2.12.0"); + assert_eq!( + staged + .iter() + .map(|file| file.relative_path.as_str()) + .collect::>(), + vec![ + SINGBOX_LICENSE_FILE, + WINSW_WRAPPER_FILE, + WINSW_SERVICE_XML_FILE, + SINGBOX_CRONET_FILE, + SINGBOX_RUNTIME_FILE, + ] + ); + let xml = staged + .iter() + .find(|file| file.relative_path == WINSW_SERVICE_XML_FILE) + .expect("xml"); + assert_eq!(xml.bytes, singbox_service_xml().as_bytes()); + let (_, committed) = host.metadata.expect("metadata committed"); + assert_eq!(committed.marker, metadata.marker); + assert_eq!(committed.receipt_files.len(), 6); + assert!(committed + .receipt_files + .iter() + .any(|file| file.relative_path == SINGBOX_OWNERSHIP_MARKER_FILE + && file.sha256 == committed.marker_sha256)); +} + +#[test] +fn collision_and_unsafe_zip_reach_no_mutating_host_or_service_runner() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (runtime_path, runtime_proof) = runtime_package(&temp, "1.13.19", PackageSource::Bundled); + let (wrapper_path, wrapper_proof) = wrapper_package(&temp); + let mut collision = missing_snapshot(&root); + collision.ownership = SingBoxNativeOwnership::Foreign; + let mut host = FakeHost::new(root.clone(), vec![collision]); + + let error = install_singbox_native_core( + &mut host, + package_view(&runtime_path, &runtime_proof), + package_view(&wrapper_path, &wrapper_proof), + ) + .expect_err("collision must fail"); + assert_eq!(error, SingBoxNativeError::ServiceCollision); + assert!(host.mutation_calls().is_empty()); + + let (unsafe_path, unsafe_proof) = unsafe_runtime_package(&temp, "1.13.20"); + let mut unsafe_host = FakeHost::new(root, Vec::new()); + let error = install_singbox_native_core( + &mut unsafe_host, + package_view(&unsafe_path, &unsafe_proof), + package_view(&wrapper_path, &wrapper_proof), + ) + .expect_err("unsafe archive must fail"); + assert_eq!(error, SingBoxNativeError::InvalidArchive); + assert!(unsafe_host.calls.is_empty()); +} + +#[test] +fn invalid_receipt_and_config_mismatch_run_no_service_mutation() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (update_path, update_proof) = runtime_package(&temp, "1.13.20", PackageSource::Cache); + let mut invalid = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + invalid.receipt_valid = false; + let mut update_host = FakeHost::new(root.clone(), vec![invalid]); + let error = + update_singbox_native_core(&mut update_host, package_view(&update_path, &update_proof)) + .expect_err("invalid receipt must fail"); + assert_eq!(error, SingBoxNativeError::InvalidReceipt); + assert!(update_host.mutation_calls().is_empty()); + + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut start_host = FakeHost::new(root.clone(), vec![stopped]); + start_host.config_ok = false; + let error = start_singbox_native( + &mut start_host, + &root.join(SINGBOX_RUNTIME_CONFIG_FILE), + &"a".repeat(64), + ) + .expect_err("weak config must fail"); + assert_eq!(error, SingBoxNativeError::RuntimeConfigInvalid); + assert!(!start_host.calls.contains(&CallKind::StartService)); + assert!(start_host.mutation_calls().is_empty()); +} + +#[test] +fn pending_tombstone_is_retried_before_update_inventory_or_mutation() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (update_path, update_proof) = runtime_package(&temp, "1.13.20", PackageSource::Cache); + let mut host = FakeHost::new( + root.clone(), + vec![managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + )], + ); + host.fail_on = Some(CallKind::RetryCleanup); + + let error = update_singbox_native_core(&mut host, package_view(&update_path, &update_proof)) + .expect_err("pending tombstone must block a new update"); + + assert_eq!(error, SingBoxNativeError::CleanupPending); + assert_eq!(host.calls, vec![CallKind::RetryCleanup]); + assert!(host.staged.is_empty()); + assert_eq!(host.rollback, None); +} + +#[test] +fn cached_update_is_stopped_only_preserves_winsw_and_never_restarts() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (update_path, update_proof) = runtime_package(&temp, "1.13.20", PackageSource::Cache); + let running = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Running, + ); + let mut running_host = FakeHost::new(root.clone(), vec![running]); + let error = + update_singbox_native_core(&mut running_host, package_view(&update_path, &update_proof)) + .expect_err("running update must fail"); + assert_eq!(error, SingBoxNativeError::ServiceRunning); + assert!(running_host.mutation_calls().is_empty()); + + let mut host = FakeHost::new( + root.clone(), + vec![ + managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ), + promoted_snapshot( + &root, + "1.13.20", + "2.12.0", + SingBoxNativeServiceState::Stopped, + ), + managed_snapshot( + &root, + "1.13.20", + "2.12.0", + PackageSource::Cache, + SingBoxNativeServiceState::Stopped, + ), + ], + ); + let outcome = update_singbox_native_core(&mut host, package_view(&update_path, &update_proof)) + .expect("update"); + assert_eq!(outcome, SingBoxNativeOutcome::Updated); + assert!(!host.calls.contains(&CallKind::StartService)); + assert!(!host.calls.contains(&CallKind::StopService)); + assert!(!host.calls.contains(&CallKind::InstallService)); + assert_eq!( + host.staged[0] + .1 + .iter() + .map(|file| file.relative_path.as_str()) + .collect::>(), + vec![ + SINGBOX_LICENSE_FILE, + SINGBOX_CRONET_FILE, + SINGBOX_RUNTIME_FILE + ] + ); + assert_eq!(host.staged[0].2, "1.13.20"); + assert_eq!(host.staged[0].3, "2.12.0"); + let (_, metadata) = host.metadata.expect("update metadata"); + assert_eq!(metadata.marker.wrapper.version, "2.12.0"); + assert_eq!(metadata.marker.wrapper.source, PackageSource::Bundled); + assert_eq!(metadata.marker.runtime.source, PackageSource::Cache); + assert!(host.calls.contains(&CallKind::Finalize)); +} + +#[test] +fn update_finalizer_failure_rolls_back_instead_of_reporting_success() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (update_path, update_proof) = runtime_package(&temp, "1.13.20", PackageSource::Cache); + let mut host = FakeHost::new( + root.clone(), + vec![ + managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ), + promoted_snapshot( + &root, + "1.13.20", + "2.12.0", + SingBoxNativeServiceState::Stopped, + ), + managed_snapshot( + &root, + "1.13.20", + "2.12.0", + PackageSource::Cache, + SingBoxNativeServiceState::Stopped, + ), + ], + ); + host.fail_on = Some(CallKind::Finalize); + + let error = update_singbox_native_core(&mut host, package_view(&update_path, &update_proof)) + .expect_err("failed backup cleanup must fail the update"); + + assert_eq!( + error, + SingBoxNativeError::HostStep(SingBoxNativeStep::Finalize) + ); + assert_eq!(host.rollback, Some((SingBoxNativeMode::Update, false))); + assert!(matches!( + host.calls.as_slice(), + [.., CallKind::Finalize, CallKind::Rollback] + )); +} + +#[test] +fn update_cleanup_failure_after_deletion_keeps_tombstone_and_never_rolls_back() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (update_path, update_proof) = runtime_package(&temp, "1.13.20", PackageSource::Cache); + let mut host = FakeHost::new( + root.clone(), + vec![ + managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ), + promoted_snapshot( + &root, + "1.13.20", + "2.12.0", + SingBoxNativeServiceState::Stopped, + ), + managed_snapshot( + &root, + "1.13.20", + "2.12.0", + PackageSource::Cache, + SingBoxNativeServiceState::Stopped, + ), + ], + ); + host.cleanup_pending_after = Some(2); + + let error = update_singbox_native_core(&mut host, package_view(&update_path, &update_proof)) + .expect_err("partial tombstone cleanup must be observable"); + + assert_eq!(error, SingBoxNativeError::CleanupPending); + assert_eq!(host.cleanup_pending_after, Some(2)); + assert_eq!(host.rollback, None); + assert_eq!(host.calls.last(), Some(&CallKind::Finalize)); + assert!(!host.calls.contains(&CallKind::Rollback)); +} + +#[test] +fn start_holds_sealed_config_lease_through_running_query_and_stop_has_no_restart() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let running = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Running, + ); + let mut start_host = FakeHost::new(root.clone(), vec![stopped, running.clone()]); + let outcome = start_singbox_native( + &mut start_host, + &root.join(SINGBOX_RUNTIME_CONFIG_FILE), + &"b".repeat(64), + ) + .expect("start"); + assert_eq!(outcome, SingBoxNativeOutcome::Started); + assert!(start_host.running_inspect_held_config); + assert!(!start_host.calls.iter().any(|call| { + matches!( + call, + CallKind::RetryCleanup + | CallKind::Stage + | CallKind::Promote + | CallKind::CommitMetadata + | CallKind::RemoveManagedFiles + | CallKind::Finalize + ) + })); + assert!( + !start_host.config_live.get(), + "lease must close after Running query" + ); + + let stopped_again = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut stop_host = FakeHost::new(root, vec![running, stopped_again]); + let outcome = stop_singbox_native(&mut stop_host).expect("stop"); + assert_eq!(outcome, SingBoxNativeOutcome::Stopped); + assert_eq!( + stop_host + .calls + .iter() + .filter(|call| **call == CallKind::StopService) + .count(), + 1 + ); + assert!(!stop_host.calls.contains(&CallKind::StartService)); +} + +#[test] +fn start_preflight_is_read_only_and_skips_config_promotion_when_already_running() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let running = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Running, + ); + let mut running_host = FakeHost::new(root.clone(), vec![running]); + + let outcome = preflight_start_singbox_native(&mut running_host).expect("preflight"); + + assert_eq!(outcome, SingBoxStartPreflight::AlreadyRunning); + assert_eq!(running_host.calls, vec![CallKind::Inspect]); + assert!(running_host.mutation_calls().is_empty()); + + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut stopped_host = FakeHost::new(root, vec![stopped]); + assert_eq!( + preflight_start_singbox_native(&mut stopped_host).expect("ready preflight"), + SingBoxStartPreflight::Ready + ); + assert_eq!(stopped_host.calls, vec![CallKind::Inspect]); +} + +#[test] +fn install_failure_rolls_back_and_running_uninstall_is_rejected() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let (runtime_path, runtime_proof) = runtime_package(&temp, "1.13.19", PackageSource::Bundled); + let (wrapper_path, wrapper_proof) = wrapper_package(&temp); + let mut install_host = FakeHost::new(root.clone(), vec![missing_snapshot(&root)]); + install_host.fail_on = Some(CallKind::InstallService); + let error = install_singbox_native_core( + &mut install_host, + package_view(&runtime_path, &runtime_proof), + package_view(&wrapper_path, &wrapper_proof), + ) + .expect_err("failed service install must roll back"); + assert_eq!( + error, + SingBoxNativeError::HostStep(SingBoxNativeStep::InstallService) + ); + assert_eq!( + install_host.rollback, + Some((SingBoxNativeMode::Install, true)) + ); + + let running = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Running, + ); + let mut uninstall_host = FakeHost::new(root, vec![running]); + let error = uninstall_singbox_native(&mut uninstall_host) + .expect_err("running uninstall must be rejected"); + assert_eq!(error, SingBoxNativeError::ServiceRunning); + assert!(!uninstall_host.calls.contains(&CallKind::DeleteService)); + assert!(!uninstall_host.calls.contains(&CallKind::RemoveManagedFiles)); +} + +#[test] +fn stopped_uninstall_deletes_only_service_then_receipt_owned_files() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut service_missing = stopped.clone(); + service_missing.service = SingBoxNativeServiceSnapshot::missing(); + let missing = missing_snapshot(&root); + let mut host = FakeHost::new(root, vec![stopped, service_missing, missing]); + + let outcome = uninstall_singbox_native(&mut host).expect("uninstall"); + + assert_eq!(outcome, SingBoxNativeOutcome::Uninstalled); + let delete = host + .calls + .iter() + .position(|call| *call == CallKind::DeleteService) + .expect("delete service"); + let remove = host + .calls + .iter() + .position(|call| *call == CallKind::RemoveManagedFiles) + .expect("remove exact managed files"); + assert!(delete < remove); + assert!(host.calls.contains(&CallKind::Finalize)); +} + +#[test] +fn uninstall_finalizer_failure_restores_managed_state_instead_of_reporting_success() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut service_missing = stopped.clone(); + service_missing.service = SingBoxNativeServiceSnapshot::missing(); + let missing = missing_snapshot(&root); + let mut host = FakeHost::new(root, vec![stopped, service_missing, missing]); + host.fail_on = Some(CallKind::Finalize); + + let error = uninstall_singbox_native(&mut host) + .expect_err("failed backup cleanup must fail the uninstall"); + + assert_eq!( + error, + SingBoxNativeError::HostStep(SingBoxNativeStep::Finalize) + ); + assert_eq!(host.rollback, Some((SingBoxNativeMode::Uninstall, true))); + assert!(matches!( + host.calls.as_slice(), + [.., CallKind::Finalize, CallKind::Rollback] + )); +} + +#[test] +fn uninstall_cleanup_failure_after_deletion_keeps_tombstone_and_never_rolls_back() { + let temp = TestDir::new(); + let root = temp.path.join("sing-box"); + let stopped = managed_snapshot( + &root, + "1.13.19", + "2.12.0", + PackageSource::Bundled, + SingBoxNativeServiceState::Stopped, + ); + let mut service_missing = stopped.clone(); + service_missing.service = SingBoxNativeServiceSnapshot::missing(); + let missing = missing_snapshot(&root); + let mut host = FakeHost::new(root, vec![stopped, service_missing, missing]); + host.cleanup_pending_after = Some(4); + + let error = uninstall_singbox_native(&mut host) + .expect_err("partial tombstone cleanup must be observable"); + + assert_eq!(error, SingBoxNativeError::CleanupPending); + assert_eq!(host.cleanup_pending_after, Some(4)); + assert_eq!(host.rollback, None); + assert_eq!(host.calls.last(), Some(&CallKind::Finalize)); + assert!(!host.calls.contains(&CallKind::Rollback)); +} + +fn full_installed_files( + runtime: PackageLeaseView<'_>, + wrapper: PackageLeaseView<'_>, +) -> Vec { + let mut files = read_singbox_archive(runtime).expect("runtime zip"); + files.push(read_flat_package(wrapper, WINSW_WRAPPER_FILE).expect("wrapper")); + files.push( + staged_file( + WINSW_SERVICE_XML_FILE, + singbox_service_xml().as_bytes().to_vec(), + ) + .expect("xml"), + ); + files.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + files +} + +fn package_view<'a>(path: &'a Path, proof: &'a PrivilegedPackageProof) -> PackageLeaseView<'a> { + PackageLeaseView { proof, path } +} + +fn missing_snapshot(_root: &Path) -> SingBoxNativeSnapshot { + SingBoxNativeSnapshot { + ownership: SingBoxNativeOwnership::Missing, + installed_version: None, + wrapper_version: None, + install_root_trusted: true, + install_root_reparse_free: true, + promoted_files_match: false, + receipt_valid: false, + receipt_files_match: false, + marker_valid: false, + marker_files_match: false, + service_xml_matches: false, + marker: None, + service: SingBoxNativeServiceSnapshot::missing(), + } +} + +fn promoted_snapshot( + root: &Path, + runtime_version: &str, + wrapper_version: &str, + state: SingBoxNativeServiceState, +) -> SingBoxNativeSnapshot { + SingBoxNativeSnapshot { + ownership: SingBoxNativeOwnership::Incomplete, + installed_version: Some(runtime_version.to_string()), + wrapper_version: Some(wrapper_version.to_string()), + install_root_trusted: true, + install_root_reparse_free: true, + promoted_files_match: true, + receipt_valid: false, + receipt_files_match: false, + marker_valid: false, + marker_files_match: false, + service_xml_matches: true, + marker: None, + service: service_snapshot(root, state), + } +} + +fn managed_snapshot( + root: &Path, + runtime_version: &str, + wrapper_version: &str, + runtime_source: PackageSource, + state: SingBoxNativeServiceState, +) -> SingBoxNativeSnapshot { + let marker = SingBoxOwnershipMarker { + schema_version: SINGBOX_OWNERSHIP_MARKER_SCHEMA_VERSION, + service_name: spec_service_name().to_string(), + runtime: SingBoxMarkerPackage { + version: runtime_version.to_string(), + asset_name: format!("sing-box-{runtime_version}-windows-amd64.zip"), + sha256: "a".repeat(64), + size: 100, + source: runtime_source, + }, + wrapper: SingBoxMarkerPackage { + version: wrapper_version.to_string(), + asset_name: "WinSW.NET461.exe".to_string(), + sha256: "b".repeat(64), + size: 100, + source: PackageSource::Bundled, + }, + files: SINGBOX_MANAGED_RUNTIME_FILES + .iter() + .map(|path| SingBoxInstalledFile { + relative_path: (*path).to_string(), + sha256: "c".repeat(64), + size: 10, + }) + .collect(), + }; + SingBoxNativeSnapshot { + ownership: SingBoxNativeOwnership::Managed, + installed_version: Some(runtime_version.to_string()), + wrapper_version: Some(wrapper_version.to_string()), + install_root_trusted: true, + install_root_reparse_free: true, + promoted_files_match: true, + receipt_valid: true, + receipt_files_match: true, + marker_valid: true, + marker_files_match: true, + service_xml_matches: true, + marker: Some(marker), + service: service_snapshot(root, state), + } +} + +fn service_snapshot(root: &Path, state: SingBoxNativeServiceState) -> SingBoxNativeServiceSnapshot { + SingBoxNativeServiceSnapshot { + state, + path_name: Some(format!("\"{}\"", root.join(WINSW_WRAPPER_FILE).display())), + demand_start: true, + failure_recovery_disabled: true, + builtin_users_can_start: false, + } +} + +fn runtime_package( + temp: &TestDir, + version: &str, + source: PackageSource, +) -> (PathBuf, PrivilegedPackageProof) { + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let path = temp.path.join(&asset_name); + write_zip( + &path, + &[ + ( + format!("sing-box-{version}-windows-amd64/{SINGBOX_LICENSE_FILE}"), + b"license".as_slice(), + ), + ( + format!("sing-box-{version}-windows-amd64/{SINGBOX_CRONET_FILE}"), + b"cronet".as_slice(), + ), + ( + format!("sing-box-{version}-windows-amd64/{SINGBOX_RUNTIME_FILE}"), + b"sing-box".as_slice(), + ), + ], + ); + let size = fs::metadata(&path).expect("zip metadata").len(); + let sha256 = sha256_file(&path); + let independent_proof = (source == PackageSource::Cache).then(|| GithubReleaseDigestProof { + repository: "SagerNet/sing-box".to_string(), + release_id: 10, + asset_id: 20, + stable_tag: format!("v{version}"), + asset_name: asset_name.clone(), + size, + sha256_from_api: sha256.clone(), + verified_signatures: Vec::::new(), + }); + ( + path, + PrivilegedPackageProof { + component_id: ComponentId::SingBox, + version: version.to_string(), + asset_name, + sha256, + size, + source, + independent_proof, + }, + ) +} + +fn unsafe_runtime_package(temp: &TestDir, version: &str) -> (PathBuf, PrivilegedPackageProof) { + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let path = temp.path.join(&asset_name); + write_zip( + &path, + &[ + ( + format!("sing-box-{version}-windows-amd64/{SINGBOX_LICENSE_FILE}"), + b"license".as_slice(), + ), + ( + format!("sing-box-{version}-windows-amd64/{SINGBOX_CRONET_FILE}"), + b"cronet".as_slice(), + ), + ("../sing-box.exe".to_string(), b"sing-box".as_slice()), + ], + ); + let size = fs::metadata(&path).expect("zip metadata").len(); + let sha256 = sha256_file(&path); + ( + path, + PrivilegedPackageProof { + component_id: ComponentId::SingBox, + version: version.to_string(), + asset_name, + sha256, + size, + source: PackageSource::Bundled, + independent_proof: None, + }, + ) +} + +fn wrapper_package(temp: &TestDir) -> (PathBuf, PrivilegedPackageProof) { + let asset_name = "WinSW.NET461.exe".to_string(); + let path = temp.path.join(&asset_name); + fs::write(&path, b"winsw").expect("write wrapper"); + let size = fs::metadata(&path).expect("wrapper metadata").len(); + ( + path.clone(), + PrivilegedPackageProof { + component_id: ComponentId::Winsw, + version: "2.12.0".to_string(), + asset_name, + sha256: sha256_file(&path), + size, + source: PackageSource::Bundled, + independent_proof: None, + }, + ) +} + +fn write_zip(path: &Path, files: &[(String, &[u8])]) { + let file = fs::File::create(path).expect("create zip"); + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, bytes) in files { + writer.start_file(name, options).expect("zip entry"); + writer.write_all(bytes).expect("zip bytes"); + } + writer.finish().expect("finish zip"); +} + +fn sha256_file(path: &Path) -> String { + let bytes = fs::read(path).expect("read file"); + format!("{:x}", Sha256::digest(bytes)) +} + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("proxywarden-singbox-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path).expect("create temp dir"); + Self { path } + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} diff --git a/src-tauri/src/singbox_runtime/system.rs b/src-tauri/src/singbox_runtime/system.rs new file mode 100644 index 0000000..9e4df38 --- /dev/null +++ b/src-tauri/src/singbox_runtime/system.rs @@ -0,0 +1,1978 @@ +use super::*; +use crate::privileged_jobs::{ + read_install_receipt, write_install_receipt, CanonicalComponentRoot, InstallReceipt, + InstallReceiptManager, InstalledAssetReceipt, InstalledFileReceipt, InstalledPackageSource, + ManagedComponent, INSTALL_RECEIPT_FILENAME, INSTALL_RECEIPT_SCHEMA_VERSION, +}; +use crate::process::{ + create_known_service, delete_known_service, query_service_policy_exact, start_known_service, + stop_known_service, wait_known_service_deleted, wait_service_state_exact, KnownWindowsService, + ServicePolicySnapshot, ServiceState, +}; +use crate::safe_fs; +use serde_json::Value; +use std::ffi::OsStr; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const MAX_RUNTIME_CONFIG_BYTES: u64 = 16 * 1024 * 1024; +const MAX_METADATA_BYTES: u64 = 64 * 1024; +const SERVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(15); +const SERVICE_LOG_ROOT_NAME: &str = ".proxywarden-service-logs"; +const SERVICE_LOG_COMPONENT_NAME: &str = "sing-box"; +const CLEANUP_TOMBSTONE_NAME: &str = ".proxywarden-sing-box-cleanup"; + +#[derive(Clone)] +struct PendingRuntime { + path: PathBuf, + files: Vec, + runtime_version: String, + wrapper_version: String, + has_runtime_config: bool, +} + +#[derive(Clone)] +struct BackupRoot { + path: PathBuf, + receipt: InstallReceipt, + marker: SingBoxOwnershipMarker, + has_runtime_config: bool, +} + +/// Read-only, deny-share-write/delete lease for the exact sealed service config. +/// The policy keeps this value alive through StartService, the bounded wait, and +/// the final Running query. +pub struct SystemSingBoxRuntimeConfigLease { + _file: File, + path: PathBuf, + sha256: String, +} + +/// Elevated native sing-box host. The install root and service identity are +/// derived only from the verified current Control App executable. +pub struct SystemSingBoxNativeHost { + root: CanonicalComponentRoot, + staging: Option, + backup: Option, + promoted_files: Option>, + promoted_runtime_version: Option, + promoted_wrapper_version: Option, + promoted_has_runtime_config: bool, + last_receipt: Option, + last_marker: Option, +} + +impl SystemSingBoxNativeHost { + pub fn from_current_exe() -> Result { + let current_exe = std::env::current_exe().map_err(|_| SingBoxNativeHostError)?; + let root = + CanonicalComponentRoot::from_current_exe(¤t_exe, ManagedComponent::SingBox) + .map_err(|_| SingBoxNativeHostError)?; + Ok(Self::new(root)) + } + + fn new(root: CanonicalComponentRoot) -> Self { + Self { + root, + staging: None, + backup: None, + promoted_files: None, + promoted_runtime_version: None, + promoted_wrapper_version: None, + promoted_has_runtime_config: false, + last_receipt: None, + last_marker: None, + } + } + + pub fn install_root(&self) -> &Path { + self.root.path() + } + + /// Fixed, same-volume tombstone used for retryable nonrecursive cleanup. + /// It is derived from the canonical component root and never supplied by a + /// caller or persisted user configuration. + pub fn cleanup_tombstone_path(&self) -> Result { + fixed_cleanup_tombstone_path(self.root.path()) + } + + /// Writes already-leased and verified config bytes into a newly created + /// AS/SY-only sibling, then atomically promotes them to the fixed service + /// path. Source path ownership and the source read lease belong to the + /// privileged coordinator; this boundary accepts no caller-selected path. + pub fn promote_runtime_config_bytes( + &mut self, + bytes: &[u8], + expected_sha256: &str, + ) -> Result { + let destination = self.root.path().join(SINGBOX_RUNTIME_CONFIG_FILE); + if !valid_sha256(expected_sha256) + || bytes.is_empty() + || bytes.len() as u64 > MAX_RUNTIME_CONFIG_BYTES + || format!("{:x}", Sha256::digest(bytes)) != expected_sha256 + || validate_runtime_config_schema(bytes).is_err() + { + return Err(SingBoxNativeHostError); + } + let snapshot = self.inspect()?; + if snapshot.ownership != SingBoxNativeOwnership::Managed + || snapshot.service.state != SingBoxNativeServiceState::Stopped + { + return Err(SingBoxNativeHostError); + } + + let staging = unique_config_sibling(&destination)?; + let mut sealed = safe_fs::create_new_admin_system_only_file(&staging) + .map_err(|_| SingBoxNativeHostError)?; + let write_result = sealed + .write_all(bytes) + .and_then(|()| sealed.sync_all()) + .and_then(|()| sealed.seek(SeekFrom::Start(0)).map(|_| ())); + if write_result.is_err() { + drop(sealed); + fs::remove_file(&staging).map_err(|_| SingBoxNativeHostError)?; + return Err(SingBoxNativeHostError); + } + if safe_fs::verify_admin_system_only_file(&staging).is_err() { + drop(sealed); + fs::remove_file(&staging).map_err(|_| SingBoxNativeHostError)?; + return Err(SingBoxNativeHostError); + } + drop(sealed); + if safe_fs::promote_admin_system_only_file_same_parent(&staging, &destination).is_err() { + let _ = fs::remove_file(&staging); + return Err(SingBoxNativeHostError); + } + let lease = lease_runtime_config_file(&destination, expected_sha256)?; + drop(lease); + Ok(destination) + } + + fn wrapper_path(&self) -> PathBuf { + self.root.path().join(WINSW_WRAPPER_FILE) + } + + fn inspect_metadata( + &self, + ) -> Result<(InstallReceipt, SingBoxOwnershipMarker, bool, bool), SingBoxNativeHostError> { + let receipt = read_install_receipt(&self.root).map_err(|_| SingBoxNativeHostError)?; + let marker = read_marker(self.root.path())?; + validate_receipt_marker_link(self.root.path(), &receipt, &marker)?; + let marker_files_match = verify_runtime_files(self.root.path(), &marker.files).is_ok(); + let receipt_files_match = verify_complete_managed_root( + self.root.path(), + &receipt, + &marker, + runtime_config_exists(self.root.path())?, + ) + .is_ok(); + Ok((receipt, marker, receipt_files_match, marker_files_match)) + } +} + +fn native_service_snapshot(policy: &ServicePolicySnapshot) -> SingBoxNativeServiceSnapshot { + let state = if !policy.service.exists { + SingBoxNativeServiceState::Missing + } else { + match policy.service.state { + Some(ServiceState::Stopped) => SingBoxNativeServiceState::Stopped, + Some(ServiceState::Running) => SingBoxNativeServiceState::Running, + _ => SingBoxNativeServiceState::Pending, + } + }; + SingBoxNativeServiceSnapshot { + state, + path_name: policy.service.path_name.clone(), + demand_start: policy.demand_start, + failure_recovery_disabled: policy.failure_recovery_disabled, + builtin_users_can_start: policy.service.exists && !policy.dacl_matches, + } +} + +fn service_policy_matches(policy: &ServicePolicySnapshot) -> bool { + policy.service.exists + && policy.path_matches + && policy.demand_start + && policy.failure_recovery_disabled + && policy.dacl_matches +} + +fn validate_service_spec( + root: &Path, + actual: &SingBoxServiceInstallSpec, +) -> Result<(), SingBoxNativeHostError> { + let expected = + SingBoxServiceInstallSpec::for_install_root(root).ok_or(SingBoxNativeHostError)?; + if actual == &expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn app_root_from_component_layout(component_root: &Path) -> Result<&Path, SingBoxNativeHostError> { + if component_root.file_name().and_then(OsStr::to_str) != Some("sing-box") { + return Err(SingBoxNativeHostError); + } + let components = component_root.parent().ok_or(SingBoxNativeHostError)?; + if components.file_name().and_then(OsStr::to_str) != Some("components") { + return Err(SingBoxNativeHostError); + } + components.parent().ok_or(SingBoxNativeHostError) +} + +fn canonical_app_root(component_root: &Path) -> Result<&Path, SingBoxNativeHostError> { + let app_root = app_root_from_component_layout(component_root)?; + safe_fs::verify_path_under_trusted_program_files(app_root) + .map_err(|_| SingBoxNativeHostError)?; + safe_fs::ensure_no_reparse_ancestors(app_root).map_err(|_| SingBoxNativeHostError)?; + Ok(app_root) +} + +fn canonical_service_log_dir(component_root: &Path) -> Result { + Ok(canonical_app_root(component_root)? + .join(SERVICE_LOG_ROOT_NAME) + .join(SERVICE_LOG_COMPONENT_NAME)) +} + +fn fixed_cleanup_tombstone_path(component_root: &Path) -> Result { + canonical_app_root(component_root)?; + let components = component_root.parent().ok_or(SingBoxNativeHostError)?; + Ok(components.join(CLEANUP_TOMBSTONE_NAME)) +} + +fn ensure_service_log_directory(component_root: &Path) -> Result<(), SingBoxNativeHostError> { + let app_root = canonical_app_root(component_root)?; + let logs = app_root.join(SERVICE_LOG_ROOT_NAME); + safe_fs::create_directory_admin_owned_user_read_only(&logs) + .map_err(|_| SingBoxNativeHostError)?; + let singbox = canonical_service_log_dir(component_root)?; + safe_fs::create_directory_admin_owned_user_read_only(&singbox) + .map_err(|_| SingBoxNativeHostError)?; + verify_service_log_directory(component_root) +} + +fn verify_service_log_directory(component_root: &Path) -> Result<(), SingBoxNativeHostError> { + let singbox = canonical_service_log_dir(component_root)?; + let logs = singbox.parent().ok_or(SingBoxNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(logs).map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(&singbox).map_err(|_| SingBoxNativeHostError) +} + +fn missing_target_is_reparse_free(path: &Path) -> bool { + !path.try_exists().unwrap_or(true) && safe_fs::ensure_no_reparse_ancestors(path).is_ok() +} + +fn missing_target_has_trusted_parent(path: &Path) -> bool { + let mut current = path.parent(); + while let Some(candidate) = current { + match candidate.try_exists() { + Ok(true) => { + return safe_fs::verify_path_under_trusted_program_files(candidate).is_ok(); + } + Ok(false) => current = candidate.parent(), + Err(_) => return false, + } + } + false +} + +fn validate_staged_files( + mode: SingBoxNativeMode, + files: &[SingBoxStagedFile], +) -> Result<(), SingBoxNativeHostError> { + let expected: &[&str] = match mode { + SingBoxNativeMode::Install => &SINGBOX_MANAGED_RUNTIME_FILES, + SingBoxNativeMode::Update => &[ + SINGBOX_LICENSE_FILE, + SINGBOX_CRONET_FILE, + SINGBOX_RUNTIME_FILE, + ], + SingBoxNativeMode::Uninstall => return Err(SingBoxNativeHostError), + }; + if files.len() != expected.len() { + return Err(SingBoxNativeHostError); + } + let expected: HashSet = expected + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let mut actual = HashSet::new(); + let mut total = 0_u64; + for file in files { + if !valid_runtime_name(&file.relative_path) + || !actual.insert(file.relative_path.to_ascii_lowercase()) + || file.size == 0 + || file.size > MAX_SINGBOX_ARCHIVE_FILE_BYTES + || file.size != file.bytes.len() as u64 + || !valid_sha256(&file.sha256) + || format!("{:x}", Sha256::digest(&file.bytes)) != file.sha256 + { + return Err(SingBoxNativeHostError); + } + total = total + .checked_add(file.size) + .filter(|size| *size <= MAX_SINGBOX_EXPANDED_BYTES) + .ok_or(SingBoxNativeHostError)?; + } + if actual == expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn validate_runtime_inventory( + files: &[SingBoxInstalledFile], +) -> Result<(), SingBoxNativeHostError> { + if files.len() != SINGBOX_MANAGED_RUNTIME_FILES.len() { + return Err(SingBoxNativeHostError); + } + let expected: HashSet = SINGBOX_MANAGED_RUNTIME_FILES + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect(); + let mut actual = HashSet::new(); + for file in files { + if !valid_runtime_name(&file.relative_path) + || !actual.insert(file.relative_path.to_ascii_lowercase()) + || file.size == 0 + || file.size > MAX_SINGBOX_ARCHIVE_FILE_BYTES + || !valid_sha256(&file.sha256) + { + return Err(SingBoxNativeHostError); + } + } + if actual == expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn valid_runtime_name(name: &str) -> bool { + !name.is_empty() + && !name.contains(['/', '\\', ':', '\0']) + && !name.starts_with('.') + && SINGBOX_MANAGED_RUNTIME_FILES + .iter() + .any(|expected| expected.eq_ignore_ascii_case(name)) +} + +fn merge_stage_with_installed( + root: &Path, + installed: &[SingBoxInstalledFile], + updates: &[SingBoxStagedFile], +) -> Result, SingBoxNativeHostError> { + validate_runtime_inventory(installed)?; + validate_staged_files(SingBoxNativeMode::Update, updates)?; + let mut merged = Vec::with_capacity(installed.len()); + for current in installed { + if let Some(update) = updates.iter().find(|update| { + update + .relative_path + .eq_ignore_ascii_case(¤t.relative_path) + }) { + merged.push(update.clone()); + } else { + let bytes = read_verified_admin_file( + &root.join(¤t.relative_path), + current.size, + ¤t.sha256, + )?; + merged.push(SingBoxStagedFile { + relative_path: current.relative_path.clone(), + size: current.size, + sha256: current.sha256.clone(), + bytes, + }); + } + } + merged.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + validate_staged_files(SingBoxNativeMode::Install, &merged)?; + Ok(merged) +} + +fn write_staged_files( + root: &Path, + files: &[SingBoxStagedFile], +) -> Result<(), SingBoxNativeHostError> { + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| SingBoxNativeHostError)?; + for staged in files { + let destination = root.join(&staged.relative_path); + let mut file = safe_fs::create_new_admin_owned_user_read_only_file(&destination) + .map_err(|_| SingBoxNativeHostError)?; + file.write_all(&staged.bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(&destination) + .map_err(|_| SingBoxNativeHostError)?; + } + Ok(()) +} + +fn verify_runtime_files( + root: &Path, + files: &[SingBoxInstalledFile], +) -> Result<(), SingBoxNativeHostError> { + validate_runtime_inventory(files)?; + for expected in files { + read_verified_admin_file( + &root.join(&expected.relative_path), + expected.size, + &expected.sha256, + )?; + } + verify_service_xml(root) +} + +fn verify_runtime_root_shape( + root: &Path, + files: &[SingBoxInstalledFile], + allow_metadata: bool, + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + verify_trusted_component_directory(root)?; + verify_runtime_files(root, files)?; + let mut allowed: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + if allow_metadata { + allowed.insert(SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()); + allowed.insert(INSTALL_RECEIPT_FILENAME.to_ascii_lowercase()); + } + if has_runtime_config { + allowed.insert(SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase()); + } + verify_exact_directory_names(root, &allowed)?; + if has_runtime_config { + verify_runtime_config_without_expected_hash(&root.join(SINGBOX_RUNTIME_CONFIG_FILE))?; + } + if allow_metadata { + for name in [SINGBOX_OWNERSHIP_MARKER_FILE, INSTALL_RECEIPT_FILENAME] { + safe_fs::verify_path_admin_owned_user_read_only(&root.join(name)) + .map_err(|_| SingBoxNativeHostError)?; + } + } + Ok(()) +} + +fn verify_trusted_component_directory(root: &Path) -> Result<(), SingBoxNativeHostError> { + let metadata = fs::symlink_metadata(root).map_err(|_| SingBoxNativeHostError)?; + if !metadata.file_type().is_dir() { + return Err(SingBoxNativeHostError); + } + safe_fs::ensure_no_reparse_ancestors(root).map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_path_under_trusted_program_files(root).map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| SingBoxNativeHostError) +} + +fn verify_exact_directory_names( + root: &Path, + expected: &HashSet, +) -> Result<(), SingBoxNativeHostError> { + let mut found = HashSet::new(); + for entry in fs::read_dir(root).map_err(|_| SingBoxNativeHostError)? { + let entry = entry.map_err(|_| SingBoxNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| SingBoxNativeHostError)?; + let folded = name.to_ascii_lowercase(); + if !expected.contains(&folded) || !found.insert(folded) { + return Err(SingBoxNativeHostError); + } + let metadata = fs::symlink_metadata(entry.path()).map_err(|_| SingBoxNativeHostError)?; + if !metadata.file_type().is_file() { + return Err(SingBoxNativeHostError); + } + } + if &found == expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn verify_service_xml(root: &Path) -> Result<(), SingBoxNativeHostError> { + let expected = singbox_service_xml().as_bytes(); + let path = root.join(WINSW_SERVICE_XML_FILE); + let bytes = read_admin_file_bounded(&path, MAX_METADATA_BYTES)?; + if bytes == expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn read_marker(root: &Path) -> Result { + let bytes = read_admin_file_bounded( + &root.join(SINGBOX_OWNERSHIP_MARKER_FILE), + MAX_METADATA_BYTES, + )?; + let marker: SingBoxOwnershipMarker = + serde_json::from_slice(&bytes).map_err(|_| SingBoxNativeHostError)?; + validate_marker(&marker).map_err(|_| SingBoxNativeHostError)?; + Ok(marker) +} + +fn validate_receipt_marker_link( + canonical_root: &Path, + receipt: &InstallReceipt, + marker: &SingBoxOwnershipMarker, +) -> Result<(), SingBoxNativeHostError> { + validate_marker(marker).map_err(|_| SingBoxNativeHostError)?; + if !same_windows_path(Path::new(&receipt.install_root), canonical_root) + || receipt.schema_version != INSTALL_RECEIPT_SCHEMA_VERSION + || receipt.manager != InstallReceiptManager::ProxyWarden + || receipt.component != ManagedComponent::SingBox + || receipt.service_name != spec_service_name() + || receipt.version != marker.runtime.version + || receipt.source != installed_source(marker.runtime.source) + || receipt.asset.name != marker.runtime.asset_name + || receipt.asset.sha256 != marker.runtime.sha256 + || receipt.asset.size != marker.runtime.size + || receipt.windows_packet_filter.is_some() + || receipt.installed_files.len() != marker.files.len() + 1 + { + return Err(SingBoxNativeHostError); + } + let mut expected: HashSet = marker + .files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + expected.insert(SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()); + let mut actual = HashSet::new(); + for file in &receipt.installed_files { + let folded = file.relative_path.to_ascii_lowercase(); + if !expected.contains(&folded) + || !actual.insert(folded) + || file.size == 0 + || !valid_sha256(&file.sha256) + { + return Err(SingBoxNativeHostError); + } + if file.relative_path != SINGBOX_OWNERSHIP_MARKER_FILE { + let marker_file = marker + .files + .iter() + .find(|item| item.relative_path.eq_ignore_ascii_case(&file.relative_path)) + .ok_or(SingBoxNativeHostError)?; + if file.sha256 != marker_file.sha256 || file.size != marker_file.size { + return Err(SingBoxNativeHostError); + } + } + } + if actual == expected { + Ok(()) + } else { + Err(SingBoxNativeHostError) + } +} + +fn verify_complete_managed_root( + root: &Path, + receipt: &InstallReceipt, + marker: &SingBoxOwnershipMarker, + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + verify_complete_managed_root_at(root, root, receipt, marker, has_runtime_config) +} + +fn verify_complete_managed_root_at( + actual_root: &Path, + canonical_root: &Path, + receipt: &InstallReceipt, + marker: &SingBoxOwnershipMarker, + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + validate_receipt_marker_link(canonical_root, receipt, marker)?; + verify_trusted_component_directory(actual_root)?; + let files = receipt_inventory(receipt)?; + for file in &files { + read_verified_admin_file( + &actual_root.join(&file.relative_path), + file.size, + &file.sha256, + )?; + } + verify_service_xml(actual_root)?; + safe_fs::verify_path_admin_owned_user_read_only(&actual_root.join(INSTALL_RECEIPT_FILENAME)) + .map_err(|_| SingBoxNativeHostError)?; + let mut expected: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + expected.insert(INSTALL_RECEIPT_FILENAME.to_ascii_lowercase()); + if has_runtime_config { + expected.insert(SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase()); + verify_runtime_config_without_expected_hash( + &actual_root.join(SINGBOX_RUNTIME_CONFIG_FILE), + )?; + } + verify_exact_directory_names(actual_root, &expected) +} + +fn receipt_inventory( + receipt: &InstallReceipt, +) -> Result, SingBoxNativeHostError> { + let files: Vec<_> = receipt + .installed_files + .iter() + .map(|file| SingBoxInstalledFile { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect(); + if files.len() != SINGBOX_MANAGED_RUNTIME_FILES.len() + 1 { + return Err(SingBoxNativeHostError); + } + Ok(files) +} + +fn validate_native_metadata( + metadata: &SingBoxNativeMetadata, +) -> Result<(), SingBoxNativeHostError> { + validate_marker(&metadata.marker).map_err(|_| SingBoxNativeHostError)?; + if SingBoxMarkerPackage::from(&metadata.runtime) != metadata.marker.runtime { + return Err(SingBoxNativeHostError); + } + let decoded: SingBoxOwnershipMarker = + serde_json::from_slice(&metadata.marker_bytes).map_err(|_| SingBoxNativeHostError)?; + if decoded != metadata.marker + || metadata.marker_bytes.is_empty() + || metadata.marker_bytes.len() as u64 > MAX_METADATA_BYTES + || format!("{:x}", Sha256::digest(&metadata.marker_bytes)) != metadata.marker_sha256 + || metadata.receipt_files.len() != metadata.marker.files.len() + 1 + { + return Err(SingBoxNativeHostError); + } + let marker_file = metadata + .receipt_files + .iter() + .find(|file| file.relative_path == SINGBOX_OWNERSHIP_MARKER_FILE) + .ok_or(SingBoxNativeHostError)?; + if marker_file.sha256 != metadata.marker_sha256 + || marker_file.size != metadata.marker_bytes.len() as u64 + { + return Err(SingBoxNativeHostError); + } + let mut expected: HashSet = metadata + .marker + .files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + expected.insert(SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()); + let mut actual = HashSet::new(); + for file in &metadata.receipt_files { + let folded = file.relative_path.to_ascii_lowercase(); + if !expected.contains(&folded) + || !actual.insert(folded) + || file.size == 0 + || !valid_sha256(&file.sha256) + { + return Err(SingBoxNativeHostError); + } + if file.relative_path != SINGBOX_OWNERSHIP_MARKER_FILE { + let expected_file = metadata + .marker + .files + .iter() + .find(|item| item.relative_path.eq_ignore_ascii_case(&file.relative_path)) + .ok_or(SingBoxNativeHostError)?; + if expected_file.sha256 != file.sha256 || expected_file.size != file.size { + return Err(SingBoxNativeHostError); + } + } + } + if actual != expected { + return Err(SingBoxNativeHostError); + } + Ok(()) +} + +fn install_receipt_from_metadata( + root: &Path, + metadata: &SingBoxNativeMetadata, + installed_at: u64, +) -> Result { + let install_root = root.to_str().ok_or(SingBoxNativeHostError)?.to_owned(); + Ok(InstallReceipt { + schema_version: INSTALL_RECEIPT_SCHEMA_VERSION, + manager: InstallReceiptManager::ProxyWarden, + component: ManagedComponent::SingBox, + install_root, + service_name: spec_service_name().to_owned(), + version: metadata.runtime.version.clone(), + source: installed_source(metadata.runtime.source), + asset: InstalledAssetReceipt { + name: metadata.runtime.asset_name.clone(), + sha256: metadata.runtime.sha256.clone(), + size: metadata.runtime.size, + }, + installed_files: metadata + .receipt_files + .iter() + .map(|file| InstalledFileReceipt { + relative_path: file.relative_path.clone(), + sha256: file.sha256.clone(), + size: file.size, + }) + .collect(), + installed_at, + windows_packet_filter: None, + }) +} + +fn installed_source(source: PackageSource) -> InstalledPackageSource { + match source { + PackageSource::Bundled => InstalledPackageSource::Bundled, + PackageSource::Cache => InstalledPackageSource::Cache, + } +} + +fn runtime_config_exists(root: &Path) -> Result { + let path = root.join(SINGBOX_RUNTIME_CONFIG_FILE); + match path.try_exists() { + Ok(false) => Ok(false), + Ok(true) => { + safe_fs::verify_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError)?; + Ok(true) + } + Err(_) => Err(SingBoxNativeHostError), + } +} + +fn read_existing_runtime_config(root: &Path) -> Result>, SingBoxNativeHostError> { + let path = root.join(SINGBOX_RUNTIME_CONFIG_FILE); + if !path.try_exists().map_err(|_| SingBoxNativeHostError)? { + return Ok(None); + } + let mut lease = open_file_deny_write_delete(&path)?; + safe_fs::verify_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError)?; + let bytes = read_bounded_file(&mut lease, MAX_RUNTIME_CONFIG_BYTES)?; + validate_runtime_config_schema(&bytes)?; + safe_fs::verify_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError)?; + Ok(Some(bytes)) +} + +fn write_sealed_runtime_config(root: &Path, bytes: &[u8]) -> Result<(), SingBoxNativeHostError> { + validate_runtime_config_schema(bytes)?; + let path = root.join(SINGBOX_RUNTIME_CONFIG_FILE); + let mut file = + safe_fs::create_new_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError)?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError) +} + +fn verify_runtime_config_without_expected_hash(path: &Path) -> Result<(), SingBoxNativeHostError> { + safe_fs::verify_admin_system_only_file(path).map_err(|_| SingBoxNativeHostError)?; + let mut file = open_file_deny_write_delete(path)?; + let bytes = read_bounded_file(&mut file, MAX_RUNTIME_CONFIG_BYTES)?; + validate_runtime_config_schema(&bytes)?; + safe_fs::verify_admin_system_only_file(path).map_err(|_| SingBoxNativeHostError) +} + +fn lease_runtime_config_file( + path: &Path, + expected_sha256: &str, +) -> Result { + if !valid_sha256(expected_sha256) { + return Err(SingBoxNativeHostError); + } + safe_fs::verify_admin_system_only_file(path).map_err(|_| SingBoxNativeHostError)?; + let mut file = open_file_deny_write_delete(path)?; + let metadata = file.metadata().map_err(|_| SingBoxNativeHostError)?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > MAX_RUNTIME_CONFIG_BYTES { + return Err(SingBoxNativeHostError); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + (&mut file) + .take(MAX_RUNTIME_CONFIG_BYTES.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| SingBoxNativeHostError)?; + if bytes.len() as u64 != metadata.len() + || format!("{:x}", Sha256::digest(&bytes)) != expected_sha256 + { + return Err(SingBoxNativeHostError); + } + validate_runtime_config_schema(&bytes)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| SingBoxNativeHostError)?; + safe_fs::verify_admin_system_only_file(path).map_err(|_| SingBoxNativeHostError)?; + Ok(SystemSingBoxRuntimeConfigLease { + _file: file, + path: path.to_path_buf(), + sha256: expected_sha256.to_owned(), + }) +} + +fn validate_config_lease( + lease: &SystemSingBoxRuntimeConfigLease, + expected_path: &Path, +) -> Result<(), SingBoxNativeHostError> { + if !same_windows_path(&lease.path, expected_path) || !valid_sha256(&lease.sha256) { + return Err(SingBoxNativeHostError); + } + safe_fs::verify_admin_system_only_file(&lease.path).map_err(|_| SingBoxNativeHostError)?; + let mut file = lease + ._file + .try_clone() + .map_err(|_| SingBoxNativeHostError)?; + file.seek(SeekFrom::Start(0)) + .map_err(|_| SingBoxNativeHostError)?; + let bytes = read_bounded_file(&mut file, MAX_RUNTIME_CONFIG_BYTES)?; + if format!("{:x}", Sha256::digest(&bytes)) != lease.sha256 { + return Err(SingBoxNativeHostError); + } + validate_runtime_config_schema(&bytes) +} + +fn validate_runtime_config_schema(bytes: &[u8]) -> Result<(), SingBoxNativeHostError> { + if bytes.is_empty() || bytes.len() as u64 > MAX_RUNTIME_CONFIG_BYTES { + return Err(SingBoxNativeHostError); + } + let value: Value = serde_json::from_slice(bytes).map_err(|_| SingBoxNativeHostError)?; + let object = value.as_object().ok_or(SingBoxNativeHostError)?; + let inbounds = object + .get("inbounds") + .and_then(Value::as_array) + .filter(|items| !items.is_empty()) + .ok_or(SingBoxNativeHostError)?; + let outbounds = object + .get("outbounds") + .and_then(Value::as_array) + .filter(|items| !items.is_empty()) + .ok_or(SingBoxNativeHostError)?; + if !inbounds.iter().all(Value::is_object) + || !outbounds.iter().all(Value::is_object) + || !object.get("route").is_some_and(Value::is_object) + { + return Err(SingBoxNativeHostError); + } + Ok(()) +} + +fn open_file_deny_write_delete(path: &Path) -> Result { + if !path.is_absolute() { + return Err(SingBoxNativeHostError); + } + safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| SingBoxNativeHostError)?; + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + options.share_mode(FILE_SHARE_READ); + } + let file = options.open(path).map_err(|_| SingBoxNativeHostError)?; + let metadata = file.metadata().map_err(|_| SingBoxNativeHostError)?; + if !metadata.is_file() { + return Err(SingBoxNativeHostError); + } + Ok(file) +} + +fn read_bounded_file(file: &mut File, max_bytes: u64) -> Result, SingBoxNativeHostError> { + let metadata = file.metadata().map_err(|_| SingBoxNativeHostError)?; + if !metadata.is_file() || metadata.len() == 0 || metadata.len() > max_bytes { + return Err(SingBoxNativeHostError); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes) + .map_err(|_| SingBoxNativeHostError)?; + if bytes.len() as u64 == metadata.len() { + Ok(bytes) + } else { + Err(SingBoxNativeHostError) + } +} + +fn read_verified_admin_file( + path: &Path, + expected_size: u64, + expected_sha256: &str, +) -> Result, SingBoxNativeHostError> { + if expected_size == 0 + || expected_size > MAX_SINGBOX_ARCHIVE_FILE_BYTES + || !valid_sha256(expected_sha256) + { + return Err(SingBoxNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| SingBoxNativeHostError)?; + let mut file = open_file_deny_write_delete(path)?; + let metadata = file.metadata().map_err(|_| SingBoxNativeHostError)?; + if metadata.len() != expected_size { + return Err(SingBoxNativeHostError); + } + let bytes = read_bounded_file(&mut file, expected_size)?; + if format!("{:x}", Sha256::digest(&bytes)) != expected_sha256 { + return Err(SingBoxNativeHostError); + } + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| SingBoxNativeHostError)?; + Ok(bytes) +} + +fn read_admin_file_bounded(path: &Path, max_bytes: u64) -> Result, SingBoxNativeHostError> { + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| SingBoxNativeHostError)?; + let mut file = open_file_deny_write_delete(path)?; + let bytes = read_bounded_file(&mut file, max_bytes)?; + safe_fs::verify_path_admin_owned_user_read_only(path).map_err(|_| SingBoxNativeHostError)?; + Ok(bytes) +} + +fn unique_root_sibling(root: &Path, label: &str) -> Result { + let name = root + .file_name() + .and_then(OsStr::to_str) + .ok_or(SingBoxNativeHostError)?; + if !label.bytes().all(|byte| byte.is_ascii_lowercase()) { + return Err(SingBoxNativeHostError); + } + Ok(root.with_file_name(format!( + "{name}.{label}-{}", + uuid::Uuid::new_v4().hyphenated() + ))) +} + +fn unique_config_sibling(path: &Path) -> Result { + let name = path + .file_name() + .and_then(OsStr::to_str) + .ok_or(SingBoxNativeHostError)?; + Ok(path.with_file_name(format!( + "{name}.staging-{}", + uuid::Uuid::new_v4().hyphenated() + ))) +} + +fn unix_now() -> Result { + let value = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| SingBoxNativeHostError)?; + if value == 0 { + Err(SingBoxNativeHostError) + } else { + Ok(value) + } +} + +fn remove_runtime_root_without_metadata( + root: &Path, + files: &[SingBoxInstalledFile], + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + verify_runtime_root_shape(root, files, false, has_runtime_config)?; + remove_known_files(root, files, false, has_runtime_config) +} + +fn read_cleanup_receipt(root: &Path) -> Result { + let bytes = read_admin_file_bounded(&root.join(INSTALL_RECEIPT_FILENAME), MAX_METADATA_BYTES)?; + serde_json::from_slice(&bytes).map_err(|_| SingBoxNativeHostError) +} + +/// Verifies a retryable partial tombstone while the receipt and marker are +/// still present. Missing runtime/config files are accepted because each can +/// have been deleted by an earlier attempt; every remaining entry must still +/// be allowlisted, regular, sealed, and hash-valid. +fn verified_partial_cleanup_plan( + root: &Path, + canonical_root: &Path, + receipt: &InstallReceipt, + marker: &SingBoxOwnershipMarker, +) -> Result, SingBoxNativeHostError> { + validate_receipt_marker_link(canonical_root, receipt, marker)?; + verify_trusted_component_directory(root)?; + let inventory = receipt_inventory(receipt)?; + let mut allowed: HashSet = inventory + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + allowed.insert(INSTALL_RECEIPT_FILENAME.to_ascii_lowercase()); + allowed.insert(SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase()); + let mut found = HashSet::new(); + + for entry in fs::read_dir(root).map_err(|_| SingBoxNativeHostError)? { + let entry = entry.map_err(|_| SingBoxNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| SingBoxNativeHostError)?; + let folded = name.to_ascii_lowercase(); + if !allowed.contains(&folded) || !found.insert(folded.clone()) { + return Err(SingBoxNativeHostError); + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|_| SingBoxNativeHostError)?; + if !metadata.file_type().is_file() { + return Err(SingBoxNativeHostError); + } + if folded == INSTALL_RECEIPT_FILENAME.to_ascii_lowercase() { + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| SingBoxNativeHostError)?; + } else if folded == SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase() { + verify_runtime_config_without_expected_hash(&path)?; + } else { + let expected = inventory + .iter() + .find(|file| file.relative_path.eq_ignore_ascii_case(&name)) + .ok_or(SingBoxNativeHostError)?; + read_verified_admin_file(&path, expected.size, &expected.sha256)?; + } + } + + if !found.contains(&INSTALL_RECEIPT_FILENAME.to_ascii_lowercase()) + || !found.contains(&SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()) + { + return Err(SingBoxNativeHostError); + } + + let mut plan = Vec::new(); + for file in &marker.files { + if found.contains(&file.relative_path.to_ascii_lowercase()) { + plan.push(root.join(&file.relative_path)); + } + } + if found.contains(&SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase()) { + plan.push(root.join(SINGBOX_RUNTIME_CONFIG_FILE)); + } + // Receipt first, ownership marker last. Therefore a retry with only the + // marker remaining proves every runtime/config deletion already finished. + plan.push(root.join(INSTALL_RECEIPT_FILENAME)); + plan.push(root.join(SINGBOX_OWNERSHIP_MARKER_FILE)); + Ok(plan) +} + +fn cleanup_entry_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_file() => Ok(true), + Ok(_) => Err(SingBoxNativeHostError), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(_) => Err(SingBoxNativeHostError), + } +} + +fn remove_cleanup_files_with( + paths: &[PathBuf], + mut remove: F, +) -> Result<(), SingBoxNativeHostError> +where + F: FnMut(&Path) -> Result<(), SingBoxNativeHostError>, +{ + for path in paths { + remove(path)?; + } + Ok(()) +} + +fn retry_fixed_cleanup_tombstone(canonical_root: &Path) -> Result<(), SingBoxNativeHostError> { + let tombstone = fixed_cleanup_tombstone_path(canonical_root)?; + match fs::symlink_metadata(&tombstone) { + Ok(metadata) if metadata.file_type().is_dir() => {} + Ok(_) => return Err(SingBoxNativeHostError), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + safe_fs::ensure_no_reparse_ancestors(&tombstone).map_err(|_| SingBoxNativeHostError)?; + return Ok(()); + } + Err(_) => return Err(SingBoxNativeHostError), + } + verify_trusted_component_directory(&tombstone)?; + + let receipt_path = tombstone.join(INSTALL_RECEIPT_FILENAME); + let marker_path = tombstone.join(SINGBOX_OWNERSHIP_MARKER_FILE); + let receipt_exists = cleanup_entry_exists(&receipt_path)?; + let marker_exists = cleanup_entry_exists(&marker_path)?; + let plan = match (receipt_exists, marker_exists) { + (true, true) => { + let receipt = read_cleanup_receipt(&tombstone)?; + let marker = read_marker(&tombstone)?; + verified_partial_cleanup_plan(&tombstone, canonical_root, &receipt, &marker)? + } + (false, true) => { + read_marker(&tombstone)?; + let expected = HashSet::from([SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()]); + verify_exact_directory_names(&tombstone, &expected)?; + vec![marker_path] + } + (true, false) => return Err(SingBoxNativeHostError), + (false, false) => { + verify_exact_directory_names(&tombstone, &HashSet::new())?; + Vec::new() + } + }; + + remove_cleanup_files_with(&plan, |path| { + fs::remove_file(path).map_err(|_| SingBoxNativeHostError) + })?; + verify_exact_directory_names(&tombstone, &HashSet::new())?; + safe_fs::verify_path_admin_owned_user_read_only(&tombstone) + .map_err(|_| SingBoxNativeHostError)?; + fs::remove_dir(&tombstone).map_err(|_| SingBoxNativeHostError) +} + +fn remove_runtime_root_allow_partial_metadata( + root: &Path, + files: &[SingBoxInstalledFile], + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + verify_trusted_component_directory(root)?; + verify_runtime_files(root, files)?; + let mut allowed: HashSet = files + .iter() + .map(|file| file.relative_path.to_ascii_lowercase()) + .collect(); + allowed.insert(SINGBOX_OWNERSHIP_MARKER_FILE.to_ascii_lowercase()); + allowed.insert(INSTALL_RECEIPT_FILENAME.to_ascii_lowercase()); + if has_runtime_config { + allowed.insert(SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase()); + } + let mut paths = Vec::new(); + for entry in fs::read_dir(root).map_err(|_| SingBoxNativeHostError)? { + let entry = entry.map_err(|_| SingBoxNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| SingBoxNativeHostError)?; + if !allowed.contains(&name.to_ascii_lowercase()) { + return Err(SingBoxNativeHostError); + } + let path = entry.path(); + if name.eq_ignore_ascii_case(SINGBOX_RUNTIME_CONFIG_FILE) { + verify_runtime_config_without_expected_hash(&path)?; + } else { + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| SingBoxNativeHostError)?; + } + paths.push(path); + } + for path in paths { + fs::remove_file(path).map_err(|_| SingBoxNativeHostError)?; + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| SingBoxNativeHostError)?; + fs::remove_dir(root).map_err(|_| SingBoxNativeHostError) +} + +fn remove_known_files( + root: &Path, + runtime_files: &[SingBoxInstalledFile], + has_metadata: bool, + has_runtime_config: bool, +) -> Result<(), SingBoxNativeHostError> { + for file in runtime_files { + fs::remove_file(root.join(&file.relative_path)).map_err(|_| SingBoxNativeHostError)?; + } + if has_metadata { + for name in [SINGBOX_OWNERSHIP_MARKER_FILE, INSTALL_RECEIPT_FILENAME] { + let path = root.join(name); + if path.try_exists().map_err(|_| SingBoxNativeHostError)? { + fs::remove_file(path).map_err(|_| SingBoxNativeHostError)?; + } + } + } + if has_runtime_config { + fs::remove_file(root.join(SINGBOX_RUNTIME_CONFIG_FILE)) + .map_err(|_| SingBoxNativeHostError)?; + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| SingBoxNativeHostError)?; + fs::remove_dir(root).map_err(|_| SingBoxNativeHostError) +} + +fn remove_partial_staging(root: &Path) -> Result<(), SingBoxNativeHostError> { + verify_trusted_component_directory(root)?; + let allowed: HashSet = SINGBOX_MANAGED_RUNTIME_FILES + .iter() + .map(|name| name.to_ascii_lowercase()) + .chain(std::iter::once( + SINGBOX_RUNTIME_CONFIG_FILE.to_ascii_lowercase(), + )) + .collect(); + let mut paths = Vec::new(); + for entry in fs::read_dir(root).map_err(|_| SingBoxNativeHostError)? { + let entry = entry.map_err(|_| SingBoxNativeHostError)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| SingBoxNativeHostError)?; + if !allowed.contains(&name.to_ascii_lowercase()) { + return Err(SingBoxNativeHostError); + } + let path = entry.path(); + if name.eq_ignore_ascii_case(SINGBOX_RUNTIME_CONFIG_FILE) { + safe_fs::verify_admin_system_only_file(&path).map_err(|_| SingBoxNativeHostError)?; + } else { + safe_fs::verify_path_admin_owned_user_read_only(&path) + .map_err(|_| SingBoxNativeHostError)?; + } + paths.push(path); + } + for path in paths { + fs::remove_file(path).map_err(|_| SingBoxNativeHostError)?; + } + safe_fs::verify_path_admin_owned_user_read_only(root).map_err(|_| SingBoxNativeHostError)?; + fs::remove_dir(root).map_err(|_| SingBoxNativeHostError) +} + +impl SingBoxNativeHost for SystemSingBoxNativeHost { + type RuntimeConfigLease = SystemSingBoxRuntimeConfigLease; + + fn install_root(&self) -> &Path { + self.root.path() + } + + fn inspect(&mut self) -> Result { + let wrapper_path = self.wrapper_path(); + let service = query_service_policy_exact(spec_service_name(), &wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + let root_metadata = fs::symlink_metadata(self.root.path()); + let root_exists = match &root_metadata { + Ok(metadata) if metadata.file_type().is_dir() => true, + Ok(_) => return Err(SingBoxNativeHostError), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => return Err(SingBoxNativeHostError), + }; + let install_root_reparse_free = if root_exists { + safe_fs::ensure_no_reparse_ancestors(self.root.path()).is_ok() + } else { + missing_target_is_reparse_free(self.root.path()) + }; + let install_root_trusted = if root_exists { + install_root_reparse_free + && safe_fs::verify_path_under_trusted_program_files(self.root.path()).is_ok() + && safe_fs::verify_path_admin_owned_user_read_only(self.root.path()).is_ok() + } else { + install_root_reparse_free && missing_target_has_trusted_parent(self.root.path()) + }; + + let mut receipt_valid = false; + let mut receipt_files_match = false; + let mut marker_valid = false; + let mut marker_files_match = false; + let mut service_xml_matches = false; + let mut marker = None; + if root_exists && install_root_trusted { + if let Ok((verified_receipt, verified_marker, receipt_match, marker_match)) = + self.inspect_metadata() + { + receipt_valid = true; + marker_valid = true; + receipt_files_match = receipt_match; + marker_files_match = marker_match; + service_xml_matches = verify_service_xml(self.root.path()).is_ok(); + self.last_receipt = Some(verified_receipt); + self.last_marker = Some(verified_marker.clone()); + marker = Some(verified_marker); + } + } + + let promoted_files_match = if let Some(files) = self.promoted_files.as_ref() { + root_exists + && install_root_trusted + && verify_runtime_root_shape( + self.root.path(), + files, + false, + self.promoted_has_runtime_config, + ) + .is_ok() + && verify_service_xml(self.root.path()).is_ok() + } else { + receipt_files_match && marker_files_match && service_xml_matches + }; + let installed_version = marker + .as_ref() + .map(|value| value.runtime.version.clone()) + .or_else(|| self.promoted_runtime_version.clone()); + let wrapper_version = marker + .as_ref() + .map(|value| value.wrapper.version.clone()) + .or_else(|| self.promoted_wrapper_version.clone()); + let service_snapshot = native_service_snapshot(&service); + let ownership = if service.service.exists && !service.path_matches { + SingBoxNativeOwnership::Foreign + } else if !root_exists && !service.service.exists { + SingBoxNativeOwnership::Missing + } else if install_root_trusted + && receipt_valid + && receipt_files_match + && marker_valid + && marker_files_match + && service_xml_matches + && service.service.exists + && service.path_matches + { + SingBoxNativeOwnership::Managed + } else { + SingBoxNativeOwnership::Incomplete + }; + + Ok(SingBoxNativeSnapshot { + ownership, + installed_version, + wrapper_version, + install_root_trusted, + install_root_reparse_free, + promoted_files_match, + receipt_valid, + receipt_files_match, + marker_valid, + marker_files_match, + service_xml_matches, + marker, + service: service_snapshot, + }) + } + + fn stage_runtime( + &mut self, + mode: SingBoxNativeMode, + files: &[SingBoxStagedFile], + runtime_version: &str, + wrapper_version: &str, + ) -> Result<(), SingBoxNativeHostError> { + if mode == SingBoxNativeMode::Uninstall + || self.staging.is_some() + || self.backup.is_some() + || parse_numeric_version(runtime_version).is_none() + || parse_numeric_version(wrapper_version).is_none() + { + return Err(SingBoxNativeHostError); + } + validate_staged_files(mode, files)?; + let parent = self.root.path().parent().ok_or(SingBoxNativeHostError)?; + safe_fs::create_directory_admin_owned_user_read_only(parent) + .map_err(|_| SingBoxNativeHostError)?; + if mode == SingBoxNativeMode::Install && self.root.path().try_exists().unwrap_or(true) { + return Err(SingBoxNativeHostError); + } + + let (complete_files, existing_config) = match mode { + SingBoxNativeMode::Install => (files.to_vec(), None), + SingBoxNativeMode::Update => { + let (receipt, marker, receipt_match, marker_match) = self.inspect_metadata()?; + if !receipt_match || !marker_match || marker.wrapper.version != wrapper_version { + return Err(SingBoxNativeHostError); + } + let merged = merge_stage_with_installed(self.root.path(), &marker.files, files)?; + let config = read_existing_runtime_config(self.root.path())?; + self.last_receipt = Some(receipt); + self.last_marker = Some(marker); + (merged, config) + } + SingBoxNativeMode::Uninstall => return Err(SingBoxNativeHostError), + }; + + let staging_path = unique_root_sibling(self.root.path(), "staging")?; + safe_fs::create_directory_admin_owned_user_read_only(&staging_path) + .map_err(|_| SingBoxNativeHostError)?; + let write_result = write_staged_files(&staging_path, &complete_files).and_then(|()| { + if let Some(config) = existing_config.as_ref() { + write_sealed_runtime_config(&staging_path, config) + } else { + Ok(()) + } + }); + if write_result.is_err() { + remove_partial_staging(&staging_path)?; + return Err(SingBoxNativeHostError); + } + let inventory = installed_file_inventory(&complete_files); + let has_runtime_config = existing_config.is_some(); + if verify_runtime_root_shape(&staging_path, &inventory, false, has_runtime_config).is_err() + { + remove_partial_staging(&staging_path)?; + return Err(SingBoxNativeHostError); + } + self.staging = Some(PendingRuntime { + path: staging_path, + files: inventory, + runtime_version: runtime_version.to_owned(), + wrapper_version: wrapper_version.to_owned(), + has_runtime_config, + }); + Ok(()) + } + + fn promote_runtime(&mut self, mode: SingBoxNativeMode) -> Result<(), SingBoxNativeHostError> { + let pending = self.staging.clone().ok_or(SingBoxNativeHostError)?; + verify_runtime_root_shape( + &pending.path, + &pending.files, + false, + pending.has_runtime_config, + )?; + match mode { + SingBoxNativeMode::Install => { + if self.root.path().try_exists().unwrap_or(true) { + return Err(SingBoxNativeHostError); + } + fs::rename(&pending.path, self.root.path()).map_err(|_| SingBoxNativeHostError)?; + } + SingBoxNativeMode::Update => { + let receipt = self.last_receipt.clone().ok_or(SingBoxNativeHostError)?; + let marker = self.last_marker.clone().ok_or(SingBoxNativeHostError)?; + let has_runtime_config = runtime_config_exists(self.root.path())?; + verify_complete_managed_root( + self.root.path(), + &receipt, + &marker, + has_runtime_config, + )?; + let backup_path = unique_root_sibling(self.root.path(), "backup")?; + fs::rename(self.root.path(), &backup_path).map_err(|_| SingBoxNativeHostError)?; + if fs::rename(&pending.path, self.root.path()).is_err() { + let _ = fs::rename(&backup_path, self.root.path()); + return Err(SingBoxNativeHostError); + } + self.backup = Some(BackupRoot { + path: backup_path, + receipt, + marker, + has_runtime_config, + }); + } + SingBoxNativeMode::Uninstall => return Err(SingBoxNativeHostError), + } + self.staging = None; + self.promoted_files = Some(pending.files); + self.promoted_runtime_version = Some(pending.runtime_version); + self.promoted_wrapper_version = Some(pending.wrapper_version); + self.promoted_has_runtime_config = pending.has_runtime_config; + Ok(()) + } + + fn discard_staging(&mut self) { + if let Some(staging) = self.staging.take() { + let _ = remove_runtime_root_without_metadata( + &staging.path, + &staging.files, + staging.has_runtime_config, + ); + } + } + + fn retry_cleanup(&mut self) -> Result<(), SingBoxNativeHostError> { + if self.staging.is_some() { + return Err(SingBoxNativeHostError); + } + let tombstone = fixed_cleanup_tombstone_path(self.root.path())?; + retry_fixed_cleanup_tombstone(self.root.path())?; + if self + .backup + .as_ref() + .is_some_and(|backup| same_windows_path(&backup.path, &tombstone)) + { + self.backup = None; + } + Ok(()) + } + + fn finalize(&mut self, mode: SingBoxNativeMode) -> Result<(), SingBoxNativeFinalizeError> { + if self.staging.is_some() { + return Err(SingBoxNativeFinalizeError::Reversible); + } + match mode { + SingBoxNativeMode::Install => { + if self.backup.is_some() { + return Err(SingBoxNativeFinalizeError::Reversible); + } + } + SingBoxNativeMode::Update | SingBoxNativeMode::Uninstall => { + let mut backup = self + .backup + .take() + .ok_or(SingBoxNativeFinalizeError::Reversible)?; + if verify_complete_managed_root_at( + &backup.path, + self.root.path(), + &backup.receipt, + &backup.marker, + backup.has_runtime_config, + ) + .is_err() + { + self.backup = Some(backup); + return Err(SingBoxNativeFinalizeError::Reversible); + } + let tombstone = match fixed_cleanup_tombstone_path(self.root.path()) { + Ok(path) => path, + Err(_) => { + self.backup = Some(backup); + return Err(SingBoxNativeFinalizeError::Reversible); + } + }; + let tombstone_is_missing = matches!( + fs::symlink_metadata(&tombstone), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ); + if !tombstone_is_missing + || safe_fs::ensure_no_reparse_ancestors(&tombstone).is_err() + || !backup + .path + .parent() + .zip(tombstone.parent()) + .is_some_and(|(left, right)| same_windows_path(left, right)) + || fs::rename(&backup.path, &tombstone).is_err() + { + self.backup = Some(backup); + return Err(SingBoxNativeFinalizeError::Reversible); + } + backup.path = tombstone; + self.backup = Some(backup); + if retry_fixed_cleanup_tombstone(self.root.path()).is_err() { + return Err(SingBoxNativeFinalizeError::CleanupPending); + } + self.backup = None; + } + } + self.clear_transient_state(); + Ok(()) + } + + fn commit_metadata( + &mut self, + mode: SingBoxNativeMode, + metadata: &SingBoxNativeMetadata, + ) -> Result<(), SingBoxNativeHostError> { + if mode == SingBoxNativeMode::Uninstall + || self.promoted_runtime_version.as_deref() + != Some(metadata.marker.runtime.version.as_str()) + || self.promoted_wrapper_version.as_deref() + != Some(metadata.marker.wrapper.version.as_str()) + { + return Err(SingBoxNativeHostError); + } + validate_native_metadata(metadata)?; + verify_runtime_files(self.root.path(), &metadata.marker.files)?; + safe_fs::write_admin_owned_user_read_only_atomic( + &self.root.path().join(SINGBOX_OWNERSHIP_MARKER_FILE), + &metadata.marker_bytes, + ) + .map_err(|_| SingBoxNativeHostError)?; + let installed_at = match mode { + SingBoxNativeMode::Install => unix_now()?, + SingBoxNativeMode::Update => self + .last_receipt + .as_ref() + .map(|receipt| receipt.installed_at) + .ok_or(SingBoxNativeHostError)?, + SingBoxNativeMode::Uninstall => return Err(SingBoxNativeHostError), + }; + let receipt = install_receipt_from_metadata(self.root.path(), metadata, installed_at)?; + write_install_receipt(&self.root, &receipt).map_err(|_| SingBoxNativeHostError)?; + let (verified_receipt, verified_marker, receipt_match, marker_match) = + self.inspect_metadata()?; + if !receipt_match + || !marker_match + || verified_receipt != receipt + || verified_marker != metadata.marker + { + return Err(SingBoxNativeHostError); + } + self.last_receipt = Some(receipt); + self.last_marker = Some(metadata.marker.clone()); + Ok(()) + } + + fn install_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + validate_service_spec(self.root.path(), spec)?; + let files = self.promoted_files.as_ref().ok_or(SingBoxNativeHostError)?; + verify_runtime_root_shape( + self.root.path(), + files, + false, + self.promoted_has_runtime_config, + )?; + ensure_service_log_directory(self.root.path())?; + let policy = create_known_service(KnownWindowsService::SingBox, &spec.wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) || !service_policy_matches(&policy) { + return Err(SingBoxNativeHostError); + } + Ok(()) + } + + fn delete_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + validate_service_spec(self.root.path(), spec)?; + let policy = query_service_policy_exact(spec.service_name, &spec.wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) || !service_policy_matches(&policy) { + return Err(SingBoxNativeHostError); + } + delete_known_service(KnownWindowsService::SingBox, &spec.wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + wait_known_service_deleted( + KnownWindowsService::SingBox, + &spec.wrapper_path, + SERVICE_WAIT_TIMEOUT, + ) + .map_err(|_| SingBoxNativeHostError) + } + + fn lease_runtime_config( + &mut self, + path: &Path, + expected_sha256: &str, + ) -> Result { + if !same_windows_path(path, &self.root.path().join(SINGBOX_RUNTIME_CONFIG_FILE)) { + return Err(SingBoxNativeHostError); + } + lease_runtime_config_file(path, expected_sha256) + } + + fn start_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + config: &Self::RuntimeConfigLease, + ) -> Result<(), SingBoxNativeHostError> { + validate_service_spec(self.root.path(), spec)?; + validate_config_lease(config, &self.root.path().join(SINGBOX_RUNTIME_CONFIG_FILE))?; + verify_service_log_directory(self.root.path())?; + let policy = query_service_policy_exact(spec.service_name, &spec.wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) || !service_policy_matches(&policy) { + return Err(SingBoxNativeHostError); + } + start_known_service(KnownWindowsService::SingBox, &spec.wrapper_path) + .map(|_| ()) + .map_err(|_| SingBoxNativeHostError) + } + + fn stop_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + ) -> Result<(), SingBoxNativeHostError> { + validate_service_spec(self.root.path(), spec)?; + let policy = query_service_policy_exact(spec.service_name, &spec.wrapper_path) + .map_err(|_| SingBoxNativeHostError)?; + if !service_policy_matches(&policy) { + return Err(SingBoxNativeHostError); + } + stop_known_service(KnownWindowsService::SingBox, &spec.wrapper_path) + .map(|_| ()) + .map_err(|_| SingBoxNativeHostError) + } + + fn wait_service( + &mut self, + spec: &SingBoxServiceInstallSpec, + state: SingBoxNativeServiceState, + config: Option<&Self::RuntimeConfigLease>, + ) -> Result<(), SingBoxNativeHostError> { + validate_service_spec(self.root.path(), spec)?; + let expected = match state { + SingBoxNativeServiceState::Stopped => ServiceState::Stopped, + SingBoxNativeServiceState::Running => { + validate_config_lease( + config.ok_or(SingBoxNativeHostError)?, + &self.root.path().join(SINGBOX_RUNTIME_CONFIG_FILE), + )?; + verify_service_log_directory(self.root.path())?; + ServiceState::Running + } + SingBoxNativeServiceState::Missing | SingBoxNativeServiceState::Pending => { + return Err(SingBoxNativeHostError); + } + }; + let policy = wait_service_state_exact( + spec.service_name, + &spec.wrapper_path, + expected, + SERVICE_WAIT_TIMEOUT, + ) + .map_err(|_| SingBoxNativeHostError)?; + if !service_policy_matches(&policy) { + return Err(SingBoxNativeHostError); + } + Ok(()) + } + + fn remove_managed_files(&mut self) -> Result<(), SingBoxNativeHostError> { + if self.backup.is_some() || self.staging.is_some() { + return Err(SingBoxNativeHostError); + } + let service = query_service_policy_exact(spec_service_name(), &self.wrapper_path()) + .map_err(|_| SingBoxNativeHostError)?; + if service.service.exists { + return Err(SingBoxNativeHostError); + } + let (receipt, marker, receipt_match, marker_match) = self.inspect_metadata()?; + let has_runtime_config = runtime_config_exists(self.root.path())?; + if !receipt_match || !marker_match { + return Err(SingBoxNativeHostError); + } + verify_complete_managed_root(self.root.path(), &receipt, &marker, has_runtime_config)?; + let backup_path = unique_root_sibling(self.root.path(), "uninstall")?; + fs::rename(self.root.path(), &backup_path).map_err(|_| SingBoxNativeHostError)?; + self.last_receipt = Some(receipt.clone()); + self.last_marker = Some(marker.clone()); + self.backup = Some(BackupRoot { + path: backup_path, + receipt, + marker, + has_runtime_config, + }); + Ok(()) + } + + fn rollback( + &mut self, + mode: SingBoxNativeMode, + restore_or_remove_service: bool, + ) -> Result<(), SingBoxNativeHostError> { + match mode { + SingBoxNativeMode::Install => { + if restore_or_remove_service { + let _ = + delete_known_service(KnownWindowsService::SingBox, &self.wrapper_path()); + wait_known_service_deleted( + KnownWindowsService::SingBox, + &self.wrapper_path(), + SERVICE_WAIT_TIMEOUT, + ) + .map_err(|_| SingBoxNativeHostError)?; + } + if self + .root + .path() + .try_exists() + .map_err(|_| SingBoxNativeHostError)? + { + let files = self.promoted_files.as_ref().ok_or(SingBoxNativeHostError)?; + remove_runtime_root_allow_partial_metadata( + self.root.path(), + files, + self.promoted_has_runtime_config, + )?; + } + } + SingBoxNativeMode::Update => { + if let Some(backup) = self.backup.take() { + if verify_complete_managed_root_at( + &backup.path, + self.root.path(), + &backup.receipt, + &backup.marker, + backup.has_runtime_config, + ) + .is_err() + { + self.backup = Some(backup); + return Err(SingBoxNativeHostError); + } + let failed = unique_root_sibling(self.root.path(), "failed")?; + fs::rename(self.root.path(), &failed).map_err(|_| SingBoxNativeHostError)?; + if fs::rename(&backup.path, self.root.path()).is_err() { + let _ = fs::rename(&failed, self.root.path()); + self.backup = Some(backup); + return Err(SingBoxNativeHostError); + } + let files = self.promoted_files.as_ref().ok_or(SingBoxNativeHostError)?; + remove_runtime_root_allow_partial_metadata( + &failed, + files, + self.promoted_has_runtime_config, + )?; + } else { + let receipt = self.last_receipt.as_ref().ok_or(SingBoxNativeHostError)?; + let marker = self.last_marker.as_ref().ok_or(SingBoxNativeHostError)?; + verify_complete_managed_root( + self.root.path(), + receipt, + marker, + runtime_config_exists(self.root.path())?, + )?; + } + } + SingBoxNativeMode::Uninstall => { + if let Some(backup) = self.backup.take() { + if verify_complete_managed_root_at( + &backup.path, + self.root.path(), + &backup.receipt, + &backup.marker, + backup.has_runtime_config, + ) + .is_err() + { + self.backup = Some(backup); + return Err(SingBoxNativeHostError); + } + if self.root.path().try_exists().unwrap_or(true) { + self.backup = Some(backup); + return Err(SingBoxNativeHostError); + } + fs::rename(&backup.path, self.root.path()) + .map_err(|_| SingBoxNativeHostError)?; + } + if restore_or_remove_service { + ensure_service_log_directory(self.root.path())?; + let policy = + create_known_service(KnownWindowsService::SingBox, &self.wrapper_path()) + .map_err(|_| SingBoxNativeHostError)?; + if policy.service.state != Some(ServiceState::Stopped) + || !service_policy_matches(&policy) + { + return Err(SingBoxNativeHostError); + } + } + } + } + self.clear_transient_state(); + Ok(()) + } +} + +impl SystemSingBoxNativeHost { + fn clear_transient_state(&mut self) { + self.staging = None; + self.promoted_files = None; + self.promoted_runtime_version = None; + self.promoted_wrapper_version = None; + self.promoted_has_runtime_config = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn staged(name: &str, bytes: &[u8]) -> SingBoxStagedFile { + SingBoxStagedFile { + relative_path: name.to_owned(), + bytes: bytes.to_vec(), + sha256: format!("{:x}", Sha256::digest(bytes)), + size: bytes.len() as u64, + } + } + + #[test] + fn staged_contract_is_exact_for_install_and_update() { + let install = vec![ + staged(SINGBOX_LICENSE_FILE, b"license"), + staged(WINSW_WRAPPER_FILE, b"wrapper"), + staged(WINSW_SERVICE_XML_FILE, singbox_service_xml().as_bytes()), + staged(SINGBOX_CRONET_FILE, b"cronet"), + staged(SINGBOX_RUNTIME_FILE, b"runtime"), + ]; + assert!(validate_staged_files(SingBoxNativeMode::Install, &install).is_ok()); + let update: Vec<_> = install + .iter() + .filter(|file| { + matches!( + file.relative_path.as_str(), + SINGBOX_LICENSE_FILE | SINGBOX_CRONET_FILE | SINGBOX_RUNTIME_FILE + ) + }) + .cloned() + .collect(); + assert!(validate_staged_files(SingBoxNativeMode::Update, &update).is_ok()); + assert!(validate_staged_files(SingBoxNativeMode::Update, &install).is_err()); + } + + #[test] + fn service_log_layout_is_a_fixed_sibling_of_components() { + let app_root = Path::new("trusted-app"); + let component_root = app_root.join("components").join("sing-box"); + + assert_eq!( + crate::singbox_service::SINGBOX_SERVICE_LOG_DIR, + format!("%BASE%\\..\\..\\{SERVICE_LOG_ROOT_NAME}\\{SERVICE_LOG_COMPONENT_NAME}") + ); + assert_eq!( + app_root_from_component_layout(&component_root).expect("fixed layout"), + app_root + ); + assert_eq!( + app_root + .join(SERVICE_LOG_ROOT_NAME) + .join(SERVICE_LOG_COMPONENT_NAME), + Path::new("trusted-app") + .join(".proxywarden-service-logs") + .join("sing-box") + ); + assert!(!app_root + .join(SERVICE_LOG_ROOT_NAME) + .join(SERVICE_LOG_COMPONENT_NAME) + .starts_with(&component_root)); + assert!(app_root_from_component_layout(&app_root.join("sing-box")).is_err()); + assert!(app_root_from_component_layout( + &app_root.join("components").join("another-component") + ) + .is_err()); + } + + #[test] + fn runtime_config_schema_rejects_empty_or_wrong_shapes() { + let valid = br#"{ + "inbounds": [{"type":"mixed"}], + "outbounds": [{"type":"direct"}], + "route": {"final":"direct"} + }"#; + assert!(validate_runtime_config_schema(valid).is_ok()); + for invalid in [ + b"{}".as_slice(), + br#"{"inbounds":[],"outbounds":[{}],"route":{}}"#, + br#"{"inbounds":[{}],"outbounds":"bad","route":{}}"#, + br#"{"inbounds":[{}],"outbounds":[{}],"route":[]}"#, + ] { + assert!(validate_runtime_config_schema(invalid).is_err()); + } + } + + #[test] + fn tombstone_file_deletion_is_retryable_after_every_checkpoint() { + let plan = [ + "sing-box.exe", + "ProxyWardenSingBox.xml", + SINGBOX_RUNTIME_CONFIG_FILE, + INSTALL_RECEIPT_FILENAME, + SINGBOX_OWNERSHIP_MARKER_FILE, + ] + .into_iter() + .map(PathBuf::from) + .collect::>(); + + for fail_after in 1..=plan.len() { + let mut remaining = plan.iter().cloned().collect::>(); + let mut deleted = 0; + let first = remove_cleanup_files_with(&plan, |path| { + remaining.remove(path); + deleted += 1; + if deleted == fail_after { + Err(SingBoxNativeHostError) + } else { + Ok(()) + } + }); + assert!(first.is_err(), "fault after deletion {fail_after}"); + + let retry = plan + .iter() + .filter(|path| remaining.contains(*path)) + .cloned() + .collect::>(); + remove_cleanup_files_with(&retry, |path| { + remaining.remove(path); + Ok(()) + }) + .expect("retry remaining allowlisted files"); + assert!(remaining.is_empty()); + } + } +} diff --git a/src-tauri/src/singbox_service.rs b/src-tauri/src/singbox_service.rs index 5a21c0c..e89a6a5 100644 --- a/src-tauri/src/singbox_service.rs +++ b/src-tauri/src/singbox_service.rs @@ -1,32 +1,121 @@ use crate::component_detection::DetectedSingBox; use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME}; +use crate::process::service_path_matches_exact; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe"; +pub const WINSW_SERVICE_XML_FILE: &str = "ProxyWardenSingBox.xml"; +pub const SINGBOX_RUNTIME_FILE: &str = "sing-box.exe"; +pub const SINGBOX_CRONET_FILE: &str = "libcronet.dll"; +pub const SINGBOX_LICENSE_FILE: &str = "LICENSE"; +pub const SINGBOX_RUNTIME_CONFIG_FILE: &str = "config.json"; +pub const SINGBOX_OWNERSHIP_MARKER_FILE: &str = "proxywarden-singbox.json"; +/// WinSW expands `%BASE%` to the sealed component root. The fixed two-parent +/// hop lands at the verified Control App root while keeping wrapper output out +/// of the immutable runtime inventory. +pub const SINGBOX_SERVICE_LOG_DIR: &str = r"%BASE%\..\..\.proxywarden-service-logs\sing-box"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SingBoxServiceAction { - Start, - Stop, +pub enum SingBoxNativeServiceState { + Missing, + Stopped, + Running, + Pending, } -impl SingBoxServiceAction { - pub fn action_name(self) -> &'static str { - match self { - SingBoxServiceAction::Start => "start", - SingBoxServiceAction::Stop => "stop", +/// Fresh SCM state queried at the privileged boundary. `path_name` is the raw +/// `QueryServiceConfigW` value; the policy compares it with the one fixed +/// wrapper path and rejects arguments or another executable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxNativeServiceSnapshot { + pub state: SingBoxNativeServiceState, + pub path_name: Option, + pub demand_start: bool, + pub failure_recovery_disabled: bool, + pub builtin_users_can_start: bool, +} + +impl SingBoxNativeServiceSnapshot { + pub fn missing() -> Self { + Self { + state: SingBoxNativeServiceState::Missing, + path_name: None, + demand_start: false, + failure_recovery_disabled: false, + builtin_users_can_start: false, } } - pub fn label(self) -> &'static str { - match self { - SingBoxServiceAction::Start => "запустить", - SingBoxServiceAction::Stop => "остановить", - } + pub fn matches_managed_policy(&self, spec: &SingBoxServiceInstallSpec) -> bool { + self.path_name + .as_deref() + .is_some_and(|path_name| service_path_matches_exact(path_name, &spec.wrapper_path)) + && self.demand_start + && self.failure_recovery_disabled + && !self.builtin_users_can_start } } +/// Fixed native SCM creation contract. A host maps this directly to +/// `CreateServiceW`/`ChangeServiceConfig2W`; there is no caller-supplied +/// command line or service name. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SingBoxServiceInstallSpec { + pub service_name: &'static str, + pub display_name: &'static str, + pub wrapper_path: PathBuf, + pub command_line: String, + pub demand_start: bool, + pub failure_recovery_disabled: bool, + pub builtin_users_can_start: bool, +} + +impl SingBoxServiceInstallSpec { + pub fn for_install_root(install_root: &Path) -> Option { + if !install_root.is_absolute() + || install_root.file_name().and_then(|name| name.to_str()) != Some("sing-box") + { + return None; + } + let wrapper_path = install_root.join(WINSW_WRAPPER_FILE); + let command_line = quote_windows_executable(&wrapper_path)?; + Some(Self { + service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME, + display_name: "ProxyWarden Local sing-box", + wrapper_path, + command_line, + demand_start: true, + failure_recovery_disabled: true, + builtin_users_can_start: false, + }) + } +} + +pub fn singbox_service_xml() -> &'static str { + concat!( + "\r\n", + " ProxyWardenSingBox\r\n", + " ProxyWarden Local sing-box\r\n", + " Local sing-box runtime managed by ProxyWarden\r\n", + " %BASE%\\sing-box.exe\r\n", + " run -c "%BASE%\\config.json"\r\n", + " Manual\r\n", + " \r\n", + " %BASE%\\..\\..\\.proxywarden-service-logs\\sing-box\r\n", + " \r\n", + "\r\n", + ) +} + +fn quote_windows_executable(path: &Path) -> Option { + let value = path.to_str()?; + if value.is_empty() || value.contains(['\0', '"', '\r', '\n']) { + return None; + } + Some(format!("\"{value}\"")) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SingBoxSetupStatus { @@ -45,16 +134,6 @@ pub struct SingBoxSetupItem { pub details: String, } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ServiceCommandOutput { - pub success: bool, - pub code: String, - pub service_name: Option, - pub status: Option, - pub process_id: Option, -} - pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus { build_singbox_setup_status_with_install_root( detected, @@ -74,7 +153,7 @@ pub fn build_singbox_setup_status_with_install_root( id: "sing-box-binary".to_string(), name: "sing-box".to_string(), installed: true, - version: Some("binary найден".to_string()), + version: singbox.version.clone(), details: singbox.executable_path.display().to_string(), }, _ => SingBoxSetupItem { @@ -92,7 +171,7 @@ pub fn build_singbox_setup_status_with_install_root( id: "winsw-wrapper".to_string(), name: "WinSW service wrapper".to_string(), installed: true, - version: Some("wrapper найден".to_string()), + version: singbox.wrapper_version.clone(), details: singbox.wrapper_path.display().to_string(), }, _ => SingBoxSetupItem { @@ -110,14 +189,14 @@ pub fn build_singbox_setup_status_with_install_root( id: "windows-service".to_string(), name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), installed: true, - version: Some("служба запущена".to_string()), + version: None, details: format!("Служба {}", singbox.service_name), }, Some(singbox) => SingBoxSetupItem { id: "windows-service".to_string(), name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(), installed: true, - version: Some("служба остановлена".to_string()), + version: None, details: format!("Служба {}", singbox.service_name), }, None => SingBoxSetupItem { @@ -138,143 +217,3 @@ pub fn build_singbox_setup_status_with_install_root( items, } } - -pub fn parse_service_command_output(stdout: &[u8]) -> Option { - let stdout = String::from_utf8_lossy(stdout); - let payload = stdout - .lines() - .rev() - .map(str::trim) - .find(|line| line.starts_with('{') && line.ends_with('}'))?; - - serde_json::from_str(payload).ok() -} - -pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> { - let normalized = path - .display() - .to_string() - .replace('/', "\\") - .to_ascii_lowercase(); - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\"); - let is_legacy_proxywarden_child = normalized.ends_with("\\proxywarden\\sing-box"); - - if file_name == "sing-box" && (is_proxywarden_component || is_legacy_proxywarden_child) { - return Ok(()); - } - - Err(format!( - "Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}", - path.display() - )) -} - -pub fn service_control_script( - action: SingBoxServiceAction, - service_name: &str, - config_source: Option<&Path>, - config_target: Option<&Path>, -) -> String { - let action_name = action.action_name(); - let escaped_service_name = escape_powershell_single(service_name); - let escaped_config_source = config_source - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - let escaped_config_target = config_target - .map(|path| escape_powershell_single(&path.display().to_string())) - .unwrap_or_default(); - format!( - r#" -$ErrorActionPreference = 'Stop' -$serviceName = '{escaped_service_name}' -$action = '{action_name}' -$configSource = '{escaped_config_source}' -$configTarget = '{escaped_config_target}' - -function Get-ServiceProcessId([string]$name) {{ - $escapedName = $name.Replace("'", "''") - $record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue - if ($null -eq $record) {{ return 0 }} - return [int]$record.ProcessId -}} - -function Get-ServiceStatus([string]$name) {{ - $current = Get-Service -Name $name -ErrorAction SilentlyContinue - if ($null -eq $current) {{ return $null }} - return $current.Status.ToString() -}} - -function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{ - [PSCustomObject]@{{ - success = $success - code = $code - serviceName = $serviceName - status = $status - processId = $processId - }} | ConvertTo-Json -Compress - exit 0 -}} - -function Sync-ServiceConfig {{ - if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }} - if (-not (Test-Path -LiteralPath $configSource)) {{ - Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) - }} - if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }} - - try {{ - Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop - }} catch {{ - Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) - }} -}} - -$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue -if ($null -eq $service) {{ - Write-ServiceResult $false 'service_not_found' $null 0 -}} - -if ($action -eq 'start') {{ - Sync-ServiceConfig - - if ($service.Status -eq 'Running') {{ - Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName) - }} - - try {{ - Start-Service -Name $serviceName -ErrorAction Stop - $service = Get-Service -Name $serviceName -ErrorAction Stop - $service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15)) - }} catch {{ - Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) - }} - - Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName) -}} - -if ($service.Status -eq 'Stopped') {{ - Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName) -}} - -try {{ - Stop-Service -Name $serviceName -Force -ErrorAction Stop - $service = Get-Service -Name $serviceName -ErrorAction Stop - $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15)) -}} catch {{ - Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName) -}} - -Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName) -"# - ) -} - -fn escape_powershell_single(value: &str) -> String { - value.replace('\'', "''") -} diff --git a/src-tauri/src/singbox_subscription.rs b/src-tauri/src/singbox_subscription.rs index ffcecaa..3eb282f 100644 --- a/src-tauri/src/singbox_subscription.rs +++ b/src-tauri/src/singbox_subscription.rs @@ -5,6 +5,7 @@ use crate::command_dto::*; use crate::component_detection::{ detect_singbox_install, singbox_component_from_detection, DetectedSingBox, }; +use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction}; use crate::models::{ ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer, }; @@ -59,15 +60,26 @@ pub(crate) fn read_singbox_status_with_detection( storage: &JsonStorage, detected: Option<&DetectedSingBox>, ) -> Result { + let _guard = read_guard(storage).map_err(storage_error)?; let config = storage.read_local_singbox_config().map_err(storage_error)?; let cache = storage .read_singbox_subscription_cache() .map_err(storage_error)?; + status_from_source(storage, &config, cache.as_ref(), detected) +} + +fn status_from_source( + storage: &JsonStorage, + config: &LocalSingBoxConfig, + cache: Option<&SubscriptionCache>, + detected: Option<&DetectedSingBox>, +) -> Result { let component = singbox_component_from_detection(detected); Ok(LocalSingBoxStatusResponse { - config: LocalSingBoxConfigDto::from(&config), - cache: cache.as_ref().map(SubscriptionCacheDto::from), + saved_state: crate::configuration_use_case::read_saved_state_locked(storage)?, + config: LocalSingBoxConfigDto::from(config), + cache: cache.map(SubscriptionCacheDto::from), component: ComponentStatusDto::from(&component), generated_config_path: storage .paths() @@ -86,10 +98,18 @@ pub fn save_singbox_subscription_to_storage( input: SaveSingBoxSubscriptionInputDto, clock: &impl Clock, ) -> Result { + let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?; let subscription_url = input.subscription_url.trim().to_string(); validate_subscription_url(&subscription_url)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + if config.subscription_url.as_deref() != Some(&subscription_url) { + storage + .remove_singbox_subscription_cache() + .map_err(storage_error)?; + config.selected_server_id = None; + config.selected_server_tag = None; + } config.subscription_url = Some(subscription_url); ensure_device_hwid(&mut config); config.updated_at = Some(clock.now()); @@ -97,7 +117,17 @@ pub fn save_singbox_subscription_to_storage( .write_local_singbox_config(&config) .map_err(storage_error)?; - read_singbox_status(storage) + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let mut result = status_from_source( + storage, + &config, + cache.as_ref(), + detect_singbox_install().as_ref(), + )?; + result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?; + Ok(result) } pub fn fetch_singbox_subscription_with_fetcher( @@ -105,7 +135,21 @@ pub fn fetch_singbox_subscription_with_fetcher( fetcher: &impl SubscriptionFetcher, clock: &impl Clock, ) -> Result { + fetch_singbox_subscription_candidate(storage, None, fetcher, clock) +} + +pub fn fetch_singbox_subscription_candidate( + storage: &JsonStorage, + candidate_url: Option<&str>, + fetcher: &impl SubscriptionFetcher, + clock: &impl Clock, +) -> Result { + let guard = read_guard(storage).map_err(storage_error)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + if let Some(candidate) = candidate_url { + validate_subscription_url(candidate.trim())?; + config.subscription_url = Some(candidate.trim().to_string()); + } let subscription_url = config .subscription_url .as_deref() @@ -119,28 +163,28 @@ pub fn fetch_singbox_subscription_with_fetcher( ) })?; - let device_hwid_created = ensure_device_hwid(&mut config); - if device_hwid_created { - config.updated_at = Some(clock.now()); - storage - .write_local_singbox_config(&config) - .map_err(storage_error)?; - } + ensure_device_hwid(&mut config); + let revision = revision_locked(storage).map_err(storage_error)?; + drop(guard); let identity = subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); let cache = fetcher .fetch_subscription(&subscription_url, &identity) .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; - let selected_server = config - .selected_server_id - .as_deref() - .and_then(|id| cache.servers.iter().find(|server| server.id == id)) - .or_else(|| { - let tag = config.selected_server_tag.as_deref()?; - cache.servers.iter().find(|server| server.tag == tag) - }) - .or_else(|| cache.servers.first()); + let transaction = ConfigurationTransaction::begin(storage, Some(&revision)).map_err(|_| { + CommandError::new( + "configuration_changed", + "Настройки изменились во время загрузки. Повторите обновление подписки.", + ) + })?; + let selected_server = if let Some(id) = config.selected_server_id.as_deref() { + cache.servers.iter().find(|server| server.id == id) + } else if let Some(tag) = config.selected_server_tag.as_deref() { + find_subscription_server(&cache, None, tag, None, None) + } else { + cache.servers.first() + }; config.selected_server_id = selected_server.map(|server| server.id.clone()); config.selected_server_tag = selected_server.map(|server| server.tag.clone()); @@ -151,23 +195,35 @@ pub fn fetch_singbox_subscription_with_fetcher( storage .write_local_singbox_config(&config) .map_err(storage_error)?; - storage - .append_activity(ActivityEntry { - id: "singbox-subscription-fetched".to_string(), - at: clock.now(), - level: ActivityLevel::Success, - title: "Подписка Local sing-box обновлена".to_string(), - message: format!("Серверов найдено: {}", cache.servers.len()), - }) - .map_err(storage_error)?; - read_singbox_status(storage) + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let mut result = status_from_source( + storage, + &config, + cache.as_ref(), + detect_singbox_install().as_ref(), + )?; + result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?; + let _ = storage.append_activity(ActivityEntry { + id: "singbox-subscription-fetched".to_string(), + at: clock.now(), + level: ActivityLevel::Success, + title: "Подписка Local sing-box обновлена".to_string(), + message: format!( + "Серверов найдено: {}", + result.cache.as_ref().map_or(0, |cache| cache.servers.len()) + ), + }); + Ok(result) } pub fn forget_singbox_subscription_in_storage( storage: &JsonStorage, clock: &impl Clock, ) -> Result { + let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?; let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.subscription_url = None; config.selected_server_tag = None; @@ -176,11 +232,24 @@ pub fn forget_singbox_subscription_in_storage( storage .write_local_singbox_config(&config) .map_err(storage_error)?; + storage + .discard_local_singbox_config_backup() + .map_err(storage_error)?; storage .remove_singbox_subscription_cache() .map_err(storage_error)?; - read_singbox_status(storage) + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let mut result = status_from_source( + storage, + &config, + cache.as_ref(), + detect_singbox_install().as_ref(), + )?; + result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?; + Ok(result) } pub fn select_singbox_server_in_storage( @@ -188,6 +257,7 @@ pub fn select_singbox_server_in_storage( input: SelectSingBoxServerInputDto, clock: &impl Clock, ) -> Result { + let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?; let requested_tag = input.tag.trim().to_string(); let requested_id = input .id @@ -233,7 +303,17 @@ pub fn select_singbox_server_in_storage( .write_local_singbox_config(&config) .map_err(storage_error)?; - read_singbox_status(storage) + let cache = storage + .read_singbox_subscription_cache() + .map_err(storage_error)?; + let mut result = status_from_source( + storage, + &config, + cache.as_ref(), + detect_singbox_install().as_ref(), + )?; + result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?; + Ok(result) } pub fn ping_singbox_server_in_storage( @@ -267,6 +347,7 @@ pub fn ping_all_singbox_servers_in_storage( pub(crate) fn read_required_singbox_cache( storage: &JsonStorage, ) -> Result { + let _guard = read_guard(storage).map_err(storage_error)?; storage .read_singbox_subscription_cache() .map_err(storage_error)? @@ -338,28 +419,26 @@ fn find_subscription_server<'a>( requested_server: Option<&str>, requested_port: Option, ) -> Option<&'a SubscriptionServer> { - requested_id - .and_then(|id| cache.servers.iter().find(|server| server.id == id)) - .or_else(|| { - cache - .servers - .iter() - .find(|server| server.tag == requested_tag) - }) - .or_else(|| { - let requested = comparable_server_tag(requested_tag); - cache - .servers - .iter() - .find(|server| comparable_server_tag(&server.tag) == requested) - }) - .or_else(|| { - let server_name = requested_server?.trim(); - let server_port = requested_port?; - cache.servers.iter().find(|server| { - server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port - }) - }) + if let Some(id) = requested_id { + return cache.servers.iter().find(|server| server.id == id); + } + let tag = comparable_server_tag(requested_tag); + let mut matches = cache.servers.iter().filter(|server| { + comparable_server_tag(&server.tag) == tag + && requested_server.is_none_or(|host| server.server.eq_ignore_ascii_case(host.trim())) + && requested_port.is_none_or(|port| server.server_port == port) + }); + if let Some(found) = matches.next() { + return matches.next().is_none().then_some(found); + } + let host = requested_server?.trim(); + let port = requested_port?; + let mut endpoints = cache + .servers + .iter() + .filter(|server| server.server.eq_ignore_ascii_case(host) && server.server_port == port); + let found = endpoints.next()?; + endpoints.next().is_none().then_some(found) } fn comparable_server_tag(value: &str) -> String { diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 7c1b622..2313e51 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -1,6 +1,11 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT}; +use crate::component_cutover::{ + validate_component_cutover_observation, validate_component_cutover_user_evidence, + ComponentCutoverObservation, ComponentCutoverUserEvidence, +}; use crate::models::{ - ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target, + ActivityEntry, ComponentLayoutMeta, LocalSingBoxConfig, Profile, StorageMeta, + SubscriptionCache, Target, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, }; use crate::safe_fs; use serde::{de::DeserializeOwned, Serialize}; @@ -17,11 +22,19 @@ pub struct StoragePaths { pub root: PathBuf, pub config_dir: PathBuf, pub state_dir: PathBuf, + pub packages_dir: PathBuf, pub generated_dir: PathBuf, pub profiles_file: PathBuf, pub targets_file: PathBuf, pub components_file: PathBuf, pub local_singbox_file: PathBuf, + pub storage_meta_file: PathBuf, + pub component_layout_file: PathBuf, + pub component_updates_file: PathBuf, + pub component_cutover_observation_file: PathBuf, + pub component_cutover_user_evidence_file: PathBuf, + pub migrations_dir: PathBuf, + pub privileged_jobs_dir: PathBuf, pub singbox_subscription_cache_file: PathBuf, pub activity_file: PathBuf, } @@ -32,17 +45,30 @@ impl StoragePaths { let config_dir = root.join("config"); let state_dir = root.join("state"); let generated_dir = root.join("generated"); + let packages_dir = root.join("packages"); Self { root, profiles_file: config_dir.join("profiles.json"), targets_file: config_dir.join("targets.json"), + // Legacy migration input only. Live component status is always + // rebuilt from native inventory and never read from this file. components_file: config_dir.join("components.json"), local_singbox_file: config_dir.join("local-singbox.json"), + storage_meta_file: config_dir.join("storage-meta.json"), + component_layout_file: state_dir.join("component-layout.json"), + component_updates_file: state_dir.join("component-updates.json"), + component_cutover_observation_file: state_dir + .join("component-cutover-observation.json"), + component_cutover_user_evidence_file: state_dir + .join("component-cutover-user-evidence.json"), + migrations_dir: state_dir.join("migrations"), + privileged_jobs_dir: state_dir.join("privileged-jobs"), singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"), activity_file: state_dir.join("activity.json"), config_dir, state_dir, + packages_dir, generated_dir, } } @@ -92,14 +118,13 @@ impl JsonStorage { self.write_json(&self.paths.targets_file, targets) } - pub fn read_components(&self) -> io::Result> { - self.read_json_or_default(&self.paths.components_file) - } - pub fn read_local_singbox_config(&self) -> io::Result { let mut config: LocalSingBoxConfig = self.read_json_or_default(&self.paths.local_singbox_file)?; config.normalize_percent_encoded_tags(); + // The persisted pre-1.2 install_root is legacy discovery input only. + // Runtime layout is owned by component inventory, not user storage. + config.install_root = DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string(); Ok(config) } @@ -107,6 +132,97 @@ impl JsonStorage { self.write_json(&self.paths.local_singbox_file, config) } + pub fn read_storage_meta(&self) -> io::Result> { + self.read_optional_json(&self.paths.storage_meta_file) + } + + pub fn write_storage_meta(&self, meta: &StorageMeta) -> io::Result<()> { + self.write_json(&self.paths.storage_meta_file, meta) + } + + pub fn read_component_layout(&self) -> io::Result> { + self.read_optional_json(&self.paths.component_layout_file) + } + + pub fn write_component_layout(&self, layout: &ComponentLayoutMeta) -> io::Result<()> { + self.write_json(&self.paths.component_layout_file, layout) + } + + pub fn read_component_cutover_observation( + &self, + ) -> io::Result> { + let path = &self.paths.component_cutover_observation_file; + match fs::read_to_string(path) { + Ok(contents) => { + let observation: ComponentCutoverObservation = parse_json(path, &contents)?; + validate_component_cutover_observation(&observation).map_err(|_| { + io::Error::new( + ErrorKind::InvalidData, + "invalid component cutover observation", + ) + })?; + Ok(Some(observation)) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + pub fn write_component_cutover_observation( + &self, + observation: &ComponentCutoverObservation, + ) -> io::Result<()> { + validate_component_cutover_observation(observation).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + "invalid component cutover observation", + ) + })?; + let contents = serde_json::to_vec_pretty(observation) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?; + safe_fs::write_restricted_with_backup( + &self.paths.component_cutover_observation_file, + &contents, + ) + } + + /// Reads the normal-process cutover evidence without corruption recovery or + /// any other write. Elevated callers must still live-revalidate it. + pub fn read_component_cutover_user_evidence( + &self, + ) -> io::Result> { + let path = &self.paths.component_cutover_user_evidence_file; + match fs::read_to_string(path) { + Ok(contents) => { + let evidence: ComponentCutoverUserEvidence = parse_json(path, &contents)?; + validate_component_cutover_user_evidence(&evidence).map_err(|_| { + io::Error::new(ErrorKind::InvalidData, "invalid component cutover evidence") + })?; + Ok(Some(evidence)) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } + } + + pub fn write_component_cutover_user_evidence( + &self, + evidence: &ComponentCutoverUserEvidence, + ) -> io::Result<()> { + validate_component_cutover_user_evidence(evidence).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + "invalid component cutover evidence", + ) + })?; + let contents = serde_json::to_vec_pretty(evidence) + .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?; + safe_fs::write_restricted_with_backup( + &self.paths.component_cutover_user_evidence_file, + &contents, + ) + } + pub fn read_singbox_subscription_cache(&self) -> io::Result> { let mut cache = self .read_optional_json::(&self.paths.singbox_subscription_cache_file)?; @@ -121,6 +237,7 @@ impl JsonStorage { } pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> { + remove_optional_file(&backup_path(&self.paths.singbox_subscription_cache_file))?; match fs::remove_file(&self.paths.singbox_subscription_cache_file) { Ok(()) => Ok(()), Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), @@ -128,6 +245,10 @@ impl JsonStorage { } } + pub fn discard_local_singbox_config_backup(&self) -> io::Result<()> { + remove_optional_file(&backup_path(&self.paths.local_singbox_file)) + } + pub fn read_activity(&self) -> io::Result> { let entries = self.read_json_or_default(&self.paths.activity_file)?; Ok(cap_activity(entries, self.activity_limit)) @@ -148,7 +269,17 @@ impl JsonStorage { Ok(contents) => { parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error)) } - Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), + Err(error) if error.kind() == ErrorKind::NotFound => { + match fs::read_to_string(backup_path(path)) { + Ok(contents) => { + let value = parse_json(&backup_path(path), &contents)?; + safe_fs::write_atomic_without_backup(path, contents.as_bytes())?; + Ok(value) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(error), + } + } Err(error) => Err(error), } } @@ -206,23 +337,21 @@ fn recover_corrupt_json(path: &Path, parse_error: io::Error) -> io::Result where T: DeserializeOwned, { - let corrupt_path = safe_fs::corrupt_path(path); - move_corrupt_file(path, &corrupt_path)?; - let backup_path = backup_path(path); - if backup_path.exists() { + if backup_path.try_exists()? { let backup_contents = fs::read_to_string(&backup_path)?; match parse_json(&backup_path, &backup_contents) { Ok(value) => { - fs::copy(&backup_path, path)?; + let corrupt_path = safe_fs::corrupt_path(path); + safe_fs::write_atomic_without_backup(&corrupt_path, &fs::read(path)?)?; + safe_fs::write_atomic_without_backup(path, backup_contents.as_bytes())?; Ok(value) } Err(backup_error) => Err(io::Error::new( ErrorKind::InvalidData, format!( - "Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}", + "Invalid JSON in '{}'; original preserved; backup '{}' could not be restored: {backup_error}; original error: {parse_error}", path.display(), - corrupt_path.display(), backup_path.display() ), )), @@ -231,24 +360,18 @@ where Err(io::Error::new( ErrorKind::InvalidData, format!( - "Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}", + "Invalid JSON in '{}'; original preserved; no valid backup available: {parse_error}", path.display(), - corrupt_path.display() ), )) } } -fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> { - match fs::rename(path, corrupt_path) { +fn remove_optional_file(path: &Path) -> io::Result<()> { + safe_fs::ensure_no_reparse_ancestors(path)?; + match fs::remove_file(path) { Ok(()) => Ok(()), - Err(rename_error) => { - fs::copy(path, corrupt_path)?; - fs::remove_file(path)?; - if !corrupt_path.exists() { - return Err(rename_error); - } - Ok(()) - } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), } } diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index 6ee0548..c298916 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -175,7 +175,10 @@ pub fn fetch_subscription_with_identity_and_policy( } let response = request.send().map_err(|error| { - SubscriptionError::new(format!("Subscription request failed: {error}")) + SubscriptionError::new(format!( + "Subscription request failed: {}", + error.without_url() + )) })?; let status = response.status(); if status.is_redirection() { @@ -209,8 +212,8 @@ pub fn fetch_subscription_with_identity_and_policy( .get("subscription-userinfo") .and_then(|value| value.to_str().ok()), ); - let body = response.text().map_err(|error| { - SubscriptionError::new(format!("Subscription body read failed: {error}")) + let body = response.text().map_err(|_error| { + SubscriptionError::new("Subscription body read failed".to_string()) })?; let parsed = parse_subscription_body(&body)?; @@ -615,7 +618,7 @@ fn server_from_outbound(outbound: &Value) -> Option { }) } -fn outbound_server_id(outbound: &Value) -> String { +pub(crate) fn outbound_server_id(outbound: &Value) -> String { let bytes = serde_json::to_vec(outbound).unwrap_or_default(); let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| { (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ada75a6..e48b3bf 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyWarden", - "version": "1.1.0", + "version": "2.0.0", "identifier": "ru.dokops.proxywarden.windows", "build": { "beforeDevCommand": "npm run dev", @@ -29,13 +29,17 @@ "active": true, "targets": "nsis", "resources": [ - "bundled/proxifyre", - "bundled/cleanup" + "bundled/components" ], "windows": { + "webviewInstallMode": { + "type": "offlineInstaller", + "silent": true + }, "nsis": { "installMode": "perMachine", - "installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh" + "installerHooks": "bundled/installer-hooks/proxywarden-hooks.nsh", + "template": "bundled/installer-hooks/installer-template.nsi" } }, "icon": [ diff --git a/src-tauri/tests/apply_flow_tests.rs b/src-tauri/tests/apply_flow_tests.rs index 83ee140..a621fbf 100644 --- a/src-tauri/tests/apply_flow_tests.rs +++ b/src-tauri/tests/apply_flow_tests.rs @@ -14,6 +14,8 @@ use proxywarden_lib::models::{ Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind, }; +#[cfg(windows)] +use proxywarden_lib::safe_fs; use proxywarden_lib::storage::JsonStorage; use std::{cell::Cell, fs, path::Path}; @@ -44,6 +46,9 @@ fn external_apply_commits_one_source_state_without_service_control() { target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080 })); assert!(Path::new(&result.generated_config_path).exists()); + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(Path::new(&result.generated_config_path)) + .expect("generated config keeps restricted ACL"); } #[test] @@ -169,6 +174,15 @@ fn helper_failure_rolls_back_source_and_generated_artifact() { fs::read(&generated_path).expect("generated after"), b"old-generated" ); + #[cfg(windows)] + { + safe_fs::verify_path_protected_for_owner_admin_system(&generated_path) + .expect("rollback keeps generated config restricted"); + assert!( + !safe_fs::backup_path(&generated_path).exists(), + "rollback restores prior absence of backup" + ); + } } #[test] @@ -213,6 +227,7 @@ fn local_apply_with_missing_running_service_stops_at_preflight() { let error = run_apply( &fixture.storage, ApplyConfigurationInput { + expected_revision: None, route_mode: ApplyRouteMode::LocalSingbox, profile: profile_input(), external_target: None, @@ -230,8 +245,61 @@ fn local_apply_with_missing_running_service_stops_at_preflight() { ); } +#[test] +fn editing_shared_target_preserves_the_other_profile_and_target() { + let fixture = ApplyFixture::new("shared-target"); + fixture.seed_old_state(); + let before_profiles = fixture.storage.read_profiles().unwrap(); + let before_targets = fixture.storage.read_targets().unwrap(); + let mut input = external_input(); + input.disable_other_profiles = false; + input.external_target.as_mut().unwrap().id = Some("legacy-target".into()); + input.profile.protocols = vec!["TCP".into()]; + input.profile.items = vec![ProfileItemInput { + item_type: "folder".into(), + value: r"C:\Games".into(), + recursive: Some(false), + }]; + run_apply(&fixture.storage, input, &RecordingHelper::success()).unwrap(); + let profiles = fixture.storage.read_profiles().unwrap(); + let targets = fixture.storage.read_targets().unwrap(); + assert_eq!(profiles[0], before_profiles[0]); + assert_eq!(targets[0], before_targets[0]); + let edited = profiles.iter().find(|p| p.id == "main-profile").unwrap(); + assert_ne!(edited.target_id, "legacy-target"); + assert_eq!(edited.protocols, vec![Protocol::Tcp]); + assert!(!edited.items[0].recursive); + assert_eq!( + targets + .iter() + .find(|t| t.id == edited.target_id) + .unwrap() + .host, + "proxy.example.test" + ); +} + +#[test] +fn clearing_last_profile_requires_explicit_stop_and_does_not_call_helper() { + let fixture = ApplyFixture::new("clear-running"); + fixture.seed_old_state(); + let before = fixture.storage.read_profiles().unwrap(); + let mut input = external_input(); + input.profile.id = Some("legacy".into()); + input.profile.target_id = "legacy-target".into(); + input.profile.enabled = false; + input.profile.items.clear(); + input.disable_other_profiles = false; + let helper = RecordingHelper::success(); + let error = run_apply(&fixture.storage, input, &helper).unwrap_err(); + assert_eq!(error.code(), "stop_before_clearing_route"); + assert_eq!(helper.calls.get(), 0); + assert_eq!(fixture.storage.read_profiles().unwrap(), before); +} + fn external_input() -> ApplyConfigurationInput { ApplyConfigurationInput { + expected_revision: None, route_mode: ApplyRouteMode::External, profile: profile_input(), external_target: Some(TargetInput { @@ -299,6 +367,7 @@ fn test_proxyfier() -> DetectedProxyfier { running: true, service_name: Some("ProxiFyreService".to_string()), service_status: Some("running".to_string()), + version: Some("2.2.1.0".to_string()), } } diff --git a/src-tauri/tests/authenticode_tests.rs b/src-tauri/tests/authenticode_tests.rs new file mode 100644 index 0000000..16228e1 --- /dev/null +++ b/src-tauri/tests/authenticode_tests.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +#[cfg(windows)] +use proxywarden_lib::process::AuthenticodePublisher; +use proxywarden_lib::process::{verify_authenticode, AuthenticodeError}; + +#[cfg(windows)] +#[test] +fn bundled_windows_packet_filter_has_expected_trusted_publisher() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("bundled/components/windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi"); + + let verification = verify_authenticode(path).expect("bundled MSI should be verifiable"); + + assert!(verification.is_trusted); + assert_eq!( + verification.publisher, + Some(AuthenticodePublisher { + common_name: "The Anti-Cloud Corporation".to_owned(), + organization: "The Anti-Cloud Corporation".to_owned(), + }) + ); + assert_eq!(verification.status_code, 0); +} + +#[cfg(windows)] +#[test] +fn unsigned_regular_file_is_not_trusted() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + + let verification = verify_authenticode(path).expect("regular file should be inspectable"); + + assert!(!verification.is_trusted); + assert_eq!(verification.publisher, None); + assert_ne!(verification.status_code, 0); +} + +#[cfg(windows)] +#[test] +fn reparse_target_is_rejected_when_symlink_creation_is_available() { + use std::{fs, os::windows::fs::symlink_file}; + + let root = std::env::temp_dir().join(format!( + "proxywarden-authenticode-test-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&root).expect("test root should be creatable"); + let link = root.join("linked-target.exe"); + let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + if let Err(error) = symlink_file(target, &link) { + fs::remove_dir(&root).expect("test root should be removable"); + if error.raw_os_error() == Some(1314) { + eprintln!("skipping reparse probe because this process lacks symlink privilege"); + return; + } + panic!("test symlink creation failed: {error}"); + } + + let result = verify_authenticode(&link); + fs::remove_file(&link).expect("test symlink should be removable"); + fs::remove_dir(&root).expect("test root should be removable"); + + assert_eq!(result, Err(AuthenticodeError::UnsafeTarget)); +} + +#[test] +fn missing_target_fails_closed() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("missing-signature-target.exe"); + + #[cfg(windows)] + assert_eq!( + verify_authenticode(path), + Err(AuthenticodeError::InvalidTarget) + ); + + #[cfg(not(windows))] + assert_eq!( + verify_authenticode(path), + Err(AuthenticodeError::UnsupportedPlatform) + ); +} diff --git a/src-tauri/tests/command_tests.rs b/src-tauri/tests/command_tests.rs index a3969dc..b2c0124 100644 --- a/src-tauri/tests/command_tests.rs +++ b/src-tauri/tests/command_tests.rs @@ -1,26 +1,24 @@ use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter; use proxywarden_lib::commands::{ self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status, - read_saved_state_with_proxifyre_config, resolve_component_statuses, resolve_preview, - save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, - HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, - TargetInputDto, + read_saved_state, resolve_component_statuses, resolve_preview, save_profile_to_storage, + save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest, + HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto, }; use proxywarden_lib::component_detection::{ DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, }; use proxywarden_lib::models::{ - self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType, - Protocol, ProxyProtocol, Target, TargetKind, + self, ComponentId, ComponentState, Profile, ProfileItem, ProfileItemType, Protocol, + ProxyProtocol, Target, TargetKind, }; -use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership; +#[cfg(windows)] +use proxywarden_lib::safe_fs; use proxywarden_lib::storage::JsonStorage; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fs; use std::net::TcpListener; use std::path::{Path, PathBuf}; -#[cfg(windows)] -use std::process::Command as ProcessCommand; use std::time::{SystemTime, UNIX_EPOCH}; #[test] @@ -75,7 +73,7 @@ fn save_commands_normalize_and_persist_profile_and_target() { } #[test] -fn saved_state_bootstraps_from_existing_proxifyre_app_config() { +fn saved_state_read_never_opportunistically_imports_proxifyre_config() { let root = test_root("proxifyre-config-import"); let storage = JsonStorage::new(root.clone()); let install_dir = root.join("ProxiFyre"); @@ -96,29 +94,19 @@ fn saved_state_bootstraps_from_existing_proxifyre_app_config() { }"#, ) .expect("write proxifyre config"); + let source_before = fs::read(&config_path).expect("read proxifyre config before normal read"); - let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path)) - .expect("state should import proxifyre app config"); + let state = + read_saved_state(&storage).expect("normal read should ignore legacy runtime config"); - assert_eq!(state.profiles.len(), 1); - assert_eq!(state.targets.len(), 1); - assert_eq!(state.profiles[0].id, "main-profile"); - assert_eq!(state.profiles[0].target_id, "main-proxy"); - assert_eq!(state.profiles[0].items.len(), 2); + assert!(state.profiles.is_empty()); + assert!(state.targets.is_empty()); + assert!(!storage.paths().profiles_file.exists()); + assert!(!storage.paths().targets_file.exists()); assert_eq!( - state.profiles[0].items[0].item_type, - ProfileItemType::Process + fs::read(&config_path).expect("read proxifyre config after normal read"), + source_before ); - assert_eq!(state.profiles[0].items[0].value, "Discord"); - assert_eq!(state.profiles[0].items[1].item_type, ProfileItemType::Exe); - assert_eq!(state.profiles[0].items[1].value, r"C:\Games\Launcher.exe"); - assert_eq!(state.targets[0].id, "main-proxy"); - assert_eq!(state.targets[0].host, "127.0.0.1"); - assert_eq!(state.targets[0].port, 1090); - - let persisted = storage.read_profiles().expect("read persisted profiles"); - assert_eq!(persisted.len(), 1); - assert_eq!(persisted[0].items.len(), 2); cleanup(&root); } @@ -152,8 +140,7 @@ fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() { .write_targets(&[external_socks5_target()]) .expect("write targets"); - let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path)) - .expect("state should keep proxywarden storage"); + let state = read_saved_state(&storage).expect("state should keep proxywarden storage"); assert_eq!(state.profiles.len(), 1); assert_eq!(state.profiles[0].id, "discord"); @@ -218,318 +205,6 @@ fn ping_proxy_target_reports_open_tcp_endpoint() { assert!(result.probes.is_empty()); } -#[test] -#[cfg(windows)] -fn proxifyre_install_script_parses_as_powershell() { - let root = test_root("proxifyre-install-script"); - fs::create_dir_all(&root).expect("test root should be created"); - - let script = commands::wrap_elevated_package_script( - &commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")), - &root.join("install.log"), - ); - let script_path = root.join("install.ps1"); - let mut script_bytes = vec![0xEF, 0xBB, 0xBF]; - script_bytes.extend_from_slice(script.as_bytes()); - fs::write(&script_path, script_bytes).expect("script should be written"); - - let escaped_path = script_path.display().to_string().replace('\'', "''"); - let parser = format!( - "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" - ); - let output = ProcessCommand::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) - .output() - .expect("powershell parser should run"); - - assert!( - output.status.success(), - "install script should parse\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - cleanup(&root); -} - -#[test] -fn proxifyre_install_script_uses_resilient_download_helpers() { - let root = test_root("proxifyre-install-script-downloads"); - let script = commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")); - - assert!(script.contains("function Get-SafeUriForLog([string]$uri)")); - assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)")); - assert!(script.contains("function Resolve-ReleaseAsset(")); - assert!(script.contains("function Get-PinnedWindowsPacketFilterAsset([string]$arch)")); - assert!(script.contains("function Get-PinnedProxiFyreAsset([string]$arch)")); - assert!( - script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)") - ); - assert!(script.contains("foreach ($attempt in 1..3)")); - assert!(script.contains("Invoke-WebClientDownload $uri $partialPath")); - assert!(script.contains("Invoke-CurlDownload $uri $partialPath")); - assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri")); - assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force")); - assert!(script.contains("function Get-BundledAsset([string]$pattern, [string]$label)")); - assert!(script.contains("function Verify-BundledAssetHash([string]$path, [string]$label)")); - assert!(script - .contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'")); - assert!(script - .contains("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'")); - assert!(script.contains( - "Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'" - )); - assert!( - script.contains("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'") - ); - assert!(script.contains( - "Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'" - )); - assert!(script.contains("github.com/wiresock/ndisapi/releases/download")); - assert!(script.contains("github.com/wiresock/proxifyre/releases/download")); - assert!(script.contains( - "[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))" - )); - assert!(!script - .contains("ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8")); - let packet_filter_step = script - .find("Write-ProxyWardenProgress 'install' 'packet-filter'") - .expect("packet filter install step should be present"); - let vc_runtime_step = script - .find("Write-ProxyWardenProgress 'install' 'vc-runtime'") - .expect("runtime install step should be present"); - let proxifyre_step = script - .find("Write-ProxyWardenProgress 'install' 'proxifyre'") - .expect("proxifyre install step should be present"); - assert!(packet_filter_step < vc_runtime_step); - assert!(vc_runtime_step < proxifyre_step); - - cleanup(&root); -} - -#[test] -fn proxifyre_install_script_prefers_bundled_assets_before_downloads() { - let root = test_root("proxifyre-install-script-bundled-assets"); - let bundle_dir = root.join("bundle"); - let script = commands::install_proxifyre_script_with_bundle( - &root.join("proxifyre-app-config.json"), - Some(&bundle_dir), - ); - - assert!(script.contains(&format!( - "$bundledAssetDir = '{}'", - bundle_dir.display().to_string().replace('\'', "''") - ))); - assert!(script.contains("$script:bundledAssetDir = [string]$bundledAssetDir")); - assert!(script.contains("function Get-BundledAssetDir")); - assert!(script.contains("$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')")); - assert!(script.contains("$script:bundledAssetManifest = Get-BundledAssetManifest")); - assert!(script.contains("Copy-BundledAsset $bundledNdisPath $ndisPath")); - assert!(script.contains("Copy-BundledAsset $bundledVcPath $vcRedistPath")); - assert!(script.contains("Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath")); - - let bundled_ndis = script - .find("Get-BundledAsset $ndisPattern 'Windows Packet Filter'") - .expect("ndis bundle check should be present"); - let online_ndis = script - .find("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'") - .expect("ndis online fallback should be present"); - assert!(bundled_ndis < online_ndis); - - let bundled_proxifyre = script - .find("Get-BundledAsset $proxifyrePattern 'ProxiFyre'") - .expect("proxifyre bundle check should be present"); - let online_proxifyre = script - .find("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'") - .expect("proxifyre online fallback should be present"); - assert!(bundled_proxifyre < online_proxifyre); - - cleanup(&root); -} - -#[test] -fn proxifyre_firewall_script_scopes_rules_to_managed_executable() { - let executable = - PathBuf::from(r"C:\Program Files\Proxy'Warden\components\ProxiFyre\ProxiFyre.exe"); - let script = commands::configure_proxifyre_firewall_script(&executable); - - assert!(script.contains( - "$exePath = 'C:\\Program Files\\Proxy''Warden\\components\\ProxiFyre\\ProxiFyre.exe'" - )); - assert!(script.contains("ProxyWarden.ProxiFyre.Inbound")); - assert!(script.contains("ProxyWarden.ProxiFyre.Outbound")); - assert!(script.contains("-Program $exePath")); - assert!(script.contains("Get-NetFirewallRule -Name $rule.Name")); - assert!(!script.contains("Get-NetFirewallRule -DisplayName")); -} - -#[test] -#[cfg(windows)] -fn proxifyre_firewall_script_parses_as_powershell() { - let root = test_root("proxifyre-firewall-script"); - fs::create_dir_all(&root).expect("test root should be created"); - let script = commands::configure_proxifyre_firewall_script( - &root.join("ProxiFyre").join("ProxiFyre.exe"), - ); - let script_path = root.join("firewall.ps1"); - fs::write(&script_path, script).expect("script should be written"); - - let escaped_path = script_path.display().to_string().replace('\'', "''"); - let parser = format!( - "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" - ); - let output = ProcessCommand::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) - .output() - .expect("powershell parser should run"); - - assert!( - output.status.success(), - "firewall script should parse\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - cleanup(&root); -} - -#[test] -#[cfg(windows)] -fn proxifyre_uninstall_script_parses_as_powershell() { - let root = test_root("proxifyre-uninstall-script"); - fs::create_dir_all(&root).expect("test root should be created"); - let detected = DetectedProxyfier { - engine: ProxyfierEngine::ProxiFyre, - name: "ProxiFyre".to_string(), - install_dir: root.join("ProxiFyre"), - executable_path: root.join("ProxiFyre").join("ProxiFyre.exe"), - config_path: Some(root.join("ProxiFyre").join("app-config.json")), - running: false, - service_name: Some("ProxiFyreService".to_string()), - service_status: Some("stopped".to_string()), - }; - - let script = commands::wrap_elevated_package_script( - &commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)), - &root.join("uninstall.log"), - ); - let script_path = root.join("uninstall.ps1"); - let mut script_bytes = vec![0xEF, 0xBB, 0xBF]; - script_bytes.extend_from_slice(script.as_bytes()); - fs::write(&script_path, script_bytes).expect("script should be written"); - - let escaped_path = script_path.display().to_string().replace('\'', "''"); - let parser = format!( - "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" - ); - let output = ProcessCommand::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) - .output() - .expect("powershell parser should run"); - - assert!( - output.status.success(), - "uninstall script should parse\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - cleanup(&root); -} - -#[test] -fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() { - let detected = DetectedProxyfier { - engine: ProxyfierEngine::ProxiFyre, - name: "ProxiFyre".to_string(), - install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"), - executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"), - config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")), - running: true, - service_name: Some("ProxiFyreService".to_string()), - service_status: Some("running".to_string()), - }; - let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)); - - assert!(script.contains("function Find-ManagedProxiFyreService")); - assert!(script.contains("Get-CimInstance Win32_Service")); - assert!(script.contains("[StringComparison]::OrdinalIgnoreCase")); - assert!(!script.contains("function Find-ProxiFyreService")); - assert!(!script.contains("Where-Object { $_.Name -match 'ProxiFyre|Proxifyre'")); - assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)")); - assert!(script.contains("Отказываюсь запускать произвольный UninstallString")); - assert!(script.contains("Start-Process -FilePath 'msiexec.exe'")); - assert!(script.contains("ArgumentList @('/x', $productCode, '/qn', '/norestart'")); - assert!(script.contains("Uninstall-MsiProgram $packetFilter 'Windows Packet Filter'")); - assert!(script.contains("ProxyWarden.ProxiFyre.Inbound")); - assert!(script.contains("ProxyWarden.ProxiFyre.Outbound")); - let proxifyre_step = script - .find("Write-ProxyWardenProgress 'uninstall' 'proxifyre'") - .expect("proxifyre uninstall step should be present"); - let packet_filter_step = script - .find("Write-ProxyWardenProgress 'uninstall' 'packet-filter'") - .expect("packet filter uninstall step should be present"); - assert!(proxifyre_step < packet_filter_step); -} - -#[test] -fn proxywarden_uninstall_hook_removes_only_managed_firewall_rules() { - let script = include_str!("../bundled/cleanup/uninstall-managed-components.ps1"); - - assert!(script.contains("ProxyWarden.ProxiFyre.Inbound")); - assert!(script.contains("ProxyWarden.ProxiFyre.Outbound")); - assert!(script.contains("Get-NetFirewallRule -Name $name")); - assert!(!script.contains("Get-NetFirewallRule -DisplayName")); -} - -#[test] -fn proxifyre_uninstall_script_leaves_shared_packet_filter_installed() { - let detected = DetectedProxyfier { - engine: ProxyfierEngine::ProxiFyre, - name: "ProxiFyre".to_string(), - install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"), - executable_path: PathBuf::from( - r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe", - ), - config_path: None, - running: false, - service_name: Some("ProxiFyreService".to_string()), - service_status: Some("stopped".to_string()), - }; - - let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(false)); - - assert!(script.contains("$removePacketFilter = $false")); - assert!(script.contains("if ($removePacketFilter)")); - assert!(script.contains("Windows Packet Filter оставлен")); - assert!(!script.contains("Get-Process -Name 'ProxiFyre'")); -} - -#[test] -fn singbox_runner_preserves_installer_args_with_spaces() { - let script = commands::singbox_installer_runner_script( - Path::new(r"C:\ProgramData\ProxyWarden\state\install-singbox.ps1"), - Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"), - &[ - "-InstallRoot".to_string(), - r"C:\Program Files\ProxyWarden\components\sing-box".to_string(), - "-ServiceName".to_string(), - "ProxyWardenSingBox".to_string(), - "-Uninstall".to_string(), - ], - ); - - assert!(script.contains( - "$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'" - )); - assert!(script.contains( - "& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs" - )); - assert!( - !script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList") - ); -} - #[test] fn apply_generates_derived_config_and_records_activity_with_mock_helper() { let root = test_root("apply"); @@ -540,11 +215,6 @@ fn apply_generates_derived_config_and_records_activity_with_mock_helper() { storage .write_targets(&[external_socks5_target()]) .expect("write targets"); - write_json( - &storage.paths().components_file, - &[proxyfier_running(), singbox_missing()], - ); - let response = apply_profiles_with_services( &storage, &ProxiFyreAdapter::default(), @@ -566,6 +236,9 @@ fn apply_generates_derived_config_and_records_activity_with_mock_helper() { assert!(generated_contents.contains("\"appNames\"")); assert!(generated_contents.contains("Discord")); assert!(generated_path.ends_with("proxifyre-app-config.json")); + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(&generated_path) + .expect("generated ProxiFyre config keeps restricted ACL"); assert_eq!(activity.len(), 1); assert_eq!(activity[0].at, "2026-07-03T00:00:00Z"); assert_eq!(activity[0].title, "Конфиг ProxiFyre создан"); @@ -583,8 +256,6 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() { storage .write_targets(&[local_singbox_target()]) .expect("write targets"); - write_json(&storage.paths().components_file, &[singbox_missing()]); - let error = apply_profiles_with_services_and_detection( &storage, &ProxiFyreAdapter::default(), @@ -607,7 +278,6 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() { #[test] fn component_status_merges_detected_existing_proxifyre() { let components = resolve_component_statuses( - Vec::new(), Some(DetectedProxyfier { engine: ProxyfierEngine::ProxiFyre, name: "ProxiFyre".to_string(), @@ -617,6 +287,7 @@ fn component_status_merges_detected_existing_proxifyre() { running: true, service_name: Some("ProxiFyreService".to_string()), service_status: Some("running".to_string()), + version: Some("2.2.1.0".to_string()), }), None, ); @@ -633,8 +304,8 @@ fn component_status_merges_detected_existing_proxifyre() { } #[test] -fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() { - let components = resolve_component_statuses(vec![proxyfier_running()], None, None); +fn component_status_reports_missing_when_detection_is_missing() { + let components = resolve_component_statuses(None, None); let proxyfier = components .iter() .find(|component| component.id == ComponentId::Proxyfier) @@ -647,18 +318,37 @@ fn component_status_does_not_keep_stale_installed_state_when_detection_is_missin } #[test] -fn detected_proxy_apply_helper_writes_proxifyre_app_config() { +fn managed_current_apply_stages_generated_config_without_writing_sealed_runtime_snapshot() { let root = test_root("detected-proxifyre"); let install_dir = root.join("ProxiFyre"); fs::create_dir_all(&install_dir).expect("install dir"); fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe"); fs::write(install_dir.join("app-config.json"), "{}").expect("existing config"); + fs::write( + install_dir.join("proxywarden-component.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": install_dir.display().to_string(), + "packetFilterInstalledByProxyWarden": false + })) + .expect("marker JSON"), + ) + .expect("managed marker"); let generated_config = root.join("generated").join("proxifyre-app-config.json"); let host = DetectionHost::new() .with_registry("ProxiFyre", &install_dir) .with_path(&install_dir) - .with_path(&install_dir.join("ProxiFyre.exe")); - let helper = DetectedProxyApplyHelper::from(host); + .with_path(&install_dir.join("ProxiFyre.exe")) + .with_service_path( + "ProxiFyreService", + &format!( + r#""{}" --service"#, + install_dir.join("ProxiFyre.exe").display() + ), + ); + let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone()); let result = helper .apply_proxy_config(HelperApplyRequest { @@ -668,17 +358,61 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() { }) .expect("detected helper should apply"); - let applied = - fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config"); - let backup = - fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config"); - assert!(result.success); assert!(result.changed); - assert_eq!(result.action, "proxifyre.apply-detected-config"); - assert_eq!(applied, r#"{"proxies":[]}"#); - assert_eq!(backup, "{}"); - assert!(install_dir.join("app-config.json.bak").exists()); + assert_eq!(result.action, "proxifyre.stage-managed-config"); + assert_eq!( + fs::read_to_string(install_dir.join("app-config.json")) + .expect("read unchanged runtime snapshot"), + "{}" + ); + assert!(!install_dir.join("app-config.json.bak").exists()); + assert!(result.message.contains("следующем явном запуске")); + + cleanup(&root); +} + +#[test] +fn detected_proxy_apply_helper_does_not_write_for_foreign_service_collision() { + let root = test_root("detected-proxifyre-foreign-service"); + let install_dir = root.join("ProxiFyre"); + let config_path = install_dir.join("app-config.json"); + fs::create_dir_all(&install_dir).expect("install dir"); + fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe"); + fs::write(&config_path, "original").expect("existing config"); + fs::write( + install_dir.join("proxywarden-component.json"), + serde_json::to_vec_pretty(&serde_json::json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": install_dir.display().to_string(), + "packetFilterInstalledByProxyWarden": false + })) + .expect("marker JSON"), + ) + .expect("managed marker"); + let generated_config = root.join("generated").join("proxifyre-app-config.json"); + let host = DetectionHost::new() + .with_path(&install_dir) + .with_path(&install_dir.join("ProxiFyre.exe")) + .with_service_path( + "ProxiFyreService", + r#""C:\Foreign\ProxiFyre.exe" --service"#, + ); + let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone()); + + let error = helper + .apply_proxy_config(HelperApplyRequest { + adapter_id: "proxifyre", + config_path: &generated_config, + config_contents: r#"{"proxies":[]}"#, + }) + .expect_err("foreign service collision must fail before config write"); + + assert_eq!(error.code, "ownership_mismatch"); + assert_eq!(fs::read_to_string(&config_path).unwrap(), "original"); + assert!(!install_dir.join("app-config.json.bak").exists()); cleanup(&root); } @@ -746,6 +480,7 @@ impl Clock for FixedClock { struct DetectionHost { paths: HashSet, registry: Vec, + service_paths: HashMap, } impl DetectionHost { @@ -766,6 +501,12 @@ impl DetectionHost { }); self } + + fn with_service_path(mut self, service_name: &str, path_name: &str) -> Self { + self.service_paths + .insert(service_name.to_ascii_lowercase(), path_name.to_string()); + self + } } impl ProxyfierDetectionHost for DetectionHost { @@ -782,12 +523,33 @@ impl ProxyfierDetectionHost for DetectionHost { } fn service_status(&self, _service_name: &str) -> Option { - None + self.service_paths + .contains_key(&_service_name.to_ascii_lowercase()) + .then(|| "stopped".to_string()) + } + + fn service_info( + &self, + service_name: &str, + ) -> Option { + self.service_paths + .get(&service_name.to_ascii_lowercase()) + .map( + |path_name| proxywarden_lib::component_detection::DetectedService { + name: service_name.to_string(), + status: "stopped".to_string(), + path_name: Some(path_name.clone()), + }, + ) } fn registry_install_entries(&self) -> Vec { self.registry.clone() } + + fn read_text(&self, path: &Path) -> Option { + fs::read_to_string(path).ok() + } } fn normalize_path(path: &Path) -> String { @@ -810,14 +572,6 @@ fn cleanup(root: &Path) { let _ = fs::remove_dir_all(root); } -fn write_json(path: &Path, value: &T) { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).expect("create json parent dir"); - } - let contents = serde_json::to_vec_pretty(value).expect("serialize json"); - fs::write(path, contents).expect("write json"); -} - fn discord_profile(target_id: &str) -> Profile { Profile { id: "discord".to_string(), @@ -856,42 +610,3 @@ fn local_singbox_target() -> Target { requires_component: Some(ComponentId::Singbox), } } - -fn proxyfier_running() -> ComponentStatus { - ComponentStatus { - id: ComponentId::Proxyfier, - name: "ProxiFyre".to_string(), - state: ComponentState::Running, - installed: true, - running: true, - version: Some("2.2.1".to_string()), - path: Some(r"C:\Tools\ProxiFyre".to_string()), - service_name: Some("ProxiFyreService".to_string()), - service_status: Some("running".to_string()), - problems: Vec::new(), - actions: vec!["Restart".to_string()], - } -} - -fn singbox_missing() -> ComponentStatus { - ComponentStatus { - id: ComponentId::Singbox, - name: "Локальный sing-box".to_string(), - state: ComponentState::Missing, - installed: false, - running: false, - version: None, - path: None, - service_name: Some("ProxyWardenSingBox".to_string()), - service_status: None, - problems: vec!["Локальный sing-box не установлен".to_string()], - actions: vec!["Установить локальный sing-box".to_string()], - } -} - -fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership { - ManagedProxiFyreOwnership { - service_name: "ProxiFyreService".to_string(), - remove_packet_filter, - } -} diff --git a/src-tauri/tests/component_catalog_tests.rs b/src-tauri/tests/component_catalog_tests.rs new file mode 100644 index 0000000..a8ab739 --- /dev/null +++ b/src-tauri/tests/component_catalog_tests.rs @@ -0,0 +1,640 @@ +use proxywarden_lib::component_catalog::{ + parse_bundled_catalog_if_present, parse_catalog, validate_bundle, AssetArch, ComponentId, + TargetArch, +}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command; +use uuid::Uuid; + +#[test] +fn parses_exact_x64_catalog_and_all_trust_policy_variants() { + let catalog = parse_value(&valid_catalog()).expect("valid catalog must parse"); + + assert_eq!(catalog.target_arch, TargetArch::X64); + assert_eq!(catalog.components.len(), 5); + assert_eq!( + catalog + .components + .iter() + .find(|component| component.id == ComponentId::Winsw) + .expect("WinSW entry") + .asset_arch, + AssetArch::Anycpu + ); +} + +#[test] +fn rejects_unknown_schema_arch_fields_duplicates_and_incomplete_set() { + assert_rejected(mutate(|catalog| catalog["schemaVersion"] = json!(2))); + assert_rejected(mutate(|catalog| catalog["targetArch"] = json!("arm64"))); + assert_rejected(mutate(|catalog| catalog["unexpected"] = json!(true))); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["unexpected"] = json!(true); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["sourceUrl"] = json!( + "https://github.com/attacker/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi" + ); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["sourceUrl"] = + json!("https://attacker.example/vc_redist.x64.exe"); + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] = + json!(["attacker.example"]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["license"]["unexpected"] = json!(true); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["unexpected"] = json!(true); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["id"] = json!("proxifyre"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["installRole"] = json!("proxifyre-runtime"); + })); + assert_rejected(mutate(|catalog| { + catalog["components"] + .as_array_mut() + .expect("components array") + .pop(); + })); +} + +#[test] +fn rejects_wrong_component_role_or_architecture() { + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["installRole"] = json!("packet-filter-driver"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["assetArch"] = json!("anycpu"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "winsw")["assetArch"] = json!("x64"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "winsw")["effectiveTarget"] = json!("anycpu"); + })); +} + +#[test] +fn rejects_unsafe_paths_hash_size_license_version_and_source() { + for invalid_path in [ + "../asset.zip", + "proxifyre/../asset.zip", + "proxifyre\\asset.zip", + "/proxifyre/asset.zip", + "proxifyre/CON.zip", + "other/asset.zip", + ] { + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["assetPath"] = json!(invalid_path); + })); + } + + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["license"]["path"] = json!("../LICENSE.txt"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["license"]["path"] = + json!("proxifyre/WPF-LICENSE.txt"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["license"]["path"] = + json!("proxifyre/LICENSE.txt"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["license"]["id"] = json!("GPL 3"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["sha256"] = json!("A".repeat(64)); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["sha256"] = json!("a".repeat(63)); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["size"] = json!(0); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["version"] = json!("2.4.0-beta.1"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "winsw")["productVersion"] = json!("2.12.0-rc.1"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["sourceUrl"] = json!( + "http://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip" + ); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["sourceUrl"] = json!( + "https://user:secret@github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip" + ); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["sourceUrl"] = + json!("https://github.com/wiresock/proxifyre/releases/download/v2.4.0/wrong.zip"); + })); +} + +#[test] +fn rejects_invalid_trust_policies() { + for id in ["proxifyre", "windows-packet-filter", "sing-box"] { + assert_rejected(mutate(|catalog| { + component_mut(catalog, id)["updateTrustPolicy"] = json!({ + "type": "bundledOnlyNoIndependentProof", + "reason": "Wrong policy for this component." + }); + })); + } + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"] = json!({ + "type": "bundledOnlyNoIndependentProof", + "reason": "Wrong policy for this component." + }); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "winsw")["updateTrustPolicy"] = json!({ + "type": "githubReleaseDigest", + "repository": "winsw/winsw", + "tagPattern": "v*", + "assetPattern": "WinSW.NET461.exe", + "requireStable": true + }); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["requireStable"] = json!(false); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["repository"] = + json!("attacker/proxifyre"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["tagPattern"] = json!("v**"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["authenticodePublishers"] = + json!([]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] = json!([]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["publishers"] = json!([" "]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["assetPattern"] = + json!("other.exe"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "winsw")["updateTrustPolicy"]["type"] = json!("unknownPolicy"); + })); +} + +#[test] +fn rejects_component_policy_allowlist_expansion() { + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["repository"] = + json!("Wiresock/proxifyre"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["tagPattern"] = json!("v2.*"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["assetPattern"] = json!("*"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "proxifyre")["updateTrustPolicy"]["authenticodePublishers"] = + Value::Null; + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["updateTrustPolicy"]["assetPattern"] = + json!("Windows.Packet.Filter.*"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "windows-packet-filter")["updateTrustPolicy"] + ["authenticodePublishers"] = + json!(["The Anti-Cloud Corporation", "Unexpected Publisher"]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "sing-box")["updateTrustPolicy"]["repository"] = + json!("sagernet/sing-box"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "sing-box")["updateTrustPolicy"]["authenticodePublishers"] = + json!(["Unexpected Publisher"]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] = + json!(["aka.ms", "attacker.example"]); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["assetPattern"] = json!("*"); + })); + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["publishers"] = + json!(["Microsoft Corporation", "Unexpected Publisher"]); + })); +} + +#[test] +fn rejects_wrong_component_license_ids() { + for (id, wrong_license) in [ + ("proxifyre", "MIT"), + ("windows-packet-filter", "GPL-3.0-only"), + ("vc-runtime", "LicenseRef-Microsoft-VCRedist"), + ("sing-box", "GPL-3.0-or-later"), + ("winsw", "AGPL-3.0-only"), + ] { + assert_rejected(mutate(|catalog| { + component_mut(catalog, id)["license"]["id"] = json!(wrong_license); + })); + } +} + +#[test] +fn rejects_unpinned_or_wrong_vc_runtime_source() { + for source in [ + "https://aka.ms/vs/17/release/vc_redist.x64.exe", + "https://aka.ms/vs/18/release/vc_redist.x64.exe", + "https://aka.ms/vs/18/release/14.50.35719/VC_redist.x64.exe", + ] { + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["sourceUrl"] = json!(source); + })); + } + assert_rejected(mutate(|catalog| { + component_mut(catalog, "vc-runtime")["version"] = json!("14.50.35719.0"); + })); +} + +#[test] +fn validates_exact_bundle_contents_hashes_sizes_and_licenses() { + let bundle = TestBundle::new(); + let catalog = validate_bundle(bundle.path()).expect("complete bundle must validate"); + + assert_eq!(catalog.components.len(), 5); +} + +#[test] +fn rejects_missing_extra_or_changed_package_assets() { + let missing = TestBundle::new(); + fs::remove_file(missing.path().join(asset_path("proxifyre"))).expect("remove fixture asset"); + assert!(validate_bundle(missing.path()).is_err()); + + let extra = TestBundle::new(); + fs::write(extra.path().join("unexpected.bin"), b"extra").expect("write extra file"); + assert!(validate_bundle(extra.path()).is_err()); + + let changed = TestBundle::new(); + let path = changed.path().join(asset_path("proxifyre")); + let original = fs::read(&path).expect("read fixture asset"); + fs::write(&path, vec![b'x'; original.len()]).expect("change fixture asset"); + assert!(validate_bundle(changed.path()).is_err()); + + let wrong_size = TestBundle::new(); + let mut catalog: Value = serde_json::from_slice( + &fs::read(wrong_size.path().join("catalog.json")).expect("read fixture catalog"), + ) + .expect("parse fixture catalog"); + component_mut(&mut catalog, "proxifyre")["size"] = json!(999); + write_catalog(wrong_size.path(), &catalog); + assert!(validate_bundle(wrong_size.path()).is_err()); +} + +#[test] +fn rejects_missing_or_empty_license_copy() { + let missing = TestBundle::new(); + fs::remove_file(missing.path().join("proxifyre/LICENSE.txt")).expect("remove fixture license"); + assert!(validate_bundle(missing.path()).is_err()); + + let empty = TestBundle::new(); + fs::write(empty.path().join("proxifyre/LICENSE.txt"), b"").expect("empty fixture license"); + assert!(validate_bundle(empty.path()).is_err()); +} + +#[test] +fn optional_bundle_parse_is_none_only_when_catalog_is_absent() { + let absent = TempDirectory::new(); + assert!(parse_bundled_catalog_if_present(absent.path()) + .expect("absent catalog is allowed") + .is_none()); + + let present = TestBundle::new(); + assert!(parse_bundled_catalog_if_present(present.path()) + .expect("present catalog must validate") + .is_some()); +} + +#[test] +fn production_bundle_validates_when_catalog_exists() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components"); + + let catalog = validate_bundle(&root).expect("production component bundle must validate"); + assert_eq!(catalog.components.len(), 5); +} + +#[cfg(windows)] +#[test] +fn rejects_reparse_bundle_root_and_nested_directory() { + let target = TestBundle::new(); + let junctions = TempDirectory::new(); + let root_junction = junctions.path().join("bundle-root-junction"); + let root_guard = create_junction(&root_junction, target.path()); + assert!(validate_bundle(&root_junction).is_err()); + drop(root_guard); + + let nested = TestBundle::new(); + let proxifyre_target = junctions.path().join("proxifyre-target"); + fs::rename(nested.path().join("proxifyre"), &proxifyre_target) + .expect("move fixture component behind a junction"); + let nested_guard = create_junction(&nested.path().join("proxifyre"), &proxifyre_target); + assert!(validate_bundle(nested.path()).is_err()); + drop(nested_guard); +} + +fn valid_catalog() -> Value { + json!({ + "schemaVersion": 1, + "targetArch": "x64", + "components": [ + component( + "proxifyre", + "2.4.0", + "ProxiFyre-v2.4.0-x64-signed.zip", + "x64", + "https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip", + json!({ + "type": "githubReleaseDigest", + "repository": "wiresock/proxifyre", + "tagPattern": "v*", + "assetPattern": "ProxiFyre-v*-x64-signed.zip", + "requireStable": true, + "authenticodePublishers": ["The Anti-Cloud Corporation"] + }) + ), + component( + "windows-packet-filter", + "3.6.2", + "Windows.Packet.Filter.3.6.2.1.x64.msi", + "x64", + "https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi", + json!({ + "type": "githubReleaseDigest", + "repository": "wiresock/ndisapi", + "tagPattern": "v*", + "assetPattern": "Windows.Packet.Filter.*.x64.msi", + "requireStable": true, + "authenticodePublishers": ["The Anti-Cloud Corporation"] + }) + ), + component( + "vc-runtime", + "14.51.36247.0", + "VC_redist.x64.exe", + "x64", + "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe", + json!({ + "type": "buildTimeOnlyAuthenticode", + "allowedSourceHosts": ["aka.ms"], + "assetPattern": "VC_redist.x64.exe", + "publishers": ["Microsoft Corporation"] + }) + ), + component( + "sing-box", + "1.13.19", + "sing-box-1.13.19-windows-amd64.zip", + "x64", + "https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip", + json!({ + "type": "githubReleaseDigest", + "repository": "SagerNet/sing-box", + "tagPattern": "v*", + "assetPattern": "sing-box-*-windows-amd64.zip", + "requireStable": true + }) + ), + component( + "winsw", + "2.12.0", + "WinSW.NET461.exe", + "anycpu", + "https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe", + json!({ + "type": "bundledOnlyNoIndependentProof", + "reason": "Upstream provides no independent digest or Authenticode proof for this asset." + }) + ) + ] + }) +} + +fn component( + id: &str, + version: &str, + asset_name: &str, + asset_arch: &str, + source_url: &str, + update_trust_policy: Value, +) -> Value { + let bytes = asset_bytes(id); + let (license_id, install_role) = match id { + "proxifyre" => ("AGPL-3.0-only", "proxifyre-runtime"), + "windows-packet-filter" => ("MIT", "packet-filter-driver"), + "vc-runtime" => ( + "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026", + "vc-runtime-prerequisite", + ), + "sing-box" => ("LicenseRef-Sing-Box-Project", "sing-box-runtime"), + "winsw" => ("MIT", "sing-box-service-wrapper"), + _ => panic!("unknown fixture component"), + }; + json!({ + "id": id, + "version": version, + "fileVersion": if id == "windows-packet-filter" { "3.6.2.1" } else { version }, + "productVersion": match id { + "windows-packet-filter" => "3.6.2.1", + "winsw" => "2.12.0+eef5c6a", + _ => version + }, + "assetPath": format!("{id}/{asset_name}"), + "assetArch": asset_arch, + "effectiveTarget": "x64", + "sha256": sha256(bytes), + "size": bytes.len(), + "sourceUrl": source_url, + "license": { + "id": license_id, + "path": format!("{id}/LICENSE.txt") + }, + "installRole": install_role, + "updateTrustPolicy": update_trust_policy + }) +} + +fn asset_bytes(id: &str) -> &'static [u8] { + match id { + "proxifyre" => b"fixture-proxifyre-asset", + "windows-packet-filter" => b"fixture-packet-filter-asset", + "vc-runtime" => b"fixture-vc-runtime-asset", + "sing-box" => b"fixture-sing-box-asset", + "winsw" => b"fixture-winsw-asset", + _ => panic!("unknown fixture component"), + } +} + +fn asset_path(id: &str) -> String { + valid_catalog()["components"] + .as_array() + .expect("components array") + .iter() + .find(|component| component["id"] == id) + .expect("fixture component")["assetPath"] + .as_str() + .expect("asset path") + .to_string() +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn mutate(change: impl FnOnce(&mut Value)) -> Value { + let mut catalog = valid_catalog(); + change(&mut catalog); + catalog +} + +fn component_mut<'a>(catalog: &'a mut Value, id: &str) -> &'a mut Value { + catalog["components"] + .as_array_mut() + .expect("components array") + .iter_mut() + .find(|component| component["id"] == id) + .expect("fixture component") +} + +fn parse_value( + value: &Value, +) -> Result< + proxywarden_lib::component_catalog::ComponentCatalog, + proxywarden_lib::component_catalog::ComponentCatalogError, +> { + parse_catalog(&serde_json::to_vec(value).expect("serialize fixture catalog")) +} + +fn assert_rejected(value: Value) { + assert!( + parse_value(&value).is_err(), + "catalog unexpectedly passed: {value}" + ); +} + +fn write_catalog(root: &Path, catalog: &Value) { + fs::write( + root.join("catalog.json"), + serde_json::to_vec_pretty(catalog).expect("serialize fixture catalog"), + ) + .expect("write fixture catalog"); +} + +struct TestBundle { + directory: TempDirectory, +} + +impl TestBundle { + fn new() -> Self { + let directory = TempDirectory::new(); + let catalog = valid_catalog(); + for component in catalog["components"].as_array().expect("components array") { + let id = component["id"].as_str().expect("component id"); + let asset_path = component["assetPath"].as_str().expect("asset path"); + let license_path = component["license"]["path"].as_str().expect("license path"); + fs::create_dir_all( + directory + .path() + .join(asset_path) + .parent() + .expect("asset parent"), + ) + .expect("create component directory"); + fs::write(directory.path().join(asset_path), asset_bytes(id)) + .expect("write fixture asset"); + fs::write( + directory.path().join(license_path), + format!("License fixture for {id}\n"), + ) + .expect("write fixture license"); + } + write_catalog(directory.path(), &catalog); + Self { directory } + } + + fn path(&self) -> &Path { + self.directory.path() + } +} + +struct TempDirectory { + path: PathBuf, +} + +impl TempDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "proxywarden-component-catalog-test-{}", + Uuid::new_v4() + )); + fs::create_dir_all(&path).expect("create temporary test directory"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[cfg(windows)] +struct JunctionGuard { + path: PathBuf, +} + +#[cfg(windows)] +impl Drop for JunctionGuard { + fn drop(&mut self) { + let _ = fs::remove_dir(&self.path); + } +} + +#[cfg(windows)] +fn create_junction(path: &Path, target: &Path) -> JunctionGuard { + let output = Command::new("cmd") + .args(["/d", "/c", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("run mklink for reparse-point fixture"); + assert!( + output.status.success(), + "mklink failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + JunctionGuard { + path: path.to_path_buf(), + } +} diff --git a/src-tauri/tests/component_cutover_tests.rs b/src-tauri/tests/component_cutover_tests.rs new file mode 100644 index 0000000..0ceae1a --- /dev/null +++ b/src-tauri/tests/component_cutover_tests.rs @@ -0,0 +1,2312 @@ +use proxywarden_lib::component_cutover::*; +use proxywarden_lib::component_detection::{ + matches_legacy_proxifyre_2_2_1_manifest, LegacyPackageFileIdentity, + LEGACY_PROXIFYRE_2_2_1_MANIFEST, +}; +use proxywarden_lib::component_inventory::{ + classify_component_candidates, legacy_proxifyre_topshelf_path_matches, prove_legacy_cutover, + BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, InventoryIssue, + LegacyCutoverEvidence, LegacyCutoverProof, MarkerEvidence, ServiceEvidence, +}; +use proxywarden_lib::models::ComponentId; +use proxywarden_lib::process::{ + FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceConfig2Kind, ServiceConfig2Snapshot, + ServiceFailureActionsSnapshot, ServiceSecuritySnapshot, ServiceStableState, + SERVICE_CONFIG2_KINDS, +}; +use proxywarden_lib::safe_fs::{ + SecurityDescriptorSnapshot, StableObjectIdentity, StableObjectKind, +}; +use proxywarden_lib::storage::JsonStorage; +use serde::Serialize; +use std::cell::{Cell, RefCell}; +use std::collections::BTreeSet; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +#[derive(Default)] +struct MemoryStore { + journal: RefCell>, + observation: RefCell>, + fail_observation: Cell, + fail_retirement_once: Cell, + retirement_calls: Cell, +} + +impl CutoverJournalStore for MemoryStore { + fn load(&self) -> Result, CutoverError> { + Ok(self.journal.borrow().clone()) + } + + fn create(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + let mut slot = self.journal.borrow_mut(); + if slot.is_some() { + return Err(CutoverError::AlreadyExists); + } + *slot = Some(journal.clone()); + Ok(()) + } + + fn replace(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + if self.journal.borrow().is_none() { + return Err(CutoverError::Missing); + } + *self.journal.borrow_mut() = Some(journal.clone()); + Ok(()) + } + + fn write_observation( + &self, + observation: &ComponentCutoverObservation, + ) -> Result<(), CutoverError> { + if self.fail_observation.get() { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "injected").into()); + } + *self.observation.borrow_mut() = Some(observation.clone()); + Ok(()) + } + + fn retire_completed(&self, journal: &CutoverJournal) -> Result<(), CutoverError> { + if journal.phase != CutoverPhase::ComponentComplete + || journal.execution_mode != CutoverExecutionMode::Finished + { + return Err(CutoverError::StateConflict); + } + self.retirement_calls + .set(self.retirement_calls.get().saturating_add(1)); + if self.fail_retirement_once.replace(false) { + return Err(CutoverError::Interrupted); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct MockMachine { + legacy_root_live: bool, + quarantine_live: bool, + tombstone_live: bool, + quarantine_entries: BTreeSet, + hardened_legacy: BTreeSet, + legacy_service_exists: bool, + legacy_service_running: bool, + legacy_service_policies: BTreeSet, + legacy_service_security_exact: bool, + current_components_parent: bool, + current_candidate_root: bool, + current_candidate_entries: BTreeSet, + current_root: bool, + current_config: bool, + current_tombstone: bool, + current_service_exists: bool, + current_service_running: bool, + current_service_policies: BTreeSet, + current_service_security: bool, + windows_packet_filter_present: bool, + vc_runtime_present: bool, + foreign_change: bool, +} + +impl MockMachine { + fn from_before(before: &LegacyBeforeState) -> Self { + Self { + legacy_root_live: true, + quarantine_live: false, + tombstone_live: false, + quarantine_entries: BTreeSet::new(), + hardened_legacy: BTreeSet::new(), + legacy_service_exists: true, + legacy_service_running: before.service.original_state == ServiceStableState::Running, + legacy_service_policies: SERVICE_CONFIG2_KINDS.iter().map(policy_key).collect(), + legacy_service_security_exact: true, + current_components_parent: matches!( + &before.current_components_parent, + CurrentComponentsParentBeforeState::Present(_) + ), + current_candidate_root: false, + current_candidate_entries: BTreeSet::new(), + current_root: false, + current_config: false, + current_tombstone: false, + current_service_exists: false, + current_service_running: false, + current_service_policies: BTreeSet::new(), + current_service_security: false, + windows_packet_filter_present: before.prerequisites.windows_packet_filter_present, + vc_runtime_present: before.prerequisites.vc_runtime_present, + foreign_change: false, + } + } + + fn fingerprint(&self) -> StateFingerprint { + StateFingerprint::digest( + "mock-cutover-machine-v1", + &serde_json::to_vec(self).unwrap(), + ) + } + + fn current_fingerprint(&self) -> StateFingerprint { + StateFingerprint::digest( + "mock-cutover-current-v1", + &serde_json::to_vec(&( + self.current_components_parent, + self.current_root, + self.current_config, + &self.current_candidate_entries, + self.current_service_exists, + self.current_service_running, + &self.current_service_policies, + self.current_service_security, + self.windows_packet_filter_present, + self.vc_runtime_present, + )) + .unwrap(), + ) + } + + fn current_inventory_fingerprint(&self) -> StateFingerprint { + StateFingerprint::digest( + "mock-component-inventory-v1", + &serde_json::to_vec(&( + self.current_root, + self.current_service_exists, + self.current_service_running, + )) + .unwrap(), + ) + } + + fn apply( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result<(), CutoverHostError> { + match operation { + CutoverOperation::HardenLegacyRootSecurity => { + self.hardened_legacy.insert("".to_string()); + } + CutoverOperation::HardenLegacyEntrySecurity(path) => { + self.hardened_legacy.insert(path_key(path)); + } + CutoverOperation::InstallWindowsPacketFilterPrerequisite => { + self.windows_packet_filter_present = true; + } + CutoverOperation::InstallVcRuntimePrerequisite => self.vc_runtime_present = true, + CutoverOperation::CreateCurrentComponentsParent => { + self.current_components_parent = true; + } + CutoverOperation::CreateCurrentCandidateRoot => self.current_candidate_root = true, + CutoverOperation::WriteCurrentCandidatePackageEntry(path) => { + self.current_candidate_entries.insert(path_key(path)); + } + CutoverOperation::WriteCurrentCandidateConfig => { + self.current_candidate_entries + .insert("app-config.json".to_string()); + } + CutoverOperation::WriteCurrentCandidateMarker => { + self.current_candidate_entries + .insert("proxywarden-component.json".to_string()); + } + CutoverOperation::WriteCurrentCandidateReceipt => { + self.current_candidate_entries + .insert("proxywarden-install-receipt.json".to_string()); + } + CutoverOperation::PromoteCurrentCandidate => { + self.current_candidate_root = false; + self.current_root = true; + self.current_config = self.current_candidate_entries.contains("app-config.json"); + } + CutoverOperation::StopLegacyService => self.legacy_service_running = false, + CutoverOperation::DeleteLegacyService => { + self.legacy_service_exists = false; + self.legacy_service_running = false; + self.legacy_service_policies.clear(); + self.legacy_service_security_exact = false; + } + CutoverOperation::CreateCurrentService => { + self.current_service_exists = true; + self.current_service_policies = + SERVICE_CONFIG2_KINDS.iter().map(policy_key).collect(); + } + CutoverOperation::SetCurrentServicePolicy(kind) => { + self.current_service_policies.insert(policy_key(kind)); + } + CutoverOperation::SetCurrentServiceSecurity => self.current_service_security = true, + CutoverOperation::StartCurrentService => self.current_service_running = true, + CutoverOperation::QuarantineLegacyRoot => { + self.legacy_root_live = false; + self.quarantine_live = true; + self.quarantine_entries = journal + .before_state + .filesystem + .entries + .iter() + .map(|entry| path_key(&entry.relative_path)) + .collect(); + } + CutoverOperation::RestoreLegacyRoot => { + self.quarantine_live = false; + self.legacy_root_live = true; + self.quarantine_entries.clear(); + } + CutoverOperation::StopCurrentService => self.current_service_running = false, + CutoverOperation::DeleteCurrentService => { + self.current_service_exists = false; + self.current_service_running = false; + self.current_service_policies.clear(); + self.current_service_security = false; + } + CutoverOperation::TombstoneCurrentRoot => { + self.current_root = false; + self.current_tombstone = true; + } + CutoverOperation::TombstoneCurrentCandidate => { + self.current_candidate_root = false; + self.current_tombstone = true; + } + CutoverOperation::DeleteCurrentEntry(path) => { + self.current_candidate_entries.remove(&path_key(path)); + } + CutoverOperation::DeleteCurrentTombstoneRoot => { + if !self.current_candidate_entries.is_empty() { + return Err(CutoverHostError::MutationFailed); + } + self.current_tombstone = false; + self.current_config = false; + } + CutoverOperation::DeleteTransactionCurrentComponentsParent => { + if self.current_root + || self.current_candidate_root + || self.current_tombstone + || !self.current_candidate_entries.is_empty() + { + return Err(CutoverHostError::MutationFailed); + } + self.current_components_parent = false; + } + CutoverOperation::UninstallTransactionWindowsPacketFilter => { + self.windows_packet_filter_present = false; + } + CutoverOperation::CreateLegacyService => { + self.legacy_service_exists = true; + self.legacy_service_policies = SERVICE_CONFIG2_KINDS + .iter() + .copied() + .filter(|kind| *kind != ServiceConfig2Kind::Description) + .map(|kind| policy_key(&kind)) + .collect(); + self.legacy_service_security_exact = true; + } + CutoverOperation::RestoreLegacyServicePolicy(kind) => { + self.legacy_service_policies.insert(policy_key(kind)); + } + CutoverOperation::RestoreLegacyServiceSecurity => { + self.legacy_service_security_exact = true; + } + CutoverOperation::RestoreLegacyEntrySecurity(path) => { + self.hardened_legacy.remove(&path_key(path)); + } + CutoverOperation::RestoreLegacyRootSecurity => { + self.hardened_legacy.remove(""); + } + CutoverOperation::StartLegacyService => self.legacy_service_running = true, + CutoverOperation::TombstoneQuarantine => { + self.quarantine_live = false; + self.tombstone_live = true; + } + CutoverOperation::DeleteQuarantineEntry(path) => { + self.quarantine_entries.remove(&path_key(path)); + } + CutoverOperation::DeleteQuarantineRoot => { + if !self.quarantine_entries.is_empty() { + return Err(CutoverHostError::MutationFailed); + } + self.tombstone_live = false; + } + } + Ok(()) + } +} + +struct MockHost { + before: LegacyBeforeState, + machine: MockMachine, + capture_calls: usize, + mutation_calls: usize, + fail_once: Option, + fail_verify_current: bool, + fail_verify_current_observation: bool, + fail_verify_rollback: bool, + fail_verify_cleanup_once: Option, + wpf_installer_reported_unchanged_once: bool, + fail_capture_precondition: bool, + fail_reacquire_once: bool, + externally_apply_before_observe: Option, + mutation_order: Vec, +} + +impl MockHost { + fn new(before: LegacyBeforeState) -> Self { + let machine = MockMachine::from_before(&before); + Self { + before, + machine, + capture_calls: 0, + mutation_calls: 0, + fail_once: None, + fail_verify_current: false, + fail_verify_current_observation: false, + fail_verify_rollback: false, + fail_verify_cleanup_once: None, + wpf_installer_reported_unchanged_once: false, + fail_capture_precondition: false, + fail_reacquire_once: false, + externally_apply_before_observe: None, + mutation_order: Vec::new(), + } + } +} + +impl CutoverHost for MockHost { + fn capture_and_lease( + &mut self, + _plan: &ProxifyreCutoverPlan, + ) -> Result { + self.capture_calls += 1; + if self.fail_capture_precondition { + return Err(CutoverHostError::PreconditionFailed); + } + Ok(self.before.clone()) + } + + fn reacquire_leases(&mut self, _journal: &CutoverJournal) -> Result<(), CutoverHostError> { + if self.fail_reacquire_once { + self.fail_reacquire_once = false; + return Err(CutoverHostError::ObservationFailed); + } + Ok(()) + } + + fn observe( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + if self.externally_apply_before_observe.as_ref() == Some(operation) { + self.externally_apply_before_observe = None; + self.machine.apply(operation, journal)?; + } + Ok(self.machine.fingerprint()) + } + + fn observed_object_identity( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + if operation == &CutoverOperation::CreateCurrentComponentsParent { + return Ok(Some(StableObjectIdentity { + volume_serial_number: 8, + file_id: 199, + kind: StableObjectKind::Directory, + size: 0, + })); + } + if operation == &CutoverOperation::CreateCurrentCandidateRoot { + return Ok(Some(StableObjectIdentity { + volume_serial_number: 8, + file_id: 200, + kind: StableObjectKind::Directory, + size: 0, + })); + } + let (role, package_path, file_id) = match operation { + CutoverOperation::WriteCurrentCandidatePackageEntry(path) => { + (CurrentCandidateFileRole::Package, Some(path.as_path()), 201) + } + CutoverOperation::WriteCurrentCandidateConfig => { + (CurrentCandidateFileRole::Config, None, 202) + } + CutoverOperation::WriteCurrentCandidateMarker => { + (CurrentCandidateFileRole::Marker, None, 203) + } + CutoverOperation::WriteCurrentCandidateReceipt => { + (CurrentCandidateFileRole::Receipt, None, 204) + } + _ => return Ok(None), + }; + let file = journal + .before_state + .current_candidate + .files + .iter() + .find(|file| { + file.role == role + && package_path + .is_none_or(|path| path_key(&file.relative_path) == path_key(path)) + }) + .ok_or(CutoverHostError::VerificationFailed)?; + Ok(Some(StableObjectIdentity { + volume_serial_number: 8, + file_id, + kind: StableObjectKind::RegularFile, + size: file.size, + })) + } + + fn expected_effect( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result { + let mut expected = self.machine.clone(); + expected.apply(operation, journal)?; + Ok(expected.fingerprint()) + } + + fn mutate( + &mut self, + operation: &CutoverOperation, + journal: &CutoverJournal, + ) -> Result, CutoverHostError> { + self.mutation_calls += 1; + self.mutation_order.push(operation.clone()); + if operation == &CutoverOperation::InstallWindowsPacketFilterPrerequisite + && self.wpf_installer_reported_unchanged_once + { + self.wpf_installer_reported_unchanged_once = false; + self.machine.apply(operation, journal)?; + return Err(CutoverHostError::MutationFailed); + } + if self.fail_once.as_ref() == Some(operation) { + self.fail_once = None; + return Err(CutoverHostError::MutationFailed); + } + self.machine.apply(operation, journal)?; + Ok( + (operation == &CutoverOperation::InstallWindowsPacketFilterPrerequisite) + .then_some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction), + ) + } + + fn verify_current( + &mut self, + journal: &CutoverJournal, + ) -> Result { + if self.fail_verify_current { + return Err(CutoverHostError::VerificationFailed); + } + if self.fail_verify_current_observation { + return Err(CutoverHostError::ObservationFailed); + } + let should_run = journal.plan.original_service_state == LegacyServiceState::Running; + if !self.machine.current_root + || !self.machine.current_config + || !self.machine.current_service_exists + || self.machine.current_service_running != should_run + || self.machine.current_service_policies.len() != SERVICE_CONFIG2_KINDS.len() + || !self.machine.current_service_security + { + return Err(CutoverHostError::VerificationFailed); + } + Ok(self.machine.current_fingerprint()) + } + + fn verify_rollback(&mut self, _journal: &CutoverJournal) -> Result<(), CutoverHostError> { + if self.fail_verify_rollback || self.machine != MockMachine::from_before(&self.before) { + Err(CutoverHostError::VerificationFailed) + } else { + Ok(()) + } + } + + fn verify_current_inventory( + &mut self, + _journal: &CutoverJournal, + ) -> Result { + Ok(self.machine.current_inventory_fingerprint()) + } + + fn verify_cleanup(&mut self, _journal: &CutoverJournal) -> Result<(), CutoverHostError> { + if let Some(error) = self.fail_verify_cleanup_once.take() { + return Err(error); + } + if self.machine.current_service_exists + && (self.machine.quarantine_live || self.machine.tombstone_live) + { + Ok(()) + } else { + Err(CutoverHostError::VerificationFailed) + } + } +} + +struct CrashAfterMutation { + target: usize, + seen: usize, +} + +impl CutoverFaultInjector for CrashAfterMutation { + fn after_host_mutation( + &mut self, + _direction: MutationDirection, + _operation: &CutoverOperation, + ) -> Result<(), CutoverError> { + self.seen += 1; + if self.seen == self.target { + Err(CutoverError::Interrupted) + } else { + Ok(()) + } + } +} + +#[test] +fn crash_after_every_forward_mutation_recovers_without_ambiguous_state() { + let before = sample_before(LegacyServiceState::Running, true, true); + let clean_plan = sample_plan(LegacyServiceState::Running); + let clean_store = MemoryStore::default(); + let mut clean_host = MockHost::new(before.clone()); + let clean_outcome = + begin_proxifyre_cutover(&clean_store, &mut clean_host, clean_plan, 10).unwrap(); + assert_eq!(clean_outcome, CutoverRunOutcome::AwaitingNextStart); + let forward_mutations = clean_host.mutation_calls; + assert!(forward_mutations > 20); + + for target in 1..=forward_mutations { + let store = MemoryStore::default(); + let mut host = MockHost::new(before.clone()); + let initial = host.machine.clone(); + let mut fault = CrashAfterMutation { target, seen: 0 }; + let error = begin_proxifyre_cutover_with_fault( + &store, + &mut host, + sample_plan(LegacyServiceState::Running), + 20, + &mut fault, + ) + .expect_err("fault must interrupt after the selected mutation"); + assert!(matches!(error, CutoverError::Interrupted)); + + let outcome = recover_proxifyre_cutover(&store, &mut host, 21).unwrap(); + let journal = store.load().unwrap().unwrap(); + assert!(journal + .mutations + .iter() + .all(|record| record.effect.is_some())); + if target == forward_mutations { + assert_eq!(outcome, CutoverRunOutcome::AwaitingNextStart); + assert_eq!(journal.phase, CutoverPhase::LegacyQuarantined); + } else { + assert_eq!(outcome, CutoverRunOutcome::RolledBack); + assert_eq!(journal.phase, CutoverPhase::RolledBack); + assert_eq!( + host.machine, initial, + "failed recovery at mutation {target}" + ); + } + } +} + +#[test] +fn legacy_root_is_sealed_before_other_mutations_and_entries_wait_for_service_stop() { + let before = sample_before(LegacyServiceState::Running, false, false); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Running), + 25, + ) + .unwrap(); + + assert_eq!( + host.mutation_order.first(), + Some(&CutoverOperation::HardenLegacyRootSecurity), + "no prerequisite or candidate write may precede the root trust boundary" + ); + let promote = host + .mutation_order + .iter() + .position(|operation| operation == &CutoverOperation::PromoteCurrentCandidate) + .unwrap(); + let stop = host + .mutation_order + .iter() + .position(|operation| operation == &CutoverOperation::StopLegacyService) + .unwrap(); + let first_entry_harden = host + .mutation_order + .iter() + .position(|operation| matches!(operation, CutoverOperation::HardenLegacyEntrySecurity(_))) + .unwrap(); + assert!( + promote < stop, + "candidate staging must finish before downtime" + ); + assert!( + stop < first_entry_harden, + "legacy entry mutation leases and ACL writes require the durable stop effect" + ); +} + +#[test] +fn observed_packet_filter_presence_without_changed_authority_never_becomes_owned() { + let before = sample_before(LegacyServiceState::Stopped, false, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.wpf_installer_reported_unchanged_once = true; + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 26, + ), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + let install = failed + .mutations + .iter() + .find(|record| record.operation == CutoverOperation::InstallWindowsPacketFilterPrerequisite) + .expect("WPF install intent must remain sealed"); + assert!(install.effect.is_none()); + assert!(install.authority_evidence.is_none()); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!( + failed.recovery_code.as_deref(), + Some("mutation_authority_ambiguous") + ); + assert!(!host + .mutation_order + .contains(&CutoverOperation::UninstallTransactionWindowsPacketFilter)); + + let mutations_before_restart = host.mutation_calls; + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 27), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!(host.mutation_calls, mutations_before_restart); + assert!(!host + .mutation_order + .contains(&CutoverOperation::UninstallTransactionWindowsPacketFilter)); +} + +#[test] +fn forged_or_missing_packet_filter_authority_is_rejected_on_load() { + let before = sample_before(LegacyServiceState::Stopped, false, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 27, + ) + .unwrap(); + let exact = store.load().unwrap().unwrap(); + let mutation_calls = host.mutation_calls; + + let mut missing = exact.clone(); + missing + .mutations + .iter_mut() + .find(|record| record.operation == CutoverOperation::InstallWindowsPacketFilterPrerequisite) + .unwrap() + .authority_evidence = None; + store.replace(&missing).unwrap(); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 28), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_calls); + + let mut forged = exact; + forged.mutations[0].authority_evidence = + Some(MutationAuthorityEvidence::WindowsPacketFilterInstalledByTransaction); + store.replace(&forged).unwrap(); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 29), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_calls); +} + +#[test] +fn running_legacy_root_only_hardening_rolls_back_after_restart_before_service_stop() { + let before = sample_before(LegacyServiceState::Running, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before.clone()); + let clean_store = MemoryStore::default(); + let mut clean = MockHost::new(before); + begin_proxifyre_cutover( + &clean_store, + &mut clean, + sample_plan(LegacyServiceState::Running), + 28, + ) + .unwrap(); + let crash_target = clean + .mutation_order + .iter() + .position(|operation| operation == &CutoverOperation::PromoteCurrentCandidate) + .map(|index| index + 1) + .unwrap(); + let mut fault = CrashAfterMutation { + target: crash_target, + seen: 0, + }; + + assert!(matches!( + begin_proxifyre_cutover_with_fault( + &store, + &mut host, + sample_plan(LegacyServiceState::Running), + 29, + &mut fault, + ), + Err(CutoverError::Interrupted) + )); + assert!(host.machine.legacy_service_running); + assert!(!host + .mutation_order + .contains(&CutoverOperation::StopLegacyService)); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 30).unwrap(), + CutoverRunOutcome::RolledBack + ); + assert!(host.machine.legacy_service_running); + assert!(!host.machine.hardened_legacy.contains("")); + assert!(host + .mutation_order + .contains(&CutoverOperation::RestoreLegacyRootSecurity)); +} + +#[test] +fn ambiguous_live_state_is_sealed_as_recovery_required_without_another_mutation() { + let before = sample_before(LegacyServiceState::Running, true, true); + let plan = sample_plan(LegacyServiceState::Running); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + let mut fault = CrashAfterMutation { target: 1, seen: 0 }; + begin_proxifyre_cutover_with_fault(&store, &mut host, plan, 30, &mut fault) + .expect_err("injected crash"); + let calls_before_recovery = host.mutation_calls; + host.machine.foreign_change = true; + + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 31), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!(host.mutation_calls, calls_before_recovery); + let journal = store.load().unwrap().unwrap(); + assert_eq!(journal.phase, CutoverPhase::RecoveryRequired); + assert_eq!( + journal.recovery_code.as_deref(), + Some("ambiguous_live_state") + ); +} + +#[test] +fn external_pre_effect_is_recovery_required_with_zero_host_mutations() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.externally_apply_before_observe = Some(CutoverOperation::HardenLegacyRootSecurity); + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 32, + ), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!(host.mutation_calls, 0); + let journal = store.load().unwrap().unwrap(); + assert_eq!(journal.phase, CutoverPhase::RecoveryRequired); + assert_eq!( + journal.recovery_code.as_deref(), + Some("operation_already_at_expected_effect") + ); +} + +#[test] +fn ambiguous_current_verification_never_starts_rollback() { + let before = sample_before(LegacyServiceState::Running, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_verify_current_observation = true; + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Running), + 33, + ), + Err(CutoverError::RecoveryRequired) + )); + assert!(!host.mutation_order.iter().any(|operation| matches!( + operation, + CutoverOperation::CreateLegacyService + | CutoverOperation::RestoreLegacyServicePolicy(_) + | CutoverOperation::RestoreLegacyServiceSecurity + | CutoverOperation::TombstoneCurrentRoot + ))); + let journal = store.load().unwrap().unwrap(); + assert_eq!(journal.phase, CutoverPhase::RecoveryRequired); + assert_eq!( + journal.recovery_code.as_deref(), + Some("current_verification_ambiguous") + ); +} + +#[test] +fn quarantine_failure_keeps_verified_current_and_never_moves_legacy_root() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let plan = sample_plan(LegacyServiceState::Stopped); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_once = Some(CutoverOperation::QuarantineLegacyRoot); + let outcome = begin_proxifyre_cutover(&store, &mut host, plan, 40).unwrap(); + + assert_eq!(outcome, CutoverRunOutcome::QuarantinePending); + assert!(host.machine.current_service_exists); + assert!(host.machine.legacy_root_live); + assert!(!host.machine.quarantine_live); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::ComponentVerified + ); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 41).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + assert!(host.machine.quarantine_live); +} + +#[test] +fn cleanup_requires_new_start_and_explicit_route_smoke() { + let before = sample_before(LegacyServiceState::Running, true, true); + let store = MemoryStore::default(); + let plan = sample_plan(LegacyServiceState::Running); + let original_session = plan.created_startup_session_id.clone(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover(&store, &mut host, plan, 50).unwrap(); + let journal = store.load().unwrap().unwrap(); + let original_evidence = sample_user_evidence(&journal, &host, original_session, 51, false); + + assert!(matches!( + mark_proxifyre_next_start_verified(&store, &mut host, &original_evidence, 51), + Err(CutoverError::StateConflict) + )); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 52, + false, + ); + let mut mismatched_evidence = evidence.clone(); + mismatched_evidence.current_inventory_fingerprint = hash('d'); + let mutations_before_mismatch = host.mutation_calls; + assert!(matches!( + mark_proxifyre_next_start_verified(&store, &mut host, &mismatched_evidence, 52), + Err(CutoverError::StateConflict) + )); + assert_eq!(host.mutation_calls, mutations_before_mismatch); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::LegacyQuarantined + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 52).unwrap(); + assert!(matches!( + confirm_proxifyre_cleanup(&store, &evidence, 53), + Err(CutoverError::StateConflict) + )); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(54), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 54).unwrap(); + assert_eq!( + cleanup_proxifyre_quarantine(&store, &mut host, 55).unwrap(), + CutoverRunOutcome::Complete + ); + assert!(!host.machine.quarantine_live); + assert!(!host.machine.tombstone_live); + assert!(host.machine.current_service_running); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::ComponentComplete + ); +} + +#[test] +fn completed_retirement_failure_retries_from_durable_terminal_journal() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 55, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 56, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 56).unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(57), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 57).unwrap(); + + store.fail_retirement_once.set(true); + assert!(matches!( + cleanup_proxifyre_quarantine(&store, &mut host, 58), + Err(CutoverError::Interrupted) + )); + let terminal = store.load().unwrap().unwrap(); + assert_eq!(terminal.phase, CutoverPhase::ComponentComplete); + assert_eq!(terminal.execution_mode, CutoverExecutionMode::Finished); + assert_eq!(store.retirement_calls.get(), 1); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 59).unwrap(), + CutoverRunOutcome::Complete + ); + assert_eq!(store.retirement_calls.get(), 2); +} + +#[test] +fn awaiting_user_recovery_preserves_next_start_milestone_and_evidence() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 55, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 56, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 56).unwrap(); + let sealed_next_start = store.load().unwrap().unwrap().next_start.unwrap(); + let mutations_before = host.mutation_calls; + + host.fail_reacquire_once = true; + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 57), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!(failed.execution_mode, CutoverExecutionMode::AwaitingUser); + assert_eq!(failed.next_start.as_ref(), Some(&sealed_next_start)); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 58).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + let recovered = store.load().unwrap().unwrap(); + assert_eq!(recovered.phase, CutoverPhase::NextStartVerified); + assert_eq!(recovered.next_start.as_ref(), Some(&sealed_next_start)); + assert_eq!(host.mutation_calls, mutations_before); +} + +#[test] +fn awaiting_user_recovery_preserves_cleanup_confirmation_and_rechecks_preflight() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 59, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 60, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 60).unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(61), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 61).unwrap(); + let sealed = store.load().unwrap().unwrap(); + let mutations_before = host.mutation_calls; + + host.fail_reacquire_once = true; + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 62), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 63).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + let recovered = store.load().unwrap().unwrap(); + assert_eq!(recovered.phase, CutoverPhase::CleanupConfirmed); + assert_eq!(recovered.next_start, sealed.next_start); + assert_eq!(recovered.route_smoke, sealed.route_smoke); + assert_eq!(host.mutation_calls, mutations_before); +} + +#[test] +fn cleanup_preflight_failures_are_durable_and_retry_without_mutation() { + for (index, verify_failure) in [ + None, + Some(CutoverHostError::ObservationFailed), + Some(CutoverHostError::VerificationFailed), + ] + .into_iter() + .enumerate() + { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 64 + index as u64 * 10, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 65 + index as u64 * 10, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 65 + index as u64 * 10) + .unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(66 + index as u64 * 10), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 66 + index as u64 * 10).unwrap(); + if let Some(failure) = verify_failure { + host.fail_verify_cleanup_once = Some(failure); + } else { + host.fail_reacquire_once = true; + } + let mutations_before = host.mutation_calls; + + assert!(matches!( + cleanup_proxifyre_quarantine(&store, &mut host, 67 + index as u64 * 10), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!(failed.execution_mode, CutoverExecutionMode::AwaitingUser); + assert!(failed.next_start.is_some()); + assert!(failed.route_smoke.is_some()); + assert_eq!(host.mutation_calls, mutations_before); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 68 + index as u64 * 10).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::CleanupConfirmed + ); + assert_eq!( + cleanup_proxifyre_quarantine(&store, &mut host, 69 + index as u64 * 10).unwrap(), + CutoverRunOutcome::Complete + ); + } +} + +#[test] +fn next_start_observation_failure_preserves_awaiting_user_without_mutation() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 56, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 57, + false, + ); + + let machine_before = host.machine.clone(); + let mutation_calls_before = host.mutation_calls; + host.fail_verify_current_observation = true; + assert!(matches!( + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 57), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!(failed.execution_mode, CutoverExecutionMode::AwaitingUser); + assert!( + read_sealed_cutover_status(&store) + .unwrap() + .unwrap() + .can_recover + ); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 58), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!(host.mutation_calls, mutation_calls_before); + assert_eq!(host.machine, machine_before); + + host.fail_verify_current_observation = false; + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 59).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + assert_eq!(host.mutation_calls, mutation_calls_before); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::LegacyQuarantined + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 60).unwrap(); +} + +#[test] +fn next_start_definite_verification_failure_selects_retryable_rollback() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let original_machine = MockMachine::from_before(&before); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 61, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 62, + false, + ); + + host.fail_verify_current = true; + assert!(matches!( + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 62), + Err(CutoverError::RecoveryRequired) + )); + assert_eq!( + store.load().unwrap().unwrap().execution_mode, + CutoverExecutionMode::RollingBack + ); + + host.fail_verify_current = false; + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 63).unwrap(), + CutoverRunOutcome::RolledBack + ); + assert_eq!(host.machine, original_machine); +} + +#[test] +fn unsupported_identity_or_downgrade_has_zero_host_calls_and_zero_journal() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let exact_plan = sample_plan(LegacyServiceState::Stopped); + let store = MemoryStore::default(); + let mut host = MockHost::new(before.clone()); + let mut unsupported = before.clone(); + unsupported.additional_matching_service = true; + assert!(sample_cutover_proof_result(&exact_plan, &unsupported).is_err()); + assert_eq!(host.capture_calls, 0); + assert!(store.load().unwrap().is_none()); + + let mut downgrade = exact_plan; + downgrade.bundled_version = "2.1.9".to_string(); + assert!(matches!( + begin_proxifyre_cutover(&store, &mut host, downgrade, 61,), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.capture_calls, 0); + assert!(store.load().unwrap().is_none()); +} + +#[test] +fn additional_matching_service_in_leased_snapshot_aborts_before_journal_or_mutation() { + let mut before = sample_before(LegacyServiceState::Stopped, true, true); + before.additional_matching_service = true; + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 65, + ), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.capture_calls, 1); + assert_eq!(host.mutation_calls, 0); + assert!(store.load().unwrap().is_none()); +} + +#[test] +fn unsafe_scm_profile_in_leased_snapshot_aborts_before_journal_or_mutation() { + let mut before = sample_before(LegacyServiceState::Stopped, true, true); + let delayed_auto_start = before + .service + .config2 + .iter_mut() + .find_map(|value| match value { + ServiceConfig2Snapshot::DelayedAutoStart(enabled) => Some(enabled), + _ => None, + }) + .expect("complete sample config2"); + *delayed_auto_start = true; + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 66, + ), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.capture_calls, 1); + assert_eq!(host.mutation_calls, 0); + assert!(store.load().unwrap().is_none()); +} + +#[test] +fn scm_snapshot_fingerprint_binds_base_config2_security_and_original_state() { + let before = sample_service(LegacyServiceState::Running); + let expected = legacy_service_snapshot_fingerprint(&before); + let mutations: [fn(&mut FullServiceSnapshot); 4] = [ + |snapshot| snapshot.base.tag_id = 9, + |snapshot| { + let value = snapshot + .config2 + .iter_mut() + .find_map(|value| match value { + ServiceConfig2Snapshot::PreshutdownTimeout(timeout) => Some(timeout), + _ => None, + }) + .expect("complete sample config2"); + *value += 1; + }, + |snapshot| snapshot.security.self_relative_descriptor.push(5), + |snapshot| snapshot.original_state = ServiceStableState::Stopped, + ]; + + for mutate in mutations { + let mut changed = before.clone(); + mutate(&mut changed); + assert_ne!(legacy_service_snapshot_fingerprint(&changed), expected); + } +} + +#[test] +fn forged_persisted_legacy_proof_fingerprint_aborts_recovery_without_mutation() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + let mut fault = CrashAfterMutation { target: 1, seen: 0 }; + assert!(matches!( + begin_proxifyre_cutover_with_fault( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 67, + &mut fault, + ), + Err(CutoverError::Interrupted) + )); + let mutation_count = host.mutation_calls; + let mut forged = store.load().unwrap().unwrap(); + forged.before_state.identity_fingerprint = hash('0'); + store.replace(&forged).unwrap(); + + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 68), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_count); +} + +#[test] +fn forged_operation_order_or_terminal_phase_is_rejected_without_host_mutation() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 68, + ) + .unwrap(); + let mutation_count = host.mutation_calls; + let exact = store.load().unwrap().unwrap(); + + let mut reordered = exact.clone(); + let first = reordered.mutations[0].operation.clone(); + reordered.mutations[0].operation = reordered.mutations[1].operation.clone(); + reordered.mutations[1].operation = first; + store.replace(&reordered).unwrap(); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 69), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_count); + + let mut forged_forward_noop = exact.clone(); + let first = &mut forged_forward_noop.mutations[0]; + first.before_state = first.expected_effect.clone(); + first.effect = Some(MutationEffect { + disposition: EffectDisposition::AlreadySatisfied, + observed: first.expected_effect.clone(), + object_identity: None, + observed_at_epoch_seconds: first.intent_written_at_epoch_seconds, + }); + store.replace(&forged_forward_noop).unwrap(); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 69), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_count); + + store.replace(&exact).unwrap(); + let evidence = sample_user_evidence( + &exact, + &host, + Uuid::new_v4().hyphenated().to_string(), + 69, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 69).unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(70), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 70).unwrap(); + let mut forged_complete = store.load().unwrap().unwrap(); + forged_complete.phase = CutoverPhase::ComponentComplete; + forged_complete.execution_mode = CutoverExecutionMode::Finished; + store.replace(&forged_complete).unwrap(); + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 71), + Err(CutoverError::InvalidPlan) + )); + assert_eq!(host.mutation_calls, mutation_count); +} + +#[test] +fn transaction_fingerprint_binds_all_immutable_before_state_but_not_progress() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 68, + ) + .unwrap(); + let journal = store.load().unwrap().unwrap(); + let fingerprint = cutover_transaction_fingerprint(&journal).unwrap(); + assert_eq!( + store + .observation + .borrow() + .as_ref() + .unwrap() + .transaction_fingerprint, + fingerprint + ); + + let mut progress_only = journal.clone(); + progress_only.phase = CutoverPhase::RecoveryRequired; + progress_only.execution_mode = CutoverExecutionMode::RollingBack; + progress_only.mutations.clear(); + progress_only.recovery_code = Some("injected_progress_only".to_string()); + progress_only.updated_at_epoch_seconds += 100; + assert_eq!( + cutover_transaction_fingerprint(&progress_only).unwrap(), + fingerprint + ); + + let mut changed_filesystem = journal.clone(); + changed_filesystem + .before_state + .filesystem + .root + .identity + .file_id += 1; + assert_ne!( + cutover_transaction_fingerprint(&changed_filesystem).unwrap(), + fingerprint + ); + let mut changed_security = journal.clone(); + changed_security.before_state.filesystem.root.security = serde_json::from_value( + serde_json::json!({"selfRelative": [9, 8, 7, 6], "sacl": "present"}), + ) + .unwrap(); + assert_ne!( + cutover_transaction_fingerprint(&changed_security).unwrap(), + fingerprint + ); + let mut changed_service = journal.clone(); + changed_service + .before_state + .service + .security + .self_relative_descriptor + .push(9); + assert_ne!( + cutover_transaction_fingerprint(&changed_service).unwrap(), + fingerprint + ); + let mut changed_prerequisite = journal.clone(); + changed_prerequisite + .before_state + .prerequisites + .windows_packet_filter_present = false; + assert_ne!( + cutover_transaction_fingerprint(&changed_prerequisite).unwrap(), + fingerprint + ); + + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 69, + false, + ); + let next_start = mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 69).unwrap(); + assert_eq!(next_start.transaction_fingerprint, fingerprint); + assert!(next_start.next_start_verified); + assert!(!next_start.route_smoke_confirmed); + let next_evidence = next_start.evidence_fingerprint.unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(70), + ..evidence + }; + let cleanup = confirm_proxifyre_cleanup(&store, &confirmed, 70).unwrap(); + assert_eq!(cleanup.transaction_fingerprint, fingerprint); + assert!(cleanup.route_smoke_confirmed); + assert_ne!(cleanup.evidence_fingerprint.unwrap(), next_evidence); +} + +#[test] +fn observation_cache_failure_cannot_block_sealed_transaction() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let plan = sample_plan(LegacyServiceState::Stopped); + let store = MemoryStore::default(); + store.fail_observation.set(true); + let mut host = MockHost::new(before); + assert_eq!( + begin_proxifyre_cutover(&store, &mut host, plan, 70,).unwrap(), + CutoverRunOutcome::AwaitingNextStart + ); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::LegacyQuarantined + ); +} + +#[test] +fn stopped_route_smoke_lifecycle_hint_is_narrow_and_never_general_authority() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 71, + ) + .unwrap(); + let observation = read_sealed_cutover_status(&store).unwrap().unwrap(); + + assert!(observation_suggests_lifecycle_blocked(Some(&observation))); + assert!(observation_allows_stopped_route_smoke_lifecycle(Some( + &observation + ))); + + let mut denied = observation.clone(); + denied.original_service_state = LegacyServiceState::Running; + assert!(!observation_allows_stopped_route_smoke_lifecycle(Some( + &denied + ))); + denied = observation.clone(); + denied.disabled_code = Some("manual_intervention_required".to_string()); + assert!(!observation_allows_stopped_route_smoke_lifecycle(Some( + &denied + ))); + denied = observation; + denied.phase = CutoverPhase::NextStartVerified; + denied.state = CutoverDisplayState::AwaitingRouteSmoke; + assert!(!observation_allows_stopped_route_smoke_lifecycle(Some( + &denied + ))); + assert!(!observation_allows_stopped_route_smoke_lifecycle(None)); +} + +#[test] +fn corrupt_redacted_observation_read_is_strictly_read_only() { + let root = std::env::temp_dir().join(format!( + "proxywarden-cutover-observation-{}", + Uuid::new_v4().hyphenated() + )); + let state = root.join("state"); + let observation = state.join("component-cutover-observation.json"); + fs::create_dir_all(&state).unwrap(); + fs::write(&observation, b"{ definitely not valid json").unwrap(); + fs::write(observation.with_extension("json.bak"), b"backup sentinel").unwrap(); + let before: BTreeSet<_> = fs::read_dir(&state) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + + let error = read_redacted_observation(&JsonStorage::new(&root)) + .expect_err("invalid observation must be reported without recovery writes"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!( + fs::read(&observation).unwrap(), + b"{ definitely not valid json" + ); + assert_eq!( + fs::read(observation.with_extension("json.bak")).unwrap(), + b"backup sentinel" + ); + let after: BTreeSet<_> = fs::read_dir(&state) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(after, before); + + let structurally_invalid = serde_json::json!({ + "schemaVersion": CUTOVER_OBSERVATION_SCHEMA_VERSION + 1, + "component": "proxifyre", + "cutoverId": Uuid::new_v4().hyphenated().to_string(), + "state": "awaiting_next_start", + "phase": "legacy_quarantined", + "originalServiceState": "stopped", + "legacyVersion": "2.2.1", + "bundledVersion": "2.4.0", + "operationFingerprint": hash('a'), + "transactionFingerprint": hash('b'), + "evidenceFingerprint": null, + "nextStartVerified": false, + "routeSmokeConfirmed": false, + "legacyPathLabel": "legacy ProxiFyre installation", + "currentPathLabel": "ProxyWarden managed components", + "canRecover": false, + "canCleanup": false, + "disabledCode": null, + "updatedAtEpochSeconds": 1, + }); + let invalid_bytes = serde_json::to_vec(&structurally_invalid).unwrap(); + fs::write(&observation, &invalid_bytes).unwrap(); + assert_eq!( + read_redacted_observation(&JsonStorage::new(&root)) + .expect_err("schema-valid but inconsistent observation must be rejected") + .kind(), + io::ErrorKind::InvalidData + ); + assert_eq!(fs::read(&observation).unwrap(), invalid_bytes); + let final_files: BTreeSet<_> = fs::read_dir(&state) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(final_files, before); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn redacted_observation_validator_rejects_impossible_lifecycle_flags() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 72, + ) + .unwrap(); + let observation = read_sealed_cutover_status(&store).unwrap().unwrap(); + validate_component_cutover_observation(&observation).unwrap(); + + let mut invalid_state = observation.clone(); + invalid_state.state = CutoverDisplayState::Complete; + assert!(validate_component_cutover_observation(&invalid_state).is_err()); + + let mut invalid_cleanup = observation.clone(); + invalid_cleanup.can_cleanup = true; + assert!(validate_component_cutover_observation(&invalid_cleanup).is_err()); + + let mut invalid_evidence = observation; + invalid_evidence.evidence_fingerprint = Some(hash('c')); + assert!(validate_component_cutover_observation(&invalid_evidence).is_err()); +} + +#[test] +fn untrusted_user_evidence_is_structured_hashed_and_stored_separately() { + let root = std::env::temp_dir().join(format!( + "proxywarden-cutover-user-evidence-{}", + Uuid::new_v4().hyphenated() + )); + let storage = JsonStorage::new(&root); + let evidence = ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: Uuid::new_v4().hyphenated().to_string(), + startup_session_id: Uuid::new_v4().hyphenated().to_string(), + current_inventory_fingerprint: hash('a'), + route_smoke_confirmed: false, + observed_at_epoch_seconds: 100, + confirmed_at_epoch_seconds: None, + }; + validate_component_cutover_user_evidence(&evidence).unwrap(); + let fingerprint = component_cutover_user_evidence_fingerprint(&evidence).unwrap(); + storage + .write_component_cutover_user_evidence(&evidence) + .unwrap(); + assert_eq!( + storage.read_component_cutover_user_evidence().unwrap(), + Some(evidence.clone()) + ); + + let mut confirmed = evidence.clone(); + confirmed.route_smoke_confirmed = true; + confirmed.confirmed_at_epoch_seconds = Some(101); + validate_component_cutover_user_evidence(&confirmed).unwrap(); + assert_ne!( + component_cutover_user_evidence_fingerprint(&confirmed).unwrap(), + fingerprint + ); + + let mut malformed = evidence; + malformed.route_smoke_confirmed = true; + assert!(validate_component_cutover_user_evidence(&malformed).is_err()); + assert!(storage + .write_component_cutover_user_evidence(&malformed) + .is_err()); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn current_verification_failure_rolls_back_in_same_run_before_artifact_cleanup() { + let before = sample_before(LegacyServiceState::Running, false, true); + let plan = sample_plan(LegacyServiceState::Running); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + let initial = host.machine.clone(); + host.fail_verify_current = true; + + assert_eq!( + begin_proxifyre_cutover(&store, &mut host, plan, 80,).unwrap(), + CutoverRunOutcome::RolledBack + ); + assert_eq!(host.machine, initial); + let start_legacy = operation_index(&host.mutation_order, &CutoverOperation::StartLegacyService); + let delete_current = operation_index( + &host.mutation_order, + &CutoverOperation::TombstoneCurrentRoot, + ); + let remove_wpf = operation_index( + &host.mutation_order, + &CutoverOperation::UninstallTransactionWindowsPacketFilter, + ); + assert!(start_legacy < delete_current); + assert!(start_legacy < remove_wpf); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::RolledBack + ); +} + +#[test] +fn transaction_created_service_defaults_are_journaled_without_redundant_mutation() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_verify_current = true; + + assert_eq!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 82, + ) + .unwrap(), + CutoverRunOutcome::RolledBack + ); + let journal = store.load().unwrap().unwrap(); + let already_satisfied: Vec<_> = journal + .mutations + .iter() + .filter(|record| { + record + .effect + .as_ref() + .is_some_and(|effect| effect.disposition == EffectDisposition::AlreadySatisfied) + }) + .map(|record| record.operation.clone()) + .collect(); + assert!( + already_satisfied.contains(&CutoverOperation::RestoreLegacyServicePolicy( + ServiceConfig2Kind::FailureActions, + )) + ); + assert!(already_satisfied.contains(&CutoverOperation::RestoreLegacyServiceSecurity)); + assert!(host + .mutation_order + .contains(&CutoverOperation::RestoreLegacyServicePolicy( + ServiceConfig2Kind::Description + ))); + assert!(!host + .mutation_order + .contains(&CutoverOperation::RestoreLegacyServicePolicy( + ServiceConfig2Kind::FailureActions + ))); + assert!(!host + .mutation_order + .contains(&CutoverOperation::RestoreLegacyServiceSecurity)); + assert!(!host + .mutation_order + .iter() + .any(|operation| matches!(operation, CutoverOperation::SetCurrentServicePolicy(_)))); +} + +#[test] +fn transaction_created_components_parent_is_deleted_last_and_preexisting_parent_is_preserved() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_verify_current = true; + assert_eq!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 84, + ) + .unwrap(), + CutoverRunOutcome::RolledBack + ); + let tombstone = operation_index( + &host.mutation_order, + &CutoverOperation::TombstoneCurrentRoot, + ); + let delete_root = operation_index( + &host.mutation_order, + &CutoverOperation::DeleteCurrentTombstoneRoot, + ); + let delete_parent = operation_index( + &host.mutation_order, + &CutoverOperation::DeleteTransactionCurrentComponentsParent, + ); + assert!(tombstone < delete_root && delete_root < delete_parent); + assert!(!host.machine.current_components_parent); + + let mut present_before = sample_before(LegacyServiceState::Stopped, true, true); + present_before.current_components_parent = + CurrentComponentsParentBeforeState::Present(SealedPathSnapshot { + identity: StableObjectIdentity { + volume_serial_number: 8, + file_id: 99, + kind: StableObjectKind::Directory, + size: 0, + }, + security: security_snapshot(), + }); + let present_store = MemoryStore::default(); + let mut present_host = MockHost::new(present_before); + present_host.fail_verify_current = true; + begin_proxifyre_cutover( + &present_store, + &mut present_host, + sample_plan(LegacyServiceState::Stopped), + 85, + ) + .unwrap(); + assert!(present_host.machine.current_components_parent); + assert!(!present_host + .mutation_order + .contains(&CutoverOperation::CreateCurrentComponentsParent)); + assert!(!present_host + .mutation_order + .contains(&CutoverOperation::DeleteTransactionCurrentComponentsParent)); +} + +#[test] +fn foreign_or_weak_components_parent_fails_capture_before_journal_or_mutation() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_capture_precondition = true; + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Stopped), + 86, + ), + Err(CutoverError::Host(CutoverHostError::PreconditionFailed)) + )); + assert_eq!(host.capture_calls, 1); + assert_eq!(host.mutation_calls, 0); + assert!(store.load().unwrap().is_none()); +} + +#[test] +fn rollback_failure_is_durable_and_retryable_without_claiming_rollback() { + let before = sample_before(LegacyServiceState::Running, true, true); + let plan = sample_plan(LegacyServiceState::Running); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_verify_current = true; + host.fail_once = Some(CutoverOperation::RestoreLegacyServicePolicy( + ServiceConfig2Kind::Description, + )); + + assert!(matches!( + begin_proxifyre_cutover(&store, &mut host, plan, 90,), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!(failed.execution_mode, CutoverExecutionMode::RollingBack); + assert!(host.machine.legacy_root_live); + assert!(host.machine.current_root); + assert!(!host + .mutation_order + .contains(&CutoverOperation::TombstoneCurrentRoot)); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 91).unwrap(), + CutoverRunOutcome::RolledBack + ); + assert_eq!( + store.load().unwrap().unwrap().phase, + CutoverPhase::RolledBack + ); +} + +#[test] +fn final_rollback_verification_failure_never_claims_rolled_back() { + let before = sample_before(LegacyServiceState::Running, true, true); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + host.fail_verify_current = true; + host.fail_verify_rollback = true; + + assert!(matches!( + begin_proxifyre_cutover( + &store, + &mut host, + sample_plan(LegacyServiceState::Running), + 95, + ), + Err(CutoverError::RecoveryRequired) + )); + let failed = store.load().unwrap().unwrap(); + assert_eq!(failed.phase, CutoverPhase::RecoveryRequired); + assert_eq!( + failed.recovery_code.as_deref(), + Some("rollback_verification_failed") + ); + assert_eq!(host.machine, MockMachine::from_before(&host.before)); + + host.fail_verify_rollback = false; + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 96).unwrap(), + CutoverRunOutcome::RolledBack + ); +} + +#[test] +fn partial_cleanup_stays_pending_and_retry_completes_idempotently() { + let before = sample_before(LegacyServiceState::Stopped, true, true); + let plan = sample_plan(LegacyServiceState::Stopped); + let first_entry = before.filesystem.entries[0].relative_path.clone(); + let store = MemoryStore::default(); + let mut host = MockHost::new(before); + begin_proxifyre_cutover(&store, &mut host, plan, 100).unwrap(); + let journal = store.load().unwrap().unwrap(); + let evidence = sample_user_evidence( + &journal, + &host, + Uuid::new_v4().hyphenated().to_string(), + 101, + false, + ); + mark_proxifyre_next_start_verified(&store, &mut host, &evidence, 101).unwrap(); + let confirmed = ComponentCutoverUserEvidence { + route_smoke_confirmed: true, + confirmed_at_epoch_seconds: Some(102), + ..evidence + }; + confirm_proxifyre_cleanup(&store, &confirmed, 102).unwrap(); + host.fail_once = Some(CutoverOperation::DeleteQuarantineEntry(first_entry)); + + assert_eq!( + cleanup_proxifyre_quarantine(&store, &mut host, 103).unwrap(), + CutoverRunOutcome::CleanupPending + ); + let pending = store.load().unwrap().unwrap(); + assert_eq!(pending.phase, CutoverPhase::CleanupConfirmed); + assert_eq!(pending.execution_mode, CutoverExecutionMode::CleaningUp); + assert!(host.machine.tombstone_live); + + host.fail_reacquire_once = true; + let mutations_before_reacquire = host.mutation_calls; + assert!(matches!( + recover_proxifyre_cutover(&store, &mut host, 104), + Err(CutoverError::RecoveryRequired) + )); + let recovery = store.load().unwrap().unwrap(); + assert_eq!(recovery.phase, CutoverPhase::RecoveryRequired); + assert_eq!(recovery.execution_mode, CutoverExecutionMode::CleaningUp); + assert_eq!(host.mutation_calls, mutations_before_reacquire); + + assert_eq!( + recover_proxifyre_cutover(&store, &mut host, 105).unwrap(), + CutoverRunOutcome::Complete + ); + assert_eq!( + cleanup_proxifyre_quarantine(&store, &mut host, 106) + .unwrap_err() + .to_string(), + CutoverError::StateConflict.to_string() + ); + assert!(!host.machine.tombstone_live); +} + +fn sample_plan(state: LegacyServiceState) -> ProxifyreCutoverPlan { + ProxifyreCutoverPlan::new( + Path::new(r"C:\Program Files\ProxyWarden"), + PathBuf::from(r"C:\Tools\ProxiFyre"), + state, + "2.4.0".to_string(), + hash('a'), + hash('b'), + hash('c'), + Uuid::new_v4().hyphenated().to_string(), + ) +} + +fn sample_user_evidence( + journal: &CutoverJournal, + host: &MockHost, + startup_session_id: String, + observed_at_epoch_seconds: u64, + route_smoke_confirmed: bool, +) -> ComponentCutoverUserEvidence { + ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: journal.plan.cutover_id.clone(), + startup_session_id, + current_inventory_fingerprint: host + .machine + .current_inventory_fingerprint() + .as_str() + .to_string(), + route_smoke_confirmed, + observed_at_epoch_seconds, + confirmed_at_epoch_seconds: route_smoke_confirmed.then_some(observed_at_epoch_seconds), + } +} + +fn sample_cutover_proof_result( + plan: &ProxifyreCutoverPlan, + before: &LegacyBeforeState, +) -> Result { + let executable = plan.legacy_root.join("ProxiFyre.exe"); + let status = match before.service.original_state { + ServiceStableState::Running => "running", + ServiceStableState::Stopped => "stopped", + }; + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: CandidateRole::Legacy, + root: plan.legacy_root.clone(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(executable.clone()), + missing_files: Vec::new(), + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some(plan.legacy_version.clone()), + service: Some(ServiceEvidence { + name: before.service.service_name.clone(), + status: status.to_string(), + path_name: Some(before.service.base.binary_path_name.clone()), + executable_path: Some(executable.clone()), + path_matches_candidate: legacy_proxifyre_topshelf_path_matches( + &before.service.base.binary_path_name, + &executable, + ), + binary_version: Some(plan.legacy_version.clone()), + }), + service_required: true, + legacy_identity_complete: true, + }], + ); + let immutable_files: Vec<_> = LEGACY_PROXIFYRE_2_2_1_MANIFEST + .iter() + .filter_map(|expected| { + before + .filesystem + .entries + .iter() + .find(|entry| entry.relative_path == Path::new(expected.relative_path)) + .map(|entry| LegacyPackageFileIdentity { + relative_path: entry.relative_path.clone(), + size: entry.identity.size, + sha256: entry.sha256.clone().unwrap_or_default(), + }) + }) + .collect(); + let evidence = LegacyCutoverEvidence { + proxifyre_manifest_matches: matches_legacy_proxifyre_2_2_1_manifest(&immutable_files), + proxifyre_scm_profile: legacy_proxifyre_scm_profile_from_snapshot(&before.service), + proxifyre_scm_snapshot_fingerprint: legacy_service_snapshot_fingerprint(&before.service), + additional_matching_service: before.additional_matching_service, + }; + prove_legacy_cutover(&inventory, &evidence) +} + +fn sample_before( + state: LegacyServiceState, + windows_packet_filter_present: bool, + vc_runtime_present: bool, +) -> LegacyBeforeState { + let mut entries: Vec<_> = LEGACY_PROXIFYRE_2_2_1_MANIFEST + .iter() + .enumerate() + .map(|(index, expected)| LegacyManifestEntry { + relative_path: PathBuf::from(expected.relative_path), + identity: file_identity(index as u64 + 10, expected.size), + sha256: Some(expected.sha256.to_string()), + security: security_snapshot(), + }) + .collect(); + let config_entry = LegacyManifestEntry { + relative_path: PathBuf::from("app-config.json"), + identity: file_identity(100, 128), + sha256: Some(hash('b')), + security: security_snapshot(), + }; + entries.push(config_entry.clone()); + LegacyBeforeState { + identity: ProxifyreLegacyIdentity::V2_2_1ToolsPrimaryService, + identity_fingerprint: hash('e'), + additional_matching_service: false, + service: sample_service(state), + filesystem: LegacyFilesystemSnapshot { + root: SealedPathSnapshot { + identity: StableObjectIdentity { + volume_serial_number: 7, + file_id: 1, + kind: StableObjectKind::Directory, + size: 0, + }, + security: security_snapshot(), + }, + config: SealedPathSnapshot { + identity: config_entry.identity.clone(), + security: config_entry.security.clone(), + }, + config_relative_path: PathBuf::from("app-config.json"), + entries, + }, + current_components_parent: CurrentComponentsParentBeforeState::Absent, + current_candidate: sample_current_candidate(), + prerequisites: PrerequisiteBeforeState { + windows_packet_filter_present, + vc_runtime_present, + }, + package_fingerprint: hash('a'), + config_fingerprint: hash('b'), + } +} + +fn sample_current_candidate() -> CurrentCandidateSnapshot { + let mut files: Vec<_> = CURRENT_PROXIFYRE_PACKAGE_FILES + .iter() + .enumerate() + .map(|(index, relative_path)| CurrentCandidateFile { + relative_path: PathBuf::from(relative_path), + role: CurrentCandidateFileRole::Package, + size: 2_048 + index as u64, + sha256: hash('c'), + }) + .collect(); + files.extend([ + CurrentCandidateFile { + relative_path: PathBuf::from("app-config.json"), + role: CurrentCandidateFileRole::Config, + size: 128, + sha256: hash('b'), + }, + CurrentCandidateFile { + relative_path: PathBuf::from("proxywarden-component.json"), + role: CurrentCandidateFileRole::Marker, + size: 256, + sha256: hash('d'), + }, + CurrentCandidateFile { + relative_path: PathBuf::from("proxywarden-install-receipt.json"), + role: CurrentCandidateFileRole::Receipt, + size: 256, + sha256: hash('f'), + }, + ]); + CurrentCandidateSnapshot { + manifest_fingerprint: current_candidate_manifest_fingerprint(&files) + .expect("sample current candidate must be valid"), + files, + } +} + +fn sample_service(state: LegacyServiceState) -> FullServiceSnapshot { + FullServiceSnapshot { + service_name: "ProxiFyreService".to_string(), + base: ServiceBaseConfigSnapshot { + service_type: 0x10, + start_type: 2, + error_control: 1, + binary_path_name: r#"C:\Tools\ProxiFyre\ProxiFyre.exe -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#.to_string(), + load_order_group: None, + tag_id: 0, + dependencies: Vec::new(), + service_start_name: "LocalSystem".to_string(), + display_name: "ProxiFyre Service".to_string(), + }, + config2: vec![ + ServiceConfig2Snapshot::Description(Some( + "ProxiFyre - SOCKS5 ProxiFyre Service".to_string(), + )), + ServiceConfig2Snapshot::FailureActions(ServiceFailureActionsSnapshot { + reset_period_seconds: 0, + reboot_message: None, + command: None, + actions: Vec::new(), + }), + ServiceConfig2Snapshot::FailureActionsFlag(false), + ServiceConfig2Snapshot::DelayedAutoStart(false), + ServiceConfig2Snapshot::SidType(0), + ServiceConfig2Snapshot::RequiredPrivileges(Vec::new()), + ServiceConfig2Snapshot::PreshutdownTimeout(180_000), + ServiceConfig2Snapshot::Triggers(Vec::new()), + ServiceConfig2Snapshot::PreferredNode { + node: 0, + delete: false, + }, + ServiceConfig2Snapshot::LaunchProtected(0), + ], + security: ServiceSecuritySnapshot { + self_relative_descriptor: vec![1, 2, 3, 4], + untrusted_mutation_rights: false, + }, + original_state: match state { + LegacyServiceState::Running => ServiceStableState::Running, + LegacyServiceState::Stopped => ServiceStableState::Stopped, + }, + } +} + +fn security_snapshot() -> SecurityDescriptorSnapshot { + serde_json::from_value(serde_json::json!({ + "selfRelative": [1, 2, 3, 4], + "sacl": "absent" + })) + .unwrap() +} + +fn file_identity(file_id: u64, size: u64) -> StableObjectIdentity { + StableObjectIdentity { + volume_serial_number: 7, + file_id, + kind: StableObjectKind::RegularFile, + size, + } +} + +fn hash(value: char) -> String { + std::iter::repeat_n(value, 64).collect() +} + +fn policy_key(kind: &ServiceConfig2Kind) -> String { + format!("{kind:?}") +} + +fn path_key(path: &Path) -> String { + path.as_os_str().to_string_lossy().replace('\\', "/") +} + +fn operation_index(order: &[CutoverOperation], expected: &CutoverOperation) -> usize { + order + .iter() + .position(|operation| operation == expected) + .expect("operation must be present") +} diff --git a/src-tauri/tests/component_detection_tests.rs b/src-tauri/tests/component_detection_tests.rs index 7d33281..e7c1d62 100644 --- a/src-tauri/tests/component_detection_tests.rs +++ b/src-tauri/tests/component_detection_tests.rs @@ -1,7 +1,15 @@ +#[cfg(windows)] +use proxywarden_lib::component_detection::SystemProxyfierDetectionHost; use proxywarden_lib::component_detection::{ detect_proxyfier_install_with_host, detect_singbox_install_with_host, - proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost, - ProxyfierEngine, RegistryInstallEntry, + has_additional_matching_legacy_proxifyre_service_with_host, inventory_proxyfier_with_host, + inventory_singbox_with_host, matches_legacy_proxifyre_2_2_1_manifest, + proxyfier_component_from_detection, proxyfier_component_from_inventory, + service_executable_from_path_name, singbox_component_from_detection, LegacyPackageFileIdentity, + ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, LEGACY_PROXIFYRE_2_2_1_MANIFEST, +}; +use proxywarden_lib::component_inventory::{ + BinaryIdentityEvidence, ComponentClassification, OWNERSHIP_MISMATCH, }; use proxywarden_lib::models::ComponentState; use std::{ @@ -18,7 +26,9 @@ fn detects_existing_proxifyre_from_registry_install_location() { .with_service_path( "ProxiFyreService", r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, - ); + ) + .with_known_binary(r"C:\Tools\ProxiFyre\ProxiFyre.exe") + .with_version(r"C:\Tools\ProxiFyre\ProxiFyre.exe", "2.2.1.0"); let detected = detect_proxyfier_install_with_host(&host) .expect("existing ProxiFyre install should be detected"); @@ -32,6 +42,7 @@ fn detects_existing_proxifyre_from_registry_install_location() { assert!(detected.running); assert_eq!(detected.service_name, Some("ProxiFyreService".to_string())); assert_eq!(detected.service_status, Some("running".to_string())); + assert_eq!(detected.version, Some("2.2.1.0".to_string())); let component = proxyfier_component_from_detection(Some(&detected)); assert_eq!(component.state, ComponentState::Running); @@ -40,9 +51,72 @@ fn detects_existing_proxifyre_from_registry_install_location() { assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string())); assert_eq!(component.service_name, Some("ProxiFyreService".to_string())); assert_eq!(component.service_status, Some("running".to_string())); + assert_eq!(component.version, Some("2.2.1.0".to_string())); assert!(component.problems.is_empty()); } +#[test] +fn detects_current_proxifyre_only_with_strong_marker_and_exact_service_path() { + let root = r"C:\Program Files\ProxyWarden\components\ProxiFyre"; + let executable = r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe"; + let marker = serde_json::json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": root, + "packetFilterInstalledByProxyWarden": false + }) + .to_string(); + let host = MockHost::new() + .with_path(root) + .with_path(executable) + .with_text( + r"C:\Program Files\ProxyWarden\components\ProxiFyre\proxywarden-component.json", + &marker, + ) + .with_stopped_service_path( + "ProxiFyreService", + r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" --service"#, + ) + .with_version(executable, "2.4.0.0"); + + let detected = detect_proxyfier_install_with_host(&host).expect("managed current ProxiFyre"); + assert_eq!(detected.install_dir, PathBuf::from(root)); + assert_eq!(detected.version, Some("2.4.0.0".to_string())); +} + +#[test] +fn current_proxifyre_with_foreign_same_name_service_is_ownership_mismatch() { + let root = r"C:\Program Files\ProxyWarden\components\ProxiFyre"; + let executable = r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe"; + let marker = serde_json::json!({ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": root, + "packetFilterInstalledByProxyWarden": false + }) + .to_string(); + let host = MockHost::new() + .with_path(root) + .with_path(executable) + .with_text( + r"C:\Program Files\ProxyWarden\components\ProxiFyre\proxywarden-component.json", + &marker, + ) + .with_service_path( + "ProxiFyreService", + r#""C:\Foreign\ProxiFyre.exe" --service"#, + ); + + let inventory = inventory_proxyfier_with_host(&host); + assert_eq!(inventory.classification(), ComponentClassification::Foreign); + assert_eq!( + inventory.selected_candidate().unwrap().issues[0].code, + OWNERSHIP_MISMATCH + ); +} + #[test] fn ignores_empty_common_proxifyre_folder_without_executable() { let host = MockHost::new().with_path(r"C:\Tools\ProxiFyre"); @@ -65,19 +139,14 @@ fn ignores_plain_proxifier_install() { } #[test] -fn env_override_can_point_to_portable_proxifyre_install() { +fn env_override_does_not_make_portable_proxifyre_managed() { let host = MockHost::new() .with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre") .with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe"); - let detected = detect_proxyfier_install_with_host(&host) - .expect("env override should be checked before common paths"); - - assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre); - assert_eq!( - detected.executable_path, - PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe") - ); + assert!(detect_proxyfier_install_with_host(&host).is_none()); + let inventory = inventory_proxyfier_with_host(&host); + assert_eq!(inventory.classification(), ComponentClassification::Foreign); } #[test] @@ -88,7 +157,8 @@ fn reports_stopped_proxifyre_service_when_executable_exists() { .with_stopped_service_path( "ProxiFyreService", r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, - ); + ) + .with_known_binary(r"C:\Tools\ProxiFyre\ProxiFyre.exe"); let detected = detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected"); @@ -102,6 +172,45 @@ fn reports_stopped_proxifyre_service_when_executable_exists() { assert!(component.problems.is_empty()); } +#[test] +fn primary_and_alias_same_root_remain_discoverable_but_cutover_is_ambiguous() { + let root = Path::new(r"C:\Tools\ProxiFyre"); + let executable = root.join("ProxiFyre.exe"); + let host = MockHost::new() + .with_path(executable.to_str().expect("fixture path")) + .with_service_path( + "ProxiFyreService", + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService"#, + ) + .with_service_path( + "ProxiFyre", + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, + ) + .with_known_binary(executable.to_str().expect("fixture path")); + + assert_eq!( + inventory_proxyfier_with_host(&host).classification(), + ComponentClassification::ManagedLegacy, + "Task 5 discovery/Start/Stop classification stays unchanged" + ); + assert!(has_additional_matching_legacy_proxifyre_service_with_host( + &host, root + )); +} + +#[test] +fn any_present_alias_service_makes_the_strict_service_set_ambiguous() { + let root = Path::new(r"C:\Tools\ProxiFyre"); + for host in [ + MockHost::new().with_service("ProxiFyre"), + MockHost::new().with_service_path("ProxiFyre", r#""C:\Foreign\ProxiFyre.exe" --service"#), + ] { + assert!(has_additional_matching_legacy_proxifyre_service_with_host( + &host, root + )); + } +} + #[test] fn missing_proxyfier_returns_install_action_status() { let component = proxyfier_component_from_detection(None); @@ -121,11 +230,15 @@ fn ignores_known_service_name_when_path_points_to_foreign_binary() { r#""C:\Foreign\ProxiFyre.exe" --service"#, ); - let detected = - detect_proxyfier_install_with_host(&host).expect("executable should still be detected"); - - assert!(!detected.running); - assert_eq!(detected.service_status, None); + assert!(detect_proxyfier_install_with_host(&host).is_none()); + let inventory = inventory_proxyfier_with_host(&host); + let candidate = inventory.selected_candidate().expect("foreign collision"); + assert_eq!(candidate.classification, ComponentClassification::Foreign); + assert_eq!(candidate.issues[0].code, OWNERSHIP_MISMATCH); + let component = proxyfier_component_from_inventory(&inventory); + assert_eq!(component.state, ComponentState::Error); + assert!(!component.running); + assert!(component.actions.is_empty()); } #[test] @@ -135,18 +248,32 @@ fn ignores_known_service_name_without_path_metadata() { .with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe") .with_service("ProxiFyreService"); - let detected = - detect_proxyfier_install_with_host(&host).expect("executable should still be detected"); - - assert!(!detected.running); - assert_eq!(detected.service_status, None); + assert!(detect_proxyfier_install_with_host(&host).is_none()); + let inventory = inventory_proxyfier_with_host(&host); + assert_eq!(inventory.classification(), ComponentClassification::Foreign); + assert_eq!( + inventory.selected_candidate().unwrap().issues[0].code, + OWNERSHIP_MISMATCH + ); } #[test] fn detects_running_local_singbox_from_default_install_root_and_service() { let host = MockHost::new() .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") - .with_service("ProxyWardenSingBox"); + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe") + .with_text( + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml", + winsw_xml(), + ) + .with_service_path( + "ProxyWardenSingBox", + r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#, + ) + .with_version( + r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe", + "1.11.0.0", + ); let detected = detect_singbox_install_with_host(&host).expect("existing sing-box should be detected"); @@ -157,6 +284,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() { ); assert_eq!(detected.service_name, "ProxyWardenSingBox"); assert!(detected.running); + assert_eq!(detected.version, Some("1.11.0.0".to_string())); let component = singbox_component_from_detection(Some(&detected)); assert_eq!(component.state, ComponentState::Running); @@ -170,30 +298,99 @@ fn detects_running_local_singbox_from_default_install_root_and_service() { } #[test] -fn detects_stopped_local_singbox_from_env_override() { +fn current_singbox_with_foreign_same_name_service_is_ownership_mismatch() { + let host = MockHost::new() + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe") + .with_text( + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml", + winsw_xml(), + ) + .with_service_path( + "ProxyWardenSingBox", + r#""C:\Foreign\ProxyWardenSingBox.exe""#, + ); + + let inventory = inventory_singbox_with_host(&host); + assert_eq!(inventory.classification(), ComponentClassification::Foreign); + assert_eq!( + inventory.selected_candidate().unwrap().issues[0].code, + OWNERSHIP_MISMATCH + ); +} + +#[test] +fn winsw_identity_cannot_be_spoofed_by_comments_or_unrelated_nodes() { + let spoofed_xml = r#" + + + run -c "%BASE%\config.json" + ForeignService + C:\Foreign\sing-box.exe + run -c "C:\Foreign\config.json" +"#; + let host = MockHost::new() + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe") + .with_text( + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml", + spoofed_xml, + ) + .with_service_path( + "ProxyWardenSingBox", + r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#, + ); + + let inventory = inventory_singbox_with_host(&host); + assert_eq!( + inventory.classification(), + ComponentClassification::Incomplete + ); + assert!(detect_singbox_install_with_host(&host).is_none()); +} + +#[test] +fn winsw_identity_rejects_duplicate_dtd_cdata_second_root_and_oversized_xml() { + let oversized = format!( + "ProxyWardenSingBox%BASE%\\sing-box.exerun -c \"%BASE%\\config.json\"{}", + "x".repeat(65 * 1024) + ); + let invalid_xml = vec![ + r#"ProxyWardenSingBoxProxyWardenSingBox%BASE%\sing-box.exerun -c "%BASE%\config.json""#.to_string(), + r#"]>&owned;%BASE%\sing-box.exerun -c "%BASE%\config.json""#.to_string(), + r#"%BASE%\sing-box.exerun -c "%BASE%\config.json""#.to_string(), + format!("{}", winsw_xml()), + oversized, + ]; + + for xml in invalid_xml { + let host = current_singbox_host_with_xml(&xml); + assert!( + detect_singbox_install_with_host(&host).is_none(), + "unsafe WinSW XML was accepted" + ); + } +} + +#[test] +fn winsw_identity_accepts_xml_declaration_bom_and_current_extra_nodes() { + let xml = "\u{feff}ProxyWardenSingBoxProxyWarden Local sing-box%BASE%\\sing-box.exerun -c \"%BASE%\\config.json\"4".to_string(); + let host = current_singbox_host_with_xml(&xml); + + assert!(detect_singbox_install_with_host(&host).is_some()); +} + +#[test] +fn portable_singbox_env_override_remains_foreign() { let host = MockHost::new() .with_env("PROXYWARDEN_SINGBOX_ROOT", r"D:\Portable\sing-box") .with_path(r"D:\Portable\sing-box\sing-box.exe"); - let detected = detect_singbox_install_with_host(&host).expect("env override should be checked"); - let component = singbox_component_from_detection(Some(&detected)); - + assert!(detect_singbox_install_with_host(&host).is_none()); assert_eq!( - detected.executable_path, - PathBuf::from(r"D:\Portable\sing-box\sing-box.exe") + inventory_singbox_with_host(&host).classification(), + ComponentClassification::Foreign ); - assert_eq!(component.state, ComponentState::Stopped); - assert!(component.installed); - assert!(!component.running); - assert_eq!( - component.service_name, - Some("ProxyWardenSingBox".to_string()) - ); - assert_eq!(component.service_status, Some("stopped".to_string())); - assert!(component - .problems - .iter() - .any(|problem| problem.contains("остановлена"))); } #[test] @@ -207,6 +404,153 @@ fn missing_local_singbox_returns_optional_install_action_status() { assert!(component.problems.is_empty()); } +#[test] +fn parses_service_pathname_without_accepting_malformed_quotes() { + assert_eq!( + service_executable_from_path_name( + r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" install"#, + ), + Some(PathBuf::from( + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" + )) + ); + assert_eq!( + service_executable_from_path_name(r"C:\Tools\ProxiFyre\ProxiFyre.exe --service"), + Some(PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe")) + ); + assert!(service_executable_from_path_name(r#""C:\Broken\ProxiFyre.exe --service"#).is_none()); + assert!(service_executable_from_path_name(" ").is_none()); +} + +#[test] +fn frozen_proxifyre_manifest_matches_all_ten_files_and_nothing_less() { + let observed = LEGACY_PROXIFYRE_2_2_1_MANIFEST + .iter() + .rev() + .map(|file| LegacyPackageFileIdentity { + relative_path: PathBuf::from(file.relative_path.to_ascii_uppercase()), + size: file.size, + sha256: file.sha256.to_ascii_uppercase(), + }) + .collect::>(); + assert!(matches_legacy_proxifyre_2_2_1_manifest(&observed)); + + let mut missing = observed.clone(); + missing.pop(); + assert!(!matches_legacy_proxifyre_2_2_1_manifest(&missing)); + + let mut wrong_size = observed.clone(); + wrong_size[0].size += 1; + assert!(!matches_legacy_proxifyre_2_2_1_manifest(&wrong_size)); + + let mut wrong_hash = observed.clone(); + wrong_hash[0].sha256 = "0".repeat(64); + assert!(!matches_legacy_proxifyre_2_2_1_manifest(&wrong_hash)); + + let mut extra = observed.clone(); + extra.push(LegacyPackageFileIdentity { + relative_path: PathBuf::from("unexpected.dll"), + size: 1, + sha256: "0".repeat(64), + }); + assert!(!matches_legacy_proxifyre_2_2_1_manifest(&extra)); + + let mut duplicate = observed; + duplicate[0] = duplicate[1].clone(); + assert!(!matches_legacy_proxifyre_2_2_1_manifest(&duplicate)); +} + +#[test] +#[cfg(windows)] +fn reads_windows_pe_file_version_without_executing_binary() { + let windows_dir = std::env::var("WINDIR").expect("WINDIR on Windows"); + let notepad = PathBuf::from(windows_dir) + .join("System32") + .join("notepad.exe"); + let version = SystemProxyfierDetectionHost + .file_version(¬epad) + .expect("notepad PE version"); + + assert_eq!(version.split('.').count(), 4); + assert!(version + .split('.') + .all(|segment| segment.parse::().is_ok())); +} + +#[test] +fn production_component_detection_has_only_native_windows_owners() { + let source = include_str!("../src/component_detection.rs"); + let source_lower = source.to_ascii_lowercase(); + + for forbidden in [ + "command_no_window(", + "get-process", + "get-service", + "get-ciminstance", + "\"powershell\"", + "std::process::command", + "extern \"system\"", + "#[link(", + ] { + assert!( + !source_lower.contains(forbidden), + "production detection still contains shell boundary: {forbidden}" + ); + } + for native_owner in [ + "CreateToolhelp32Snapshot", + "OpenSCManagerW", + "QueryServiceStatusEx", + "QueryServiceConfigW", + "winreg::", + ] { + assert!( + source.contains(native_owner), + "native detection owner is missing: {native_owner}" + ); + } +} + +#[test] +#[cfg(windows)] +fn native_process_inventory_finds_the_running_test_binary() { + let executable_name = std::env::current_exe() + .expect("current test executable") + .file_name() + .expect("current test executable file name") + .to_string_lossy() + .into_owned(); + + assert!(SystemProxyfierDetectionHost.process_running(&executable_name)); + assert!(SystemProxyfierDetectionHost.process_running(&executable_name.to_ascii_uppercase())); + assert!(SystemProxyfierDetectionHost.process_running(executable_name.trim_end_matches(".exe"))); +} + +#[test] +#[cfg(windows)] +fn native_service_inventory_reads_status_and_path_from_scm() { + let service = SystemProxyfierDetectionHost + .service_info("EventLog") + .expect("Windows EventLog service should be queryable without elevation"); + + assert_eq!(service.name, "EventLog"); + assert!(matches!( + service.status.as_str(), + "stopped" + | "start pending" + | "stop pending" + | "running" + | "continue pending" + | "pause pending" + | "paused" + | "unknown" + )); + assert!(service + .path_name + .as_deref() + .is_some_and(|path| !path.trim().is_empty())); +} + #[derive(Default)] struct MockHost { env: HashMap, @@ -214,6 +558,9 @@ struct MockHost { processes: HashSet, services: HashMap, service_paths: HashMap, + texts: HashMap, + known_binaries: HashSet, + versions: HashMap, registry: Vec, } @@ -267,6 +614,24 @@ impl MockHost { }); self } + + fn with_text(mut self, path: &str, contents: &str) -> Self { + self.paths.insert(normalize_path(path)); + self.texts + .insert(normalize_path(path), contents.to_string()); + self + } + + fn with_known_binary(mut self, path: &str) -> Self { + self.known_binaries.insert(normalize_path(path)); + self + } + + fn with_version(mut self, path: &str, version: &str) -> Self { + self.versions + .insert(normalize_path(path), version.to_string()); + self + } } impl ProxyfierDetectionHost for MockHost { @@ -306,8 +671,57 @@ impl ProxyfierDetectionHost for MockHost { fn registry_install_entries(&self) -> Vec { self.registry.clone() } + + fn read_text(&self, path: &Path) -> Option { + self.texts + .get(&normalize_path(&path.display().to_string())) + .cloned() + } + + fn file_version(&self, path: &Path) -> Option { + self.versions + .get(&normalize_path(&path.display().to_string())) + .cloned() + } + + fn binary_identity( + &self, + _component_id: &proxywarden_lib::models::ComponentId, + path: &Path, + ) -> BinaryIdentityEvidence { + if self + .known_binaries + .contains(&normalize_path(&path.display().to_string())) + { + BinaryIdentityEvidence::KnownPackage + } else { + BinaryIdentityEvidence::Unknown + } + } } fn normalize_path(path: &str) -> String { path.replace('/', "\\").to_ascii_lowercase() } + +fn winsw_xml() -> &'static str { + r#" + ProxyWardenSingBox + %BASE%\sing-box.exe + run -c "%BASE%\config.json" +"# +} + +fn current_singbox_host_with_xml(xml: &str) -> MockHost { + MockHost::new() + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe") + .with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe") + .with_text( + r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml", + xml, + ) + .with_service_path( + "ProxyWardenSingBox", + r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#, + ) +} diff --git a/src-tauri/tests/component_inventory_tests.rs b/src-tauri/tests/component_inventory_tests.rs new file mode 100644 index 0000000..9f6cf3d --- /dev/null +++ b/src-tauri/tests/component_inventory_tests.rs @@ -0,0 +1,666 @@ +use proxywarden_lib::component_detection::{ + proxyfier_component_from_inventory, singbox_component_from_inventory, +}; +use proxywarden_lib::component_inventory::{ + authorize_component_action, classify_component_candidates, + component_inventory_fingerprint_for_cutover, legacy_proxifyre_topshelf_path_matches, + prove_legacy_cutover, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, + ComponentClassification, InventoryAction, InventoryIssue, LegacyCutoverEvidence, + LegacyCutoverProof, LegacyProxifyreScmProfile, MarkerEvidence, ServiceEvidence, + AMBIGUOUS_LEGACY, MANUAL_MIGRATION_REQUIRED, OWNERSHIP_MISMATCH, +}; +use proxywarden_lib::component_status::resolve_component_statuses_with_inventories; +use proxywarden_lib::models::{ComponentId, ComponentState}; +use std::path::{Path, PathBuf}; + +#[test] +fn managed_current_requires_marker_files_and_exact_service_path() { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![probe( + ComponentId::Proxyfier, + CandidateRole::Current, + &root, + true, + MarkerEvidence::Valid, + BinaryIdentityEvidence::Unknown, + Some(service(&root.join("ProxiFyre.exe"), true)), + )], + ); + + assert_eq!( + inventory.classification(), + ComponentClassification::ManagedCurrent + ); + assert_eq!( + inventory + .selected_candidate() + .expect("selected current") + .binary_version, + Some("2.4.0.0".to_string()) + ); +} + +#[test] +fn same_service_name_with_foreign_path_is_ownership_mismatch() { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![probe( + ComponentId::Proxyfier, + CandidateRole::Current, + &root, + true, + MarkerEvidence::Valid, + BinaryIdentityEvidence::KnownPackage, + Some(service( + PathBuf::from(r"C:\Foreign\ProxiFyre.exe").as_path(), + false, + )), + )], + ); + + let candidate = inventory + .selected_candidate() + .expect("foreign current candidate"); + assert_eq!(candidate.classification, ComponentClassification::Foreign); + assert_eq!(candidate.issues[0].code, OWNERSHIP_MISMATCH); +} + +#[test] +fn tools_proxifyre_is_legacy_and_never_current() { + let root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![probe( + ComponentId::Proxyfier, + CandidateRole::Legacy, + &root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::KnownPackage, + Some(service(&root.join("ProxiFyre.exe"), true)), + )], + ); + + assert_eq!( + inventory.classification(), + ComponentClassification::ManagedLegacy + ); + let component = proxyfier_component_from_inventory(&inventory); + assert_eq!(component.actions, vec!["Перенести ProxiFyre"]); + assert!(component + .problems + .iter() + .any(|problem| problem.contains("явного переноса"))); +} + +#[test] +fn bare_singbox_root_stays_foreign_without_complete_identity() { + let root = PathBuf::from(r"C:\Program Files\sing-box"); + let mut candidate = probe( + ComponentId::Singbox, + CandidateRole::ForeignByDefault, + &root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::Unknown, + Some(service(&root.join("ProxyWardenSingBox.exe"), true)), + ); + candidate.legacy_identity_complete = false; + let inventory = classify_component_candidates(ComponentId::Singbox, vec![candidate]); + + assert_eq!(inventory.classification(), ComponentClassification::Foreign); +} + +#[test] +fn current_root_without_required_marker_is_incomplete() { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![probe( + ComponentId::Proxyfier, + CandidateRole::Current, + &root, + true, + MarkerEvidence::Missing, + BinaryIdentityEvidence::KnownPackage, + Some(service(&root.join("ProxiFyre.exe"), true)), + )], + ); + + assert_eq!( + inventory.classification(), + ComponentClassification::Incomplete + ); +} + +#[test] +fn startup_status_preserves_foreign_and_incomplete_inventory_errors() { + let proxyfier_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let foreign_proxyfier = classify_component_candidates( + ComponentId::Proxyfier, + vec![probe( + ComponentId::Proxyfier, + CandidateRole::Current, + &proxyfier_root, + true, + MarkerEvidence::Valid, + BinaryIdentityEvidence::KnownPackage, + Some(service( + PathBuf::from(r"C:\Foreign\ProxiFyre.exe").as_path(), + false, + )), + )], + ); + let singbox_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"); + let mut incomplete_singbox_probe = probe( + ComponentId::Singbox, + CandidateRole::Current, + &singbox_root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::KnownPackage, + Some(service(&singbox_root.join("ProxyWardenSingBox.exe"), true)), + ); + incomplete_singbox_probe + .missing_files + .push(singbox_root.join("ProxyWardenSingBox.xml")); + let incomplete_singbox = + classify_component_candidates(ComponentId::Singbox, vec![incomplete_singbox_probe]); + + let statuses = + resolve_component_statuses_with_inventories(&foreign_proxyfier, &incomplete_singbox); + let proxyfier = statuses + .iter() + .find(|status| status.id == ComponentId::Proxyfier) + .expect("ProxiFyre status"); + let singbox = statuses + .iter() + .find(|status| status.id == ComponentId::Singbox) + .expect("sing-box status"); + + assert_eq!(proxyfier.state, ComponentState::Error); + assert_eq!(singbox.state, ComponentState::Error); + assert!(!proxyfier.problems.is_empty()); + assert!(!singbox.problems.is_empty()); +} + +#[test] +fn current_candidate_wins_but_legacy_remains_visible() { + let current_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let legacy_root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![ + probe( + ComponentId::Proxyfier, + CandidateRole::Legacy, + &legacy_root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::KnownPackage, + Some(service(&legacy_root.join("ProxiFyre.exe"), true)), + ), + probe( + ComponentId::Proxyfier, + CandidateRole::Current, + ¤t_root, + true, + MarkerEvidence::Valid, + BinaryIdentityEvidence::KnownPackage, + Some(service(¤t_root.join("ProxiFyre.exe"), true)), + ), + ], + ); + + assert_eq!(inventory.candidates.len(), 2); + assert_eq!( + inventory.classification(), + ComponentClassification::ManagedCurrent + ); + assert_eq!( + inventory + .selected_candidate() + .expect("selected current") + .root, + current_root + ); +} + +#[test] +fn multiple_managed_legacy_candidates_block_selection() { + let roots = [ + PathBuf::from(r"C:\Tools\ProxiFyre"), + PathBuf::from(r"C:\Program Files\ProxiFyre"), + ]; + let probes = roots + .iter() + .map(|root| { + probe( + ComponentId::Proxyfier, + CandidateRole::Legacy, + root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::KnownPackage, + Some(service(&root.join("ProxiFyre.exe"), true)), + ) + }) + .collect(); + let inventory = classify_component_candidates(ComponentId::Proxyfier, probes); + + assert!(inventory.selected_candidate().is_none()); + assert_eq!(inventory.issues[0].code, AMBIGUOUS_LEGACY); +} + +#[test] +fn reparse_point_is_never_managed() { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"); + let mut candidate = probe( + ComponentId::Singbox, + CandidateRole::Current, + &root, + false, + MarkerEvidence::NotRequired, + BinaryIdentityEvidence::KnownPackage, + Some(service(&root.join("ProxyWardenSingBox.exe"), true)), + ); + candidate.has_reparse_point = true; + let inventory = classify_component_candidates(ComponentId::Singbox, vec![candidate]); + + assert_eq!(inventory.classification(), ComponentClassification::Foreign); + assert_eq!( + inventory.selected_candidate().unwrap().issues[0].code, + OWNERSHIP_MISMATCH + ); +} + +#[test] +fn exact_frozen_proxifyre_identity_is_the_only_automatic_cutover() { + let root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = legacy_inventory( + ComponentId::Proxyfier, + &root, + "ProxiFyreService", + &topshelf_path(&root), + "2.2.1.0", + ); + + let proof = prove_legacy_cutover(&inventory, &exact_cutover_evidence()) + .expect("exact identity must produce an opaque proof"); + assert_eq!(proof.fingerprint().len(), 64); +} + +#[test] +fn discovery_evidence_never_substitutes_for_cutover_identity() { + let auto_root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let cases = [ + legacy_inventory( + ComponentId::Proxyfier, + Path::new(r"C:\Program Files\ProxiFyre"), + "ProxiFyreService", + &topshelf_path(Path::new(r"C:\Program Files\ProxiFyre")), + "2.2.1.0", + ), + legacy_inventory( + ComponentId::Proxyfier, + &auto_root, + "ProxiFyre", + &topshelf_path(&auto_root), + "2.2.1.0", + ), + legacy_inventory( + ComponentId::Proxyfier, + &auto_root, + "ProxiFyreService", + &format!( + r#""{}" --service"#, + auto_root.join("ProxiFyre.exe").display() + ), + "2.2.1.0", + ), + legacy_inventory( + ComponentId::Proxyfier, + &auto_root, + "ProxiFyreService", + &topshelf_path(&auto_root), + "2.4.0.0", + ), + ]; + + for inventory in cases { + assert_eq!( + inventory.classification(), + ComponentClassification::ManagedLegacy + ); + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &exact_cutover_evidence())); + } + + let inventory = legacy_inventory( + ComponentId::Proxyfier, + &auto_root, + "ProxiFyreService", + &topshelf_path(&auto_root), + "2.2.1.0", + ); + let mut bad_manifest = exact_cutover_evidence(); + bad_manifest.proxifyre_manifest_matches = false; + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_manifest)); + + let mut bad_snapshot_fingerprint = exact_cutover_evidence(); + bad_snapshot_fingerprint + .proxifyre_scm_snapshot_fingerprint + .clear(); + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_snapshot_fingerprint)); + + let mut bad_profile = exact_cutover_evidence(); + bad_profile + .proxifyre_scm_profile + .as_mut() + .expect("profile") + .delayed_auto_start = true; + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_profile)); + + let mut extra_candidate = inventory.clone(); + extra_candidate + .candidates + .push(extra_candidate.candidates[0].clone()); + assert_manual_without_mutation(prove_legacy_cutover( + &extra_candidate, + &exact_cutover_evidence(), + )); + + let mut alias_collision = exact_cutover_evidence(); + alias_collision.additional_matching_service = true; + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &alias_collision)); +} + +#[test] +fn legacy_singbox_is_always_manual_and_has_zero_mutation_authority() { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box"); + let inventory = legacy_inventory( + ComponentId::Singbox, + &root, + "ProxyWardenSingBox", + &format!(r#""{}""#, root.join("ProxyWardenSingBox.exe").display()), + "1.13.19", + ); + + assert_eq!( + inventory.classification(), + ComponentClassification::ManagedLegacy + ); + let component = singbox_component_from_inventory(&inventory); + assert!(component.actions.is_empty()); + assert!(component + .problems + .iter() + .any(|problem| problem.contains("ручного переноса"))); + assert_manual_without_mutation(prove_legacy_cutover(&inventory, &exact_cutover_evidence())); +} + +#[test] +fn generic_inventory_authorization_never_grants_cutover() { + let root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = legacy_inventory( + ComponentId::Proxyfier, + &root, + "ProxiFyreService", + &topshelf_path(&root), + "2.2.1.0", + ); + + let error = authorize_component_action(&inventory, InventoryAction::Cutover) + .expect_err("generic lifecycle authorization must not grant cutover"); + assert_eq!(error.code, "legacy_cutover_required"); + let mut current = inventory.clone(); + current.candidates[0].classification = ComponentClassification::ManagedCurrent; + let missing = + proxywarden_lib::component_inventory::ComponentInventory::missing(ComponentId::Proxyfier); + for inventory in [¤t, &missing] { + assert!(authorize_component_action(inventory, InventoryAction::Cutover).is_err()); + } + prove_legacy_cutover(&inventory, &exact_cutover_evidence()) + .expect("strict gate remains the only proof constructor"); +} + +#[test] +fn generic_inventory_authorization_never_writes_legacy_runtime_config() { + let root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = legacy_inventory( + ComponentId::Proxyfier, + &root, + "ProxiFyreService", + &topshelf_path(&root), + "2.2.1.0", + ); + + for action in [ + InventoryAction::Apply, + InventoryAction::Start, + InventoryAction::Stop, + ] { + let error = authorize_component_action(&inventory, action) + .expect_err("legacy runtime actions require explicit cutover"); + assert_eq!(error.code, "legacy_cutover_required"); + } +} + +#[test] +fn cutover_inventory_fingerprint_is_stable_and_binds_live_service_state() { + let root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let inventory = legacy_inventory( + ComponentId::Proxyfier, + &root, + "ProxiFyreService", + &topshelf_path(&root), + "2.2.1.0", + ); + let first = component_inventory_fingerprint_for_cutover(&inventory); + assert_eq!( + first, + component_inventory_fingerprint_for_cutover(&inventory) + ); + + let mut changed = inventory.clone(); + changed.candidates[0] + .service + .as_mut() + .expect("service") + .status = "running".to_string(); + assert_ne!(first, component_inventory_fingerprint_for_cutover(&changed)); +} + +#[test] +fn frozen_scm_profile_rejects_each_unsafe_or_unknown_field() { + let mutations: [fn(&mut LegacyProxifyreScmProfile); 15] = [ + |profile| profile.service_type = 0x20, + |profile| profile.start_type = 3, + |profile| profile.error_control = 0, + |profile| profile.account_name = "NetworkService".to_string(), + |profile| profile.display_name = "ProxiFyre".to_string(), + |profile| profile.description.clear(), + |profile| profile.dependencies.push("Tcpip".to_string()), + |profile| profile.load_order_group = Some("Network".to_string()), + |profile| profile.has_failure_actions = true, + |profile| profile.failure_actions_on_non_crash = true, + |profile| profile.delayed_auto_start = true, + |profile| profile.sid_type = 1, + |profile| { + profile + .required_privileges + .push("SeDebugPrivilege".to_string()) + }, + |profile| profile.has_triggers = true, + |profile| profile.untrusted_mutation_rights = true, + ]; + + assert!(exact_scm_profile().matches_frozen_2_2_1_profile()); + for mutate in mutations { + let mut profile = exact_scm_profile(); + mutate(&mut profile); + assert!(!profile.matches_frozen_2_2_1_profile()); + } +} + +#[test] +fn topshelf_cutover_path_is_token_exact_and_pair_order_independent() { + let executable = Path::new(r"C:\Tools\ProxiFyre\ProxiFyre.exe"); + for path_name in [ + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#, + r#"C:\Tools\ProxiFyre\ProxiFyre.exe -servicename ProxiFyreService -displayname "ProxiFyre Service""#, + ] { + assert!(legacy_proxifyre_topshelf_path_matches( + path_name, executable + )); + } + for path_name in [ + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#, + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService --run"#, + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "Foreign" -servicename ProxiFyreService"#, + r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyre"#, + r#""C:\Tools\ProxiFyre\ProxiFyre.exe -displayname "ProxiFyre Service" -servicename ProxiFyreService"#, + ] { + assert!(!legacy_proxifyre_topshelf_path_matches( + path_name, executable + )); + } + assert!(!legacy_proxifyre_topshelf_path_matches( + r#""C:\Program Files\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService"#, + Path::new(r"C:\Program Files\ProxiFyre\ProxiFyre.exe"), + )); +} + +fn assert_manual_without_mutation(result: Result) { + assert_eq!( + result + .expect_err("manual identity must not yield a proof") + .code, + MANUAL_MIGRATION_REQUIRED + ); +} + +fn exact_cutover_evidence() -> LegacyCutoverEvidence { + LegacyCutoverEvidence { + proxifyre_manifest_matches: true, + proxifyre_scm_profile: Some(exact_scm_profile()), + proxifyre_scm_snapshot_fingerprint: "9".repeat(64), + additional_matching_service: false, + } +} + +fn exact_scm_profile() -> LegacyProxifyreScmProfile { + LegacyProxifyreScmProfile { + service_type: 0x10, + start_type: 2, + error_control: 1, + account_name: "LocalSystem".to_string(), + display_name: "ProxiFyre Service".to_string(), + description: "ProxiFyre - SOCKS5 ProxiFyre Service".to_string(), + dependencies: Vec::new(), + load_order_group: None, + has_failure_actions: false, + failure_actions_on_non_crash: false, + delayed_auto_start: false, + sid_type: 0, + required_privileges: Vec::new(), + has_triggers: false, + untrusted_mutation_rights: false, + } +} + +fn legacy_inventory( + component_id: ComponentId, + root: &Path, + service_name: &str, + path_name: &str, + version: &str, +) -> proxywarden_lib::component_inventory::ComponentInventory { + let executable_name = match component_id { + ComponentId::Proxyfier => "ProxiFyre.exe", + ComponentId::Singbox => "sing-box.exe", + ComponentId::ControlApp => "ProxyWarden.exe", + }; + let service_executable = match component_id { + ComponentId::Singbox => root.join("ProxyWardenSingBox.exe"), + ComponentId::Proxyfier | ComponentId::ControlApp => root.join(executable_name), + }; + classify_component_candidates( + component_id.clone(), + vec![ComponentCandidateProbe { + component_id, + role: CandidateRole::Legacy, + root: root.to_path_buf(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(root.join(executable_name)), + missing_files: Vec::new(), + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some(version.to_string()), + service: Some(ServiceEvidence { + name: service_name.to_string(), + status: "stopped".to_string(), + path_name: Some(path_name.to_string()), + executable_path: Some(service_executable), + path_matches_candidate: true, + binary_version: Some(version.to_string()), + }), + service_required: true, + legacy_identity_complete: true, + }], + ) +} + +fn topshelf_path(root: &Path) -> String { + format!( + r#""{}" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#, + root.join("ProxiFyre.exe").display() + ) +} + +fn probe( + component_id: ComponentId, + role: CandidateRole, + root: &Path, + marker_required: bool, + marker: MarkerEvidence, + binary_identity: BinaryIdentityEvidence, + service: Option, +) -> ComponentCandidateProbe { + let executable_name = match component_id { + ComponentId::Proxyfier => "ProxiFyre.exe", + ComponentId::Singbox => "sing-box.exe", + ComponentId::ControlApp => "ProxyWarden.exe", + }; + ComponentCandidateProbe { + component_id, + role, + root: root.to_path_buf(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(root.join(executable_name)), + missing_files: Vec::new(), + marker, + marker_required, + binary_identity, + binary_version: Some("2.4.0.0".to_string()), + service, + service_required: true, + legacy_identity_complete: true, + } +} + +fn service(executable: &std::path::Path, matches: bool) -> ServiceEvidence { + ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "stopped".to_string(), + path_name: Some(format!(r#""{}" --service"#, executable.display())), + executable_path: Some(executable.to_path_buf()), + path_matches_candidate: matches, + binary_version: Some("2.4.0.0".to_string()), + } +} diff --git a/src-tauri/tests/component_package_update_tests.rs b/src-tauri/tests/component_package_update_tests.rs new file mode 100644 index 0000000..d7a96c2 --- /dev/null +++ b/src-tauri/tests/component_package_update_tests.rs @@ -0,0 +1,1219 @@ +use proxywarden_lib::component_catalog::{ComponentId, ComponentPackage, UpdateTrustPolicy}; +use proxywarden_lib::component_packages::{ + ComponentInstallSource, ComponentPackageService, ComponentPackagesError, ComponentUpdateState, + InstalledComponentSnapshot, PackageCacheManifest, PackageSignatureVerifier, PackageSource, + SignaturePublisher, SignatureVerifierError, UpdateCheckTrust, UpdateFreshness, + UpdateRequestKind, UpdateTransport, UpdateTransportError, UpdateTransportRequest, + UpdateTransportResponse, UPDATE_CHECK_STALE_AFTER_SECS, +}; +use proxywarden_lib::safe_fs::ensure_no_reparse_ancestors; +use proxywarden_lib::storage::StoragePaths; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::VecDeque; +use std::fs; +use std::io::{Cursor, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use uuid::Uuid; + +const CHECKED_AT: u64 = 1_800_000_000; +const PUBLISHER: &str = "The Anti-Cloud Corporation"; + +#[test] +fn trusted_check_persists_redacted_state_recovers_backup_and_reports_stale() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::Proxifyre); + let asset_name = "ProxiFyre-v2.5.0-x64-signed.zip"; + let bytes = b"release bytes"; + let transport = MockTransport::new(vec![metadata_response( + &component, + "2.5.0", + asset_name, + bytes, + DigestMode::Trusted, + )]); + + let result = service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) + .expect("trusted explicit check"); + assert_eq!(result.trust, UpdateCheckTrust::Trusted); + assert!(result.update_available); + assert_eq!(transport.call_count(), 1); + + let persisted = fs::read_to_string(&workspace.state_path).expect("read update state"); + assert!(!persisted.contains("https://")); + assert!(!persisted.contains("browser_download_url")); + assert!(!persisted.contains("?token=")); + assert!(!persisted.contains("release bytes")); + + let fresh = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT + 1, + installed("2.4.0", ComponentInstallSource::Bundled), + ) + .expect("fresh status"); + assert_eq!(fresh.freshness, UpdateFreshness::Fresh); + assert_eq!(fresh.update_state, ComponentUpdateState::UpdateAvailable); + assert_eq!(fresh.installed_version.as_deref(), Some("2.4.0")); + assert_eq!(fresh.available_offline_version, "2.4.0"); + assert_eq!(fresh.install_source, ComponentInstallSource::Bundled); + assert_eq!(fresh.offline_package_source, PackageSource::Bundled); + assert!(fresh.can_install_offline); + assert!(fresh.can_download); + + let stale = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT + UPDATE_CHECK_STALE_AFTER_SECS, + installed("2.4.0", ComponentInstallSource::Bundled), + ) + .expect("stale status"); + assert_eq!(stale.freshness, UpdateFreshness::Stale); + assert_eq!(stale.update_state, ComponentUpdateState::CheckStale); + let future_clock = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT - 1, + installed("2.4.0", ComponentInstallSource::Bundled), + ) + .expect("future timestamp status"); + assert_eq!(future_clock.update_state, ComponentUpdateState::CheckStale); + + let second = MockTransport::new(vec![metadata_response( + &component, + "2.5.0", + asset_name, + bytes, + DigestMode::Trusted, + )]); + service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT + 10, &second) + .expect("second write creates backup"); + fs::write(&workspace.state_path, b"{broken").expect("corrupt primary state"); + let recovered = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT + 20, + installed("2.4.0", ComponentInstallSource::Bundled), + ) + .expect("recover valid backup"); + assert_eq!(recovered.last_checked_at_unix, Some(CHECKED_AT)); + serde_json::from_slice::(&fs::read(&workspace.state_path).expect("restored primary")) + .expect("restored state must be JSON"); +} + +#[test] +fn missing_or_malformed_digest_is_observable_and_never_downloaded() { + for (mode, expected) in [ + ( + DigestMode::Missing, + UpdateCheckTrust::MissingIndependentDigest, + ), + ( + DigestMode::Malformed, + UpdateCheckTrust::MalformedIndependentDigest, + ), + ] { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let transport = MockTransport::new(vec![metadata_response( + &component, + "1.14.0", + "sing-box-1.14.0-windows-amd64.zip", + b"asset", + mode, + )]); + let result = service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .expect("untrusted observation is still visible"); + assert_eq!(result.trust, expected); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), + Err(ComponentPackagesError::NoTrustedUpdate) + )); + assert_eq!(transport.call_count(), 1, "download transport must not run"); + } +} + +#[test] +fn draft_prerelease_wrong_identity_and_ambiguous_metadata_are_rejected() { + let seed_workspace = TestWorkspace::new(); + let seed_service = seed_workspace.service(); + let component = catalog_component(&seed_service, ComponentId::SingBox); + let valid_response = metadata_response( + &component, + "1.14.0", + "sing-box-1.14.0-windows-amd64.zip", + b"asset", + DigestMode::Trusted, + ); + let valid: Value = serde_json::from_slice(&valid_response.body).expect("parse seed metadata"); + let mut cases = Vec::new(); + + let mut draft = valid.clone(); + draft["draft"] = json!(true); + cases.push(draft); + let mut prerelease = valid.clone(); + prerelease["prerelease"] = json!(true); + cases.push(prerelease); + let mut wrong_tag = valid.clone(); + wrong_tag["tag_name"] = json!("v1.14.0-beta.1"); + cases.push(wrong_tag); + let mut wrong_asset = valid.clone(); + wrong_asset["assets"][0]["name"] = json!("other.zip"); + cases.push(wrong_asset); + let mut mismatched_asset_version = valid.clone(); + mismatched_asset_version["assets"][0]["name"] = json!("sing-box-9.9.9-windows-amd64.zip"); + mismatched_asset_version["assets"][0]["browser_download_url"] = json!( + "https://github.com/SagerNet/sing-box/releases/download/v1.14.0/sing-box-9.9.9-windows-amd64.zip" + ); + cases.push(mismatched_asset_version); + let mut dotted_sing_version = valid.clone(); + dotted_sing_version["assets"][0]["name"] = json!("sing-box-1.14.0.1-windows-amd64.zip"); + dotted_sing_version["assets"][0]["browser_download_url"] = json!( + "https://github.com/SagerNet/sing-box/releases/download/v1.14.0/sing-box-1.14.0.1-windows-amd64.zip" + ); + cases.push(dotted_sing_version); + let mut wrong_repository = valid.clone(); + wrong_repository["assets"][0]["browser_download_url"] = json!( + "https://github.com/attacker/sing-box/releases/download/v1.14.0/sing-box-1.14.0-windows-amd64.zip" + ); + cases.push(wrong_repository); + let mut ambiguous = valid; + let mut duplicate = ambiguous["assets"][0].clone(); + duplicate["id"] = json!(85); + ambiguous["assets"] + .as_array_mut() + .expect("assets array") + .push(duplicate); + cases.push(ambiguous); + + for metadata in cases { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let transport = MockTransport::new(vec![ResponseSpec::ok( + serde_json::to_vec(&metadata).expect("serialize rejected metadata"), + )]); + assert!(service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .is_err()); + assert_eq!(transport.call_count(), 1); + assert!(!workspace.state_path.exists()); + assert_bundled(&service, ComponentId::SingBox); + } + + let proxifyre = catalog_component(&seed_service, ComponentId::Proxifyre); + let dotted_proxifyre = metadata_response( + &proxifyre, + "2.5.0", + "ProxiFyre-v2.5.0.1-x64-signed.zip", + b"asset", + DigestMode::Trusted, + ); + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let transport = MockTransport::new(vec![dotted_proxifyre]); + assert!(service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) + .is_err()); + assert_eq!(transport.call_count(), 1); + assert!(!workspace.state_path.exists()); + assert_bundled(&service, ComponentId::Proxifyre); +} + +#[test] +fn out_of_order_checks_never_regress_latest_observation() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + + let newer = MockTransport::new(vec![metadata_response( + &component, + "1.15.0", + "sing-box-1.15.0-windows-amd64.zip", + b"newer asset", + DigestMode::Trusted, + )]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT + 2, &newer) + .expect("persist newer completion first"); + + let delayed_older = MockTransport::new(vec![metadata_response( + &component, + "1.14.0", + "sing-box-1.14.0-windows-amd64.zip", + b"older asset", + DigestMode::Trusted, + )]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &delayed_older) + .expect("merge delayed older proof without regressing observation"); + let status = service + .update_status( + ComponentId::SingBox, + CHECKED_AT + 3, + installed("1.13.19", ComponentInstallSource::Bundled), + ) + .expect("read non-regressed status"); + assert_eq!(status.latest_known_version.as_deref(), Some("1.15.0")); + assert_eq!(status.last_checked_at_unix, Some(CHECKED_AT + 2)); + + let before_conflict = fs::read(&workspace.state_path).expect("read state before conflict"); + let equal_timestamp_conflict = MockTransport::new(vec![metadata_response( + &component, + "1.16.0", + "sing-box-1.16.0-windows-amd64.zip", + b"conflicting asset", + DigestMode::Trusted, + )]); + assert!(matches!( + service.check_for_update( + ComponentId::SingBox, + CHECKED_AT + 2, + &equal_timestamp_conflict + ), + Err(ComponentPackagesError::InvalidUpdateState) + )); + assert_eq!( + fs::read(&workspace.state_path).expect("read state after conflict"), + before_conflict + ); +} + +#[test] +fn build_time_and_bundled_only_components_make_zero_transport_calls() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let transport = MockTransport::new(Vec::new()); + + for component_id in [ComponentId::VcRuntime, ComponentId::Winsw] { + let result = service + .check_for_update(component_id, CHECKED_AT, &transport) + .expect("unsupported check result"); + assert_eq!(result.trust, UpdateCheckTrust::Unsupported); + assert!(!result.update_available); + assert!(matches!( + service.download_checked_update(component_id, &transport, &NoopVerifier), + Err(ComponentPackagesError::NoTrustedUpdate) + )); + let status = service + .update_status( + component_id, + CHECKED_AT, + InstalledComponentSnapshot::not_installed(), + ) + .expect("unsupported status"); + assert_eq!(status.update_state, ComponentUpdateState::Unsupported); + assert_eq!(status.install_source, ComponentInstallSource::None); + assert_eq!(status.available_offline_version, status.bundled_version); + assert!(status.can_install_offline); + } + assert_eq!(transport.call_count(), 0); +} + +#[test] +fn status_uses_inventory_version_and_keeps_offline_package_separate() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + + let unchecked = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT, + installed("2.2.0", ComponentInstallSource::External), + ) + .expect("unchecked installed status"); + assert_eq!(unchecked.installed_version.as_deref(), Some("2.2.0")); + assert_eq!(unchecked.available_offline_version, "2.4.0"); + assert_eq!(unchecked.install_source, ComponentInstallSource::External); + assert_eq!(unchecked.offline_package_source, PackageSource::Bundled); + assert_eq!(unchecked.update_state, ComponentUpdateState::UnknownOffline); + + let missing = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT, + InstalledComponentSnapshot::not_installed(), + ) + .expect("not-installed status"); + assert_eq!(missing.installed_version, None); + assert_eq!(missing.install_source, ComponentInstallSource::None); + assert_eq!(missing.update_state, ComponentUpdateState::UnknownOffline); + assert!(missing.can_install_offline); + assert!(matches!( + InstalledComponentSnapshot::new(Some(""), ComponentInstallSource::External), + Err(ComponentPackagesError::InvalidInstalledSnapshot) + )); + assert!(matches!( + InstalledComponentSnapshot::new(Some("service running"), ComponentInstallSource::External), + Err(ComponentPackagesError::InvalidInstalledSnapshot) + )); + assert!(matches!( + InstalledComponentSnapshot::new(None, ComponentInstallSource::External), + Err(ComponentPackagesError::InvalidInstalledSnapshot) + )); + assert!(matches!( + InstalledComponentSnapshot::new(Some("2.4.0"), ComponentInstallSource::None), + Err(ComponentPackagesError::InvalidInstalledSnapshot) + )); + + let component = catalog_component(&service, ComponentId::Proxifyre); + let transport = MockTransport::new(vec![metadata_response( + &component, + "2.4.0", + "ProxiFyre-v2.4.0-x64-signed.zip", + b"same baseline release", + DigestMode::Trusted, + )]); + service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) + .expect("fresh baseline observation"); + + let older = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT + 1, + installed("2.2.0", ComponentInstallSource::External), + ) + .expect("older installed status"); + assert_eq!(older.update_state, ComponentUpdateState::UpdateAvailable); + let newer = service + .update_status( + ComponentId::Proxifyre, + CHECKED_AT + 1, + installed("9.0.0", ComponentInstallSource::External), + ) + .expect("newer external status"); + assert_eq!(newer.update_state, ComponentUpdateState::Current); + assert_eq!(newer.install_source, ComponentInstallSource::External); + assert!(!newer.can_download); +} + +#[test] +fn sing_box_download_follows_only_the_exact_github_cdn_redirect_and_promotes() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let asset_name = "sing-box-1.14.0-windows-amd64.zip"; + let bytes = b"verified sing-box archive".to_vec(); + let cdn = "https://release-assets.githubusercontent.com/github-production-release-asset/1/object?token=secret"; + let transport = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + asset_name, + &bytes, + DigestMode::Trusted, + ), + ResponseSpec::redirect(cdn), + ResponseSpec::ok(bytes.clone()), + ]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .expect("trusted sing-box check"); + let selected = service + .download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier) + .expect("download and promote sing-box"); + + assert_eq!(selected.source, PackageSource::Cache); + assert_eq!(selected.version, "1.14.0"); + assert_eq!(transport.call_count(), 3); + assert_eq!(transport.requests()[2].url, cdn); + let state = fs::read_to_string(&workspace.state_path).expect("read persisted state"); + assert!(!state.contains("token=secret")); + let status = service + .update_status( + ComponentId::SingBox, + CHECKED_AT + 1, + installed("1.14.0", ComponentInstallSource::Cache), + ) + .expect("downloaded current status"); + assert_eq!(status.update_state, ComponentUpdateState::Current); + assert!(!status.can_download); +} + +#[test] +fn prior_verified_cache_survives_untrusted_check_and_interrupted_newer_download() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let v114_name = "sing-box-1.14.0-windows-amd64.zip"; + let v114 = b"trusted 1.14 package".to_vec(); + let initial = MockTransport::new(vec![ + metadata_response(&component, "1.14.0", v114_name, &v114, DigestMode::Trusted), + ResponseSpec::ok(v114), + ]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &initial) + .expect("check 1.14"); + service + .download_checked_update(ComponentId::SingBox, &initial, &NoopVerifier) + .expect("cache 1.14"); + + let changed_tag = b"different bytes for mutable tag".to_vec(); + let mutable_tag = MockTransport::new(vec![metadata_response( + &component, + "1.14.0", + v114_name, + &changed_tag, + DigestMode::Trusted, + )]); + assert!(matches!( + service.check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &mutable_tag), + Err(ComponentPackagesError::InvalidUpdateState) + )); + assert_eq!( + service + .select_verified(ComponentId::SingBox) + .expect("retain cache after mutable tag rejection") + .version, + "1.14.0" + ); + + let v115_name = "sing-box-1.15.0-windows-amd64.zip"; + let v115 = b"newer 1.15 package".to_vec(); + let missing = MockTransport::new(vec![metadata_response( + &component, + "1.15.0", + v115_name, + &v115, + DigestMode::Missing, + )]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT + 2, &missing) + .expect("observe 1.15 without proof"); + let selected = service + .select_verified(ComponentId::SingBox) + .expect("retain prior cache after untrusted check"); + assert_eq!( + (selected.source, selected.version.as_str()), + (PackageSource::Cache, "1.14.0") + ); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &missing, &NoopVerifier), + Err(ComponentPackagesError::NoTrustedUpdate) + )); + + let trusted_metadata = + metadata_response(&component, "1.15.0", v115_name, &v115, DigestMode::Trusted); + let interrupted = InterruptedTransport::new(trusted_metadata, v115); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT + 3, &interrupted) + .expect("trusted 1.15 check"); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &interrupted, &NoopVerifier), + Err(ComponentPackagesError::StagingIo(_)) + )); + let selected = service + .select_verified(ComponentId::SingBox) + .expect("retain prior cache after interrupted download"); + assert_eq!( + (selected.source, selected.version.as_str()), + (PackageSource::Cache, "1.14.0") + ); +} + +#[test] +fn second_redirect_and_hash_mismatch_never_promote() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let asset_name = "sing-box-1.14.0-windows-amd64.zip"; + let expected = b"expected".to_vec(); + let cdn = "https://release-assets.githubusercontent.com/github-production-release-asset/1/object?token=one"; + let transport = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + asset_name, + &expected, + DigestMode::Trusted, + ), + ResponseSpec::redirect(cdn), + ResponseSpec::redirect("https://release-assets.githubusercontent.com/other?token=two"), + ]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .expect("trusted check"); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), + Err(ComponentPackagesError::InvalidRedirect) + )); + assert_bundled(&service, ComponentId::SingBox); + + let mismatch_workspace = TestWorkspace::new(); + let mismatch_service = mismatch_workspace.service(); + let component = catalog_component(&mismatch_service, ComponentId::SingBox); + let mismatch = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + asset_name, + &expected, + DigestMode::Trusted, + ), + ResponseSpec::ok(b"tampered".to_vec()), + ]); + mismatch_service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &mismatch) + .expect("trusted check"); + assert!(matches!( + mismatch_service.download_checked_update(ComponentId::SingBox, &mismatch, &NoopVerifier), + Err(ComponentPackagesError::DigestMismatch) + )); + assert_bundled(&mismatch_service, ComponentId::SingBox); + assert_no_staging_or_version(&mismatch_workspace, ComponentId::SingBox, "1.14.0"); +} + +#[test] +fn invalid_redirect_forms_are_rejected_before_any_asset_body_is_accepted() { + for redirect in [ + "http://release-assets.githubusercontent.com/object?token=x", + "https://user@release-assets.githubusercontent.com/object?token=x", + "https://release-assets.githubusercontent.com:444/object?token=x", + "https://attacker.example/object?token=x", + "https://release-assets.githubusercontent.com/object", + ] { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let bytes = b"expected".to_vec(); + let transport = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + "sing-box-1.14.0-windows-amd64.zip", + &bytes, + DigestMode::Trusted, + ), + ResponseSpec::redirect(redirect), + ]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .expect("trusted metadata"); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), + Err(ComponentPackagesError::InvalidRedirect) + )); + assert_eq!(transport.call_count(), 2); + assert_no_staging_or_version(&workspace, ComponentId::SingBox, "1.14.0"); + } +} + +#[test] +fn size_mismatch_and_interrupted_body_leave_no_active_cache() { + let expected = b"complete package".to_vec(); + let asset_name = "sing-box-1.14.0-windows-amd64.zip"; + + let size_workspace = TestWorkspace::new(); + let size_service = size_workspace.service(); + let component = catalog_component(&size_service, ComponentId::SingBox); + let size_transport = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + asset_name, + &expected, + DigestMode::Trusted, + ), + ResponseSpec { + status: 200, + location: None, + content_length: Some(expected.len() as u64 + 1), + body: expected.clone(), + }, + ]); + size_service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &size_transport) + .expect("trusted metadata"); + assert!(matches!( + size_service.download_checked_update(ComponentId::SingBox, &size_transport, &NoopVerifier), + Err(ComponentPackagesError::SizeMismatch) + )); + assert_no_staging_or_version(&size_workspace, ComponentId::SingBox, "1.14.0"); + + let interrupted_workspace = TestWorkspace::new(); + let interrupted_service = interrupted_workspace.service(); + let component = catalog_component(&interrupted_service, ComponentId::SingBox); + let metadata = metadata_response( + &component, + "1.14.0", + asset_name, + &expected, + DigestMode::Trusted, + ); + let interrupted = InterruptedTransport::new(metadata, expected); + interrupted_service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &interrupted) + .expect("trusted metadata"); + assert!(matches!( + interrupted_service.download_checked_update( + ComponentId::SingBox, + &interrupted, + &NoopVerifier + ), + Err(ComponentPackagesError::StagingIo(_)) + )); + assert_no_staging_or_version(&interrupted_workspace, ComponentId::SingBox, "1.14.0"); +} + +#[test] +fn transport_timeout_never_writes_state_or_replaces_prior_cache() { + let metadata_workspace = TestWorkspace::new(); + let metadata_service = metadata_workspace.service(); + let metadata_timeout = AlwaysFailTransport::default(); + assert!(matches!( + metadata_service.check_for_update(ComponentId::SingBox, CHECKED_AT, &metadata_timeout), + Err(ComponentPackagesError::Transport) + )); + assert_eq!(metadata_timeout.call_count(), 1); + assert!(!metadata_workspace.state_path.exists()); + + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let old_name = "sing-box-1.14.0-windows-amd64.zip"; + let old_bytes = b"trusted old cache".to_vec(); + let initial = MockTransport::new(vec![ + metadata_response( + &component, + "1.14.0", + old_name, + &old_bytes, + DigestMode::Trusted, + ), + ResponseSpec::ok(old_bytes), + ]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &initial) + .expect("check old release"); + service + .download_checked_update(ComponentId::SingBox, &initial, &NoopVerifier) + .expect("cache old release"); + + let new_bytes = b"trusted newer cache".to_vec(); + let newer = MockTransport::new(vec![metadata_response( + &component, + "1.15.0", + "sing-box-1.15.0-windows-amd64.zip", + &new_bytes, + DigestMode::Trusted, + )]); + service + .check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &newer) + .expect("check newer release"); + let prior = service + .select_verified(ComponentId::SingBox) + .expect("select prior cache before timeout"); + let prior_asset = fs::read(&prior.asset_path).expect("read prior cached asset"); + let prior_manifest = + fs::read(prior.package_root.join("package.json")).expect("read prior cache manifest"); + let prior_state = fs::read(&workspace.state_path).expect("read state before timeout"); + let asset_timeout = AlwaysFailTransport::default(); + assert!(matches!( + service.download_checked_update(ComponentId::SingBox, &asset_timeout, &NoopVerifier), + Err(ComponentPackagesError::Transport) + )); + assert_eq!(asset_timeout.call_count(), 1); + let selected = service + .select_verified(ComponentId::SingBox) + .expect("prior cache remains selected"); + assert_eq!(selected.version, "1.14.0"); + assert_eq!( + fs::read(&selected.asset_path).expect("read cache after timeout"), + prior_asset + ); + assert_eq!( + fs::read(selected.package_root.join("package.json")).expect("read manifest after timeout"), + prior_manifest + ); + assert_eq!( + fs::read(&workspace.state_path).expect("read state after timeout"), + prior_state + ); + assert_no_staging_or_version(&workspace, ComponentId::SingBox, "1.15.0"); +} + +#[test] +fn wpf_requires_exact_common_name_and_organization() { + let bytes = b"signed MSI bytes".to_vec(); + let asset_name = "Windows.Packet.Filter.3.7.0.1.x64.msi"; + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::WindowsPacketFilter); + let transport = MockTransport::new(vec![ + metadata_response(&component, "3.7.0", asset_name, &bytes, DigestMode::Trusted), + ResponseSpec::ok(bytes.clone()), + ]); + service + .check_for_update(ComponentId::WindowsPacketFilter, CHECKED_AT, &transport) + .expect("trusted WPF check"); + let verifier = FixedVerifier::new(PUBLISHER, PUBLISHER); + let selected = service + .download_checked_update(ComponentId::WindowsPacketFilter, &transport, &verifier) + .expect("exact publisher accepted"); + assert_eq!(selected.source, PackageSource::Cache); + assert_eq!(verifier.call_count(), 1); + + let bad_workspace = TestWorkspace::new(); + let bad_service = bad_workspace.service(); + let component = catalog_component(&bad_service, ComponentId::WindowsPacketFilter); + let bad_transport = MockTransport::new(vec![ + metadata_response(&component, "3.7.0", asset_name, &bytes, DigestMode::Trusted), + ResponseSpec::ok(bytes), + ]); + bad_service + .check_for_update(ComponentId::WindowsPacketFilter, CHECKED_AT, &bad_transport) + .expect("trusted WPF check"); + assert!(matches!( + bad_service.download_checked_update( + ComponentId::WindowsPacketFilter, + &bad_transport, + &FixedVerifier::new(PUBLISHER, "Attacker") + ), + Err(ComponentPackagesError::PublisherMismatch) + )); + assert_bundled(&bad_service, ComponentId::WindowsPacketFilter); +} + +#[test] +fn proxifyre_zip_requires_exact_unique_top_level_targets_and_records_both_signatures() { + let bytes = proxifyre_zip(["ProxiFyre.exe", "socksify.dll"]); + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::Proxifyre); + let asset_name = "ProxiFyre-v2.5.0-x64-signed.zip"; + let transport = MockTransport::new(vec![ + metadata_response(&component, "2.5.0", asset_name, &bytes, DigestMode::Trusted), + ResponseSpec::ok(bytes), + ]); + service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) + .expect("trusted ProxiFyre check"); + let selected = service + .download_checked_update( + ComponentId::Proxifyre, + &transport, + &FixedVerifier::new(PUBLISHER, PUBLISHER), + ) + .expect("verify both signed ZIP targets"); + let manifest: PackageCacheManifest = serde_json::from_slice( + &fs::read(selected.package_root.join("package.json")).expect("read manifest"), + ) + .expect("parse manifest"); + let mut targets = manifest + .independent_proof + .verified_signatures + .iter() + .map(|evidence| evidence.target_name.as_str()) + .collect::>(); + targets.sort_unstable(); + assert_eq!(targets, ["ProxiFyre.exe", "socksify.dll"]); + + for names in [ + vec!["ProxiFyre.exe", "SOCKSIFY.DLL"], + vec!["ProxiFyre.exe", "nested/socksify.dll"], + vec!["ProxiFyre.exe", "proxifyre.exe", "socksify.dll"], + vec!["ProxiFyre.exe", "socksify.dll", "socksify.dll/"], + vec!["ProxiFyre.exe", "socksify.dll", "ProxiFyre.exe/evil"], + ] { + let bad_workspace = TestWorkspace::new(); + let bad_service = bad_workspace.service(); + let component = catalog_component(&bad_service, ComponentId::Proxifyre); + let bad_zip = proxifyre_zip(names); + let bad_transport = MockTransport::new(vec![ + metadata_response( + &component, + "2.5.0", + asset_name, + &bad_zip, + DigestMode::Trusted, + ), + ResponseSpec::ok(bad_zip), + ]); + bad_service + .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &bad_transport) + .expect("trusted metadata"); + assert!(matches!( + bad_service.download_checked_update( + ComponentId::Proxifyre, + &bad_transport, + &FixedVerifier::new(PUBLISHER, PUBLISHER) + ), + Err(ComponentPackagesError::InvalidArchive) + )); + } +} + +fn bundled_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components") +} + +fn catalog_component(service: &ComponentPackageService, id: ComponentId) -> ComponentPackage { + service + .catalog() + .components + .iter() + .find(|component| component.id == id) + .expect("component in production catalog") + .clone() +} + +fn repository(component: &ComponentPackage) -> &str { + match &component.update_trust_policy { + UpdateTrustPolicy::GithubReleaseDigest { repository, .. } => repository, + _ => panic!("component does not use GitHub digest trust"), + } +} + +#[derive(Clone, Copy)] +enum DigestMode { + Trusted, + Missing, + Malformed, +} + +fn metadata_response( + component: &ComponentPackage, + version: &str, + asset_name: &str, + asset_bytes: &[u8], + digest_mode: DigestMode, +) -> ResponseSpec { + let digest = format!("sha256:{:x}", Sha256::digest(asset_bytes)); + let digest = match digest_mode { + DigestMode::Trusted => Value::String(digest), + DigestMode::Missing => Value::Null, + DigestMode::Malformed => Value::String("sha256:not-a-digest".to_string()), + }; + let repository = repository(component); + ResponseSpec::ok( + serde_json::to_vec(&json!({ + "id": 42, + "tag_name": format!("v{version}"), + "draft": false, + "prerelease": false, + "assets": [{ + "id": 84, + "name": asset_name, + "size": asset_bytes.len(), + "digest": digest, + "browser_download_url": format!( + "https://github.com/{repository}/releases/download/v{version}/{asset_name}" + ) + }] + })) + .expect("serialize GitHub fixture"), + ) +} + +fn assert_bundled(service: &ComponentPackageService, component_id: ComponentId) { + assert_eq!( + service + .select_verified(component_id) + .expect("bundled fallback") + .source, + PackageSource::Bundled + ); +} + +fn installed(version: &str, source: ComponentInstallSource) -> InstalledComponentSnapshot<'_> { + InstalledComponentSnapshot::new(Some(version), source).expect("valid installed snapshot") +} + +fn assert_no_staging_or_version( + workspace: &TestWorkspace, + component_id: ComponentId, + version: &str, +) { + let root = workspace.packages_root.join(component_id.as_str()); + assert!(!root.join(version).exists(), "failed update became active"); + if root.exists() { + assert!( + fs::read_dir(root) + .expect("read component cache") + .all(|entry| !entry + .expect("cache entry") + .file_name() + .to_string_lossy() + .starts_with(".staging-")), + "failed update left staging behind" + ); + } +} + +fn proxifyre_zip(names: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + let cursor = Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(cursor); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for name in names { + writer + .start_file(name.as_ref(), options) + .expect("start ZIP entry"); + writer + .write_all(b"signed PE fixture") + .expect("write ZIP entry"); + } + writer.finish().expect("finish ZIP").into_inner() +} + +struct ResponseSpec { + status: u16, + location: Option, + content_length: Option, + body: Vec, +} + +impl ResponseSpec { + fn ok(body: Vec) -> Self { + Self { + status: 200, + location: None, + content_length: Some(body.len() as u64), + body, + } + } + + fn redirect(location: &str) -> Self { + Self { + status: 302, + location: Some(location.to_string()), + content_length: Some(0), + body: Vec::new(), + } + } +} + +struct MockTransport { + responses: Mutex>, + requests: Mutex>, +} + +impl MockTransport { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + requests: Mutex::new(Vec::new()), + } + } + + fn call_count(&self) -> usize { + self.requests.lock().expect("request lock").len() + } + + fn requests(&self) -> Vec { + self.requests.lock().expect("request lock").clone() + } +} + +impl UpdateTransport for MockTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + assert!(matches!( + request.kind, + UpdateRequestKind::GithubReleaseMetadata | UpdateRequestKind::PackageAsset + )); + self.requests + .lock() + .expect("request lock") + .push(request.clone()); + let response = self + .responses + .lock() + .expect("response lock") + .pop_front() + .ok_or(UpdateTransportError::RequestFailed)?; + Ok(UpdateTransportResponse { + status: response.status, + location: response.location, + content_length: response.content_length, + body: Box::new(Cursor::new(response.body)), + }) + } +} + +#[derive(Default)] +struct AlwaysFailTransport { + calls: Mutex, +} + +impl AlwaysFailTransport { + fn call_count(&self) -> usize { + *self.calls.lock().expect("request lock") + } +} + +impl UpdateTransport for AlwaysFailTransport { + fn get( + &self, + _request: &UpdateTransportRequest, + ) -> Result { + *self.calls.lock().expect("request lock") += 1; + Err(UpdateTransportError::RequestFailed) + } +} + +struct InterruptedTransport { + metadata: Mutex>, + asset: Vec, +} + +impl InterruptedTransport { + fn new(metadata: ResponseSpec, asset: Vec) -> Self { + Self { + metadata: Mutex::new(Some(metadata)), + asset, + } + } +} + +impl UpdateTransport for InterruptedTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + if request.kind == UpdateRequestKind::GithubReleaseMetadata { + let response = self + .metadata + .lock() + .expect("metadata lock") + .take() + .ok_or(UpdateTransportError::RequestFailed)?; + return Ok(UpdateTransportResponse { + status: response.status, + location: response.location, + content_length: response.content_length, + body: Box::new(Cursor::new(response.body)), + }); + } + Ok(UpdateTransportResponse { + status: 200, + location: None, + content_length: Some(self.asset.len() as u64), + body: Box::new(InterruptedReader { + bytes: self.asset[..self.asset.len() / 2].to_vec(), + position: 0, + failed: false, + }), + }) + } +} + +struct InterruptedReader { + bytes: Vec, + position: usize, + failed: bool, +} + +impl std::io::Read for InterruptedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + if self.position < self.bytes.len() { + let count = buffer.len().min(self.bytes.len() - self.position); + buffer[..count].copy_from_slice(&self.bytes[self.position..self.position + count]); + self.position += count; + return Ok(count); + } + if !self.failed { + self.failed = true; + return Err(std::io::Error::new( + std::io::ErrorKind::ConnectionReset, + "simulated interrupted download", + )); + } + Ok(0) + } +} + +struct NoopVerifier; + +impl PackageSignatureVerifier for NoopVerifier { + fn verify(&self, _path: &Path) -> Result { + Err(SignatureVerifierError) + } +} + +struct FixedVerifier { + common_name: String, + organization: String, + calls: Mutex, +} + +impl FixedVerifier { + fn new(common_name: &str, organization: &str) -> Self { + Self { + common_name: common_name.to_string(), + organization: organization.to_string(), + calls: Mutex::new(0), + } + } + + fn call_count(&self) -> usize { + *self.calls.lock().expect("verifier call lock") + } +} + +impl PackageSignatureVerifier for FixedVerifier { + fn verify(&self, _path: &Path) -> Result { + *self.calls.lock().expect("verifier call lock") += 1; + Ok(SignaturePublisher { + common_name: self.common_name.clone(), + organization: self.organization.clone(), + }) + } +} + +struct TestWorkspace { + root: PathBuf, + packages_root: PathBuf, + state_path: PathBuf, + storage_paths: StoragePaths, +} + +impl TestWorkspace { + fn new() -> Self { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target).expect("create Cargo target"); + let root = target.join(format!("component-update-tests-{}", Uuid::new_v4())); + fs::create_dir(&root).expect("create test workspace"); + let storage_paths = StoragePaths::new(&root); + Self { + packages_root: storage_paths.packages_dir.clone(), + state_path: storage_paths.component_updates_file.clone(), + storage_paths, + root, + } + } + + fn service(&self) -> ComponentPackageService { + ComponentPackageService::open(bundled_root(), &self.storage_paths) + .expect("open package service") + } +} + +impl Drop for TestWorkspace { + fn drop(&mut self) { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + if self.root.parent() == Some(target.as_path()) + && self.root.file_name().is_some_and(|name| { + name.to_string_lossy() + .starts_with("component-update-tests-") + }) + && ensure_no_reparse_ancestors(&self.root).is_ok() + { + let _ = fs::remove_dir_all(&self.root); + } + } +} diff --git a/src-tauri/tests/component_packages_tests.rs b/src-tauri/tests/component_packages_tests.rs new file mode 100644 index 0000000..32d5b34 --- /dev/null +++ b/src-tauri/tests/component_packages_tests.rs @@ -0,0 +1,423 @@ +use proxywarden_lib::component_catalog::{ComponentId, ComponentPackage, UpdateTrustPolicy}; +use proxywarden_lib::component_packages::{ + ComponentPackageService, ComponentUpdateObservation, ComponentUpdatesState, + GithubReleaseDigestProof, PackageCacheManifest, PackageSource, TrustedGithubReleaseObservation, + COMPONENT_UPDATES_STATE_SCHEMA_VERSION, PACKAGE_CACHE_MANIFEST_FILENAME, + PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, +}; +use proxywarden_lib::safe_fs::{ensure_no_reparse_ancestors, protect_path_for_owner_admin_system}; +use proxywarden_lib::storage::StoragePaths; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +#[cfg(windows)] +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +#[test] +fn offline_selection_uses_the_real_bundled_package_without_a_cache() { + let packages = TestDirectory::new(); + let paths = StoragePaths::new(packages.path().join("missing-storage")); + let service = ComponentPackageService::open(bundled_root(), &paths) + .expect("open local component package service"); + + let selected = service + .select_verified(ComponentId::Proxifyre) + .expect("select bundled package offline"); + + assert_eq!(selected.source, PackageSource::Bundled); + assert_eq!(selected.version, "2.4.0"); + assert_eq!( + selected.asset_path, + bundled_root() + .join("proxifyre") + .join("ProxiFyre-v2.4.0-x64-signed.zip") + ); +} + +#[test] +fn verified_newer_cache_wins_with_numeric_version_ordering() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (expected, manifest) = + write_verified_cache(&packages.packages_path(), &component, "1.100.0", |_| {}); + write_trusted_state(&packages.state_path(), &manifest); + + let selected = service + .select_verified(ComponentId::SingBox) + .expect("select newest verified cache"); + + assert_eq!(selected.source, PackageSource::Cache); + assert_eq!(selected.version, "1.100.0"); + assert_eq!(selected.package_root, expected); +} + +#[test] +fn same_verified_cache_does_not_replace_the_bundle() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (_, manifest) = + write_verified_cache(&packages.packages_path(), &component, "1.13.19", |_| {}); + write_trusted_state(&packages.state_path(), &manifest); + + assert_bundled(&service); +} + +#[test] +fn older_verified_cache_does_not_replace_the_bundle() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (_, manifest) = + write_verified_cache(&packages.packages_path(), &component, "1.13.18", |_| {}); + write_trusted_state(&packages.state_path(), &manifest); + + assert_bundled(&service); +} + +#[test] +fn corrupt_cache_does_not_break_bundled_fallback() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (version_root, manifest) = + write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {}); + write_trusted_state(&packages.state_path(), &manifest); + fs::write( + version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME), + b"{not-json", + ) + .expect("write corrupt manifest"); + + assert_bundled(&service); +} + +#[test] +fn cache_from_the_wrong_repository_is_rejected() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (_, manifest) = write_verified_cache( + &packages.packages_path(), + &component, + "1.14.0", + |manifest| { + manifest.independent_proof.repository = "attacker/sing-box".to_string(); + }, + ); + write_trusted_state(&packages.state_path(), &manifest); + + assert_bundled(&service); +} + +#[test] +fn handwritten_far_future_cache_without_trusted_state_is_rejected() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + write_verified_cache(&packages.packages_path(), &component, "999.0.0", |_| {}); + + assert_bundled(&service); +} + +#[test] +fn matching_manifest_and_state_with_inherited_acl_are_rejected() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (_, manifest) = write_cache( + &packages.packages_path(), + &component, + "1.14.0", + |_| {}, + false, + ); + write_state(&packages.state_path(), &manifest, false); + + assert_bundled(&service); +} + +#[test] +fn cache_with_an_extra_file_is_rejected() { + let packages = TestDirectory::new(); + let service = open_service(&packages); + let component = sing_box_component(&service); + let (version_root, manifest) = + write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {}); + write_trusted_state(&packages.state_path(), &manifest); + fs::write(version_root.join("unexpected.txt"), b"not part of package") + .expect("write unexpected cache file"); + + assert_bundled(&service); +} + +#[cfg(windows)] +#[test] +fn reparse_point_cache_root_is_rejected() { + let workspace = TestDirectory::new(); + let target = workspace.path().join("junction-target"); + fs::create_dir_all(&target).expect("create junction target"); + let storage_paths = workspace.storage_paths(); + let service = ComponentPackageService::open(bundled_root(), &storage_paths) + .expect("open service before creating junction"); + let component = sing_box_component(&service); + let (_, manifest) = write_verified_cache(&target, &component, "1.14.0", |_| {}); + write_trusted_state(&storage_paths.component_updates_file, &manifest); + + let junction = storage_paths.packages_dir.clone(); + let _junction_guard = create_junction(&junction, &target, workspace.path()); + + assert_bundled(&service); +} + +fn bundled_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components") +} + +fn open_service(packages: &TestDirectory) -> ComponentPackageService { + ComponentPackageService::open(bundled_root(), &packages.storage_paths()) + .expect("production bundle must open") +} + +fn sing_box_component(service: &ComponentPackageService) -> ComponentPackage { + service + .catalog() + .components + .iter() + .find(|component| component.id == ComponentId::SingBox) + .expect("production sing-box component") + .clone() +} + +fn assert_bundled(service: &ComponentPackageService) { + let selected = service + .select_verified(ComponentId::SingBox) + .expect("fall back to bundled package"); + assert_eq!(selected.source, PackageSource::Bundled); + assert_eq!(selected.version, "1.13.19"); +} + +fn cache_version_root(packages_root: &Path, version: &str) -> PathBuf { + packages_root + .join(ComponentId::SingBox.as_str()) + .join(version) +} + +fn write_verified_cache( + packages_root: &Path, + component: &ComponentPackage, + version: &str, + mutate: impl FnOnce(&mut PackageCacheManifest), +) -> (PathBuf, PackageCacheManifest) { + write_cache(packages_root, component, version, mutate, true) +} + +fn write_cache( + packages_root: &Path, + component: &ComponentPackage, + version: &str, + mutate: impl FnOnce(&mut PackageCacheManifest), + protect: bool, +) -> (PathBuf, PackageCacheManifest) { + let version_root = cache_version_root(packages_root, version); + fs::create_dir_all(&version_root).expect("create cache version directory"); + + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let asset_bytes = format!("verified sing-box package {version}").into_bytes(); + let sha256 = format!("{:x}", Sha256::digest(&asset_bytes)); + let repository = match &component.update_trust_policy { + UpdateTrustPolicy::GithubReleaseDigest { + repository, + authenticode_publishers, + .. + } => { + assert!( + authenticode_publishers.is_none(), + "sing-box cache must not claim Authenticode evidence" + ); + repository.clone() + } + _ => panic!("sing-box must use GitHub release digest trust"), + }; + let mut manifest = PackageCacheManifest { + schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + component_id: component.id, + version: version.to_string(), + asset_name: asset_name.clone(), + sha256: sha256.clone(), + size: asset_bytes.len() as u64, + independent_proof: GithubReleaseDigestProof { + repository, + release_id: 1, + asset_id: 1, + stable_tag: format!("v{version}"), + asset_name: asset_name.clone(), + size: asset_bytes.len() as u64, + sha256_from_api: sha256, + verified_signatures: Vec::new(), + }, + }; + mutate(&mut manifest); + + let asset_path = version_root.join(&asset_name); + fs::write(&asset_path, asset_bytes).expect("write cached package asset"); + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize cache manifest"), + ) + .expect("write cache manifest"); + if protect { + let component_root = packages_root.join(component.id.as_str()); + for path in [ + packages_root, + component_root.as_path(), + version_root.as_path(), + asset_path.as_path(), + manifest_path.as_path(), + ] { + protect_path_for_owner_admin_system(path).expect("protect trusted cache path"); + } + } + (version_root, manifest) +} + +fn write_trusted_state(state_path: &Path, manifest: &PackageCacheManifest) { + write_state(state_path, manifest, true); +} + +fn write_state(state_path: &Path, manifest: &PackageCacheManifest, protect: bool) { + let proof = &manifest.independent_proof; + let checked_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock must be after Unix epoch") + .as_secs(); + let state = ComponentUpdatesState { + schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION, + observations: vec![ComponentUpdateObservation { + component_id: manifest.component_id, + checked_at_unix, + latest_known_version: manifest.version.clone(), + trusted_releases: vec![TrustedGithubReleaseObservation { + repository: proof.repository.clone(), + release_id: proof.release_id, + asset_id: proof.asset_id, + stable_tag: proof.stable_tag.clone(), + asset_name: proof.asset_name.clone(), + size: proof.size, + sha256_from_api: proof.sha256_from_api.clone(), + }], + }], + }; + let state_parent = state_path.parent().expect("state path has parent"); + fs::create_dir_all(state_parent).expect("create state directory"); + fs::write( + state_path, + serde_json::to_vec_pretty(&state).expect("serialize trusted update state"), + ) + .expect("write trusted update state"); + if protect { + protect_path_for_owner_admin_system(state_parent).expect("protect state parent"); + protect_path_for_owner_admin_system(state_path).expect("protect trusted update state"); + } +} + +struct TestDirectory { + path: PathBuf, + target_root: PathBuf, +} + +impl TestDirectory { + fn new() -> Self { + let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target_root).expect("create Cargo target directory"); + let target_root = + fs::canonicalize(target_root).expect("canonicalize Cargo target directory"); + let path = target_root.join(format!("component-package-tests-{}", Uuid::new_v4())); + fs::create_dir(&path).expect("create isolated component package test directory"); + Self { path, target_root } + } + + fn path(&self) -> &Path { + &self.path + } + + fn storage_paths(&self) -> StoragePaths { + StoragePaths::new(&self.path) + } + + fn packages_path(&self) -> PathBuf { + self.storage_paths().packages_dir + } + + fn state_path(&self) -> PathBuf { + self.storage_paths().component_updates_file + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let has_exact_parent = self.path.parent() == Some(self.target_root.as_path()); + let has_test_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.strip_prefix("component-package-tests-") + .is_some_and(|id| Uuid::parse_str(id).is_ok()) + }); + if has_exact_parent + && has_test_name + && self.path.is_absolute() + && ensure_no_reparse_ancestors(&self.path).is_ok() + { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +#[cfg(windows)] +struct JunctionGuard { + path: PathBuf, + expected_parent: PathBuf, +} + +#[cfg(windows)] +impl Drop for JunctionGuard { + fn drop(&mut self) { + if self.path.parent() == Some(self.expected_parent.as_path()) + && self.path.file_name().is_some_and(|name| name == "packages") + { + let _ = fs::remove_dir(&self.path); + } + } +} + +#[cfg(windows)] +fn create_junction(path: &Path, target: &Path, expected_parent: &Path) -> JunctionGuard { + assert_eq!(path.parent(), Some(expected_parent)); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("packages") + ); + let output = Command::new("cmd") + .args(["/d", "/c", "mklink", "/J"]) + .arg(path) + .arg(target) + .output() + .expect("run mklink for reparse-point fixture"); + assert!( + output.status.success(), + "mklink failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + JunctionGuard { + path: path.to_path_buf(), + expected_parent: expected_parent.to_path_buf(), + } +} diff --git a/src-tauri/tests/configuration_transaction_tests.rs b/src-tauri/tests/configuration_transaction_tests.rs new file mode 100644 index 0000000..59f2ae9 --- /dev/null +++ b/src-tauri/tests/configuration_transaction_tests.rs @@ -0,0 +1,177 @@ +use proxywarden_lib::{ + configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction}, + storage::JsonStorage, +}; +use std::{fs, path::PathBuf}; + +struct Fixture { + root: PathBuf, + storage: JsonStorage, +} +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("pw-transaction-{}", uuid::Uuid::new_v4())); + let storage = JsonStorage::new(&root); + storage.write_profiles(&[]).unwrap(); + storage.write_targets(&[]).unwrap(); + Self { root, storage } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn interrupted_commit_restores_primary_and_backup_before_next_read() { + let fixture = Fixture::new(); + let path = &fixture.storage.paths().profiles_file; + let before = fs::read(path).unwrap(); + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + fixture.storage.write_profiles(&[]).unwrap(); + fs::write(path, b"half-written").unwrap(); + transaction.abort().unwrap(); + assert_eq!(fs::read(path).unwrap(), before); + assert!(!proxywarden_lib::safe_fs::backup_path(path).exists()); + let _guard = read_guard(&fixture.storage).unwrap(); + assert!(fixture.storage.read_profiles().unwrap().is_empty()); +} + +#[test] +fn shared_lock_rejects_second_writer_and_reader() { + let fixture = Fixture::new(); + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err()); + assert!(read_guard(&fixture.storage).is_err()); + transaction.commit().unwrap(); + assert!(read_guard(&fixture.storage).is_ok()); +} + +#[test] +fn committed_revision_rejects_delayed_result_even_when_values_are_identical() { + let fixture = Fixture::new(); + let revision = { + let _guard = read_guard(&fixture.storage).unwrap(); + revision_locked(&fixture.storage).unwrap() + }; + ConfigurationTransaction::begin(&fixture.storage, Some(&revision)) + .unwrap() + .commit() + .unwrap(); + assert!(ConfigurationTransaction::begin(&fixture.storage, Some(&revision)).is_err()); +} + +#[test] +fn damaged_snapshot_blocks_all_restoration_and_next_writer() { + let fixture = Fixture::new(); + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + let path = &fixture.storage.paths().profiles_file; + fs::write(path, b"new-state").unwrap(); + fs::write( + fixture + .storage + .paths() + .migrations_dir + .join("configuration-before-2.json"), + b"damaged", + ) + .unwrap(); + assert!(transaction.abort().is_err()); + assert_eq!( + fs::read(path).unwrap(), + b"new-state", + "validate all snapshots before restoring any" + ); + assert!(read_guard(&fixture.storage).is_err()); + assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err()); +} + +#[test] +fn successful_commit_removes_sensitive_fixed_snapshots() { + let fixture = Fixture::new(); + ConfigurationTransaction::begin(&fixture.storage, None) + .unwrap() + .commit() + .unwrap(); + for item in fs::read_dir(&fixture.storage.paths().migrations_dir).unwrap() { + let name = item.unwrap().file_name().to_string_lossy().to_string(); + assert!( + !name.starts_with("configuration-before-") && !name.starts_with("configuration-commit") + ); + } +} + +#[test] +fn transaction_child() { + let Some(root) = std::env::var_os("PW_TEST_TRANSACTION_ROOT") else { + return; + }; + let storage = JsonStorage::new(PathBuf::from(root)); + let Ok(_transaction) = ConfigurationTransaction::begin(&storage, None) else { + std::process::exit(2); + }; + fs::write(&storage.paths().profiles_file, b"interrupted-child-write").unwrap(); + // Deliberately bypass Drop, as a terminated application does. + std::process::exit(0); +} + +#[test] +fn committed_marker_survives_partial_snapshot_cleanup_without_rollback() { + let fixture = Fixture::new(); + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + fs::write(&fixture.storage.paths().profiles_file, b"committed-state").unwrap(); + // Model death after publishing the terminal marker and removing one snapshot. + let journal = fixture + .storage + .paths() + .migrations_dir + .join("configuration-commit.json"); + let mut intent: serde_json::Value = + serde_json::from_slice(&fs::read(&journal).unwrap()).unwrap(); + intent["committed"] = serde_json::Value::Bool(true); + fs::write(&journal, serde_json::to_vec(&intent).unwrap()).unwrap(); + fs::remove_file( + fixture + .storage + .paths() + .migrations_dir + .join("configuration-before-0.json"), + ) + .unwrap(); + drop(transaction); + let _guard = read_guard(&fixture.storage).unwrap(); + assert_eq!( + fs::read(&fixture.storage.paths().profiles_file).unwrap(), + b"committed-state" + ); + assert!(!journal.exists()); +} + +#[test] +fn process_death_is_recovered_before_normal_read_and_lock_excludes_other_processes() { + let fixture = Fixture::new(); + let before = fs::read(&fixture.storage.paths().profiles_file).unwrap(); + let launch = || { + std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "transaction_child"]) + .env("PW_TEST_TRANSACTION_ROOT", &fixture.root) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap() + }; + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + assert_eq!(launch().code(), Some(2)); + transaction.abort().unwrap(); + assert!(launch().success()); + assert_eq!( + fs::read(&fixture.storage.paths().profiles_file).unwrap(), + b"interrupted-child-write" + ); + let _guard = read_guard(&fixture.storage).unwrap(); + assert_eq!( + fs::read(&fixture.storage.paths().profiles_file).unwrap(), + before + ); +} diff --git a/src-tauri/tests/fixture_contract_tests.rs b/src-tauri/tests/fixture_contract_tests.rs new file mode 100644 index 0000000..325b58b --- /dev/null +++ b/src-tauri/tests/fixture_contract_tests.rs @@ -0,0 +1,938 @@ +use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter; +use proxywarden_lib::adapters::proxy_router::ProxyRouterRequest; +use proxywarden_lib::component_detection::LEGACY_PROXIFYRE_2_2_1_MANIFEST; +use proxywarden_lib::models::{ + ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem, ProfileItemType, Protocol, + ProxyProtocol, Target, TargetKind, +}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use url::Url; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy") +} + +fn read_json(path: impl AsRef) -> Value { + let path = path.as_ref(); + let contents = fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read fixture {}: {error}", path.display())); + serde_json::from_str(&contents) + .unwrap_or_else(|error| panic!("invalid fixture JSON {}: {error}", path.display())) +} + +fn contract() -> Value { + read_json(fixture_root().join("contract.json")) +} + +fn fixture_case<'a>(contract: &'a Value, id: &str) -> &'a Value { + contract["fixtures"] + .as_array() + .expect("fixtures array") + .iter() + .find(|case| case["id"] == id) + .unwrap_or_else(|| panic!("missing fixture case {id}")) +} + +#[test] +fn fixture_inventory_references_existing_parseable_json() { + let contract = contract(); + assert_eq!(contract["schemaVersion"], 1); + + let mut case_ids = BTreeSet::new(); + for case in contract["fixtures"].as_array().expect("fixtures array") { + let id = case["id"].as_str().expect("fixture id"); + assert!(case_ids.insert(id), "duplicate fixture id {id}"); + + for relative in case["files"].as_array().expect("fixture files") { + let relative = relative.as_str().expect("relative fixture path"); + let path = Path::new(relative); + assert!( + !path.is_absolute(), + "fixture path must be relative: {relative}" + ); + assert!( + !path.components().any(|part| part == Component::ParentDir), + "fixture path must not escape its root: {relative}" + ); + + let full_path = fixture_root().join(path); + assert!( + full_path.is_file(), + "missing fixture: {}", + full_path.display() + ); + read_json(full_path); + } + } + + assert_eq!( + case_ids, + BTreeSet::from([ + "marker-formats", + "pre-1.2-split", + "proxifyre-generated", + "proxifyre-real-sanitized", + "proxifyre-unsupported" + ]) + ); +} + +#[test] +fn fixture_values_are_sanitized_but_sensitive_key_names_are_preserved() { + let root = fixture_root(); + let mut json_paths = Vec::new(); + collect_json_files(&root, &mut json_paths); + assert!(!json_paths.is_empty(), "legacy fixture inventory is empty"); + + for path in json_paths { + let value = read_json(&path); + assert_sanitized(&value, "$", None) + .unwrap_or_else(|error| panic!("{}: {error}", path.display())); + } + + let unsupported = read_json(root.join("proxifyre-unsupported/app-config.json")); + let proxy = &unsupported["proxies"][0]; + assert!(proxy.get("username").is_some()); + assert!(proxy.get("password").is_some()); +} + +#[test] +fn sanitizer_rejects_non_redacted_sensitive_values_and_uri_userinfo() { + for value in [ + serde_json::json!({"password": "not-a-secret-fixture"}), + serde_json::json!({"password": "__REDACTED_REAL_SECRET__"}), + serde_json::json!( + "https://fixture-user:fixture-password@subscription.example.test/redacted" + ), + serde_json::json!("fixture-user@proxy.example.test:1080"), + ] { + let error = assert_sanitized(&value, "$", None).expect_err("value must be rejected"); + assert!(error.starts_with('$')); + assert!(!error.contains("not-a-secret-fixture")); + assert!(!error.contains("fixture-password")); + } +} + +#[test] +fn real_sanitized_sample_preserves_shape_and_records_provenance() { + let contract = contract(); + let case = fixture_case(&contract, "proxifyre-real-sanitized"); + let provenance = &case["provenance"]; + let source_hash = provenance["sourceSha256"] + .as_str() + .expect("real sample source hash"); + assert_eq!(source_hash.len(), 64); + assert!(source_hash + .chars() + .all(|character| character.is_ascii_hexdigit())); + assert!(provenance["source"] + .as_str() + .is_some_and(|source| source.contains("pre-1.2 local installation"))); + + let sample = read_json(fixture_root().join("proxifyre-real-sanitized/app-config.json")); + assert_eq!( + object_keys(&sample), + BTreeSet::from(["bypassLan", "logLevel", "proxies"]) + ); + assert_eq!(sample["logLevel"], "Info"); + assert_eq!(sample["bypassLan"], true); + assert_eq!( + sample["proxies"].as_array().expect("sample proxies").len(), + 1 + ); + + let proxy = &sample["proxies"][0]; + assert_eq!( + object_keys(proxy), + BTreeSet::from(["appNames", "socks5ProxyEndpoint", "supportedProtocols"]) + ); + let app_names = proxy["appNames"].as_array().expect("sample app names"); + assert_eq!(app_names.len(), 8); + assert_eq!( + app_names + .iter() + .filter_map(Value::as_str) + .filter(|name| name.contains('\\')) + .count(), + 3 + ); + assert_eq!( + proxy["supportedProtocols"], + serde_json::json!(["TCP", "UDP"]) + ); +} + +#[test] +fn marker_fixtures_freeze_weak_and_strong_schemas() { + let root = fixture_root().join("markers"); + let weak = read_json(root.join("install-proxyfier.marker.json")); + assert_eq!( + object_keys(&weak), + BTreeSet::from(["component", "installedAt", "packagePath", "serviceName"]) + ); + assert_eq!(weak["component"], "proxyfier"); + assert_eq!(weak["serviceName"], "ProxiFyreService"); + + let strong = read_json(root.join("proxywarden-component.json")); + assert_eq!( + object_keys(&strong), + BTreeSet::from([ + "component", + "installRoot", + "manager", + "packetFilterInstalledByProxyWarden", + "serviceName", + ]) + ); + assert_eq!(strong["manager"], "ProxyWarden"); + assert_eq!(strong["component"], "proxifyre"); + assert_eq!(strong["serviceName"], "ProxiFyreService"); +} + +#[test] +fn legacy_generated_fixture_maps_to_canonical_state_and_regenerates() { + let root = fixture_root(); + let source_path = root.join("proxifyre-generated/app-config.json"); + let source_before = fs::read(&source_path).expect("legacy generated fixture bytes"); + let source = read_json(&source_path); + + let (profiles, targets) = strict_import_generated_proxifyre(&source) + .expect("historically generated config must be strictly importable"); + let expected_profiles: Vec = + serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json"))) + .expect("expected profiles"); + let expected_targets: Vec = + serde_json::from_value(read_json(root.join("pre-1.2-split/config/targets.json"))) + .expect("expected targets"); + assert_eq!(profiles, expected_profiles); + assert_eq!(targets, expected_targets); + + let regenerated = ProxiFyreAdapter::default() + .generate_proxifyre_config(ProxyRouterRequest::new(&profiles, &targets, &[])) + .expect("canonical state must regenerate"); + assert_eq!( + serde_json::to_value(regenerated).expect("regenerated JSON"), + source + ); + assert_eq!( + fs::read(&source_path).expect("legacy source after import attempt"), + source_before, + "fixture importer must not mutate its legacy source" + ); +} + +#[test] +fn every_unsupported_legacy_variant_fails_closed_without_mutating_source() { + let root = fixture_root(); + let supported = read_json(root.join("proxifyre-generated/app-config.json")); + let unsupported_path = root.join("proxifyre-unsupported/app-config.json"); + let unsupported_before = fs::read(&unsupported_path).expect("unsupported source bytes"); + let unsupported = read_json(&unsupported_path); + + assert_eq!( + object_keys(&unsupported), + BTreeSet::from(["bypassLan", "customRootField", "logLevel", "proxies"]) + ); + assert_eq!( + object_keys(&unsupported["proxies"][0]), + BTreeSet::from([ + "addressFamily", + "appNames", + "customProxyField", + "password", + "socks5ProxyEndpoint", + "supportedProtocols", + "tls", + "username", + ]) + ); + assert!(strict_import_generated_proxifyre(&unsupported).is_err()); + + let variants = unsupported_variants(&supported); + for (label, variant) in variants { + assert!( + strict_import_generated_proxifyre(&variant).is_err(), + "unsupported variant was accepted: {label}" + ); + } + + assert_eq!( + fs::read(&unsupported_path).expect("unsupported source after validation"), + unsupported_before, + "validation must preserve unsupported legacy source bytes" + ); +} + +#[test] +fn generated_plain_socks5_fixture_roundtrips_semantically() { + let root = fixture_root(); + let profiles: Vec = + serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json"))) + .expect("legacy profiles fixture"); + let targets: Vec = + serde_json::from_value(read_json(root.join("pre-1.2-split/config/targets.json"))) + .expect("legacy targets fixture"); + let expected = read_json(root.join("proxifyre-generated/app-config.json")); + + let generated = ProxiFyreAdapter::default() + .generate_proxifyre_config(ProxyRouterRequest::new(&profiles, &targets, &[])) + .expect("supported fixture must generate"); + let actual = serde_json::to_value(generated).expect("generated config JSON"); + + assert_eq!(actual, expected); +} + +#[test] +fn pre_1_2_split_fixture_still_deserializes_with_current_defaults() { + let root = fixture_root().join("pre-1.2-split/config"); + let components: Vec = + serde_json::from_value(read_json(root.join("components.json"))) + .expect("legacy components fixture"); + let local_singbox: LocalSingBoxConfig = + serde_json::from_value(read_json(root.join("local-singbox.json"))) + .expect("legacy local sing-box fixture"); + + assert!(components.iter().all(|component| { + component.service_name.is_none() && component.service_status.is_none() + })); + assert!(local_singbox.device_hwid.is_none()); + assert!(local_singbox.selected_server_id.is_none()); + assert_eq!( + local_singbox.install_root, + r"C:\Program Files\ProxyWarden\sing-box" + ); +} + +#[test] +fn field_matrix_is_total_and_fail_closed() { + let contract = contract(); + let matrix = contract["proxifyreFieldMatrix"] + .as_array() + .expect("field matrix"); + let expected_ids = BTreeSet::from([ + "address-family", + "app-names", + "bypass-lan-other", + "bypass-lan-true", + "credentials-userinfo", + "endpoint-scheme-or-userinfo", + "log-level-info", + "log-level-other", + "plain-endpoint", + "protocol-other-or-empty", + "protocol-tcp", + "protocol-udp", + "proxies", + "tls", + "unknown-proxy-key", + "unknown-root-key", + ]); + let fixture_ids: BTreeSet<&str> = contract["fixtures"] + .as_array() + .expect("fixtures array") + .iter() + .filter_map(|case| case["id"].as_str()) + .collect(); + + let mut actual_ids = BTreeSet::new(); + for rule in matrix { + let id = rule["id"].as_str().expect("matrix rule id"); + assert!(actual_ids.insert(id), "duplicate matrix rule {id}"); + let outcome = rule["outcome"].as_str().expect("matrix outcome"); + assert!( + matches!(outcome, "canonical" | "derived" | "unsupported"), + "invalid matrix outcome for {id}: {outcome}" + ); + if outcome != "unsupported" { + assert!( + rule["destination"] + .as_str() + .is_some_and(|value| !value.is_empty()), + "supported rule {id} must identify its destination" + ); + } + + let coverage = rule["coverage"].as_str().expect("matrix coverage"); + assert!( + fixture_ids.contains(coverage) || coverage.starts_with("inline-"), + "matrix rule {id} has unknown coverage {coverage}" + ); + } + + assert_eq!(actual_ids, expected_ids); + assert_eq!( + fixture_case(&contract, "proxifyre-unsupported")["status"], + "unsupported_preserve_original" + ); +} + +#[test] +fn split_source_precedence_roots_services_collisions_and_state_are_frozen() { + let contract = contract(); + + let split_sources: BTreeSet<&str> = contract["startup"]["canonicalSplitSourceFiles"] + .as_array() + .expect("split sources") + .iter() + .filter_map(Value::as_str) + .collect(); + assert_eq!( + split_sources, + BTreeSet::from([ + "config/components.json", + "config/local-singbox.json", + "config/profiles.json", + "config/targets.json", + ]) + ); + assert_eq!( + contract["startup"]["rules"]["anySplitSourceExists"], + "adopt_split_without_generated_import" + ); + + assert_eq!( + strings_at( + &contract, + "/components/proxifyre/confirmedManagedLegacyDefaultRoots" + ), + BTreeSet::from([r"C:\Tools\ProxiFyre"]) + ); + assert_eq!( + strings_at( + &contract, + "/components/singbox/confirmedManagedLegacyDefaultRoots" + ), + BTreeSet::from([r"C:\Program Files\ProxyWarden\sing-box"]) + ); + assert_eq!( + candidate_paths_at(&contract, "/components/proxifyre/legacyCandidates"), + BTreeSet::from([ + r"%LOCALAPPDATA%\ProxiFyre", + r"%LOCALAPPDATA%\ProxyWarden\ProxiFyre", + r"%ProgramFiles(x86)%\ProxiFyre", + r"%ProgramFiles(x86)%\ProxyWarden\ProxiFyre", + r"%ProgramFiles%\ProxiFyre", + r"%ProgramFiles%\ProxyWarden\ProxiFyre", + r"C:\Tools\ProxiFyre", + ]) + ); + assert_eq!( + candidate_paths_at(&contract, "/components/singbox/legacyCandidates"), + BTreeSet::from([ + r"%LOCALAPPDATA%\ProxyWarden\sing-box", + r"%ProgramFiles(x86)%\ProxyWarden\sing-box", + r"%ProgramFiles%\ProxyWarden\sing-box", + r"C:\Tools\ProxyWarden\sing-box", + ]) + ); + assert_eq!( + contract["components"]["proxifyre"]["service"]["primaryName"], + "ProxiFyreService" + ); + assert_eq!( + contract["components"]["proxifyre"]["service"]["discoveryOnlyPathNameTemplate"], + r#""{root}\ProxiFyre.exe" --service"# + ); + assert_eq!( + strings_at( + &contract, + "/components/proxifyre/service/discoveryOnlyAliases" + ), + BTreeSet::from(["ProxiFyre"]) + ); + assert_eq!( + contract["components"]["proxifyre"]["service"]["autoCutoverPathNameTemplate"], + r#""{root}\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""# + ); + assert_eq!( + contract["components"]["singbox"]["service"]["primaryName"], + "ProxyWardenSingBox" + ); + assert_eq!( + contract["components"]["singbox"]["service"]["pathNameTemplate"], + r#""{root}\ProxyWardenSingBox.exe""# + ); + assert_eq!( + contract["components"]["proxifyre"]["markers"]["managedLegacyRoot"], + "none" + ); + assert_eq!( + contract["components"]["proxifyre"]["markers"]["weakStandaloneScriptHint"] + ["ownershipProof"], + false + ); + assert_eq!( + contract["components"]["proxifyre"]["markers"]["managedCurrent"]["requiredValues"] + ["manager"], + "ProxyWarden" + ); + assert_eq!( + contract["components"]["proxifyre"]["markers"]["managedCurrent"]["requiredValues"] + ["serviceName"], + "ProxiFyreService" + ); + assert_eq!( + contract["components"]["singbox"]["markers"]["managedLegacyRoot"], + "none" + ); + assert!(strings_at( + &contract, + "/components/proxifyre/managedLegacyClassificationRequires" + ) + .contains("service PathName points to that exact executable")); + assert!(strings_at( + &contract, + "/components/singbox/managedLegacyClassificationRequires" + ) + .contains("service PathName points to that exact wrapper")); + assert_eq!( + contract["components"]["proxifyre"]["autoCutover"]["root"], + r"C:\Tools\ProxiFyre" + ); + assert_eq!( + contract["components"]["proxifyre"]["autoCutover"]["allOtherDiscoveryCandidates"] + ["decision"], + "manual_migration_required" + ); + assert_eq!( + contract["components"]["proxifyre"]["autoCutover"]["allOtherDiscoveryCandidates"] + ["mutationPlan"], + serde_json::json!([]) + ); + assert_eq!( + contract["components"]["singbox"]["autoCutover"]["decision"], + "manual_migration_required" + ); + assert_eq!( + contract["components"]["singbox"]["autoCutover"]["mutationPlan"], + serde_json::json!([]) + ); + + let frozen_manifest = contract["components"]["proxifyre"]["autoCutover"]["packageManifest"] + .as_array() + .expect("frozen ProxiFyre package manifest"); + assert_eq!(frozen_manifest.len(), LEGACY_PROXIFYRE_2_2_1_MANIFEST.len()); + for expected in LEGACY_PROXIFYRE_2_2_1_MANIFEST { + let actual = frozen_manifest + .iter() + .find(|file| file["relativePath"] == expected.relative_path) + .unwrap_or_else(|| panic!("missing frozen package file {}", expected.relative_path)); + assert_eq!(actual["size"], expected.size); + assert_eq!(actual["sha256"], expected.sha256); + } + assert_eq!( + contract["components"]["proxifyre"]["autoCutover"]["scmProfile"], + serde_json::json!({ + "serviceType": "win32_own_process", + "startType": "auto_start", + "errorControl": "normal", + "account": "LocalSystem", + "displayName": "ProxiFyre Service", + "description": "ProxiFyre - SOCKS5 ProxiFyre Service", + "dependencies": [], + "loadOrderGroup": null, + "failureActions": [], + "failureActionsOnNonCrash": false, + "delayedAutoStart": false, + "sidType": "none", + "requiredPrivileges": [], + "triggers": [], + "untrustedMutationRights": false + }) + ); + assert_eq!( + candidate_paths_at(&contract, "/components/singbox/foreignByDefaultCandidates"), + BTreeSet::from([ + r"%LOCALAPPDATA%\sing-box", + r"%ProgramFiles(x86)%\sing-box", + r"%ProgramFiles%\sing-box", + ]) + ); + + let history_commits: BTreeSet<&str> = contract["historyEvidence"] + .as_array() + .expect("history evidence") + .iter() + .filter_map(|item| item["commit"].as_str()) + .collect(); + for pointer in [ + "/components/proxifyre/legacyCandidates", + "/components/singbox/legacyCandidates", + "/components/singbox/foreignByDefaultCandidates", + ] { + for candidate in contract + .pointer(pointer) + .expect("candidate list") + .as_array() + .expect("candidate array") + { + let commit = candidate["evidenceCommit"] + .as_str() + .expect("candidate evidence commit"); + assert!( + history_commits.contains(commit), + "candidate evidence commit is absent from historyEvidence: {commit}" + ); + assert!( + candidate["evidenceFile"] + .as_str() + .is_some_and(|path| path.starts_with("src-tauri/src/")), + "candidate must name its historical source file" + ); + } + } + + assert_eq!( + contract["collisionPolicy"]["currentAndLegacy"], + "current_wins_orphan_untouched_manual" + ); + assert_eq!( + contract["collisionPolicy"]["sameServiceNameForeignPath"], + "ownership_mismatch_without_mutation" + ); + assert_eq!( + contract["runningStatePolicy"]["running"], + "restore_running_after_success_or_rollback" + ); + assert_eq!( + contract["runningStatePolicy"]["stopped"], + "keep_stopped_after_success_or_rollback" + ); + assert_eq!( + contract["runningStatePolicy"]["pendingOrUnknown"], + "block_without_mutation" + ); +} + +fn strict_import_generated_proxifyre( + value: &Value, +) -> Result<(Vec, Vec), &'static str> { + if object_keys(value) != BTreeSet::from(["bypassLan", "logLevel", "proxies"]) { + return Err("unsupported root fields"); + } + if value["logLevel"] != "Info" { + return Err("unsupported log level"); + } + if value["bypassLan"] != true { + return Err("unsupported bypassLan"); + } + + let proxies = value["proxies"] + .as_array() + .ok_or("proxies must be an array")?; + if proxies.is_empty() { + return Err("generated config contains no recoverable proxies"); + } + + let mut profiles = Vec::with_capacity(proxies.len()); + let mut targets = Vec::with_capacity(proxies.len()); + for (index, proxy) in proxies.iter().enumerate() { + if object_keys(proxy) + != BTreeSet::from(["appNames", "socks5ProxyEndpoint", "supportedProtocols"]) + { + return Err("unsupported proxy fields"); + } + + let app_names = proxy["appNames"] + .as_array() + .ok_or("appNames must be an array")?; + if app_names.is_empty() { + return Err("appNames must not be empty"); + } + let mut items = Vec::with_capacity(app_names.len()); + for app_name in app_names { + let app_name = app_name.as_str().ok_or("app name must be a string")?; + if app_name.trim().is_empty() { + return Err("app name must not be empty"); + } + let is_path = app_name.contains(['\\', '/']); + let (item_type, recursive) = + if is_path && app_name.to_ascii_lowercase().ends_with(".exe") { + (ProfileItemType::Exe, false) + } else if is_path { + (ProfileItemType::Folder, true) + } else { + (ProfileItemType::Process, false) + }; + items.push(ProfileItem { + item_type, + value: app_name.to_string(), + recursive, + }); + } + + let endpoint = proxy["socks5ProxyEndpoint"] + .as_str() + .ok_or("endpoint must be a string")?; + let (host, port) = strict_plain_endpoint(endpoint)?; + + let protocol_values = proxy["supportedProtocols"] + .as_array() + .ok_or("supportedProtocols must be an array")?; + if protocol_values.is_empty() { + return Err("supportedProtocols must not be empty"); + } + let mut protocols = Vec::with_capacity(protocol_values.len()); + for protocol in protocol_values { + let protocol = match protocol.as_str() { + Some("TCP") => Protocol::Tcp, + Some("UDP") => Protocol::Udp, + _ => return Err("unsupported protocol"), + }; + if protocols.contains(&protocol) { + return Err("duplicate protocol"); + } + protocols.push(protocol); + } + + let ordinal = index + 1; + let profile_id = if proxies.len() == 1 { + "fixture-profile".to_string() + } else { + format!("legacy-proxifyre-profile-{ordinal}") + }; + let target_id = if proxies.len() == 1 { + "fixture-target".to_string() + } else { + format!("legacy-proxifyre-target-{ordinal}") + }; + profiles.push(Profile { + id: profile_id, + name: if proxies.len() == 1 { + "Fixture profile".to_string() + } else { + format!("Legacy ProxiFyre profile {ordinal}") + }, + enabled: true, + target_id: target_id.clone(), + protocols, + items, + }); + targets.push(Target { + id: target_id, + name: if proxies.len() == 1 { + "Fixture target".to_string() + } else { + format!("Legacy ProxiFyre target {ordinal}") + }, + kind: TargetKind::External, + protocol: ProxyProtocol::Socks5, + host, + port, + requires_component: None, + }); + } + + Ok((profiles, targets)) +} + +fn strict_plain_endpoint(endpoint: &str) -> Result<(String, u16), &'static str> { + if endpoint.contains(['/', '@']) || endpoint.matches(':').count() != 1 { + return Err("endpoint must be plain host:port"); + } + let (host, port) = endpoint + .rsplit_once(':') + .ok_or("endpoint must include a port")?; + if host.trim().is_empty() || host.chars().any(char::is_whitespace) { + return Err("endpoint host is invalid"); + } + let port = port + .parse::() + .map_err(|_| "endpoint port is invalid")?; + if port == 0 { + return Err("endpoint port must not be zero"); + } + Ok((host.to_string(), port)) +} + +fn unsupported_variants(supported: &Value) -> Vec<(&'static str, Value)> { + let mut variants = Vec::new(); + let mut add = |label, mutate: fn(&mut Value)| { + let mut value = supported.clone(); + mutate(&mut value); + variants.push((label, value)); + }; + + add("non-default logLevel", |value| { + value["logLevel"] = "Debug".into() + }); + add("non-default bypassLan", |value| { + value["bypassLan"] = false.into() + }); + add("empty proxies", |value| { + value["proxies"] = serde_json::json!([]) + }); + add("empty appNames", |value| { + value["proxies"][0]["appNames"] = serde_json::json!([]) + }); + add("scheme endpoint", |value| { + value["proxies"][0]["socks5ProxyEndpoint"] = "socks5://proxy.example.test:1080".into() + }); + add("userinfo endpoint", |value| { + value["proxies"][0]["socks5ProxyEndpoint"] = "fixture-user@proxy.example.test:1080".into() + }); + add("missing endpoint port", |value| { + value["proxies"][0]["socks5ProxyEndpoint"] = "proxy.example.test".into() + }); + add("zero endpoint port", |value| { + value["proxies"][0]["socks5ProxyEndpoint"] = "proxy.example.test:0".into() + }); + add("empty protocols", |value| { + value["proxies"][0]["supportedProtocols"] = serde_json::json!([]) + }); + add("unknown protocol", |value| { + value["proxies"][0]["supportedProtocols"] = serde_json::json!(["TCP", "ICMP"]) + }); + add("username", |value| { + value["proxies"][0]["username"] = "__REDACTED_USERNAME__".into() + }); + add("password", |value| { + value["proxies"][0]["password"] = "__REDACTED_PASSWORD__".into() + }); + add("userinfo field", |value| { + value["proxies"][0]["userinfo"] = "__REDACTED_USERINFO__".into() + }); + add("tls", |value| { + value["proxies"][0]["tls"] = serde_json::json!({"enabled": true}) + }); + add("address family", |value| { + value["proxies"][0]["addressFamily"] = "IPv4".into() + }); + add("unknown root key", |value| { + value["customRootField"] = "REDACTED".into() + }); + add("unknown proxy key", |value| { + value["proxies"][0]["customProxyField"] = "REDACTED".into() + }); + + variants +} + +fn object_keys(value: &Value) -> BTreeSet<&str> { + value + .as_object() + .map(|object| object.keys().map(String::as_str).collect()) + .unwrap_or_default() +} + +fn collect_json_files(directory: &Path, output: &mut Vec) { + let mut entries: Vec<_> = fs::read_dir(directory) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", directory.display())) + .map(|entry| entry.expect("fixture directory entry").path()) + .collect(); + entries.sort(); + + for path in entries { + if path.is_dir() { + collect_json_files(&path, output); + } else if path.extension().and_then(|value| value.to_str()) == Some("json") { + output.push(path); + } + } +} + +fn assert_sanitized(value: &Value, path: &str, key: Option<&str>) -> Result<(), String> { + if key.is_some_and(is_sensitive_key) && !is_safe_sensitive_value(value) { + return Err(format!("{path}: sensitive fixture value is not redacted")); + } + + match value { + Value::Object(object) => { + for (child_key, child_value) in object { + assert_sanitized(child_value, &format!("{path}.{child_key}"), Some(child_key))?; + } + } + Value::Array(array) => { + for (index, child) in array.iter().enumerate() { + assert_sanitized(child, &format!("{path}[{index}]"), key)?; + } + } + Value::String(text) => assert_safe_string(text, path)?, + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + + Ok(()) +} + +fn is_sensitive_key(key: &str) -> bool { + matches!( + key.to_ascii_lowercase().replace(['_', '-'], "").as_str(), + "username" + | "password" + | "token" + | "secret" + | "subscriptionurl" + | "userinfo" + | "authorization" + ) +} + +fn is_safe_sensitive_value(value: &Value) -> bool { + match value { + Value::Null => true, + Value::String(text) + if matches!( + text.as_str(), + "__REDACTED_USERNAME__" + | "__REDACTED_PASSWORD__" + | "__REDACTED_USERINFO__" + | "__REDACTED_TOKEN__" + ) => + { + true + } + Value::String(text) => synthetic_url_is_safe(text), + _ => false, + } +} + +fn assert_safe_string(text: &str, path: &str) -> Result<(), String> { + if text.contains("://") && !synthetic_url_is_safe(text) { + return Err(format!("{path}: fixture URL is not safely synthetic")); + } + if text.contains('@') { + return Err(format!( + "{path}: fixture endpoint must not contain userinfo" + )); + } + Ok(()) +} + +fn synthetic_url_is_safe(text: &str) -> bool { + let Ok(url) = Url::parse(text) else { + return false; + }; + let synthetic_host = url + .host_str() + .is_some_and(|host| host == "example.test" || host.ends_with(".example.test")); + synthetic_host + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() +} + +fn strings_at<'a>(value: &'a Value, pointer: &str) -> BTreeSet<&'a str> { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("missing contract pointer {pointer}")) + .as_array() + .unwrap_or_else(|| panic!("contract pointer is not an array: {pointer}")) + .iter() + .filter_map(Value::as_str) + .collect() +} + +fn candidate_paths_at<'a>(value: &'a Value, pointer: &str) -> BTreeSet<&'a str> { + value + .pointer(pointer) + .unwrap_or_else(|| panic!("missing contract pointer {pointer}")) + .as_array() + .unwrap_or_else(|| panic!("contract pointer is not an array: {pointer}")) + .iter() + .filter_map(|candidate| candidate["path"].as_str()) + .collect() +} diff --git a/src-tauri/tests/fixtures/legacy/contract.json b/src-tauri/tests/fixtures/legacy/contract.json new file mode 100644 index 0000000..5586026 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/contract.json @@ -0,0 +1,445 @@ +{ + "schemaVersion": 1, + "historyEvidence": [ + { + "commit": "c5120669d2b86f417f6dbd8fc7e01eeafbcea3ab", + "proves": "split storage and the generated ProxiFyre config shape" + }, + { + "commit": "e745633d91880b2f795fee2496d7fb4c35c54a38", + "proves": "the later opportunistic generated-config bootstrap" + }, + { + "commit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "proves": "the historical component candidate roots" + }, + { + "commit": "dbba3806ccdc8c4792d59add359c7ade7fff1176", + "proves": "the current app-adjacent component layout and strong ProxiFyre marker" + } + ], + "startup": { + "canonicalSplitSourceFiles": [ + "config/profiles.json", + "config/targets.json", + "config/components.json", + "config/local-singbox.json" + ], + "generatedRecoverySource": "generated/proxifyre-app-config.json", + "rules": { + "currentMetadata": "no_op", + "anySplitSourceExists": "adopt_split_without_generated_import", + "allSplitSourcesAbsentAndOneSupportedGeneratedConfig": "import_once", + "partialOrCorruptSplit": "recover_or_warn_without_generated_merge" + } + }, + "fixtures": [ + { + "id": "proxifyre-generated", + "status": "supported", + "files": [ + "proxifyre-generated/app-config.json" + ] + }, + { + "id": "proxifyre-unsupported", + "status": "unsupported_preserve_original", + "files": [ + "proxifyre-unsupported/app-config.json" + ] + }, + { + "id": "proxifyre-real-sanitized", + "status": "supported_structure_preserving_sanitized_sample", + "files": [ + "proxifyre-real-sanitized/app-config.json" + ], + "provenance": { + "capturedAt": "2026-08-17", + "source": "C:\\ProgramData\\ProxyWarden\\generated\\proxifyre-app-config.json on a pre-1.2 local installation", + "sourceSha256": "078C3E74B96D26DF155229B2F6FD380F762550B0A3F863CE2E913F5B6493F710", + "preserved": "root/proxy key sets, proxy count, app count and app value categories, protocol values, default flags and endpoint shape", + "replaced": "all app names, filesystem paths, hostnames and ports" + } + }, + { + "id": "marker-formats", + "status": "schema_evidence_only", + "files": [ + "markers/install-proxyfier.marker.json", + "markers/proxywarden-component.json" + ] + }, + { + "id": "pre-1.2-split", + "status": "adopt_without_generated_import", + "files": [ + "pre-1.2-split/config/profiles.json", + "pre-1.2-split/config/targets.json", + "pre-1.2-split/config/components.json", + "pre-1.2-split/config/local-singbox.json" + ] + } + ], + "proxifyreFieldMatrix": [ + { + "id": "log-level-info", + "jsonPath": "$.logLevel == Info", + "outcome": "derived", + "destination": "generator default Info", + "coverage": "proxifyre-generated" + }, + { + "id": "log-level-other", + "jsonPath": "$.logLevel != Info", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "bypass-lan-true", + "jsonPath": "$.bypassLan == true", + "outcome": "derived", + "destination": "generator default true", + "coverage": "proxifyre-generated" + }, + { + "id": "bypass-lan-other", + "jsonPath": "$.bypassLan != true", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "proxies", + "jsonPath": "$.proxies[*]", + "outcome": "derived", + "destination": "one enabled profile and target pair per entry", + "coverage": "proxifyre-generated" + }, + { + "id": "app-names", + "jsonPath": "$.proxies[*].appNames[*]", + "outcome": "canonical", + "destination": "profiles[*].items; folder recursive is derived because the generated format cannot represent it", + "coverage": "proxifyre-generated" + }, + { + "id": "plain-endpoint", + "jsonPath": "$.proxies[*].socks5ProxyEndpoint plain host:port", + "outcome": "canonical", + "destination": "targets[*].host and targets[*].port", + "coverage": "proxifyre-generated" + }, + { + "id": "endpoint-scheme-or-userinfo", + "jsonPath": "$.proxies[*].socks5ProxyEndpoint with scheme, userinfo, missing port, or port 0", + "outcome": "unsupported", + "coverage": "inline-uri-userinfo" + }, + { + "id": "protocol-tcp", + "jsonPath": "$.proxies[*].supportedProtocols[*] == TCP", + "outcome": "canonical", + "destination": "profiles[*].protocols TCP", + "coverage": "proxifyre-generated" + }, + { + "id": "protocol-udp", + "jsonPath": "$.proxies[*].supportedProtocols[*] == UDP", + "outcome": "canonical", + "destination": "profiles[*].protocols UDP", + "coverage": "proxifyre-generated" + }, + { + "id": "protocol-other-or-empty", + "jsonPath": "$.proxies[*].supportedProtocols empty or value other than TCP/UDP", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "credentials-userinfo", + "jsonPath": "$.proxies[*].username/password/userinfo", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "tls", + "jsonPath": "$.proxies[*].tls", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "address-family", + "jsonPath": "$.proxies[*].addressFamily", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "unknown-root-key", + "jsonPath": "$.* unknown root key", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + }, + { + "id": "unknown-proxy-key", + "jsonPath": "$.proxies[*].* unknown proxy key", + "outcome": "unsupported", + "coverage": "proxifyre-unsupported" + } + ], + "components": { + "proxifyre": { + "managedCurrentRootTemplate": "{controlAppDir}\\components\\ProxiFyre", + "confirmedManagedLegacyDefaultRoots": [ + "C:\\Tools\\ProxiFyre" + ], + "legacyCandidates": [ + { + "path": "C:\\Tools\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/commands.rs" + }, + { + "path": "%ProgramFiles%\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles(x86)%\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%LOCALAPPDATA%\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles%\\ProxyWarden\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles(x86)%\\ProxyWarden\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%LOCALAPPDATA%\\ProxyWarden\\ProxiFyre", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176", + "evidenceFile": "src-tauri/src/component_detection.rs" + } + ], + "managedLegacyClassificationRequires": [ + "exact allowlisted root", + "ProxiFyre.exe exists at that root", + "service PathName points to that exact executable", + "binary matches a known bundled package identity" + ], + "service": { + "primaryName": "ProxiFyreService", + "discoveryOnlyAliases": [ + "ProxiFyre" + ], + "discoveryOnlyPathNameTemplate": "\"{root}\\ProxiFyre.exe\" --service", + "autoCutoverPathNameTemplate": "\"{root}\\ProxiFyre.exe\" -displayname \"ProxiFyre Service\" -servicename \"ProxiFyreService\"" + }, + "autoCutover": { + "decision": "automatic_proxifyre_2_2_1", + "root": "C:\\Tools\\ProxiFyre", + "versionValues": [ + "2.2.1", + "2.2.1.0" + ], + "packageManifest": [ + { + "relativePath": "Newtonsoft.Json.dll", + "size": 711952, + "sha256": "e1e27af7b07eeedf5ce71a9255f0422816a6fc5849a483c6714e1b472044fa9d" + }, + { + "relativePath": "Newtonsoft.Json.xml", + "size": 713541, + "sha256": "79ee87d4ede8783461de05b93379d576f6e8575d4ab49359f15897a854b643c4" + }, + { + "relativePath": "NLog.config", + "size": 382, + "sha256": "06b8e52be9385e4e6a2f042f0d7ca3dd0b043378b455535299846b02fd19250d" + }, + { + "relativePath": "NLog.dll", + "size": 940032, + "sha256": "4b1d3cf9f1f3c4a6ead141243069162172e9ef48ba1a9bf4f7ccd618b8194b5c" + }, + { + "relativePath": "NLog.xml", + "size": 1608606, + "sha256": "6871374d682e75aff17de2a8626a75e9c75409516f5e7527e9d159c1de6831bb" + }, + { + "relativePath": "ProxiFyre.exe", + "size": 35960, + "sha256": "2a60a76480715fca52185163d7ac6d850d4b0abe4079b7d461d7d0fcb3f02d93" + }, + { + "relativePath": "ProxiFyre.exe.config", + "size": 177, + "sha256": "8403846edd2ee98fd53b351dbf8773951c8e30f4b04dd53676a7e7dfbd8930b0" + }, + { + "relativePath": "socksify.dll", + "size": 1309184, + "sha256": "940b22ae8e97ff575317cc4a6c20467ed2ff760d01c7b056d13fcb20e7043cbd" + }, + { + "relativePath": "Topshelf.dll", + "size": 190464, + "sha256": "bd70a5832124e36840452ff46e442efa0a09a4ceba842aea72c79b2d322d7fe8" + }, + { + "relativePath": "Topshelf.xml", + "size": 80754, + "sha256": "3b2228b3333c4fd86e29020bc2d77a5260dbf03e911829d6226498ade53c2790" + } + ], + "scmProfile": { + "serviceType": "win32_own_process", + "startType": "auto_start", + "errorControl": "normal", + "account": "LocalSystem", + "displayName": "ProxiFyre Service", + "description": "ProxiFyre - SOCKS5 ProxiFyre Service", + "dependencies": [], + "loadOrderGroup": null, + "failureActions": [], + "failureActionsOnNonCrash": false, + "delayedAutoStart": false, + "sidType": "none", + "requiredPrivileges": [], + "triggers": [], + "untrustedMutationRights": false + }, + "allOtherDiscoveryCandidates": { + "decision": "manual_migration_required", + "mutationPlan": [] + } + }, + "markers": { + "managedLegacyRoot": "none", + "weakStandaloneScriptHint": { + "file": "install-proxyfier.marker.json", + "fields": [ + "component", + "packagePath", + "serviceName", + "installedAt" + ], + "ownershipProof": false + }, + "managedCurrent": { + "file": "proxywarden-component.json", + "requiredValues": { + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService" + }, + "rootField": "installRoot", + "packetFilterOwnershipField": "packetFilterInstalledByProxyWarden" + } + } + }, + "singbox": { + "managedCurrentRootTemplate": "{controlAppDir}\\components\\sing-box", + "confirmedManagedLegacyDefaultRoots": [ + "C:\\Program Files\\ProxyWarden\\sing-box" + ], + "legacyCandidates": [ + { + "path": "C:\\Tools\\ProxyWarden\\sing-box", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles%\\ProxyWarden\\sing-box", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles(x86)%\\ProxyWarden\\sing-box", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%LOCALAPPDATA%\\ProxyWarden\\sing-box", + "classificationBeforeIdentity": "candidate", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + } + ], + "managedLegacyClassificationRequires": [ + "exact allowlisted root", + "sing-box.exe and ProxyWardenSingBox.exe exist at that root", + "service PathName points to that exact wrapper", + "ProxyWardenSingBox.xml has matching id, executable, and config arguments", + "binaries match known bundled package identities" + ], + "foreignByDefaultCandidates": [ + { + "path": "%ProgramFiles%\\sing-box", + "classificationBeforeIdentity": "foreign", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%ProgramFiles(x86)%\\sing-box", + "classificationBeforeIdentity": "foreign", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + }, + { + "path": "%LOCALAPPDATA%\\sing-box", + "classificationBeforeIdentity": "foreign", + "evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a", + "evidenceFile": "src-tauri/src/component_detection.rs" + } + ], + "service": { + "primaryName": "ProxyWardenSingBox", + "pathNameTemplate": "\"{root}\\ProxyWardenSingBox.exe\"", + "identityFiles": [ + "ProxyWardenSingBox.exe", + "ProxyWardenSingBox.xml", + "sing-box.exe" + ] + }, + "markers": { + "managedLegacyRoot": "none" + }, + "autoCutover": { + "decision": "manual_migration_required", + "reason": "historical installer downloaded moving latest sing-box and WinSW-x64 without a frozen inner identity", + "mutationPlan": [] + } + } + }, + "collisionPolicy": { + "currentAndLegacy": "current_wins_orphan_untouched_manual", + "multipleLegacyCandidates": "block_without_mutation", + "sameServiceNameForeignPath": "ownership_mismatch_without_mutation" + }, + "runningStatePolicy": { + "running": "restore_running_after_success_or_rollback", + "stopped": "keep_stopped_after_success_or_rollback", + "pendingOrUnknown": "block_without_mutation" + } +} diff --git a/src-tauri/tests/fixtures/legacy/markers/install-proxyfier.marker.json b/src-tauri/tests/fixtures/legacy/markers/install-proxyfier.marker.json new file mode 100644 index 0000000..b3da292 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/markers/install-proxyfier.marker.json @@ -0,0 +1,6 @@ +{ + "component": "proxyfier", + "packagePath": "C:\\Fixture\\Packages\\proxifyre-package.zip", + "serviceName": "ProxiFyreService", + "installedAt": "2026-01-01T00:00:00Z" +} diff --git a/src-tauri/tests/fixtures/legacy/markers/proxywarden-component.json b/src-tauri/tests/fixtures/legacy/markers/proxywarden-component.json new file mode 100644 index 0000000..aa453d3 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/markers/proxywarden-component.json @@ -0,0 +1,7 @@ +{ + "manager": "ProxyWarden", + "component": "proxifyre", + "serviceName": "ProxiFyreService", + "installRoot": "C:\\Fixture\\ProxyWarden\\components\\ProxiFyre", + "packetFilterInstalledByProxyWarden": false +} diff --git a/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/components.json b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/components.json new file mode 100644 index 0000000..fe60bca --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/components.json @@ -0,0 +1,24 @@ +[ + { + "id": "proxyfier", + "name": "ProxiFyre", + "state": "installed", + "installed": true, + "running": false, + "version": null, + "path": "C:\\Tools\\ProxiFyre\\ProxiFyre.exe", + "problems": [], + "actions": [] + }, + { + "id": "singbox", + "name": "Local sing-box", + "state": "missing", + "installed": false, + "running": false, + "version": null, + "path": null, + "problems": [], + "actions": [] + } +] diff --git a/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/local-singbox.json b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/local-singbox.json new file mode 100644 index 0000000..3aea5a3 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/local-singbox.json @@ -0,0 +1,9 @@ +{ + "subscription_url": "https://subscription.example.test/redacted", + "selected_server_tag": "fixture-server", + "listen_host": "127.0.0.1", + "listen_port": 1080, + "service_name": "ProxyWardenSingBox", + "install_root": "C:\\Program Files\\ProxyWarden\\sing-box", + "updated_at": null +} diff --git a/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/profiles.json b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/profiles.json new file mode 100644 index 0000000..d2bd756 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/profiles.json @@ -0,0 +1,29 @@ +[ + { + "id": "fixture-profile", + "name": "Fixture profile", + "enabled": true, + "target_id": "fixture-target", + "protocols": [ + "TCP", + "UDP" + ], + "items": [ + { + "type": "process", + "value": "FixtureProcess", + "recursive": false + }, + { + "type": "exe", + "value": "C:\\Fixture\\Apps\\fixture.exe", + "recursive": false + }, + { + "type": "folder", + "value": "C:\\Fixture\\Games", + "recursive": true + } + ] + } +] diff --git a/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/targets.json b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/targets.json new file mode 100644 index 0000000..7a7a8a5 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/pre-1.2-split/config/targets.json @@ -0,0 +1,11 @@ +[ + { + "id": "fixture-target", + "name": "Fixture target", + "kind": "external", + "protocol": "socks5", + "host": "proxy.example.test", + "port": 1080, + "requires_component": null + } +] diff --git a/src-tauri/tests/fixtures/legacy/proxifyre-generated/app-config.json b/src-tauri/tests/fixtures/legacy/proxifyre-generated/app-config.json new file mode 100644 index 0000000..b98e3ea --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/proxifyre-generated/app-config.json @@ -0,0 +1,18 @@ +{ + "logLevel": "Info", + "bypassLan": true, + "proxies": [ + { + "appNames": [ + "FixtureProcess", + "C:\\Fixture\\Apps\\fixture.exe", + "C:\\Fixture\\Games" + ], + "socks5ProxyEndpoint": "proxy.example.test:1080", + "supportedProtocols": [ + "TCP", + "UDP" + ] + } + ] +} diff --git a/src-tauri/tests/fixtures/legacy/proxifyre-real-sanitized/app-config.json b/src-tauri/tests/fixtures/legacy/proxifyre-real-sanitized/app-config.json new file mode 100644 index 0000000..81e7a95 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/proxifyre-real-sanitized/app-config.json @@ -0,0 +1,23 @@ +{ + "logLevel": "Info", + "bypassLan": true, + "proxies": [ + { + "appNames": [ + "FixtureProcess1", + "FixtureProcess2", + "FixtureProcess3", + "FixtureProcess4", + "FixtureProcess5", + "C:\\Fixture\\Folder1", + "C:\\Fixture\\Folder2", + "C:\\Fixture\\Folder3" + ], + "socks5ProxyEndpoint": "proxy.example.test:1080", + "supportedProtocols": [ + "TCP", + "UDP" + ] + } + ] +} diff --git a/src-tauri/tests/fixtures/legacy/proxifyre-unsupported/app-config.json b/src-tauri/tests/fixtures/legacy/proxifyre-unsupported/app-config.json new file mode 100644 index 0000000..bb1a428 --- /dev/null +++ b/src-tauri/tests/fixtures/legacy/proxifyre-unsupported/app-config.json @@ -0,0 +1,25 @@ +{ + "logLevel": "Debug", + "bypassLan": false, + "customRootField": "REDACTED", + "proxies": [ + { + "appNames": [ + "FixtureProcess" + ], + "socks5ProxyEndpoint": "proxy.example.test:1080", + "supportedProtocols": [ + "TCP", + "ICMP" + ], + "username": "__REDACTED_USERNAME__", + "password": "__REDACTED_PASSWORD__", + "tls": { + "enabled": true, + "serverName": "tls.example.test" + }, + "addressFamily": "IPv4", + "customProxyField": "REDACTED" + } + ] +} diff --git a/src-tauri/tests/helper_tests.rs b/src-tauri/tests/helper_tests.rs deleted file mode 100644 index 091518c..0000000 --- a/src-tauri/tests/helper_tests.rs +++ /dev/null @@ -1,134 +0,0 @@ -use proxywarden_lib::helper::{ - helper_action_requires_elevation, install_request, parse_helper_response, - proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput, - HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper, -}; -use proxywarden_lib::models::ComponentId; -use serde_json::json; -use std::cell::RefCell; -use std::path::PathBuf; - -#[test] -fn structured_helper_serializes_request_and_parses_json_response() { - let runner = MockRunner { - output: HelperCommandOutput { - status_code: 0, - stdout: serde_json::to_string(&HelperResponse { - success: true, - action: HelperAction::ProxyfierApply, - changed: true, - message: "Applied".to_string(), - details: json!({ "serviceName": "ProxiFyreService" }), - }) - .expect("response json"), - stderr: String::new(), - }, - seen: RefCell::new(Vec::new()), - }; - let helper = StructuredHelper::new("proxywarden-helper.exe", runner); - - let response = helper - .execute(&proxifyre_apply_request( - r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json", - "ProxiFyreService", - )) - .expect("helper response"); - - assert!(response.success); - assert_eq!(response.action, HelperAction::ProxyfierApply); - assert_eq!(response.details["serviceName"], "ProxiFyreService"); -} - -#[test] -fn helper_runner_receives_json_stdin_and_elevation_flag() { - let runner = MockRunner { - output: HelperCommandOutput { - status_code: 0, - stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(), - stderr: String::new(), - }, - seen: RefCell::new(Vec::new()), - }; - let helper = StructuredHelper::new("proxywarden-helper.exe", runner); - let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart); - - let _ = helper.execute(&request).expect("helper response"); - let seen = helper.runner().seen.borrow(); - let spec = seen.first().expect("runner should be called"); - let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json"); - - assert_eq!(spec.program, PathBuf::from("proxywarden-helper.exe")); - assert_eq!(spec.args, vec!["--json"]); - assert!(spec.requires_elevation); - assert_eq!(stdin["action"], "service.restart"); - assert_eq!(stdin["component"], "proxyfier"); -} - -#[test] -fn install_requests_are_explicit_component_actions() { - let control = install_request(ComponentId::ControlApp); - let proxyfier = install_request(ComponentId::Proxyfier); - let singbox = install_request(ComponentId::Singbox); - - assert_eq!(control.action, HelperAction::InstallControlApp); - assert_eq!(proxyfier.action, HelperAction::InstallProxyfier); - assert_eq!(singbox.action, HelperAction::InstallSingbox); - assert!(helper_action_requires_elevation(&proxyfier.action)); -} - -#[test] -fn apply_request_does_not_encode_installer_action() { - let request = proxifyre_apply_request( - r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json", - "ProxiFyreService", - ); - - assert_eq!(request.action, HelperAction::ProxyfierApply); - assert_eq!(request.component, Some(ComponentId::Proxyfier)); - assert_eq!( - request.payload["configPath"], - r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json" - ); -} - -#[test] -fn non_json_helper_stdout_is_rejected() { - let error = parse_helper_response("Proxyfier restarted successfully") - .expect_err("raw stdout should not be accepted"); - - assert_eq!(error.code, "helper_response_decode"); -} - -#[test] -fn failed_helper_exit_is_structured_error() { - let runner = MockRunner { - output: HelperCommandOutput { - status_code: 5, - stdout: String::new(), - stderr: "Access denied".to_string(), - }, - seen: RefCell::new(Vec::new()), - }; - let helper = StructuredHelper::new("proxywarden-helper.exe", runner); - let error = helper - .execute(&service_request( - ComponentId::Proxyfier, - HelperAction::ServiceRestart, - )) - .expect_err("failed exit should become helper error"); - - assert_eq!(error.code, "helper_exit"); - assert!(error.message.contains("Access denied")); -} - -struct MockRunner { - output: HelperCommandOutput, - seen: RefCell>, -} - -impl HelperCommandRunner for MockRunner { - fn run(&self, spec: &HelperCommandSpec) -> Result { - self.seen.borrow_mut().push(spec.clone()); - Ok(self.output.clone()) - } -} diff --git a/src-tauri/tests/lifecycle_preflight_tests.rs b/src-tauri/tests/lifecycle_preflight_tests.rs new file mode 100644 index 0000000..2311074 --- /dev/null +++ b/src-tauri/tests/lifecycle_preflight_tests.rs @@ -0,0 +1,136 @@ +use proxywarden_lib::command_dto::CommandError; +use proxywarden_lib::component_inventory::{ + classify_component_candidates, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, + InventoryAction, MarkerEvidence, ServiceEvidence, OWNERSHIP_MISMATCH, +}; +use proxywarden_lib::models::ComponentId; +use proxywarden_lib::proxifyre_runtime::run_proxifyre_lifecycle_entrypoint; +use proxywarden_lib::singbox_runtime::{ + run_singbox_config_check_entrypoint, run_singbox_lifecycle_entrypoint, +}; +use std::cell::Cell; +use std::path::PathBuf; + +#[test] +fn foreign_component_blocks_real_proxifyre_lifecycle_runners_before_call() { + let inventory = foreign_inventory(); + + for action in [ + InventoryAction::Install, + InventoryAction::Apply, + InventoryAction::CheckBinary, + InventoryAction::Start, + InventoryAction::Stop, + InventoryAction::ConfigureFirewall, + InventoryAction::Update, + InventoryAction::Uninstall, + ] { + let calls = Cell::new(0_u32); + let result = run_proxifyre_lifecycle_entrypoint(&inventory, action, |_| { + calls.set(calls.get() + 1); + Ok::<_, CommandError>(()) + }); + + assert_eq!(calls.get(), 0, "runner was called for {action:?}"); + assert_eq!(result.unwrap_err().code, OWNERSHIP_MISMATCH); + } +} + +#[test] +fn incomplete_component_blocks_real_singbox_process_service_and_delete_runners() { + let inventory = incomplete_inventory(); + + for action in [ + InventoryAction::Install, + InventoryAction::Apply, + InventoryAction::CheckBinary, + InventoryAction::Start, + InventoryAction::Stop, + InventoryAction::Uninstall, + ] { + let calls = Cell::new(0_u32); + let result = run_singbox_lifecycle_entrypoint(&inventory, action, |_| { + calls.set(calls.get() + 1); + Ok::<_, CommandError>(()) + }); + + assert_eq!(calls.get(), 0, "runner was called for {action:?}"); + assert!(result.is_err()); + } + + let process_calls = Cell::new(0_u32); + let result = run_singbox_config_check_entrypoint(&inventory, |_| { + process_calls.set(process_calls.get() + 1); + Ok::<_, CommandError>(()) + }); + assert!(result.is_err()); + assert_eq!(process_calls.get(), 0, "sing-box checker was called"); +} + +#[test] +fn missing_component_allows_install_runner_only() { + let inventory = classify_component_candidates(ComponentId::Singbox, Vec::new()); + let calls = Cell::new(0_u32); + + run_singbox_lifecycle_entrypoint(&inventory, InventoryAction::Install, |candidate| { + assert!(candidate.is_none()); + calls.set(calls.get() + 1); + Ok::<_, CommandError>(()) + }) + .expect("missing component should permit explicit install"); + + assert_eq!(calls.get(), 1); +} + +fn foreign_inventory() -> proxywarden_lib::component_inventory::ComponentInventory { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + classify_component_candidates( + ComponentId::Proxyfier, + vec![ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: CandidateRole::Current, + root: root.clone(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(root.join("ProxiFyre.exe")), + missing_files: Vec::new(), + marker: MarkerEvidence::Valid, + marker_required: true, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some("2.4.0.0".to_string()), + service: Some(ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "running".to_string(), + path_name: Some(r#""C:\Foreign\ProxiFyre.exe" --service"#.to_string()), + executable_path: Some(PathBuf::from(r"C:\Foreign\ProxiFyre.exe")), + path_matches_candidate: false, + binary_version: None, + }), + service_required: true, + legacy_identity_complete: false, + }], + ) +} + +fn incomplete_inventory() -> proxywarden_lib::component_inventory::ComponentInventory { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box"); + classify_component_candidates( + ComponentId::Singbox, + vec![ComponentCandidateProbe { + component_id: ComponentId::Singbox, + role: CandidateRole::Current, + root: root.clone(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(root.join("sing-box.exe")), + missing_files: vec![root.join("ProxyWardenSingBox.exe")], + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity: BinaryIdentityEvidence::Unknown, + binary_version: None, + service: None, + service_required: true, + legacy_identity_complete: false, + }], + ) +} diff --git a/src-tauri/tests/migration_tests.rs b/src-tauri/tests/migration_tests.rs new file mode 100644 index 0000000..a8763ac --- /dev/null +++ b/src-tauri/tests/migration_tests.rs @@ -0,0 +1,1132 @@ +use proxywarden_lib::command_dto::{CommandError, StorageMigrationStatusDto}; +use proxywarden_lib::component_cutover::{ + ComponentCutoverObservation, CutoverDisplayState, CutoverPhase, LegacyServiceState, + CUTOVER_OBSERVATION_SCHEMA_VERSION, +}; +use proxywarden_lib::component_inventory::{ + classify_component_candidates, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe, + ComponentInventory, MarkerEvidence, ServiceEvidence, +}; +use proxywarden_lib::migration::{ + prepare_storage, prepare_storage_with_hook, reconcile_component_layout, + record_component_cutover_startup_evidence, recover_incomplete_migration, MigrationHook, + MigrationPhase, CURRENT_COMPONENT_LAYOUT, CURRENT_STORAGE_SCHEMA, +}; +use proxywarden_lib::models::{ + ComponentId, Profile, ProfileItemType, Protocol, StorageMeta, StorageMigrationOutcome, Target, +}; +#[cfg(windows)] +use proxywarden_lib::safe_fs; +use proxywarden_lib::storage::{backup_path, JsonStorage}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const ACTIVE_JOURNAL_FILE: &str = "active-storage-migration.json"; +const SECRET_SENTINEL: &str = "MIGRATION_SECRET_SENTINEL_7d39"; + +#[test] +fn current_meta_is_a_byte_for_byte_no_op() { + let root = TestRoot::new("current-no-op"); + let storage = JsonStorage::new(root.path()); + copy_split_fixture(&storage, "profiles.json"); + copy_split_fixture(&storage, "targets.json"); + storage + .write_storage_meta(&StorageMeta { + storage_schema_version: CURRENT_STORAGE_SCHEMA, + outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, + migration_id: "already-current".to_string(), + completed_at_epoch_seconds: 1, + }) + .expect("write current storage meta"); + let legacy = root.path().join("legacy-app-config.json"); + write_bytes( + &legacy, + &fixture_bytes("proxifyre-generated/app-config.json"), + ); + let before = snapshot_files(root.path()); + + let status = prepare_storage(&storage, &[legacy]).expect("read current storage"); + + assert!(!status.changed); + assert!(!status.blocking); + assert_eq!(status.storage_schema_version, CURRENT_STORAGE_SCHEMA); + assert_eq!(snapshot_files(root.path()), before); +} + +#[test] +fn any_split_source_including_an_intentional_empty_file_blocks_legacy_import() { + for (label, relative, bytes) in [ + ("profiles", "profiles.json", b"[]".as_slice()), + ("targets", "targets.json", b"[]".as_slice()), + ("components", "components.json", b"[]".as_slice()), + ("local-singbox", "local-singbox.json", b"{}".as_slice()), + ] { + let root = TestRoot::new(label); + let storage = JsonStorage::new(root.path().join("data")); + let split_path = storage.paths().config_dir.join(relative); + write_bytes(&split_path, bytes); + let split_before = fs::read(&split_path).expect("read intentional split source"); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + + let status = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt split storage"); + + assert_eq!(status.outcome, "adopted_without_legacy_import", "{label}"); + assert_eq!( + status.notice_code.as_deref(), + Some("legacy_preserved_current_wins"), + "{label}" + ); + assert_eq!( + fs::read(&split_path).expect("read adopted source"), + split_before + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + assert!(!generated_config_path(&storage).exists(), "{label}"); + assert!(storage.paths().storage_meta_file.is_file(), "{label}"); + } +} + +#[test] +fn imports_exactly_one_supported_fixture_then_second_start_is_a_no_op() { + let root = TestRoot::new("supported-import"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + let legacy_json: Value = + serde_json::from_slice(&legacy_before).expect("parse supported legacy fixture"); + + let first = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("import supported fixture"); + + assert_eq!(first.outcome, "imported_legacy_config"); + assert!(first.changed); + assert!(!first.blocking); + let profiles = storage.read_profiles().expect("read imported profiles"); + let targets = storage.read_targets().expect("read imported targets"); + assert_imported_fixture(&profiles, &targets); + let generated: Value = serde_json::from_slice( + &fs::read(generated_config_path(&storage)).expect("read regenerated config"), + ) + .expect("parse regenerated config"); + assert_eq!(generated, legacy_json); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + let meta = storage + .read_storage_meta() + .expect("read storage meta") + .expect("storage meta exists"); + let completed: Value = serde_json::from_slice( + &fs::read(archived_journal_path( + &storage, + &meta.migration_id, + "complete", + )) + .expect("read completed journal"), + ) + .expect("parse completed journal"); + assert_eq!(completed["phase"], "storage_complete"); + assert!(completed["lastErrorCode"].is_null()); + + let after_first = snapshot_files(storage.paths().root.as_path()); + let second = prepare_storage(&storage, &[legacy]).expect("repeat migration startup"); + assert!(!second.changed); + assert_eq!(second.outcome, "imported_legacy_config"); + assert_eq!(snapshot_files(storage.paths().root.as_path()), after_first); +} + +#[test] +fn populated_current_split_wins_without_merging_or_rewriting_legacy() { + let root = TestRoot::new("current-wins"); + let storage = JsonStorage::new(root.path().join("data")); + for file in [ + "profiles.json", + "targets.json", + "components.json", + "local-singbox.json", + ] { + copy_split_fixture(&storage, file); + } + let source_before = snapshot_files(&storage.paths().config_dir); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + + let status = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt current storage"); + + assert_eq!(status.outcome, "adopted_without_legacy_import"); + assert_eq!( + status.notice_code.as_deref(), + Some("legacy_preserved_current_wins") + ); + assert_eq!( + snapshot_without_meta(&storage.paths().config_dir), + source_before + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + assert!(!generated_config_path(&storage).exists()); + assert_eq!( + storage.read_profiles().expect("read current profiles")[0].id, + "fixture-profile" + ); +} + +#[test] +fn partial_split_never_falls_back_to_legacy_import() { + let root = TestRoot::new("partial-split"); + let storage = JsonStorage::new(root.path().join("data")); + copy_split_fixture(&storage, "targets.json"); + let target_before = fs::read(&storage.paths().targets_file).expect("read partial target"); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + + let status = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("adopt partial split"); + + assert_eq!(status.outcome, "adopted_without_legacy_import"); + assert!(!storage.paths().profiles_file.exists()); + assert!(!generated_config_path(&storage).exists()); + assert_eq!( + fs::read(&storage.paths().targets_file).expect("read target"), + target_before + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); +} + +#[test] +fn corrupt_split_recovers_only_from_a_valid_backup() { + let root = TestRoot::new("corrupt-with-backup"); + let storage = JsonStorage::new(root.path().join("data")); + for file in [ + "profiles.json", + "targets.json", + "components.json", + "local-singbox.json", + ] { + copy_split_fixture(&storage, file); + } + let valid_profiles = fixture_bytes("pre-1.2-split/config/profiles.json"); + write_bytes( + &backup_path(&storage.paths().profiles_file), + &valid_profiles, + ); + write_bytes(&storage.paths().profiles_file, b"{corrupt-json"); + + let status = prepare_storage(&storage, &[]).expect("recover valid split backup"); + + assert_eq!(status.outcome, "adopted_without_legacy_import"); + assert_eq!( + status.notice_code.as_deref(), + Some("split_recovered_from_backup") + ); + assert_eq!( + fs::read(&storage.paths().profiles_file).expect("read recovered profiles"), + valid_profiles + ); + assert_eq!( + fs::read(backup_path(&storage.paths().profiles_file)).expect("read preserved backup"), + valid_profiles + ); + assert_eq!( + storage.read_profiles().expect("parse recovered profiles")[0].id, + "fixture-profile" + ); +} + +#[test] +fn current_meta_never_bypasses_strict_split_validation() { + let root = TestRoot::new("current-meta-corrupt-split"); + let storage = JsonStorage::new(root.path().join("data")); + write_bytes(&storage.paths().profiles_file, b"{corrupt-json"); + storage + .write_storage_meta(&StorageMeta { + storage_schema_version: CURRENT_STORAGE_SCHEMA, + outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, + migration_id: "current-with-corrupt-split".to_string(), + completed_at_epoch_seconds: 1, + }) + .expect("write current storage meta"); + let before = snapshot_files(root.path()); + + let status = prepare_storage(&storage, &[]).expect("return blocked split status"); + + assert!(status.blocking); + assert_eq!(status.notice_code.as_deref(), Some("split_storage_invalid")); + assert_eq!(snapshot_files(root.path()), before); +} + +#[test] +fn corrupt_split_without_backup_blocks_and_preserves_all_source_bytes() { + let root = TestRoot::new("corrupt-without-backup"); + let storage = JsonStorage::new(root.path().join("private-data")); + let corrupt = b"{corrupt-json".to_vec(); + write_bytes(&storage.paths().profiles_file, &corrupt); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + + let status = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("return blocked status"); + + assert!(status.blocking); + assert_eq!(status.notice_code.as_deref(), Some("split_storage_invalid")); + assert_eq!( + fs::read(&storage.paths().profiles_file).expect("read corrupt source"), + corrupt + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + assert!(!storage.paths().storage_meta_file.exists()); + assert!(!generated_config_path(&storage).exists()); + assert_public_status_safe(&status, &[SECRET_SENTINEL, &legacy.display().to_string()]); +} + +#[test] +fn corrupt_legacy_components_file_is_preserved_but_not_treated_as_source_truth() { + let root = TestRoot::new("corrupt-legacy-components"); + let storage = JsonStorage::new(root.path().join("data")); + let corrupt = b"{corrupt-component-status".to_vec(); + write_bytes(&storage.paths().components_file, &corrupt); + + let status = prepare_storage(&storage, &[]).expect("adopt legacy component evidence"); + + assert_eq!(status.outcome, "adopted_without_legacy_import"); + assert_eq!( + status.notice_code.as_deref(), + Some("legacy_components_ignored_invalid") + ); + assert_eq!( + fs::read(&storage.paths().components_file).expect("read preserved components"), + corrupt + ); + assert!(storage.paths().storage_meta_file.exists()); +} + +#[test] +fn missing_or_corrupt_storage_meta_recovers_from_a_valid_current_backup_without_import() { + for live_state in ["missing", "corrupt"] { + let root = TestRoot::new(&format!("meta-{live_state}")); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + let backup_meta = StorageMeta { + storage_schema_version: CURRENT_STORAGE_SCHEMA, + outcome: StorageMigrationOutcome::AdoptedWithoutLegacyImport, + migration_id: "valid-backup-meta".to_string(), + completed_at_epoch_seconds: 1, + }; + write_bytes( + &backup_path(&storage.paths().storage_meta_file), + &serde_json::to_vec_pretty(&backup_meta).expect("serialize backup meta"), + ); + if live_state == "corrupt" { + write_bytes(&storage.paths().storage_meta_file, b"{corrupt-meta"); + } + + let status = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("recover storage meta"); + + assert_eq!(status.outcome, "adopted_without_legacy_import"); + assert_eq!( + status.notice_code.as_deref(), + Some("storage_meta_recovered") + ); + assert!(status.changed); + let recovered = storage + .read_storage_meta() + .expect("read recovered storage meta") + .expect("recovered storage meta exists"); + assert_eq!(recovered.storage_schema_version, CURRENT_STORAGE_SCHEMA); + assert_eq!( + recovered.outcome, + StorageMigrationOutcome::AdoptedWithoutLegacyImport + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + assert!(!storage.paths().profiles_file.exists()); + assert!(!storage.paths().targets_file.exists()); + assert!(!generated_config_path(&storage).exists()); + } +} + +#[test] +fn component_layout_advances_only_after_inventory_proves_no_managed_legacy() { + let root = TestRoot::new("component-layout"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy_root = PathBuf::from(r"C:\Tools\ProxiFyre"); + let legacy_inventory = classify_component_candidates( + ComponentId::Proxyfier, + vec![ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: CandidateRole::Legacy, + root: legacy_root.clone(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(legacy_root.join("ProxiFyre.exe")), + missing_files: Vec::new(), + marker: MarkerEvidence::NotRequired, + marker_required: false, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some("2.2.1".to_string()), + service: Some(ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "stopped".to_string(), + path_name: Some(format!( + r#""{}" --service"#, + legacy_root.join("ProxiFyre.exe").display() + )), + executable_path: Some(legacy_root.join("ProxiFyre.exe")), + path_matches_candidate: true, + binary_version: Some("2.2.1".to_string()), + }), + service_required: true, + legacy_identity_complete: true, + }], + ); + let missing_singbox = ComponentInventory::missing(ComponentId::Singbox); + + assert_eq!( + reconcile_component_layout(&storage, &legacy_inventory, &missing_singbox) + .expect("legacy layout check"), + None + ); + assert!(!storage.paths().component_layout_file.exists()); + + let missing_proxyfier = ComponentInventory::missing(ComponentId::Proxyfier); + assert_eq!( + reconcile_component_layout(&storage, &missing_proxyfier, &missing_singbox) + .expect("current layout proof"), + Some(CURRENT_COMPONENT_LAYOUT) + ); + let before = fs::read(&storage.paths().component_layout_file).expect("read layout meta"); + assert_eq!( + reconcile_component_layout(&storage, &missing_proxyfier, &missing_singbox) + .expect("repeat layout proof"), + Some(CURRENT_COMPONENT_LAYOUT) + ); + assert_eq!( + fs::read(&storage.paths().component_layout_file).expect("read repeated layout meta"), + before + ); +} + +#[test] +fn startup_cutover_evidence_binds_new_session_inventory_and_preserves_confirmation() { + let root = TestRoot::new("cutover-startup-evidence"); + let storage = JsonStorage::new(root.path().join("data")); + let cutover_id = "11111111-1111-4111-8111-111111111111"; + let session_id = "22222222-2222-4222-8222-222222222222"; + storage + .write_component_cutover_observation(&ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: cutover_id.to_string(), + state: CutoverDisplayState::AwaitingNextStart, + phase: CutoverPhase::LegacyQuarantined, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "1".repeat(64), + transaction_fingerprint: "2".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "legacy ProxiFyre installation".to_string(), + current_path_label: "ProxyWarden managed components".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 1, + }) + .expect("write cutover observation"); + let current_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let current = classify_component_candidates( + ComponentId::Proxyfier, + vec![ComponentCandidateProbe { + component_id: ComponentId::Proxyfier, + role: CandidateRole::Current, + root: current_root.clone(), + root_exists: true, + has_reparse_point: false, + executable_path: Some(current_root.join("ProxiFyre.exe")), + missing_files: Vec::new(), + marker: MarkerEvidence::Valid, + marker_required: true, + binary_identity: BinaryIdentityEvidence::KnownPackage, + binary_version: Some("2.4.0".to_string()), + service: Some(ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "stopped".to_string(), + path_name: Some( + current_root + .join("ProxiFyre.exe") + .as_os_str() + .to_string_lossy() + .into_owned(), + ), + executable_path: Some(current_root.join("ProxiFyre.exe")), + path_matches_candidate: true, + binary_version: Some("2.4.0".to_string()), + }), + service_required: true, + legacy_identity_complete: true, + }], + ); + + assert!( + record_component_cutover_startup_evidence(&storage, session_id, ¤t) + .expect("record startup evidence") + ); + let mut evidence = storage + .read_component_cutover_user_evidence() + .expect("read evidence") + .expect("evidence exists"); + assert_eq!(evidence.cutover_id, cutover_id); + assert_eq!(evidence.startup_session_id, session_id); + assert!(!evidence.route_smoke_confirmed); + + evidence.route_smoke_confirmed = true; + evidence.confirmed_at_epoch_seconds = Some(evidence.observed_at_epoch_seconds); + storage + .write_component_cutover_user_evidence(&evidence) + .expect("confirm evidence"); + assert!( + record_component_cutover_startup_evidence(&storage, session_id, ¤t) + .expect("repeat startup evidence") + ); + assert!( + storage + .read_component_cutover_user_evidence() + .expect("read repeated evidence") + .expect("repeated evidence exists") + .route_smoke_confirmed + ); + + let mut wrong_state = current.clone(); + wrong_state.candidates[0] + .service + .as_mut() + .expect("current service") + .status = "running".to_string(); + assert!(!record_component_cutover_startup_evidence( + &storage, + "33333333-3333-4333-8333-333333333333", + &wrong_state, + ) + .expect("reject wrong service state")); +} + +#[test] +fn unknown_and_credential_fields_block_before_source_or_meta_writes() { + for (label, mut legacy_json) in [ + ("unknown", supported_legacy_json()), + ("credential", supported_legacy_json()), + ] { + if label == "unknown" { + legacy_json["unknownRootField"] = SECRET_SENTINEL.into(); + } else { + legacy_json["proxies"][0]["password"] = SECRET_SENTINEL.into(); + } + let root = TestRoot::new(label); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = root.path().join("private-source").join("app-config.json"); + let legacy_before = serde_json::to_vec_pretty(&legacy_json).expect("serialize variant"); + write_bytes(&legacy, &legacy_before); + + let status = prepare_storage(&storage, std::slice::from_ref(&legacy)) + .expect("return blocked status"); + + assert!(status.blocking, "{label}"); + assert_eq!( + status.notice_code.as_deref(), + Some("legacy_config_unsupported"), + "{label}" + ); + assert_eq!( + fs::read(&legacy).expect("read preserved source"), + legacy_before + ); + for path in [ + &storage.paths().profiles_file, + &storage.paths().targets_file, + &storage.paths().storage_meta_file, + &generated_config_path(&storage), + &active_journal_path(&storage), + ] { + assert!( + !path.exists(), + "unexpected write for {label}: {}", + path.display() + ); + } + assert_public_status_safe(&status, &[SECRET_SENTINEL, &legacy.display().to_string()]); + } +} + +#[test] +fn multiple_supported_legacy_configs_block_without_any_migration_write() { + let root = TestRoot::new("ambiguous-legacy-configs"); + let storage = JsonStorage::new(root.path().join("data")); + let first = root.path().join("legacy-a").join("app-config.json"); + let second = root.path().join("legacy-b").join("app-config.json"); + let bytes = fixture_bytes("proxifyre-generated/app-config.json"); + write_bytes(&first, &bytes); + write_bytes(&second, &bytes); + let before = snapshot_files(root.path()); + + let status = prepare_storage(&storage, &[first, second]).expect("return ambiguous status"); + + assert!(status.blocking); + assert_eq!( + status.notice_code.as_deref(), + Some("legacy_config_ambiguous") + ); + assert_eq!(snapshot_files(root.path()), before); +} + +#[test] +fn every_interrupted_phase_rolls_back_and_archives_the_typed_error() { + for phase in all_phases() { + let root = TestRoot::new(phase_name(phase)); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + let tracked = import_tracked_paths(&storage); + let generated_backup = backup_path(&generated_config_path(&storage)); + for (index, path) in tracked + .iter() + .filter(|path| is_backup(path) && **path == generated_backup) + .enumerate() + { + write_bytes(path, format!("preexisting-backup-{index}").as_bytes()); + } + let before = file_states(&tracked); + + assert!( + !storage.paths().storage_meta_file.exists(), + "unexpected pre-existing meta for {phase:?}" + ); + let error = match prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &FailAt(phase), + ) { + Err(error) => error, + Ok(status) => panic!("phase hook did not interrupt {phase:?}: {status:?}"), + }; + + assert_eq!(error.code, "migration_interrupted", "{phase:?}"); + let active = active_journal_path(&storage); + assert!(!active.exists(), "active journal survived {phase:?}"); + assert_eq!(file_states(&tracked), before, "{phase:?}"); + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(&backup_path( + &generated_config_path(&storage), + )) + .expect("migration rollback keeps generated backup restricted"); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + let archived: Value = serde_json::from_slice( + &fs::read(single_archived_journal(&storage, "rolled-back")) + .expect("read rolled-back journal"), + ) + .expect("parse rolled-back journal"); + assert_eq!(archived["phase"], phase_name(phase), "{phase:?}"); + assert_eq!( + archived["lastErrorCode"], "migration_interrupted", + "{phase:?}" + ); + assert!(!recover_incomplete_migration(&storage).expect("repeat recovery")); + } +} + +#[test] +fn recovery_marks_a_crash_journal_as_unclean_shutdown() { + let root = TestRoot::new("unclean-shutdown-journal"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &FailAt(MigrationPhase::SourceConverted), + ) + .expect_err("interrupt migration"); + let active = active_journal_path(&storage); + let rolled_back = single_archived_journal(&storage, "rolled-back"); + let mut journal: Value = + serde_json::from_slice(&fs::read(&rolled_back).expect("read rolled-back journal")) + .expect("parse rolled-back journal"); + fs::remove_file(&rolled_back).expect("remove prior archive for crash simulation"); + journal["lastErrorCode"] = Value::Null; + write_bytes( + &active, + &serde_json::to_vec_pretty(&journal).expect("serialize crash journal"), + ); + let migration_id = journal["migrationId"] + .as_str() + .expect("journal migration id") + .to_string(); + + assert!(recover_incomplete_migration(&storage).expect("recover crash journal")); + + let archived: Value = serde_json::from_slice( + &fs::read(archived_journal_path( + &storage, + &migration_id, + "rolled-back", + )) + .expect("read rolled-back crash journal"), + ) + .expect("parse rolled-back crash journal"); + assert_eq!(archived["lastErrorCode"], "migration_unclean_shutdown"); +} + +#[test] +fn validation_failure_is_archived_with_a_typed_redacted_error_code() { + let root = TestRoot::new("validation-error-journal"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + + let error = prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &ReplaceProfilesAfterConversion(storage.paths().profiles_file.clone()), + ) + .expect_err("validation mismatch must roll back"); + + assert_eq!(error.code, "migration_failed"); + assert!(!active_journal_path(&storage).exists()); + let archived: Value = serde_json::from_slice( + &fs::read(single_archived_journal(&storage, "rolled-back")) + .expect("read validation journal"), + ) + .expect("parse validation journal"); + assert_eq!(archived["lastErrorCode"], "migration_validation_failed"); + assert!(!archived.to_string().contains(SECRET_SENTINEL)); +} + +#[test] +fn rollback_validates_every_snapshot_before_mutating_any_target() { + let root = TestRoot::new("rollback-prevalidation"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + write_bytes( + &generated_config_path(&storage), + b"preexisting-derived-config", + ); + let generated_backup = backup_path(&generated_config_path(&storage)); + for (index, path) in import_tracked_paths(&storage) + .into_iter() + .filter(|path| is_backup(path) && *path == generated_backup) + .enumerate() + { + write_bytes(&path, format!("preexisting-backup-{index}").as_bytes()); + } + let error = prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &CorruptRollbackSnapshot(active_journal_path(&storage)), + ) + .expect_err("interrupt migration"); + assert_eq!(error.code, "migration_rollback_failed"); + let active = active_journal_path(&storage); + let journal: Value = serde_json::from_slice(&fs::read(&active).expect("read active journal")) + .expect("parse active journal"); + let (early_target, _) = rollback_snapshot_pair(&journal); + assert_eq!( + fs::read(&early_target).expect("read untouched early target"), + CORRUPT_ROLLBACK_SENTINEL + ); + assert!(active.exists()); +} + +#[test] +fn next_normal_start_recovers_interruption_retries_and_then_becomes_no_op() { + let root = TestRoot::new("automatic-retry"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = copy_supported_legacy(root.path()); + let legacy_before = fs::read(&legacy).expect("read legacy source"); + + let first = prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &FailAt(MigrationPhase::SourceConverted), + ) + .expect_err("interrupt first migration"); + assert_eq!(first.code, "migration_interrupted"); + assert!(!active_journal_path(&storage).exists()); + + let retry = + prepare_storage(&storage, std::slice::from_ref(&legacy)).expect("recover and retry"); + assert_eq!(retry.outcome, "imported_legacy_config"); + assert!(retry.changed); + assert!(!active_journal_path(&storage).exists()); + assert_imported_fixture( + &storage.read_profiles().expect("read retried profiles"), + &storage.read_targets().expect("read retried targets"), + ); + assert_eq!( + fs::read(&legacy).expect("read preserved legacy"), + legacy_before + ); + + let after_retry = snapshot_files(storage.paths().root.as_path()); + let settled = prepare_storage(&storage, &[legacy]).expect("settled startup"); + assert!(!settled.changed); + assert_eq!(snapshot_files(storage.paths().root.as_path()), after_retry); +} + +#[test] +fn outward_error_never_contains_secret_or_full_legacy_path() { + let root = TestRoot::new("redacted-error"); + let storage = JsonStorage::new(root.path().join("data")); + let legacy = root + .path() + .join("private-source-path") + .join("app-config.json"); + let mut value = supported_legacy_json(); + value["proxies"][0]["appNames"][0] = SECRET_SENTINEL.into(); + write_bytes( + &legacy, + &serde_json::to_vec_pretty(&value).expect("serialize supported secret fixture"), + ); + + let error = prepare_storage_with_hook( + &storage, + std::slice::from_ref(&legacy), + &FailAt(MigrationPhase::SourceConverted), + ) + .expect_err("interrupt migration"); + + assert_public_error_safe(&error, &[SECRET_SENTINEL, &legacy.display().to_string()]); + assert!(!recover_incomplete_migration(&storage).expect("migration already rolled back")); +} + +struct FailAt(MigrationPhase); + +impl MigrationHook for FailAt { + fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { + if phase == self.0 { + Err(io::Error::other("injected migration interruption")) + } else { + Ok(()) + } + } +} + +struct ReplaceProfilesAfterConversion(PathBuf); + +impl MigrationHook for ReplaceProfilesAfterConversion { + fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { + if phase == MigrationPhase::SourceConverted { + fs::write(&self.0, b"[]")?; + } + Ok(()) + } +} + +const CORRUPT_ROLLBACK_SENTINEL: &[u8] = b"must-remain-after-failed-prevalidation"; + +struct CorruptRollbackSnapshot(PathBuf); + +impl MigrationHook for CorruptRollbackSnapshot { + fn after_phase(&self, phase: MigrationPhase) -> io::Result<()> { + if phase != MigrationPhase::SourceConverted { + return Ok(()); + } + let journal: Value = serde_json::from_slice(&fs::read(&self.0)?)?; + let (early_target, late_snapshot) = rollback_snapshot_pair(&journal); + write_bytes(&early_target, CORRUPT_ROLLBACK_SENTINEL); + write_bytes(&late_snapshot, b"corrupt-snapshot"); + Err(io::Error::other("injected migration interruption")) + } +} + +fn rollback_snapshot_pair(journal: &Value) -> (PathBuf, PathBuf) { + let existing = journal["trackedFiles"] + .as_array() + .expect("tracked files") + .iter() + .filter(|tracked| tracked["existed"] == true) + .collect::>(); + assert!(existing.len() >= 2, "test requires multiple snapshots"); + let early_target = PathBuf::from( + existing[0]["targetPath"] + .as_str() + .expect("early target path"), + ); + let late_snapshot = PathBuf::from(journal["snapshotDir"].as_str().expect("snapshot directory")) + .join( + existing.last().expect("late tracked file")["snapshotName"] + .as_str() + .expect("late snapshot name"), + ); + (early_target, late_snapshot) +} + +struct TestRoot { + path: PathBuf, +} + +impl TestRoot { + fn new(label: &str) -> Self { + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "proxywarden-migration-{label}-{}-{timestamp}-{}", + std::process::id(), + NEXT_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("create isolated migration root"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestRoot { + fn drop(&mut self) { + let expected_prefix = "proxywarden-migration-"; + if self.path.starts_with(std::env::temp_dir()) + && self + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(expected_prefix)) + { + let _ = fs::remove_dir_all(&self.path); + } + } +} + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy") +} + +fn fixture_bytes(relative: &str) -> Vec { + fs::read(fixture_root().join(relative)).expect("read Task 0 fixture") +} + +fn supported_legacy_json() -> Value { + serde_json::from_slice(&fixture_bytes("proxifyre-generated/app-config.json")) + .expect("parse supported Task 0 fixture") +} + +fn copy_supported_legacy(root: &Path) -> PathBuf { + let path = root.join("legacy").join("app-config.json"); + write_bytes(&path, &fixture_bytes("proxifyre-generated/app-config.json")); + path +} + +fn copy_split_fixture(storage: &JsonStorage, file: &str) { + write_bytes( + &storage.paths().config_dir.join(file), + &fixture_bytes(&format!("pre-1.2-split/config/{file}")), + ); +} + +fn write_bytes(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create fixture parent"); + } + fs::write(path, bytes).expect("write test fixture"); +} + +fn generated_config_path(storage: &JsonStorage) -> PathBuf { + storage + .paths() + .generated_dir + .join("proxifyre-app-config.json") +} + +fn active_journal_path(storage: &JsonStorage) -> PathBuf { + storage.paths().migrations_dir.join(ACTIVE_JOURNAL_FILE) +} + +fn archived_journal_path(storage: &JsonStorage, migration_id: &str, suffix: &str) -> PathBuf { + storage + .paths() + .migrations_dir + .join(format!("{migration_id}.{suffix}.json")) +} + +fn single_archived_journal(storage: &JsonStorage, suffix: &str) -> PathBuf { + let ending = format!(".{suffix}.json"); + let matches = fs::read_dir(&storage.paths().migrations_dir) + .expect("read migrations directory") + .map(|entry| entry.expect("read migration entry").path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&ending)) + }) + .collect::>(); + assert_eq!(matches.len(), 1, "expected one {suffix} journal"); + matches[0].clone() +} + +fn snapshot_files(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + collect_files(root, root, &mut files); + files +} + +fn snapshot_without_meta(root: &Path) -> BTreeMap> { + snapshot_files(root) + .into_iter() + .filter(|(path, _)| path != Path::new("storage-meta.json")) + .collect() +} + +fn collect_files(base: &Path, current: &Path, output: &mut BTreeMap>) { + let Ok(entries) = fs::read_dir(current) else { + return; + }; + let mut paths = entries + .map(|entry| entry.expect("read snapshot entry").path()) + .collect::>(); + paths.sort(); + for path in paths { + // Coordination metadata may be created by a read; source and journal bytes must remain unchanged. + if path.ends_with(Path::new("state/migrations/storage-migration.lock")) { + continue; + } + if path.is_dir() { + collect_files(base, &path, output); + } else { + output.insert( + path.strip_prefix(base) + .expect("snapshot path below root") + .to_path_buf(), + fs::read(&path).expect("read snapshot file"), + ); + } + } +} + +fn import_tracked_paths(storage: &JsonStorage) -> Vec { + let originals = [ + storage.paths().profiles_file.clone(), + storage.paths().targets_file.clone(), + storage.paths().storage_meta_file.clone(), + generated_config_path(storage), + ]; + originals + .into_iter() + .flat_map(|path| [path.clone(), backup_path(&path)]) + .collect() +} + +fn file_states(paths: &[PathBuf]) -> BTreeMap>> { + paths + .iter() + .map(|path| { + let value = match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => panic!("failed to read {}: {error}", path.display()), + }; + (path.clone(), value) + }) + .collect() +} + +fn is_backup(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".bak")) +} + +fn all_phases() -> [MigrationPhase; 5] { + [ + MigrationPhase::BackedUp, + MigrationPhase::SourceConverted, + MigrationPhase::GeneratedValidated, + MigrationPhase::StorageVerified, + MigrationPhase::StorageComplete, + ] +} + +fn phase_name(phase: MigrationPhase) -> &'static str { + match phase { + MigrationPhase::BackedUp => "backed_up", + MigrationPhase::SourceConverted => "source_converted", + MigrationPhase::GeneratedValidated => "generated_validated", + MigrationPhase::StorageVerified => "storage_verified", + MigrationPhase::StorageComplete => "storage_complete", + } +} + +fn assert_imported_fixture(profiles: &[Profile], targets: &[Target]) { + assert_eq!(profiles.len(), 1); + assert_eq!(targets.len(), 1); + let profile = &profiles[0]; + let target = &targets[0]; + assert_eq!(profile.id, "main-profile"); + assert_eq!(profile.target_id, "main-proxy"); + assert_eq!(profile.protocols, vec![Protocol::Tcp, Protocol::Udp]); + assert_eq!(profile.items.len(), 3); + assert_eq!(profile.items[0].item_type, ProfileItemType::Process); + assert_eq!(profile.items[1].item_type, ProfileItemType::Exe); + assert_eq!(profile.items[2].item_type, ProfileItemType::Folder); + assert!(profile.items[2].recursive); + assert_eq!(target.id, "main-proxy"); + assert_eq!(target.host, "proxy.example.test"); + assert_eq!(target.port, 1080); +} + +fn assert_public_status_safe(status: &StorageMigrationStatusDto, forbidden: &[&str]) { + let text = format!( + "{} {} {} {}", + status.outcome, + status.notice_code.as_deref().unwrap_or_default(), + status.message, + status.storage_schema_version + ); + assert_forbidden_absent(&text, forbidden); +} + +fn assert_public_error_safe(error: &CommandError, forbidden: &[&str]) { + let mut text = format!("{} {}", error.code, error.message); + for detail in &error.details { + text.push_str(&detail.field); + text.push_str(&detail.message); + } + assert_forbidden_absent(&text, forbidden); +} + +fn assert_forbidden_absent(text: &str, forbidden: &[&str]) { + for value in forbidden.iter().filter(|value| !value.is_empty()) { + assert!( + !text.contains(value), + "public migration text leaked a forbidden value" + ); + } +} diff --git a/src-tauri/tests/privileged_jobs_tests.rs b/src-tauri/tests/privileged_jobs_tests.rs new file mode 100644 index 0000000..81ae6d8 --- /dev/null +++ b/src-tauri/tests/privileged_jobs_tests.rs @@ -0,0 +1,1825 @@ +use proxywarden_lib::component_catalog::ComponentId as CatalogComponentId; +use proxywarden_lib::component_cutover::{ + ComponentCutoverUserEvidence, CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, +}; +use proxywarden_lib::privileged_jobs::{ + cutover_recovery_probe_fingerprint, execute_privileged_job, launch_privileged_job, + parse_early_helper_arguments, validate_install_receipt, CanonicalComponentRoot, + ElevatedJobLauncher, ElevatedLaunchOutcome, ElevationProbe, EpochClock, InstallReceipt, + InstallReceiptManager, InstalledAssetReceipt, InstalledFileReceipt, InstalledPackageSource, + LaunchedPrivilegedJob, ManagedComponent, PlannedAssetFingerprint, PlannedGithubOriginProof, + PlannedPackageFingerprint, PrivilegedAction, PrivilegedActionRunner, PrivilegedCutoverContext, + PrivilegedCutoverMode, PrivilegedJobId, PrivilegedJobLaunchState, PrivilegedJobStatus, + PrivilegedJobStore, PrivilegedJobsError, PrivilegedMutationResult, PrivilegedPlanResolver, + PrivilegedResultCode, PrivilegedRunnerFailure, ResolvedActionPlan, + INSTALL_RECEIPT_SCHEMA_VERSION, MAX_PRIVILEGED_RECORD_BYTES, PRIVILEGED_JOB_SCHEMA_VERSION, +}; +use proxywarden_lib::process::{ + known_service_path_matches_exact, service_path_matches_exact, KnownWindowsService, +}; +use proxywarden_lib::safe_fs; +use proxywarden_lib::storage::StoragePaths; +use serde_json::Value; +use std::cell::{Cell, RefCell}; +use std::ffi::OsString; +use std::fs; +#[cfg(windows)] +use std::fs::{File, OpenOptions}; +use std::io; +#[cfg(windows)] +use std::io::{Read, Seek}; +use std::path::{Path, PathBuf}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use uuid::Uuid; + +#[test] +fn early_mode_accepts_only_exact_canonical_uuid_v4_arguments() { + let id = Uuid::new_v4().hyphenated().to_string(); + assert_eq!( + parse_early_helper_arguments(Vec::::new()).expect("normal launch"), + None + ); + assert_eq!( + parse_early_helper_arguments([OsString::from("--other")]).expect("unreserved args"), + None + ); + assert_eq!( + parse_early_helper_arguments([OsString::from("--elevated-helper"), OsString::from(&id),]) + .expect("exact helper args") + .expect("helper id") + .as_str(), + id + ); + assert!(matches!( + parse_early_helper_arguments([ + OsString::from("--elevated-helper"), + OsString::from(id.to_uppercase()), + ]), + Err(PrivilegedJobsError::InvalidJobId) + )); + for invalid in ["not-a-uuid", "00000000-0000-0000-0000-000000000000"] { + assert!(matches!( + parse_early_helper_arguments([ + OsString::from("--elevated-helper"), + OsString::from(invalid), + ]), + Err(PrivilegedJobsError::InvalidJobId) + )); + } + assert!(matches!( + parse_early_helper_arguments([ + OsString::from("--elevated-helper"), + OsString::from(&id), + OsString::from("extra"), + ]), + Err(PrivilegedJobsError::InvalidArguments) + )); +} + +#[test] +fn native_service_boundary_has_fixed_names_and_service_specific_paths() { + let expected = Path::new(r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe"); + assert_eq!(KnownWindowsService::Proxifyre.name(), "ProxiFyreService"); + assert_eq!(KnownWindowsService::SingBox.name(), "ProxyWardenSingBox"); + assert!(service_path_matches_exact( + r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe""#, + expected + )); + let bare = Path::new(r"C:\ProxyWarden\sing-box.exe"); + assert!(service_path_matches_exact( + r"c:\proxywarden\sing-box.exe", + bare + )); + assert!(!service_path_matches_exact( + r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" --other"#, + expected + )); + assert!(!service_path_matches_exact( + r"C:\Program Files\ProxyWarden\components\other.exe", + expected + )); + assert!(known_service_path_matches_exact( + KnownWindowsService::Proxifyre, + r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#, + expected, + )); + assert!(!known_service_path_matches_exact( + KnownWindowsService::Proxifyre, + r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe""#, + expected, + )); +} + +#[test] +fn request_is_typed_and_contains_no_paths_programs_arguments_or_service_names() { + let fixture = Fixture::new("typed-request"); + let launcher = RecordingLauncher::completed(); + let launched = fixture.launch( + PrivilegedAction::InstallProxifyre, + &StaticResolver::new(plan(PrivilegedAction::InstallProxifyre)), + &launcher, + ); + let json = serde_json::to_string(&launched.request).expect("serialize request"); + + assert!(json.contains("proxifyre.install")); + assert!(!json.contains("C:\\")); + assert!(!json.contains("serviceName")); + assert!(!json.contains("program")); + assert!(!json.contains("arguments")); + let calls = launcher.calls.borrow(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, fixture.executable); + assert_eq!(calls[0].1, launched.request.job_id().as_str()); +} + +#[test] +fn schema_v2_actions_and_operation_fingerprints_are_typed_and_domain_separated() { + assert_eq!(PRIVILEGED_JOB_SCHEMA_VERSION, 2); + assert_eq!( + serde_json::to_string(&PrivilegedAction::CutoverProxifyre).expect("cutover action"), + "\"proxifyre.cutover\"" + ); + assert_eq!( + serde_json::to_string(&PrivilegedAction::CleanupProxifyreQuarantine) + .expect("cleanup action"), + "\"proxifyre.cleanup-quarantine\"" + ); + + let stop = plan(PrivilegedAction::StopProxifyre); + let uninstall = plan(PrivilegedAction::UninstallProxifyre); + let cutover_new = cutover_plan(PrivilegedCutoverMode::New); + let cutover_recovery = cutover_plan(PrivilegedCutoverMode::Recovery); + assert_ne!(stop.operation_fingerprint, uninstall.operation_fingerprint); + assert_ne!( + cutover_new.operation_fingerprint, + cutover_recovery.operation_fingerprint + ); + for fingerprint in [ + &stop.operation_fingerprint, + &uninstall.operation_fingerprint, + &cutover_new.operation_fingerprint, + &cutover_recovery.operation_fingerprint, + ] { + assert_eq!(fingerprint.len(), 64); + assert!(fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())); + } +} + +#[test] +fn cutover_asset_contract_is_conditional_and_exactly_ordered() { + let fixture = Fixture::new("cutover-assets"); + let launcher = RecordingLauncher::completed(); + for (action, value) in [ + ( + PrivilegedAction::CutoverProxifyre, + cutover_plan(PrivilegedCutoverMode::New), + ), + ( + PrivilegedAction::CutoverProxifyre, + cutover_plan(PrivilegedCutoverMode::Recovery), + ), + (PrivilegedAction::CleanupProxifyreQuarantine, cleanup_plan()), + ] { + fixture.launch(action, &StaticResolver::new(value), &launcher); + } + let new_plan = cutover_plan(PrivilegedCutoverMode::New); + assert_eq!( + new_plan + .package + .as_ref() + .expect("new cutover package") + .assets + .iter() + .map(|asset| asset.component_id) + .collect::>(), + vec![ + CatalogComponentId::Proxifyre, + CatalogComponentId::WindowsPacketFilter, + CatalogComponentId::VcRuntime, + ] + ); + assert!(cutover_plan(PrivilegedCutoverMode::Recovery) + .package + .is_none()); + assert!(cleanup_plan().package.is_none()); + + let mut recovery_with_package = cutover_plan(PrivilegedCutoverMode::Recovery); + recovery_with_package.package = new_plan.package.clone(); + let mut cleanup_with_package = cleanup_plan(); + cleanup_with_package.package = new_plan.package; + for (action, invalid) in [ + (PrivilegedAction::CutoverProxifyre, recovery_with_package), + ( + PrivilegedAction::CleanupProxifyreQuarantine, + cleanup_with_package, + ), + ] { + assert!(matches!( + launch_privileged_job( + &fixture.store, + action, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(invalid), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + } +} + +#[test] +fn cutover_operation_fingerprint_binds_startup_session_and_typed_cleanup_evidence() { + let first_new = cutover_plan(PrivilegedCutoverMode::New); + let mut second_context = first_new + .cutover_context + .clone() + .expect("new cutover context"); + second_context.initiating_startup_session_id = + Some("44444444-4444-4444-8444-444444444444".to_string()); + let second_new = ResolvedActionPlan::new( + PrivilegedAction::CutoverProxifyre, + first_new.package.clone(), + first_new.inventory_fingerprint.clone(), + first_new.configuration_fingerprint.clone(), + Some(second_context), + ); + assert_ne!( + first_new.operation_fingerprint, + second_new.operation_fingerprint + ); + + let cleanup = cleanup_plan(); + let mut changed_context = cleanup.cutover_context.clone().expect("cleanup context"); + changed_context + .user_evidence + .as_mut() + .expect("typed user evidence") + .startup_session_id = "55555555-5555-4555-8555-555555555555".to_string(); + let changed_cleanup = ResolvedActionPlan::new( + PrivilegedAction::CleanupProxifyreQuarantine, + None, + cleanup.inventory_fingerprint.clone(), + cleanup.configuration_fingerprint.clone(), + Some(changed_context), + ); + assert_ne!( + cleanup.operation_fingerprint, + changed_cleanup.operation_fingerprint + ); + + let fixture = Fixture::new("cleanup-evidence"); + fixture.launch( + PrivilegedAction::CleanupProxifyreQuarantine, + &StaticResolver::new(cleanup.clone()), + &RecordingLauncher::completed(), + ); + let mut invalid_context = cleanup.cutover_context.expect("cleanup context"); + invalid_context + .user_evidence + .as_mut() + .expect("typed user evidence") + .route_smoke_confirmed = false; + let invalid = ResolvedActionPlan::new( + PrivilegedAction::CleanupProxifyreQuarantine, + None, + "a".repeat(64), + "e".repeat(64), + Some(invalid_context), + ); + assert!(matches!( + launch_privileged_job( + &fixture.store, + PrivilegedAction::CleanupProxifyreQuarantine, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(invalid), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); +} + +#[test] +fn sealed_recovery_probe_is_canonical_and_bound_to_exact_inventory() { + let inventory = "a".repeat(64); + let valid = recovery_probe_plan(&inventory); + let fixture = Fixture::new("valid-recovery-probe"); + fixture.launch( + PrivilegedAction::CutoverProxifyre, + &StaticResolver::new(valid.clone()), + &RecordingLauncher::completed(), + ); + + let mut wrong_hash_context = valid.cutover_context.clone().expect("probe context"); + wrong_hash_context.evidence_fingerprint = "b".repeat(64); + let wrong_hash = ResolvedActionPlan::new( + PrivilegedAction::CutoverProxifyre, + None, + inventory.clone(), + "e".repeat(64), + Some(wrong_hash_context), + ); + let wrong_inventory = ResolvedActionPlan::new( + PrivilegedAction::CutoverProxifyre, + None, + "c".repeat(64), + "e".repeat(64), + valid.cutover_context, + ); + + for (case, invalid) in [ + ("wrong-hash", wrong_hash), + ("wrong-inventory", wrong_inventory), + ] { + let fixture = Fixture::new(case); + assert!(matches!( + launch_privileged_job( + &fixture.store, + PrivilegedAction::CutoverProxifyre, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(invalid), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + } +} + +#[test] +fn strict_schema_and_size_limit_reject_before_runner() { + let fixture = Fixture::new("strict-schema"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StartProxifyre)); + let launched = fixture.launch( + PrivilegedAction::StartProxifyre, + &resolver, + &RecordingLauncher::completed(), + ); + let request_path = fixture.request_path(launched.request.job_id()); + let mut json: Value = + serde_json::from_slice(&fs::read(&request_path).expect("read request")).expect("json"); + json.as_object_mut().expect("request object").insert( + "unexpectedPath".to_string(), + Value::String("C:\\evil".to_string()), + ); + fs::write(&request_path, serde_json::to_vec(&json).expect("encode")).expect("mutate request"); + let runner = CountingRunner::success(); + assert!(matches!( + fixture.execute(&launched, &resolver, &runner), + Err(PrivilegedJobsError::InvalidRecord) + )); + assert_eq!(runner.calls.get(), 0); + + let second = fixture.launch( + PrivilegedAction::StartProxifyre, + &resolver, + &RecordingLauncher::completed(), + ); + fs::write( + fixture.request_path(second.request.job_id()), + vec![b' '; MAX_PRIVILEGED_RECORD_BYTES as usize + 1], + ) + .expect("oversize request"); + assert!(matches!( + fixture.execute(&second, &resolver, &runner), + Err(PrivilegedJobsError::RecordTooLarge) + )); + assert_eq!(runner.calls.get(), 0); +} + +#[test] +fn expired_and_future_dated_requests_are_rejected_without_claim() { + for (name, created_at, expected) in [ + ("expired", 699_u64, "expired"), + ("future", 1_031_u64, "future"), + ] { + let fixture = Fixture::new(name); + let resolver = StaticResolver::new(plan(PrivilegedAction::StopProxifyre)); + let launched = fixture.launch( + PrivilegedAction::StopProxifyre, + &resolver, + &RecordingLauncher::completed(), + ); + rewrite_request_field( + &fixture.request_path(launched.request.job_id()), + "createdAtEpochSeconds", + Value::from(created_at), + ); + let error = fixture + .execute(&launched, &resolver, &CountingRunner::success()) + .expect_err("invalid time"); + assert!( + (expected == "expired" && matches!(error, PrivilegedJobsError::Expired)) + || (expected == "future" && matches!(error, PrivilegedJobsError::FutureDated)) + ); + assert!(!fixture.claim_path(launched.request.job_id()).exists()); + } +} + +#[test] +fn non_elevated_helper_does_not_create_system_state_or_call_runner() { + let fixture = Fixture::new("not-elevated"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox)); + let launched = fixture.launch( + PrivilegedAction::StartSingBox, + &resolver, + &RecordingLauncher::completed(), + ); + let runner = CountingRunner::success(); + let error = execute_privileged_job( + &fixture.store, + launched.request.job_id(), + &fixture.clock, + &FixedElevation(false), + &resolver, + &runner, + ) + .expect_err("non-elevated helper must fail"); + + assert!(matches!(error, PrivilegedJobsError::NotElevated)); + assert_eq!(runner.calls.get(), 0); + assert!(!fixture.system_dir().exists()); +} + +#[test] +fn same_job_is_consumed_once_and_result_identity_is_validated() { + let fixture = Fixture::new("replay"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox)); + let launched = fixture.launch( + PrivilegedAction::StartSingBox, + &resolver, + &RecordingLauncher::completed(), + ); + let runner = CountingRunner::success(); + let result = fixture + .execute(&launched, &resolver, &runner) + .expect("first execution"); + assert_eq!(result.status, PrivilegedJobStatus::Succeeded); + assert_eq!(result.code, PrivilegedResultCode::Completed); + assert_eq!(runner.calls.get(), 1); + assert!(matches!( + fixture.execute(&launched, &resolver, &runner), + Err(PrivilegedJobsError::Replay) + )); + assert_eq!(runner.calls.get(), 1); + assert_eq!( + fixture + .store + .read_result(&launched.request) + .expect("validated result"), + result + ); +} + +#[test] +fn cutover_success_and_failure_codes_are_stable_and_redacted() { + for (index, code) in [ + PrivilegedResultCode::CutoverAwaitingNextStart, + PrivilegedResultCode::CutoverQuarantinePending, + PrivilegedResultCode::CutoverRolledBack, + PrivilegedResultCode::CutoverCleanupPending, + PrivilegedResultCode::CutoverComplete, + ] + .into_iter() + .enumerate() + { + let fixture = Fixture::new(&format!("cc-{index}")); + let action = PrivilegedAction::CutoverProxifyre; + let resolver = StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery)); + let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let runner = CountingRunner { + calls: Cell::new(0), + result: Ok(PrivilegedMutationResult { + activation: None, + changed: true, + reboot_required: false, + result_code: Some(code), + }), + }; + let result = fixture + .execute(&launched, &resolver, &runner) + .expect("stable cutover result"); + assert_eq!(result.code, code); + assert!(!result.message.contains("C:\\")); + assert!(!result.message.contains(&"d".repeat(64))); + } + + for (index, (failure, code)) in [ + ( + PrivilegedRunnerFailure::CutoverRecoveryRequired, + PrivilegedResultCode::CutoverRecoveryRequired, + ), + ( + PrivilegedRunnerFailure::CutoverStateConflict, + PrivilegedResultCode::CutoverStateConflict, + ), + ( + PrivilegedRunnerFailure::CutoverIdentityRejected, + PrivilegedResultCode::CutoverIdentityRejected, + ), + ] + .into_iter() + .enumerate() + { + let fixture = Fixture::new(&format!("cf-{index}")); + let action = PrivilegedAction::CutoverProxifyre; + let resolver = StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery)); + let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let result = fixture + .execute(&launched, &resolver, &CountingRunner::failure(failure)) + .expect("stable failure result"); + assert_eq!(result.code, code); + assert_eq!(result.status, PrivilegedJobStatus::Failed); + } +} + +#[test] +fn precreated_result_blocks_before_claim_and_runner() { + let fixture = Fixture::new("precreated-result"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StopSingBox)); + let launched = fixture.launch( + PrivilegedAction::StopSingBox, + &resolver, + &RecordingLauncher::completed(), + ); + fixture.prepare_portable_system_dir(); + let result_path = fixture.result_path(launched.request.job_id()); + fs::write(&result_path, b"{}").expect("precreate result"); + safe_fs::protect_path_for_owner_admin_system(&result_path).expect("protect result"); + let runner = CountingRunner::success(); + + assert!(matches!( + fixture.execute(&launched, &resolver, &runner), + Err(PrivilegedJobsError::ResultAlreadyExists) + )); + assert_eq!(runner.calls.get(), 0); + assert!(!fixture.claim_path(launched.request.job_id()).exists()); +} + +#[test] +fn uac_cancel_removes_only_held_request_and_never_creates_system_state() { + let fixture = Fixture::new("uac-cancel"); + let resolver = StaticResolver::new(plan(PrivilegedAction::InstallSingBox)); + let launched = fixture.launch( + PrivilegedAction::InstallSingBox, + &resolver, + &RecordingLauncher::canceled(), + ); + + assert_eq!(launched.state, PrivilegedJobLaunchState::Canceled); + assert!(!fixture.request_path(launched.request.job_id()).exists()); + assert!(!fixture.system_dir().exists()); +} + +#[test] +fn indeterminate_wait_keeps_request_for_the_already_started_helper() { + let fixture = Fixture::new("indeterminate-wait"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox)); + let launched = fixture.launch( + PrivilegedAction::StartSingBox, + &resolver, + &RecordingLauncher::indeterminate(), + ); + + assert_eq!(launched.state, PrivilegedJobLaunchState::Indeterminate); + assert!(fixture.request_path(launched.request.job_id()).exists()); + assert!(!fixture.system_dir().exists()); + + fixture + .execute(&launched, &resolver, &CountingRunner::success()) + .expect("helper terminal result"); + wait_until_request_is_removed(&fixture, launched.request.job_id()); +} + +#[cfg(windows)] +#[test] +fn indeterminate_keeper_blocks_request_write_delete_and_rename_until_terminal_result() { + let fixture = Fixture::new("indeterminate-keeper"); + let action = PrivilegedAction::StartSingBox; + let resolver = StaticResolver::new(plan(action)); + let launched = fixture.launch(action, &resolver, &RecordingLauncher::indeterminate()); + let request = fixture.request_path(launched.request.job_id()); + let renamed = request.with_extension("moved.json"); + + assert!(fs::write(&request, b"{}").is_err()); + assert!(fs::remove_file(&request).is_err()); + assert!(fs::rename(&request, &renamed).is_err()); + assert!(request.exists()); + assert!(!renamed.exists()); + + fixture + .execute(&launched, &resolver, &CountingRunner::success()) + .expect("helper terminal result"); + wait_until_request_is_removed(&fixture, launched.request.job_id()); +} + +#[cfg(windows)] +#[test] +fn parent_lease_blocks_tamper_while_helper_reads_and_executes() { + let fixture = Fixture::new("request-lease"); + let action = PrivilegedAction::StartProxifyre; + let resolver = StaticResolver::new(plan(action)); + let runner = CountingRunner::success(); + let tamper_blocked = Cell::new(false); + let launcher = InlineHelperLauncher { + store: &fixture.store, + clock: &fixture.clock, + resolver: &resolver, + runner: &runner, + request_dir: fixture.paths.privileged_jobs_dir.clone(), + tamper_blocked: &tamper_blocked, + }; + + let launched = fixture.launch(action, &resolver, &launcher); + + assert!(tamper_blocked.get()); + let helper_result = fixture + .store + .read_result(&launched.request) + .expect("inline helper result"); + assert_eq!( + helper_result.status, + PrivilegedJobStatus::Succeeded, + "{helper_result:?}" + ); + assert_eq!(runner.calls.get(), 1); + assert!(matches!( + launched.state, + PrivilegedJobLaunchState::Completed { exit_code: 0 } + )); +} + +#[test] +fn helper_rebuilds_plan_and_blocks_changed_inventory_before_runner() { + let fixture = Fixture::new("plan-change-helper"); + let original = plan(PrivilegedAction::StartProxifyre); + let launched = fixture.launch( + PrivilegedAction::StartProxifyre, + &StaticResolver::new(original.clone()), + &RecordingLauncher::completed(), + ); + let mut changed = original; + changed.inventory_fingerprint = "b".repeat(64); + let runner = CountingRunner::success(); + let result = fixture + .execute(&launched, &StaticResolver::new(changed), &runner) + .expect("structured rejected result"); + + assert_eq!(result.status, PrivilegedJobStatus::Failed); + assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed); + assert_eq!(runner.calls.get(), 0); +} + +#[test] +fn elevated_cutover_mode_mismatch_is_stable_and_never_calls_runner() { + let fixture = Fixture::new("cutover-mode-change"); + let launched = fixture.launch( + PrivilegedAction::CutoverProxifyre, + &StaticResolver::new(cutover_plan(PrivilegedCutoverMode::New)), + &RecordingLauncher::completed(), + ); + let runner = CountingRunner::success(); + let result = fixture + .execute( + &launched, + &StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery)), + &runner, + ) + .expect("structured cutover mode rejection"); + + assert_eq!(result.status, PrivilegedJobStatus::Failed); + assert_eq!(result.code, PrivilegedResultCode::CutoverStateConflict); + assert_eq!(runner.calls.get(), 0); +} + +#[test] +fn client_rebuilds_plan_before_launch_and_removes_stale_request() { + let fixture = Fixture::new("plan-change-client"); + let action = PrivilegedAction::InstallProxifyre; + let mut changed = plan(action); + changed.inventory_fingerprint = "c".repeat(64); + let resolver = SequenceResolver::new([plan(action), changed]); + let launcher = RecordingLauncher::completed(); + + assert!(matches!( + launch_privileged_job( + &fixture.store, + action, + &fixture.executable, + &fixture.clock, + &resolver, + &launcher, + ), + Err(PrivilegedJobsError::PlanChanged) + )); + assert!(launcher.calls.borrow().is_empty()); + assert!(fs::read_dir(&fixture.paths.privileged_jobs_dir) + .expect("request dir") + .next() + .is_none()); +} + +#[test] +fn client_removes_request_when_commit_plan_cannot_be_rebuilt() { + let fixture = Fixture::new("client-plan-error"); + let action = PrivilegedAction::StartProxifyre; + let resolver = FailOnSecondResolve { + plan: plan(action), + calls: Cell::new(0), + }; + let launcher = RecordingLauncher::completed(); + + assert!(launch_privileged_job( + &fixture.store, + action, + &fixture.executable, + &fixture.clock, + &resolver, + &launcher, + ) + .is_err()); + assert_eq!(launcher.calls.borrow().len(), 0); + let requests = fs::read_dir(&fixture.paths.privileged_jobs_dir) + .expect("request directory") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .ends_with(".request.json") + }) + .count(); + assert_eq!(requests, 0); +} + +#[test] +fn store_owned_helper_binding_rejects_another_absolute_executable_before_launcher() { + let fixture = Fixture::new("helper-binding"); + let action = PrivilegedAction::StartProxifyre; + let launcher = RecordingLauncher::completed(); + let other_executable = fixture + .executable + .parent() + .expect("app root") + .join("CopiedProxyWarden.exe"); + + assert!(matches!( + launch_privileged_job( + &fixture.store, + action, + &other_executable, + &fixture.clock, + &StaticResolver::new(plan(action)), + &launcher, + ), + Err(PrivilegedJobsError::InvalidArguments) + )); + assert!(launcher.calls.borrow().is_empty()); + assert!(!fixture.paths.privileged_jobs_dir.exists()); +} + +#[test] +fn package_source_contract_is_bundled_for_install_and_cache_for_update() { + let fixture = Fixture::new("package-source"); + let bad_install = plan_with_source( + PrivilegedAction::InstallProxifyre, + InstalledPackageSource::Cache, + ); + let bad_update = plan_with_source( + PrivilegedAction::UpdateProxifyre, + InstalledPackageSource::Bundled, + ); + for (action, bad_plan) in [ + (PrivilegedAction::InstallProxifyre, bad_install), + (PrivilegedAction::UpdateProxifyre, bad_update), + ] { + assert!(matches!( + launch_privileged_job( + &fixture.store, + action, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(bad_plan), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + } + + let install = PrivilegedAction::InstallProxifyre; + let mut wrong_component = plan(install); + wrong_component + .package + .as_mut() + .expect("install package") + .assets[1] + .component_id = CatalogComponentId::Winsw; + let mut wrong_asset_version = plan(install); + wrong_asset_version + .package + .as_mut() + .expect("install package") + .assets[1] + .version = "latest".to_string(); + let mut reordered = plan(install); + reordered + .package + .as_mut() + .expect("install package") + .assets + .swap(0, 1); + for invalid in [wrong_component, wrong_asset_version, reordered] { + assert!(matches!( + launch_privileged_job( + &fixture.store, + install, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(invalid), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + } + + let update = PrivilegedAction::UpdateSingBox; + let mut invalid_origin_plans = Vec::new(); + for mutate in [ + |proof: &mut PlannedGithubOriginProof| proof.release_id = 0, + |proof: &mut PlannedGithubOriginProof| proof.asset_id = 0, + |proof: &mut PlannedGithubOriginProof| proof.stable_tag = "v9.9.9".to_string(), + |proof: &mut PlannedGithubOriginProof| proof.asset_name = "other.zip".to_string(), + |proof: &mut PlannedGithubOriginProof| proof.repository = "attacker/repo".to_string(), + |proof: &mut PlannedGithubOriginProof| proof.size += 1, + |proof: &mut PlannedGithubOriginProof| proof.sha256_from_api = "f".repeat(64), + ] { + let mut invalid = plan(update); + mutate( + invalid + .package + .as_mut() + .and_then(|package| package.origin_proof.as_mut()) + .expect("cache origin proof"), + ); + invalid_origin_plans.push(invalid); + } + for invalid in invalid_origin_plans { + assert!(matches!( + launch_privileged_job( + &fixture.store, + update, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(invalid), + &RecordingLauncher::completed(), + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + } + + let mut ambiguous_cache = plan(update); + ambiguous_cache + .package + .as_mut() + .expect("update package") + .assets + .push(PlannedAssetFingerprint { + component_id: CatalogComponentId::SingBox, + version: "1.2.3".to_string(), + name: "second-component.zip".to_string(), + sha256: "f".repeat(64), + size: 43, + }); + let launcher = RecordingLauncher::completed(); + assert!(matches!( + launch_privileged_job( + &fixture.store, + update, + &fixture.executable, + &fixture.clock, + &StaticResolver::new(ambiguous_cache), + &launcher, + ), + Err(PrivilegedJobsError::InvalidRecord) + )); + assert!(launcher.calls.borrow().is_empty()); +} + +#[test] +fn helper_blocks_cached_origin_proof_id_swap_before_runner() { + let fixture = Fixture::new("origin-proof-swap"); + let action = PrivilegedAction::UpdateProxifyre; + let initial = plan(action); + let launched = fixture.launch( + action, + &StaticResolver::new(initial.clone()), + &RecordingLauncher::completed(), + ); + let mut changed = initial; + changed + .package + .as_mut() + .and_then(|package| package.origin_proof.as_mut()) + .expect("cache origin proof") + .release_id += 1; + let runner = CountingRunner::success(); + let result = fixture + .execute(&launched, &StaticResolver::new(changed), &runner) + .expect("structured precondition failure"); + + assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed); + assert_eq!(runner.calls.get(), 0); +} + +#[test] +fn helper_revalidates_ttl_after_resolver_and_returns_fixed_failure_without_runner() { + let fixture = Fixture::new("ttl-after-resolver"); + let action = PrivilegedAction::StartProxifyre; + let original = plan(action); + let launched = fixture.launch( + action, + &StaticResolver::new(original.clone()), + &RecordingLauncher::completed(), + ); + let execution_clock = MutableClock::new(1_000); + let resolver = ExpireDuringResolve { + plan: original, + clock: &execution_clock, + }; + let runner = CountingRunner::success(); + + let result = execute_privileged_job( + &fixture.store, + launched.request.job_id(), + &execution_clock, + &FixedElevation(true), + &resolver, + &runner, + ) + .expect("fixed terminal failure"); + + assert_eq!(result.status, PrivilegedJobStatus::Failed); + assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed); + assert_eq!(runner.calls.get(), 0); +} + +#[cfg(windows)] +#[test] +fn failed_final_result_replacement_preserves_running_bytes_exactly() { + let fixture = Fixture::new("atomic-final-result"); + let action = PrivilegedAction::StopSingBox; + let resolver = StaticResolver::new(plan(action)); + let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let runner = ResultPinningRunner::new(fixture.result_path(launched.request.job_id())); + + assert!(matches!( + fixture.execute(&launched, &resolver, &runner), + Err(PrivilegedJobsError::Io(_)) + )); + let running_bytes = runner.release().expect("captured running bytes"); + let persisted = fs::read(fixture.result_path(launched.request.job_id())) + .expect("running result remains readable"); + + assert_eq!(persisted, running_bytes); + let persisted_json: Value = serde_json::from_slice(&persisted).expect("result json"); + assert_eq!(persisted_json["status"], "running"); +} + +#[cfg(windows)] +#[test] +fn result_watcher_open_read_lease_allows_terminal_atomic_replace() { + let fixture = Arc::new(Fixture::new("result-watcher-replace")); + let action = PrivilegedAction::StartProxifyre; + let resolver = StaticResolver::new(plan(action)); + let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let runner = BlockingRunner { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + }; + let fixture_thread = Arc::clone(&fixture); + let resolver_thread = resolver.clone(); + let job_id = launched.request.job_id().clone(); + let handle = thread::spawn(move || { + execute_privileged_job( + &fixture_thread.store, + &job_id, + &fixture_thread.clock, + &FixedElevation(true), + &resolver_thread, + &runner, + ) + }); + entered_rx + .recv() + .expect("runner entered after Running write"); + + let (observed_running, mut read_lease) = fixture + .store + .read_result_with_open_lease_for_tests(&launched.request) + .expect("watcher reads Running and keeps the bounded read handle"); + assert_eq!(observed_running.status, PrivilegedJobStatus::Running); + + release_tx.send(()).expect("release runner"); + let terminal = handle + .join() + .expect("helper thread") + .expect("ReplaceFileW succeeds while watcher read handle remains open"); + assert_eq!(terminal.status, PrivilegedJobStatus::Succeeded); + assert_eq!( + fixture + .store + .read_result(&launched.request) + .expect("new terminal result is visible"), + terminal + ); + + let mut original_running_bytes = Vec::new(); + read_lease + .seek(std::io::SeekFrom::Start(0)) + .expect("rewind old result handle"); + read_lease + .read_to_end(&mut original_running_bytes) + .expect("old open handle still references the replaced Running file"); + let original_running: Value = + serde_json::from_slice(&original_running_bytes).expect("old Running json"); + assert_eq!(original_running["status"], "running"); +} + +#[test] +fn results_are_fixed_and_do_not_echo_plan_fingerprints_or_paths() { + let fixture = Fixture::new("redaction"); + let resolver = StaticResolver::new(plan(PrivilegedAction::UpdateProxifyre)); + let launched = fixture.launch( + PrivilegedAction::UpdateProxifyre, + &resolver, + &RecordingLauncher::completed(), + ); + let result = fixture + .execute( + &launched, + &resolver, + &CountingRunner::failure(PrivilegedRunnerFailure::PackageVerificationFailed), + ) + .expect("structured failure"); + let json = serde_json::to_string(&result).expect("result json"); + + assert_eq!(result.code, PrivilegedResultCode::PackageVerificationFailed); + assert!(!json.contains(&"a".repeat(64))); + assert!(!json.contains("C:\\")); + assert!(!json.contains("https://")); +} + +#[test] +fn system_directory_is_never_blessed_when_precreated_by_issuer() { + let fixture = Fixture::new("foreign-system"); + let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox)); + let launched = fixture.launch( + PrivilegedAction::StartSingBox, + &resolver, + &RecordingLauncher::completed(), + ); + let system = fixture.system_dir(); + fs::create_dir_all(&system).expect("foreign system dir"); + let sentinel = system.join("sentinel.txt"); + fs::write(&sentinel, b"issuer-owned").expect("sentinel"); + + assert!(fixture + .execute(&launched, &resolver, &CountingRunner::success()) + .is_err()); + assert_eq!( + fs::read(&sentinel).expect("unchanged sentinel"), + b"issuer-owned" + ); + assert!(!fixture.claim_path(launched.request.job_id()).exists()); +} + +#[test] +fn production_system_layout_is_outside_programdata_and_under_the_verified_app_root() { + let fixture = Fixture::new("production-system-layout"); + let app_root = fixture.executable.parent().expect("app root"); + let store = PrivilegedJobStore::from_storage_paths_with_verified_app_root_for_tests( + &fixture.paths, + app_root, + &fixture.executable, + ); + let system = store.system_directory_for_tests(); + + assert_eq!(system, app_root.join(".proxywarden-privileged-jobs")); + assert!(!system.starts_with(&fixture.paths.root)); + assert!(!system.starts_with(&fixture.paths.privileged_jobs_dir)); +} + +#[test] +fn global_lifecycle_lock_serializes_different_jobs() { + let fixture = Arc::new(Fixture::new("global-lock")); + let action = PrivilegedAction::StartProxifyre; + let resolver = StaticResolver::new(plan(action)); + let first = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let second = fixture.launch(action, &resolver, &RecordingLauncher::completed()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let blocking = BlockingRunner { + entered: Mutex::new(Some(entered_tx)), + release: Mutex::new(release_rx), + }; + let fixture_thread = Arc::clone(&fixture); + let resolver_thread = resolver.clone(); + let first_id = first.request.job_id().clone(); + let handle = thread::spawn(move || { + execute_privileged_job( + &fixture_thread.store, + &first_id, + &fixture_thread.clock, + &FixedElevation(true), + &resolver_thread, + &blocking, + ) + }); + entered_rx.recv().expect("first runner entered"); + + assert!(matches!( + fixture.execute(&second, &resolver, &CountingRunner::success()), + Err(PrivilegedJobsError::LifecycleBusy) + )); + release_tx.send(()).expect("release first runner"); + assert!(handle.join().expect("helper thread").is_ok()); +} + +#[test] +fn install_receipt_schema_rejects_wrong_root_traversal_hash_and_unknown_fields() { + let fixture = Fixture::new("receipt"); + let root = CanonicalComponentRoot::from_current_exe_for_tests( + &fixture.executable, + ManagedComponent::Proxifyre, + ) + .expect("canonical root"); + let receipt = valid_receipt(&root); + validate_install_receipt(&root, &receipt).expect("valid receipt"); + + let mut wrong = receipt.clone(); + wrong.install_root.push_str("-other"); + assert!(matches!( + validate_install_receipt(&root, &wrong), + Err(PrivilegedJobsError::InvalidReceipt) + )); + let mut traversal = receipt.clone(); + traversal.installed_files[0].relative_path = "..\\evil.exe".to_string(); + assert!(validate_install_receipt(&root, &traversal).is_err()); + let mut uppercase_hash = receipt.clone(); + uppercase_hash.asset.sha256 = "A".repeat(64); + assert!(validate_install_receipt(&root, &uppercase_hash).is_err()); + for invalid_version in ["", "running", "1.02", "1.2.3.4.5"] { + let mut invalid = receipt.clone(); + invalid.version = invalid_version.to_string(); + assert!( + validate_install_receipt(&root, &invalid).is_err(), + "version {invalid_version:?}" + ); + } + for invalid_path in [ + "CON", + "con.txt", + "PRN.json", + "AUX", + "NUL.log", + "CONIN$", + "conout$.txt", + "CLOCK$.json", + "COM1.exe", + "COM¹.txt", + "LPT9.txt", + "folder.\\file.exe", + "folder \\file.exe", + "bad.exe", + "bad|name.exe", + "bad?name.exe", + "bad*name.exe", + "bad\"name.exe", + ] { + let mut invalid = receipt.clone(); + invalid.installed_files[0].relative_path = invalid_path.to_string(); + assert!( + validate_install_receipt(&root, &invalid).is_err(), + "path {invalid_path:?}" + ); + } + + let mut invalid_asset = receipt.clone(); + invalid_asset.asset.name = "bundle?.zip".to_string(); + assert!(validate_install_receipt(&root, &invalid_asset).is_err()); + + let mut unicode_case_duplicate = receipt.clone(); + unicode_case_duplicate + .installed_files + .push(InstalledFileReceipt { + relative_path: "BIN\\ÄPP.EXE".to_string(), + sha256: "c".repeat(64), + size: 7, + }); + unicode_case_duplicate.installed_files[0].relative_path = "bin\\äpp.exe".to_string(); + assert!(validate_install_receipt(&root, &unicode_case_duplicate).is_err()); + + let mut json = serde_json::to_value(&receipt).expect("receipt json"); + json.as_object_mut().expect("object").insert( + "arbitraryCommand".to_string(), + Value::String("cmd.exe".to_string()), + ); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn copied_executable_in_user_writable_location_is_not_a_trusted_component_root() { + let fixture = Fixture::new("untrusted-app-root"); + fs::create_dir_all(fixture.executable.parent().expect("exe parent")).expect("app dir"); + fs::write(&fixture.executable, b"not-an-installed-app").expect("copied exe"); + + assert!(CanonicalComponentRoot::from_current_exe( + &fixture.executable, + ManagedComponent::Proxifyre, + ) + .is_err()); +} + +struct Fixture { + root: PathBuf, + paths: StoragePaths, + store: PrivilegedJobStore, + executable: PathBuf, + clock: FixedClock, +} + +impl Fixture { + fn new(name: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "proxywarden-privileged-{name}-{}", + Uuid::new_v4().hyphenated() + )); + fs::create_dir_all(&root).expect("create fixture root"); + let paths = StoragePaths::new(root.join("ProgramData")); + let executable = root.join("Program Files").join("ProxyWarden.exe"); + let store = PrivilegedJobStore::from_storage_paths_for_tests(&paths, &executable); + Self { + root, + paths, + store, + executable, + clock: FixedClock(1_000), + } + } + + fn launch( + &self, + action: PrivilegedAction, + resolver: &dyn PrivilegedPlanResolver, + launcher: &dyn ElevatedJobLauncher, + ) -> LaunchedPrivilegedJob { + launch_privileged_job( + &self.store, + action, + &self.executable, + &self.clock, + resolver, + launcher, + ) + .expect("launch job") + } + + fn execute( + &self, + launched: &LaunchedPrivilegedJob, + resolver: &dyn PrivilegedPlanResolver, + runner: &dyn PrivilegedActionRunner, + ) -> Result { + execute_privileged_job( + &self.store, + launched.request.job_id(), + &self.clock, + &FixedElevation(true), + resolver, + runner, + ) + } + + fn request_path(&self, id: &PrivilegedJobId) -> PathBuf { + self.paths + .privileged_jobs_dir + .join(format!("{}.request.json", id.as_str())) + } + + fn system_dir(&self) -> PathBuf { + self.paths.privileged_jobs_dir.join("system") + } + + fn claim_path(&self, id: &PrivilegedJobId) -> PathBuf { + self.system_dir() + .join(format!("{}.claim.json", id.as_str())) + } + + fn result_path(&self, id: &PrivilegedJobId) -> PathBuf { + self.system_dir() + .join(format!("{}.result.json", id.as_str())) + } + + fn prepare_portable_system_dir(&self) { + let system = self.system_dir(); + fs::create_dir_all(&system).expect("system dir"); + safe_fs::protect_path_for_owner_admin_system(&system).expect("protect system dir"); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn wait_until_request_is_removed(fixture: &Fixture, id: &PrivilegedJobId) { + let request = fixture.request_path(id); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while request.exists() && std::time::Instant::now() < deadline { + thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(!request.exists(), "keeper did not release terminal request"); +} + +#[derive(Clone, Copy)] +struct FixedClock(u64); + +impl EpochClock for FixedClock { + fn now_epoch_seconds(&self) -> u64 { + self.0 + } +} + +struct MutableClock(Cell); + +impl MutableClock { + fn new(value: u64) -> Self { + Self(Cell::new(value)) + } +} + +impl EpochClock for MutableClock { + fn now_epoch_seconds(&self) -> u64 { + self.0.get() + } +} + +struct FixedElevation(bool); + +impl ElevationProbe for FixedElevation { + fn is_elevated(&self) -> bool { + self.0 + } +} + +#[derive(Clone)] +struct StaticResolver { + plan: ResolvedActionPlan, +} + +impl StaticResolver { + fn new(plan: ResolvedActionPlan) -> Self { + Self { plan } + } +} + +impl PrivilegedPlanResolver for StaticResolver { + fn resolve( + &self, + _action: PrivilegedAction, + ) -> Result { + Ok(self.plan.clone()) + } +} + +struct SequenceResolver { + plans: RefCell>, +} + +impl SequenceResolver { + fn new(plans: impl IntoIterator) -> Self { + let mut plans = plans.into_iter().collect::>(); + plans.reverse(); + Self { + plans: RefCell::new(plans), + } + } +} + +impl PrivilegedPlanResolver for SequenceResolver { + fn resolve( + &self, + _action: PrivilegedAction, + ) -> Result { + self.plans + .borrow_mut() + .pop() + .ok_or(PrivilegedRunnerFailure::PreconditionFailed) + } +} + +struct FailOnSecondResolve { + plan: ResolvedActionPlan, + calls: Cell, +} + +impl PrivilegedPlanResolver for FailOnSecondResolve { + fn resolve( + &self, + _action: PrivilegedAction, + ) -> Result { + let call = self.calls.get(); + self.calls.set(call + 1); + if call == 0 { + Ok(self.plan.clone()) + } else { + Err(PrivilegedRunnerFailure::OperationFailed) + } + } +} + +struct ExpireDuringResolve<'a> { + plan: ResolvedActionPlan, + clock: &'a MutableClock, +} + +impl PrivilegedPlanResolver for ExpireDuringResolve<'_> { + fn resolve( + &self, + _action: PrivilegedAction, + ) -> Result { + self.clock + .0 + .set(1_000 + proxywarden_lib::privileged_jobs::PRIVILEGED_JOB_TTL_SECONDS + 1); + Ok(self.plan.clone()) + } +} + +struct CountingRunner { + calls: Cell, + result: Result, +} + +impl CountingRunner { + fn success() -> Self { + Self { + calls: Cell::new(0), + result: Ok(PrivilegedMutationResult { + activation: None, + changed: true, + reboot_required: false, + result_code: None, + }), + } + } + + fn failure(failure: PrivilegedRunnerFailure) -> Self { + Self { + calls: Cell::new(0), + result: Err(failure), + } + } +} + +impl PrivilegedActionRunner for CountingRunner { + fn run( + &self, + _action: PrivilegedAction, + _plan: &ResolvedActionPlan, + ) -> Result { + self.calls.set(self.calls.get() + 1); + self.result.clone() + } +} + +#[cfg(windows)] +struct ResultPinningRunner { + result_path: PathBuf, + running_bytes: RefCell>>, + pin: RefCell>, +} + +#[cfg(windows)] +impl ResultPinningRunner { + fn new(result_path: PathBuf) -> Self { + Self { + result_path, + running_bytes: RefCell::new(None), + pin: RefCell::new(None), + } + } + + fn release(&self) -> Option> { + self.pin.borrow_mut().take(); + self.running_bytes.borrow_mut().take() + } +} + +#[cfg(windows)] +impl PrivilegedActionRunner for ResultPinningRunner { + fn run( + &self, + _action: PrivilegedAction, + _plan: &ResolvedActionPlan, + ) -> Result { + use std::os::windows::fs::OpenOptionsExt; + + let running = + fs::read(&self.result_path).map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + let mut options = OpenOptions::new(); + options.read(true).share_mode(0); + let pin = options + .open(&self.result_path) + .map_err(|_| PrivilegedRunnerFailure::OperationFailed)?; + self.running_bytes.replace(Some(running)); + self.pin.replace(Some(pin)); + Ok(PrivilegedMutationResult { + activation: None, + changed: true, + reboot_required: false, + result_code: None, + }) + } +} + +struct BlockingRunner { + entered: Mutex>>, + release: Mutex>, +} + +impl PrivilegedActionRunner for BlockingRunner { + fn run( + &self, + _action: PrivilegedAction, + _plan: &ResolvedActionPlan, + ) -> Result { + if let Some(sender) = self.entered.lock().expect("entered lock").take() { + sender.send(()).expect("signal entered"); + } + self.release + .lock() + .expect("release lock") + .recv() + .expect("release signal"); + Ok(PrivilegedMutationResult { + activation: None, + changed: true, + reboot_required: false, + result_code: None, + }) + } +} + +struct RecordingLauncher { + outcome: ElevatedLaunchOutcome, + calls: RefCell>, +} + +impl RecordingLauncher { + fn completed() -> Self { + Self { + outcome: ElevatedLaunchOutcome::Completed { exit_code: 0 }, + calls: RefCell::new(Vec::new()), + } + } + + fn canceled() -> Self { + Self { + outcome: ElevatedLaunchOutcome::Canceled, + calls: RefCell::new(Vec::new()), + } + } + + fn indeterminate() -> Self { + Self { + outcome: ElevatedLaunchOutcome::Indeterminate, + calls: RefCell::new(Vec::new()), + } + } +} + +impl ElevatedJobLauncher for RecordingLauncher { + fn launch( + &self, + executable: &Path, + job_id: &PrivilegedJobId, + ) -> io::Result { + self.calls + .borrow_mut() + .push((executable.to_path_buf(), job_id.as_str().to_string())); + Ok(self.outcome) + } +} + +#[cfg(windows)] +struct InlineHelperLauncher<'a> { + store: &'a PrivilegedJobStore, + clock: &'a dyn EpochClock, + resolver: &'a dyn PrivilegedPlanResolver, + runner: &'a dyn PrivilegedActionRunner, + request_dir: PathBuf, + tamper_blocked: &'a Cell, +} + +#[cfg(windows)] +impl ElevatedJobLauncher for InlineHelperLauncher<'_> { + fn launch( + &self, + _executable: &Path, + job_id: &PrivilegedJobId, + ) -> io::Result { + let request = self + .request_dir + .join(format!("{}.request.json", job_id.as_str())); + self.tamper_blocked.set(fs::write(&request, b"{}").is_err()); + execute_privileged_job( + self.store, + job_id, + self.clock, + &FixedElevation(true), + self.resolver, + self.runner, + ) + .map_err(io::Error::other)?; + Ok(ElevatedLaunchOutcome::Completed { exit_code: 0 }) + } +} + +fn plan(action: PrivilegedAction) -> ResolvedActionPlan { + let package = action.requires_test_package().map(|source| { + let components: &[CatalogComponentId] = match action { + PrivilegedAction::InstallProxifyre => &[ + CatalogComponentId::Proxifyre, + CatalogComponentId::WindowsPacketFilter, + CatalogComponentId::VcRuntime, + ], + PrivilegedAction::UpdateProxifyre => &[CatalogComponentId::Proxifyre], + PrivilegedAction::InstallSingBox => { + &[CatalogComponentId::SingBox, CatalogComponentId::Winsw] + } + PrivilegedAction::UpdateSingBox => &[CatalogComponentId::SingBox], + _ => &[], + }; + let assets = components + .iter() + .enumerate() + .map(|(index, component_id)| PlannedAssetFingerprint { + component_id: *component_id, + version: if index == 0 { + "1.2.3".to_string() + } else { + format!("1.2.{}", index + 3) + }, + name: format!("component-{index}.zip"), + sha256: format!("{:x}", index + 13).repeat(64)[..64].to_string(), + size: 42 + index as u64, + }) + .collect::>(); + let asset = assets.first().expect("package asset"); + let origin_proof = + (source == InstalledPackageSource::Cache).then(|| PlannedGithubOriginProof { + repository: match action.component() { + ManagedComponent::Proxifyre => "wiresock/proxifyre", + ManagedComponent::SingBox => "SagerNet/sing-box", + } + .to_string(), + stable_tag: "v1.2.3".to_string(), + release_id: 11, + asset_id: 22, + asset_name: asset.name.clone(), + size: asset.size, + sha256_from_api: asset.sha256.clone(), + }); + PlannedPackageFingerprint { + source, + version: "1.2.3".to_string(), + assets, + origin_proof, + } + }); + ResolvedActionPlan::new(action, package, "a".repeat(64), "e".repeat(64), None) +} + +fn cutover_plan(mode: PrivilegedCutoverMode) -> ResolvedActionPlan { + let package = (mode == PrivilegedCutoverMode::New).then(|| { + plan(PrivilegedAction::InstallProxifyre) + .package + .expect("bundle") + }); + ResolvedActionPlan::new( + PrivilegedAction::CutoverProxifyre, + package, + "a".repeat(64), + "e".repeat(64), + Some(PrivilegedCutoverContext { + mode, + cutover_id: (mode == PrivilegedCutoverMode::Recovery) + .then(|| "11111111-1111-4111-8111-111111111111".to_string()), + initiating_startup_session_id: (mode == PrivilegedCutoverMode::New) + .then(|| "22222222-2222-4222-8222-222222222222".to_string()), + evidence_fingerprint: "d".repeat(64), + user_evidence: None, + }), + ) +} + +fn recovery_probe_plan(inventory_fingerprint: &str) -> ResolvedActionPlan { + ResolvedActionPlan::new( + PrivilegedAction::CutoverProxifyre, + None, + inventory_fingerprint.to_string(), + "e".repeat(64), + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Recovery, + cutover_id: None, + initiating_startup_session_id: None, + evidence_fingerprint: cutover_recovery_probe_fingerprint(inventory_fingerprint), + user_evidence: None, + }), + ) +} + +fn cleanup_plan() -> ResolvedActionPlan { + ResolvedActionPlan::new( + PrivilegedAction::CleanupProxifyreQuarantine, + None, + "a".repeat(64), + "e".repeat(64), + Some(PrivilegedCutoverContext { + mode: PrivilegedCutoverMode::Cleanup, + cutover_id: Some("11111111-1111-4111-8111-111111111111".to_string()), + evidence_fingerprint: "c".repeat(64), + initiating_startup_session_id: None, + user_evidence: Some(ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: "11111111-1111-4111-8111-111111111111".to_string(), + startup_session_id: "33333333-3333-4333-8333-333333333333".to_string(), + current_inventory_fingerprint: "a".repeat(64), + route_smoke_confirmed: true, + observed_at_epoch_seconds: 700, + confirmed_at_epoch_seconds: Some(701), + }), + }), + ) +} + +fn plan_with_source( + action: PrivilegedAction, + source: InstalledPackageSource, +) -> ResolvedActionPlan { + let mut value = plan(action); + value.package.as_mut().expect("package action").source = source; + value +} + +trait TestActionPackage { + fn requires_test_package(self) -> Option; +} + +impl TestActionPackage for PrivilegedAction { + fn requires_test_package(self) -> Option { + match self { + Self::InstallProxifyre | Self::InstallSingBox => Some(InstalledPackageSource::Bundled), + Self::UpdateProxifyre | Self::UpdateSingBox => Some(InstalledPackageSource::Cache), + _ => None, + } + } +} + +fn rewrite_request_field(path: &Path, name: &str, value: Value) { + let mut json: Value = + serde_json::from_slice(&fs::read(path).expect("read request")).expect("request json"); + json.as_object_mut() + .expect("request object") + .insert(name.to_string(), value); + fs::write(path, serde_json::to_vec(&json).expect("encode request")).expect("write request"); +} + +fn valid_receipt(root: &CanonicalComponentRoot) -> InstallReceipt { + InstallReceipt { + schema_version: INSTALL_RECEIPT_SCHEMA_VERSION, + manager: InstallReceiptManager::ProxyWarden, + component: root.component(), + install_root: root.path().display().to_string(), + service_name: "ProxiFyreService".to_string(), + version: "2.2.1".to_string(), + source: InstalledPackageSource::Bundled, + asset: InstalledAssetReceipt { + name: "ProxiFyre.zip".to_string(), + sha256: "a".repeat(64), + size: 100, + }, + installed_files: vec![InstalledFileReceipt { + relative_path: "ProxiFyre.exe".to_string(), + sha256: "b".repeat(64), + size: 10, + }], + installed_at: 1_000, + windows_packet_filter: None, + } +} diff --git a/src-tauri/tests/privileged_package_trust_tests.rs b/src-tauri/tests/privileged_package_trust_tests.rs new file mode 100644 index 0000000..0d96329 --- /dev/null +++ b/src-tauri/tests/privileged_package_trust_tests.rs @@ -0,0 +1,346 @@ +use proxywarden_lib::component_catalog::ComponentId; +use proxywarden_lib::component_packages::{ + ComponentPackageService, ComponentPackagesError, GithubReleaseDigestProof, + NativePrivilegedBundleVerifier, PackageSignatureVerifier, PackageSource, + PrivilegedBundleVerificationError, PrivilegedBundleVerifier, PrivilegedCachedUpdatePlan, + SignaturePublisher, SignatureVerifierError, UpdateRequestKind, UpdateTransport, + UpdateTransportError, UpdateTransportRequest, UpdateTransportResponse, +}; +use proxywarden_lib::safe_fs::protect_path_for_owner_admin_system; +use proxywarden_lib::storage::StoragePaths; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use uuid::Uuid; + +#[test] +fn privileged_fresh_install_ignores_forged_newer_cache_and_has_no_transport() { + let workspace = TestWorkspace::new(); + let paths = workspace.storage_paths(); + let forged = paths + .packages_dir + .join(ComponentId::SingBox.as_str()) + .join("999.0.0"); + fs::create_dir_all(&forged).expect("create forged cache"); + fs::write(forged.join("forged.zip"), b"not an official package").expect("write forged cache"); + let service = + ComponentPackageService::open(bundled_root(), &paths).expect("open component packages"); + let verifier = ExplicitTestBundleVerifier::default(); + + let lease = service + .lease_bundled_for_privileged_install(ComponentId::SingBox, &verifier) + .expect("lease immutable bundled sing-box"); + + assert_eq!(lease.proof().source, PackageSource::Bundled); + assert_eq!(lease.proof().version, "1.13.19"); + assert_eq!(lease.proof().independent_proof, None); + assert_eq!(verifier.calls.load(Ordering::SeqCst), 1); + assert!(lease.asset_path().starts_with(bundled_root())); + // There is intentionally no transport argument on the fresh-install API. +} + +#[test] +fn native_bundle_verifier_rejects_user_owned_or_noncanonical_bundle() { + let workspace = TestWorkspace::new(); + let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths()) + .expect("open component packages"); + + assert!(matches!( + service.lease_bundled_for_privileged_install( + ComponentId::SingBox, + &NativePrivilegedBundleVerifier + ), + Err(ComponentPackagesError::UntrustedBundleRoot) + )); +} + +#[test] +fn privileged_update_requires_the_exact_live_latest_proof_before_staging() { + let workspace = TestWorkspace::new(); + let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths()) + .expect("open component packages"); + let plan = sing_box_plan(b"cached sing-box update"); + let mut wrong_latest = plan.clone(); + wrong_latest.independent_proof.release_id += 1; + let transport = MetadataTransport::success(metadata_for(&wrong_latest)); + + assert!(matches!( + service.lease_cached_update_for_privileged_install( + &plan, + workspace.path(), + &transport, + &NoopVerifier, + ), + Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan) + )); + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); + assert_no_privileged_staging(workspace.path()); + + let timeout = MetadataTransport::failure(); + assert!(matches!( + service.lease_cached_update_for_privileged_install( + &plan, + workspace.path(), + &timeout, + &NoopVerifier, + ), + Err(ComponentPackagesError::Transport) + )); + assert_eq!(timeout.calls.load(Ordering::SeqCst), 1); + assert_no_privileged_staging(workspace.path()); +} + +#[cfg(windows)] +#[test] +fn tampered_cache_fails_before_any_admin_staging_is_created() { + let workspace = TestWorkspace::new(); + let paths = workspace.storage_paths(); + let service = + ComponentPackageService::open(bundled_root(), &paths).expect("open component packages"); + let plan = sing_box_plan(b"official update bytes"); + write_cache(&paths, &plan, b"tampered cache bytes"); + let transport = MetadataTransport::success(metadata_for(&plan)); + + assert!(matches!( + service.lease_cached_update_for_privileged_install( + &plan, + workspace.path(), + &transport, + &NoopVerifier, + ), + Err(ComponentPackagesError::DigestMismatch) + )); + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); + assert_no_privileged_staging(workspace.path()); +} + +#[test] +fn winsw_and_vc_runtime_make_zero_privileged_update_transport_calls() { + let workspace = TestWorkspace::new(); + let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths()) + .expect("open component packages"); + + for component_id in [ComponentId::Winsw, ComponentId::VcRuntime] { + let transport = MetadataTransport::failure(); + let mut plan = sing_box_plan(b"unused"); + plan.component_id = component_id; + assert!(matches!( + service.lease_cached_update_for_privileged_install( + &plan, + workspace.path(), + &transport, + &NoopVerifier, + ), + Err(ComponentPackagesError::NoTrustedUpdate) + )); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + } +} + +#[derive(Default)] +struct ExplicitTestBundleVerifier { + calls: AtomicUsize, +} + +struct NoopVerifier; + +impl PackageSignatureVerifier for NoopVerifier { + fn verify(&self, _path: &Path) -> Result { + Err(SignatureVerifierError) + } +} + +impl PrivilegedBundleVerifier for ExplicitTestBundleVerifier { + fn verify( + &self, + bundled_root: &Path, + catalog_path: &Path, + asset_path: &Path, + ) -> Result<(), PrivilegedBundleVerificationError> { + self.calls.fetch_add(1, Ordering::SeqCst); + if catalog_path != bundled_root.join("catalog.json") + || !asset_path.starts_with(bundled_root) + { + return Err(PrivilegedBundleVerificationError); + } + Ok(()) + } +} + +struct MetadataTransport { + response: Result, UpdateTransportError>, + calls: AtomicUsize, +} + +impl MetadataTransport { + fn success(response: Vec) -> Self { + Self { + response: Ok(response), + calls: AtomicUsize::new(0), + } + } + + fn failure() -> Self { + Self { + response: Err(UpdateTransportError::RequestFailed), + calls: AtomicUsize::new(0), + } + } +} + +impl UpdateTransport for MetadataTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.kind, UpdateRequestKind::GithubReleaseMetadata); + assert_eq!( + request.url, + "https://api.github.com/repos/SagerNet/sing-box/releases/latest" + ); + let body = self.response.clone()?; + Ok(UpdateTransportResponse { + status: 200, + location: None, + content_length: Some(body.len() as u64), + body: Box::new(Cursor::new(body)), + }) + } +} + +fn sing_box_plan(bytes: &[u8]) -> PrivilegedCachedUpdatePlan { + let version = "1.14.0"; + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let sha256 = format!("{:x}", Sha256::digest(bytes)); + PrivilegedCachedUpdatePlan { + component_id: ComponentId::SingBox, + version: version.to_string(), + independent_proof: GithubReleaseDigestProof { + repository: "SagerNet/sing-box".to_string(), + release_id: 700, + asset_id: 701, + stable_tag: format!("v{version}"), + asset_name, + size: bytes.len() as u64, + sha256_from_api: sha256, + verified_signatures: Vec::new(), + }, + } +} + +fn metadata_for(plan: &PrivilegedCachedUpdatePlan) -> Vec { + let proof = &plan.independent_proof; + serde_json::to_vec(&json!({ + "id": proof.release_id, + "tag_name": proof.stable_tag, + "draft": false, + "prerelease": false, + "assets": [{ + "id": proof.asset_id, + "name": proof.asset_name, + "size": proof.size, + "digest": format!("sha256:{}", proof.sha256_from_api), + "browser_download_url": format!( + "https://github.com/{}/releases/download/{}/{}", + proof.repository, proof.stable_tag, proof.asset_name + ) + }] + })) + .expect("serialize GitHub metadata") +} + +#[cfg(windows)] +fn write_cache(paths: &StoragePaths, plan: &PrivilegedCachedUpdatePlan, bytes: &[u8]) { + use proxywarden_lib::component_packages::{ + PackageCacheManifest, PACKAGE_CACHE_MANIFEST_FILENAME, + PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + }; + + let component_root = paths.packages_dir.join(plan.component_id.as_str()); + let version_root = component_root.join(&plan.version); + fs::create_dir_all(&version_root).expect("create cache directory"); + let asset_path = version_root.join(&plan.independent_proof.asset_name); + fs::write(&asset_path, bytes).expect("write cache bytes"); + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + let manifest = PackageCacheManifest { + schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + component_id: plan.component_id, + version: plan.version.clone(), + asset_name: plan.independent_proof.asset_name.clone(), + sha256: plan.independent_proof.sha256_from_api.clone(), + size: plan.independent_proof.size, + independent_proof: plan.independent_proof.clone(), + }; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize cache manifest"), + ) + .expect("write cache manifest"); + for path in [ + paths.packages_dir.as_path(), + component_root.as_path(), + version_root.as_path(), + manifest_path.as_path(), + asset_path.as_path(), + ] { + protect_path_for_owner_admin_system(path).expect("protect cache path"); + } +} + +fn assert_no_privileged_staging(parent: &Path) { + let staging = fs::read_dir(parent) + .expect("read staging parent") + .filter_map(Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with(".package-")) + .collect::>(); + assert!( + staging.is_empty(), + "unexpected staging entries: {staging:?}" + ); +} + +fn bundled_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("bundled") + .join("components") +} + +struct TestWorkspace { + path: PathBuf, +} + +impl TestWorkspace { + fn new() -> Self { + let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target).expect("create Cargo target directory"); + let path = target.join(format!("privileged-package-trust-{}", Uuid::new_v4())); + fs::create_dir(&path).expect("create test workspace"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + + fn storage_paths(&self) -> StoragePaths { + StoragePaths::new(&self.path) + } +} + +impl Drop for TestWorkspace { + fn drop(&mut self) { + if self + .path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("privileged-package-trust-")) + { + let _ = fs::remove_dir_all(&self.path); + } + } +} diff --git a/src-tauri/tests/privileged_runtime_tests.rs b/src-tauri/tests/privileged_runtime_tests.rs new file mode 100644 index 0000000..68b4719 --- /dev/null +++ b/src-tauri/tests/privileged_runtime_tests.rs @@ -0,0 +1,1325 @@ +use proxywarden_lib::adapters::singbox::{ + SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError, + SingBoxGenerationRequest, +}; +use proxywarden_lib::component_catalog::ComponentId as CatalogComponentId; +use proxywarden_lib::component_cutover::{ + ComponentCutoverObservation, ComponentCutoverUserEvidence, CutoverDisplayState, + CutoverExecutionMode, CutoverHostError, CutoverPhase, LegacyServiceState, StateFingerprint, + CUTOVER_OBSERVATION_SCHEMA_VERSION, CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, +}; +use proxywarden_lib::component_inventory::{ + component_inventory_fingerprint_for_cutover, CandidateRole, ComponentCandidate, + ComponentClassification, ComponentInventory, MarkerEvidence, ServiceEvidence, +}; +use proxywarden_lib::component_packages::{ + ComponentPackageService, ComponentUpdateObservation, ComponentUpdatesState, + GithubReleaseDigestProof, PackageCacheManifest, PackageSignatureVerifier, + PrivilegedBundleVerificationError, PrivilegedBundleVerifier, PrivilegedCachedUpdatePlan, + SignaturePublisher, SignatureVerifierError, TrustedGithubReleaseObservation, UpdateRequestKind, + UpdateTransport, UpdateTransportError, UpdateTransportRequest, UpdateTransportResponse, + COMPONENT_UPDATES_STATE_SCHEMA_VERSION, PACKAGE_CACHE_MANIFEST_FILENAME, + PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, +}; +use proxywarden_lib::models::{LocalSingBoxConfig, SubscriptionCache}; +use proxywarden_lib::privileged_jobs::{ + cutover_recovery_probe_fingerprint, ManagedComponent, PrivilegedAction, PrivilegedActionRunner, + PrivilegedCutoverMode, PrivilegedPlanResolver, PrivilegedRunnerFailure, +}; +use proxywarden_lib::privileged_runtime::{ + classify_cutover_runtime_config_for_tests, dispatch_candidate_observation_for_tests, + shared_service_name_path_ownership_for_tests, + stopped_cutover_route_lifecycle_exception_for_tests, validate_generated_config_bytes_for_tests, + LocalPrivilegedPlanResolver, SystemPrivilegedRuntime, +}; +use proxywarden_lib::proxifyre_runtime::{ProxifyreNativeHostError, RuntimeConfigVerification}; +use proxywarden_lib::safe_fs; +use proxywarden_lib::storage::StoragePaths; +use proxywarden_lib::subscription::parse_subscription_body; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use uuid::Uuid; + +#[test] +fn current_verification_distinguishes_exact_config_mismatch_from_ambiguous_read_failure() { + assert_eq!( + classify_cutover_runtime_config_for_tests(Ok(RuntimeConfigVerification::Match)), + Ok(()) + ); + assert_eq!( + classify_cutover_runtime_config_for_tests(Ok(RuntimeConfigVerification::Mismatch)), + Err(CutoverHostError::VerificationFailed), + "successfully read mismatching bytes may trigger the sealed rollback path" + ); + assert_eq!( + classify_cutover_runtime_config_for_tests(Err(ProxifyreNativeHostError)), + Err(CutoverHostError::ObservationFailed), + "ACL, open, read, or revalidation failures remain ambiguous and must not trigger rollback" + ); +} + +#[test] +fn restart_recovery_dispatches_pending_candidate_observation_without_live_package_writer() { + let live_calls = AtomicUsize::new(0); + let recovered_calls = AtomicUsize::new(0); + let expected_fingerprint = StateFingerprint::digest("test:recovered-candidate", b"expected"); + let expected_identity = safe_fs::StableObjectIdentity { + volume_serial_number: 7, + file_id: 11, + kind: safe_fs::StableObjectKind::RegularFile, + size: 13, + }; + + let observed = dispatch_candidate_observation_for_tests( + false, + || { + live_calls.fetch_add(1, Ordering::SeqCst); + Err(CutoverHostError::ObservationFailed) + }, + || { + recovered_calls.fetch_add(1, Ordering::SeqCst); + Ok(( + expected_fingerprint.clone(), + Some(expected_identity.clone()), + )) + }, + ) + .expect("package-free recovery observation"); + + assert_eq!(live_calls.load(Ordering::SeqCst), 0); + assert_eq!(recovered_calls.load(Ordering::SeqCst), 1); + assert_eq!(observed.0, expected_fingerprint); + assert_eq!(observed.1, Some(expected_identity)); +} + +#[test] +fn shared_service_name_is_owned_by_exact_binary_path_not_scm_record_existence() { + assert_eq!( + shared_service_name_path_ownership_for_tests(true, false), + (true, false), + "the legacy binary owns the shared SCM name before cutover and after rollback" + ); + assert_eq!( + shared_service_name_path_ownership_for_tests(false, true), + (false, true), + "the current binary owns the same SCM name after cutover" + ); + assert_eq!( + shared_service_name_path_ownership_for_tests(false, false), + (false, false), + "a missing or foreign path is not silently assigned to either owner" + ); + assert_eq!( + shared_service_name_path_ownership_for_tests(true, true), + (false, false), + "an ambiguous dual match is never authorized" + ); +} + +#[test] +fn fresh_bundled_plans_are_exact_ordered_and_make_zero_transport_calls() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let packages = package_service(&paths); + let transport = Arc::new(CountingTransport::default()); + let bundle = Arc::new(CountingBundleVerifier::default()); + let runtime = SystemPrivilegedRuntime::from_parts_for_tests( + Some(packages), + paths, + workspace.path().join("staging"), + bundle.clone(), + Some(transport.clone()), + Arc::new(RejectingSignatureVerifier), + ); + + let proxifyre = runtime + .resolve(PrivilegedAction::InstallProxifyre) + .expect("fresh ProxiFyre plan"); + let proxifyre_package = proxifyre.package.expect("ProxiFyre package"); + assert_eq!(proxifyre_package.version, "2.4.0"); + assert_eq!( + proxifyre_package + .assets + .iter() + .map(|asset| (asset.component_id, asset.version.as_str())) + .collect::>(), + vec![ + (CatalogComponentId::Proxifyre, "2.4.0"), + (CatalogComponentId::WindowsPacketFilter, "3.6.2"), + (CatalogComponentId::VcRuntime, "14.51.36247.0"), + ] + ); + + let singbox = runtime + .resolve(PrivilegedAction::InstallSingBox) + .expect("fresh sing-box plan"); + let singbox_package = singbox.package.expect("sing-box package"); + assert_eq!(singbox_package.version, "1.13.19"); + assert_eq!( + singbox_package + .assets + .iter() + .map(|asset| (asset.component_id, asset.version.as_str())) + .collect::>(), + vec![ + (CatalogComponentId::SingBox, "1.13.19"), + (CatalogComponentId::Winsw, "2.12.0"), + ] + ); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); + assert_eq!(bundle.calls.load(Ordering::SeqCst), 5); +} + +#[test] +fn local_resolver_is_stateless_and_rebuilds_the_same_plan() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let bundle = Arc::new(CountingBundleVerifier::default()); + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests( + Some(package_service(&paths)), + paths, + bundle.clone(), + ); + + let first = resolver + .resolve(PrivilegedAction::InstallProxifyre) + .expect("first resolve"); + let second = resolver + .resolve(PrivilegedAction::InstallProxifyre) + .expect("second resolve"); + + assert_eq!(first, second); + assert_eq!(bundle.calls.load(Ordering::SeqCst), 6); +} + +#[test] +fn deleted_or_corrupt_ux_cache_converges_through_new_hint_then_asset_free_recovery() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let bundle = Arc::new(CountingBundleVerifier::default()); + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_startup_session( + Some(package_service(&paths)), + paths.clone(), + bundle.clone(), + "11111111-1111-4111-8111-111111111111", + ); + let missing_inventory = ComponentInventory { + component_id: proxywarden_lib::models::ComponentId::Proxyfier, + candidates: Vec::new(), + selected: None, + issues: Vec::new(), + }; + + let missing_cache = resolver + .resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CutoverProxifyre, + missing_inventory.clone(), + ) + .expect("missing cache emits a side-effect-free new hint"); + assert_eq!( + missing_cache + .cutover_context + .as_ref() + .expect("cutover context") + .mode, + PrivilegedCutoverMode::New + ); + assert_eq!( + missing_cache + .package + .as_ref() + .expect("new hint exact bundle") + .assets + .len(), + 3 + ); + assert!(!paths + .generated_dir + .join("proxifyre-app-config.json") + .exists()); + + fs::create_dir_all(&paths.state_dir).expect("state directory"); + fs::write(&paths.component_cutover_observation_file, b"{").expect("corrupt UX observation"); + let corrupt_cache = resolver + .resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CutoverProxifyre, + missing_inventory.clone(), + ) + .expect("corrupt cache emits the same safe new hint"); + assert_eq!(missing_cache, corrupt_cache); + + let cutover_id = "22222222-2222-4222-8222-222222222222"; + let observation = ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: cutover_id.to_string(), + state: CutoverDisplayState::AwaitingNextStart, + phase: CutoverPhase::LegacyQuarantined, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "a".repeat(64), + transaction_fingerprint: "b".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "legacy-proxifyre".to_string(), + current_path_label: "current-proxifyre".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 10, + }; + fs::write( + &paths.component_cutover_observation_file, + serde_json::to_vec(&observation).expect("encode observation"), + ) + .expect("restored redacted observation"); + let recovery = resolver + .resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CutoverProxifyre, + missing_inventory, + ) + .expect("recovery plan"); + assert!(recovery.package.is_none()); + assert_eq!( + recovery + .cutover_context + .as_ref() + .expect("recovery context") + .mode, + PrivilegedCutoverMode::Recovery + ); + assert_eq!(bundle.calls.load(Ordering::SeqCst), 6); +} + +#[test] +fn non_absent_cutover_uses_canonical_probe_without_trusting_local_observation() { + for cache_case in ["missing", "corrupt", "stale-valid"] { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + fs::create_dir_all(&paths.state_dir).expect("state directory"); + match cache_case { + "missing" => {} + "corrupt" => { + fs::write(&paths.component_cutover_observation_file, b"{") + .expect("corrupt observation"); + } + "stale-valid" => { + let observation = ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: "22222222-2222-4222-8222-222222222222".to_string(), + state: CutoverDisplayState::Complete, + phase: CutoverPhase::ComponentComplete, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "a".repeat(64), + transaction_fingerprint: "b".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "legacy-proxifyre".to_string(), + current_path_label: "current-proxifyre".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 10, + }; + fs::write( + &paths.component_cutover_observation_file, + serde_json::to_vec(&observation).expect("encode observation"), + ) + .expect("stale terminal observation"); + } + _ => unreachable!(), + } + let bundle = Arc::new(CountingBundleVerifier::default()); + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_cutover_app_root( + Some(package_service(&paths)), + paths, + bundle.clone(), + "11111111-1111-4111-8111-111111111111", + workspace.path().join("untrusted-test-app-root"), + ); + let inventory = ComponentInventory { + component_id: proxywarden_lib::models::ComponentId::Proxyfier, + candidates: Vec::new(), + selected: None, + issues: Vec::new(), + }; + let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory); + let plan = resolver + .resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CutoverProxifyre, + inventory, + ) + .expect("canonical sealed recovery probe"); + let context = plan.cutover_context.expect("probe context"); + assert_eq!(context.mode, PrivilegedCutoverMode::Recovery); + assert_eq!(context.cutover_id, None); + assert_eq!(context.initiating_startup_session_id, None); + assert_eq!(context.user_evidence, None); + assert_eq!( + context.evidence_fingerprint, + cutover_recovery_probe_fingerprint(&inventory_fingerprint) + ); + assert!(plan.package.is_none()); + assert_eq!(bundle.calls.load(Ordering::SeqCst), 0); + } +} + +#[test] +fn non_start_actions_do_not_require_generated_config_and_are_domain_separated() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests( + Some(package_service(&paths)), + paths, + Arc::new(CountingBundleVerifier::default()), + ); + + let stop = resolver + .resolve(PrivilegedAction::StopProxifyre) + .expect("stop plan without generated config"); + let uninstall = resolver + .resolve(PrivilegedAction::UninstallProxifyre) + .expect("uninstall plan without generated config"); + assert_ne!( + stop.configuration_fingerprint, + uninstall.configuration_fingerprint + ); + assert_eq!( + resolver.resolve(PrivilegedAction::StartProxifyre), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); +} + +#[cfg(windows)] +#[test] +fn missing_or_corrupt_catalog_only_blocks_package_actions() { + let workspace = TestDirectory::new(); + let missing_root = workspace.path().join("missing-components"); + let corrupt_root = workspace.path().join("corrupt-components"); + fs::create_dir(&corrupt_root).expect("create corrupt bundle root"); + fs::write(corrupt_root.join("catalog.json"), b"{").expect("write corrupt catalog"); + + for (case, bundle_root) in [("missing", missing_root), ("corrupt", corrupt_root)] { + let paths = StoragePaths::new(workspace.path().join(format!("storage-{case}"))); + assert!( + ComponentPackageService::open(&bundle_root, &paths).is_err(), + "fixture catalog must be unavailable" + ); + safe_fs::write_restricted_atomic( + &paths.generated_dir.join("proxifyre-app-config.json"), + &proxifyre_config_bytes(), + ) + .expect("restricted generated source"); + let bundle = Arc::new(CountingBundleVerifier::default()); + let local = + LocalPrivilegedPlanResolver::from_parts_for_tests(None, paths.clone(), bundle.clone()); + let system = SystemPrivilegedRuntime::from_parts_for_tests( + None, + paths, + workspace + .path() + .join(format!("app-{case}")) + .join(".proxywarden-package-staging"), + bundle.clone(), + None, + Arc::new(RejectingSignatureVerifier), + ); + + for action in [ + PrivilegedAction::StartProxifyre, + PrivilegedAction::StopProxifyre, + PrivilegedAction::UninstallSingBox, + PrivilegedAction::ConfigureProxifyreFirewall, + ] { + assert!(local.resolve(action).is_ok(), "local {case}: {action:?}"); + assert!(system.resolve(action).is_ok(), "system {case}: {action:?}"); + } + for action in [ + PrivilegedAction::InstallProxifyre, + PrivilegedAction::InstallSingBox, + PrivilegedAction::UpdateProxifyre, + PrivilegedAction::UpdateSingBox, + ] { + assert_eq!( + local.resolve(action), + Err(PrivilegedRunnerFailure::PackageVerificationFailed), + "local {case}: {action:?}" + ); + assert_eq!( + system.resolve(action), + Err(PrivilegedRunnerFailure::PackageVerificationFailed), + "system {case}: {action:?}" + ); + } + assert_eq!(bundle.calls.load(Ordering::SeqCst), 0); + } +} + +#[test] +fn pending_action_is_consumed_on_plan_mismatch_without_transport_or_host_work() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let transport = Arc::new(CountingTransport::default()); + let runtime = SystemPrivilegedRuntime::from_parts_for_tests( + Some(package_service(&paths)), + paths, + workspace.path().join("staging"), + Arc::new(CountingBundleVerifier::default()), + Some(transport.clone()), + Arc::new(RejectingSignatureVerifier), + ); + let plan = runtime + .resolve(PrivilegedAction::StopProxifyre) + .expect("prepare stop"); + let mut changed = plan.clone(); + changed.inventory_fingerprint = "f".repeat(64); + + assert_eq!( + runtime.run(PrivilegedAction::StopProxifyre, &changed), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); + assert_eq!( + runtime.run(PrivilegedAction::StopProxifyre, &plan), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); + assert_eq!(transport.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn stopped_cutover_allows_only_legacy_quarantined_start_stop_route_window() { + let config = "a".repeat(64); + for action in [ + PrivilegedAction::StartProxifyre, + PrivilegedAction::StopProxifyre, + ] { + assert_eq!( + stopped_cutover_route_lifecycle_exception_for_tests( + action, + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::AwaitingUser, + LegacyServiceState::Stopped, + &config, + ), + Some(config.clone()) + ); + } + + for (action, phase, mode, original_state) in [ + ( + PrivilegedAction::InstallProxifyre, + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::AwaitingUser, + LegacyServiceState::Stopped, + ), + ( + PrivilegedAction::StartProxifyre, + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::AwaitingUser, + LegacyServiceState::Running, + ), + ( + PrivilegedAction::StartProxifyre, + CutoverPhase::NextStartVerified, + CutoverExecutionMode::AwaitingUser, + LegacyServiceState::Stopped, + ), + ( + PrivilegedAction::StopProxifyre, + CutoverPhase::LegacyQuarantined, + CutoverExecutionMode::RollingBack, + LegacyServiceState::Stopped, + ), + ] { + assert_eq!( + stopped_cutover_route_lifecycle_exception_for_tests( + action, + phase, + mode, + original_state, + &config, + ), + None, + "unexpected lifecycle exception for {action:?}/{phase:?}/{mode:?}/{original_state:?}" + ); + } +} + +#[test] +fn cleanup_plan_requires_user_evidence_from_the_same_normal_app_session() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + fs::create_dir_all(&paths.state_dir).expect("state directory"); + let current_inventory = managed_current_proxifyre_inventory(); + let current_inventory_fingerprint = + component_inventory_fingerprint_for_cutover(¤t_inventory); + let cutover_id = "22222222-2222-4222-8222-222222222222"; + let observation = ComponentCutoverObservation { + schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION, + component: "proxifyre".to_string(), + cutover_id: cutover_id.to_string(), + state: CutoverDisplayState::AwaitingNextStart, + phase: CutoverPhase::LegacyQuarantined, + original_service_state: LegacyServiceState::Stopped, + legacy_version: "2.2.1".to_string(), + bundled_version: "2.4.0".to_string(), + operation_fingerprint: "a".repeat(64), + transaction_fingerprint: "b".repeat(64), + evidence_fingerprint: None, + next_start_verified: false, + route_smoke_confirmed: false, + legacy_path_label: "legacy-proxifyre".to_string(), + current_path_label: "current-proxifyre".to_string(), + can_recover: false, + can_cleanup: false, + disabled_code: None, + updated_at_epoch_seconds: 10, + }; + fs::write( + &paths.component_cutover_observation_file, + serde_json::to_vec(&observation).expect("encode observation"), + ) + .expect("write observation"); + let expected_session = "11111111-1111-4111-8111-111111111111"; + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_startup_session( + None, + paths.clone(), + Arc::new(CountingBundleVerifier::default()), + expected_session, + ); + let mut evidence = ComponentCutoverUserEvidence { + schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION, + cutover_id: cutover_id.to_string(), + startup_session_id: "33333333-3333-4333-8333-333333333333".to_string(), + current_inventory_fingerprint, + route_smoke_confirmed: true, + observed_at_epoch_seconds: 11, + confirmed_at_epoch_seconds: Some(12), + }; + fs::write( + &paths.component_cutover_user_evidence_file, + serde_json::to_vec(&evidence).expect("encode foreign-session evidence"), + ) + .expect("write foreign-session evidence"); + assert_eq!( + resolver.resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CleanupProxifyreQuarantine, + current_inventory.clone(), + ), + Err(PrivilegedRunnerFailure::CutoverStateConflict) + ); + + evidence.startup_session_id = expected_session.to_string(); + fs::write( + &paths.component_cutover_user_evidence_file, + serde_json::to_vec(&evidence).expect("encode same-session evidence"), + ) + .expect("write same-session evidence"); + let cleanup = resolver + .resolve_cutover_action_with_inventory_for_tests( + PrivilegedAction::CleanupProxifyreQuarantine, + current_inventory, + ) + .expect("same-session cleanup plan"); + assert!(cleanup.package.is_none()); + assert_eq!( + cleanup + .cutover_context + .as_ref() + .expect("cleanup context") + .mode, + PrivilegedCutoverMode::Cleanup + ); +} + +#[test] +fn generated_config_rejects_weak_acl_before_schema_or_runner() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + fs::create_dir_all(&paths.generated_dir).expect("generated directory"); + fs::write( + paths.generated_dir.join("proxifyre-app-config.json"), + proxifyre_config_bytes(), + ) + .expect("ordinary file"); + let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests( + Some(package_service(&paths)), + paths, + Arc::new(CountingBundleVerifier::default()), + ); + + assert_eq!( + resolver.resolve(PrivilegedAction::StartProxifyre), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); +} + +#[cfg(windows)] +#[test] +fn elevated_resolve_holds_exact_generated_source_and_mismatch_drops_it() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let generated = paths.generated_dir.join("proxifyre-app-config.json"); + safe_fs::write_restricted_atomic(&generated, &proxifyre_config_bytes()) + .expect("restricted generated source"); + let runtime = SystemPrivilegedRuntime::from_parts_for_tests( + Some(package_service(&paths)), + paths, + workspace.path().join("staging"), + Arc::new(CountingBundleVerifier::default()), + Some(Arc::new(CountingTransport::default())), + Arc::new(RejectingSignatureVerifier), + ); + let plan = runtime + .resolve(PrivilegedAction::StartProxifyre) + .expect("leased start plan"); + assert!( + fs::write(&generated, proxifyre_config_bytes()).is_err(), + "deny-share-write lease must block source replacement" + ); + let mut changed = plan.clone(); + changed.configuration_fingerprint = "f".repeat(64); + assert_eq!( + runtime.run(PrivilegedAction::StartProxifyre, &changed), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); + fs::write(&generated, proxifyre_config_bytes()).expect("lease released after mismatch"); +} + +#[test] +fn strict_generated_schema_rejects_unknown_and_dangerous_fields() { + let proxifyre = serde_json::from_slice::(&proxifyre_config_bytes()).expect("ProxiFyre"); + assert!(validate_generated_config_bytes_for_tests( + ManagedComponent::Proxifyre, + &serde_json::to_vec(&proxifyre).expect("encode") + )); + let mut unknown_root = proxifyre.clone(); + unknown_root["command"] = json!("calc.exe"); + assert!(!validate_generated_config_bytes_for_tests( + ManagedComponent::Proxifyre, + &serde_json::to_vec(&unknown_root).expect("encode") + )); + let mut unknown_proxy = proxifyre; + unknown_proxy["proxies"][0]["password"] = json!("must-not-pass"); + assert!(!validate_generated_config_bytes_for_tests( + ManagedComponent::Proxifyre, + &serde_json::to_vec(&unknown_proxy).expect("encode") + )); + + let singbox = singbox_config_value(); + assert!(validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&singbox).expect("encode") + )); + let mut unknown_outbound = singbox.clone(); + unknown_outbound["outbounds"][0]["executable"] = json!("foreign.exe"); + assert!(!validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&unknown_outbound).expect("encode") + )); + let mut path_option = singbox; + path_option["outbounds"][0]["tls"]["certificate_path"] = json!("C:\\foreign.pem"); + assert!(!validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&path_option).expect("encode") + )); +} + +#[test] +fn production_subscription_adapter_outputs_are_authorized_for_all_supported_types() { + let bodies = [ + json!({ "outbounds": [{ + "type": "vless", "tag": "vless", "server": "vless.example.test", + "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111", + "flow": "", "packet_encoding": "xudp", "tls": { "enabled": true } + }]}), + json!({ "outbounds": [{ + "type": "vmess", "tag": "vmess", "server": "vmess.example.test", + "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222", + "security": "auto", "tls": { "enabled": true }, + "transport": { "type": "ws" } + }]}), + json!({ "outbounds": [{ + "type": "trojan", "tag": "trojan", "server": "trojan.example.test", + "server_port": 443, "password": "trojan-secret", "tls": { "enabled": true } + }]}), + json!({ "outbounds": [{ + "type": "shadowsocks", "tag": "shadowsocks", "server": "ss.example.test", + "server_port": 8388, "method": "aes-256-gcm", "password": "ss-secret" + }]}), + json!({ "outbounds": [{ + "type": "hysteria2", "tag": "hysteria2", "server": "hy2.example.test", + "server_port": 443, "password": "hy2-secret", + "obfs": { "type": "salamander", "password": "obfs-secret" }, + "tls": { "enabled": true } + }]}), + ]; + + for body in bodies { + let generated = generated_from_subscription(&body.to_string()); + assert!( + validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&generated).expect("encode generated config") + ), + "production adapter output was rejected: {generated}" + ); + } +} + +#[test] +fn raw_vless_and_trojan_ws_tls_without_server_name_are_authorized() { + for kind in ["vless", "trojan"] { + let credential = if kind == "vless" { + json!({ "uuid": "33333333-3333-3333-3333-333333333333" }) + } else { + json!({ "password": "trojan-secret" }) + }; + let mut outbound = json!({ + "type": kind, + "tag": format!("{kind}-ws"), + "server": format!("{kind}.example.test"), + "server_port": 443, + "tls": { "enabled": true }, + "transport": { + "type": "ws", + "path": "/socket", + "headers": { "Host": "front.example.test" } + } + }); + outbound + .as_object_mut() + .expect("outbound object") + .extend(credential.as_object().expect("credential object").clone()); + let generated = + generated_from_subscription(&json!({ "outbounds": [outbound] }).to_string()); + + assert!(validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&generated).expect("encode generated config") + )); + } +} + +#[test] +fn singbox_authorization_rejects_dangerous_passthrough_and_unsupported_shapes() { + let base = generated_from_subscription( + &json!({ "outbounds": [{ + "type": "vless", "tag": "vless-ws", "server": "vless.example.test", + "server_port": 443, "uuid": "44444444-4444-4444-4444-444444444444", + "tls": { "enabled": true }, + "transport": { "type": "ws" } + }]}) + .to_string(), + ); + let mut rejected = Vec::new(); + for key in [ + "command", + "service", + "executable", + "plugin", + "multiplex", + "detour", + "certificate_path", + "config_path", + ] { + let mut value = base.clone(); + value["outbounds"][0][key] = json!(r"C:\foreign\payload.exe"); + rejected.push((key, value)); + } + let mut unsupported_transport = base.clone(); + unsupported_transport["outbounds"][0]["transport"]["type"] = json!("grpc"); + rejected.push(("non-ws transport", unsupported_transport)); + let mut foreign_header = base.clone(); + foreign_header["outbounds"][0]["transport"]["headers"] = json!({ "Authorization": "secret" }); + rejected.push(("foreign WS header", foreign_header)); + let mut control_path = base.clone(); + control_path["outbounds"][0]["transport"]["path"] = json!("/ws\ncontrol"); + rejected.push(("control character in WS path", control_path)); + let mut control_host = base.clone(); + control_host["outbounds"][0]["transport"]["headers"] = + json!({ "Host": "front.example.test\r\nInjected: value" }); + rejected.push(("control character in Host", control_host)); + + let mut shadowsocks_transport = generated_from_subscription( + &json!({ "outbounds": [{ + "type": "shadowsocks", "tag": "ss", "server": "ss.example.test", + "server_port": 8388, "method": "aes-256-gcm", "password": "secret" + }]}) + .to_string(), + ); + shadowsocks_transport["outbounds"][0]["transport"] = json!({ "type": "ws" }); + rejected.push(("transport on shadowsocks", shadowsocks_transport)); + + let mut wrong_obfs = generated_from_subscription( + &json!({ "outbounds": [{ + "type": "hysteria2", "tag": "hy2", "server": "hy2.example.test", + "server_port": 443, "password": "secret", + "obfs": { "type": "other", "password": "obfs-secret" } + }]}) + .to_string(), + ); + wrong_obfs["outbounds"][0]["obfs"]["type"] = json!("other"); + rejected.push(("non-salamander obfs", wrong_obfs)); + + for (label, value) in rejected { + assert!( + !validate_generated_config_bytes_for_tests( + ManagedComponent::SingBox, + &serde_json::to_vec(&value).expect("encode rejected config") + ), + "dangerous shape was authorized: {label}" + ); + } +} + +#[cfg(windows)] +#[test] +fn cached_update_resolve_holds_exact_origin_and_locked_staging_until_one_shot_run() { + let workspace = TestDirectory::new(); + let paths = StoragePaths::new(workspace.path().join("storage")); + let bytes = b"synthetic cached sing-box update"; + let cached = seed_singbox_cache(&paths, bytes); + let transport = Arc::new(ExactMetadataTransport::new(metadata_for(&cached))); + let staging_parent = workspace + .path() + .join("app") + .join(".proxywarden-package-staging"); + fs::create_dir_all(staging_parent.parent().expect("staging app root")) + .expect("create staging app root"); + let packages = package_service(&paths); + assert_eq!( + packages + .plan_cached_update(CatalogComponentId::SingBox) + .expect("select trusted cached update"), + cached + ); + let runtime = SystemPrivilegedRuntime::from_parts_for_tests( + Some(packages), + paths, + staging_parent.clone(), + Arc::new(CountingBundleVerifier::default()), + Some(transport.clone()), + Arc::new(RejectingSignatureVerifier), + ); + + let plan = runtime + .resolve(PrivilegedAction::UpdateSingBox) + .expect("prepare cached update"); + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); + let package = plan.package.as_ref().expect("cached package fingerprint"); + let origin = package + .origin_proof + .as_ref() + .expect("official origin proof"); + assert_eq!(package.version, cached.version); + assert_eq!(origin.repository, cached.independent_proof.repository); + assert_eq!(origin.release_id, cached.independent_proof.release_id); + assert_eq!(origin.asset_id, cached.independent_proof.asset_id); + assert_eq!( + origin.sha256_from_api, + cached.independent_proof.sha256_from_api + ); + + let staged_directory = only_child(&staging_parent); + let staged_asset = staged_directory.join(&cached.independent_proof.asset_name); + safe_fs::verify_path_protected_for_owner_admin_system(&staged_directory) + .expect("sealed staged directory"); + safe_fs::verify_path_protected_for_owner_admin_system(&staged_asset) + .expect("sealed staged asset"); + assert!( + fs::write(&staged_asset, b"replacement").is_err(), + "pending package lease must deny source replacement" + ); + + let mut mismatch = plan.clone(); + mismatch.inventory_fingerprint = "f".repeat(64); + assert_eq!( + runtime.run(PrivilegedAction::UpdateSingBox, &mismatch), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); + assert!( + fs::read_dir(&staging_parent) + .expect("read staging parent") + .next() + .is_none(), + "mismatch must drop the lease and remove its staging directory" + ); + assert_eq!( + runtime.run(PrivilegedAction::UpdateSingBox, &plan), + Err(PrivilegedRunnerFailure::PreconditionFailed) + ); + assert_eq!(transport.calls.load(Ordering::SeqCst), 1); +} + +fn package_service(paths: &StoragePaths) -> ComponentPackageService { + ComponentPackageService::open(bundled_root(), paths).expect("open bundled package service") +} + +fn bundled_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("bundled/components") +} + +fn proxifyre_config_bytes() -> Vec { + serde_json::to_vec(&json!({ + "logLevel": "Info", + "bypassLan": true, + "proxies": [{ + "appNames": ["browser.exe"], + "socks5ProxyEndpoint": "127.0.0.1:1080", + "supportedProtocols": ["TCP", "UDP"] + }] + })) + .expect("encode ProxiFyre config") +} + +fn managed_current_proxifyre_inventory() -> ComponentInventory { + let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"); + let executable = root.join("ProxiFyre.exe"); + ComponentInventory { + component_id: proxywarden_lib::models::ComponentId::Proxyfier, + candidates: vec![ComponentCandidate { + component_id: proxywarden_lib::models::ComponentId::Proxyfier, + classification: ComponentClassification::ManagedCurrent, + role: CandidateRole::Current, + root, + executable_path: Some(executable.clone()), + binary_version: Some("2.4.0".to_string()), + service: Some(ServiceEvidence { + name: "ProxiFyreService".to_string(), + status: "Stopped".to_string(), + path_name: Some(executable.to_string_lossy().into_owned()), + executable_path: Some(executable), + path_matches_candidate: true, + binary_version: Some("2.4.0".to_string()), + }), + marker: MarkerEvidence::Valid, + issues: Vec::new(), + }], + selected: Some(0), + issues: Vec::new(), + } +} + +fn singbox_config_value() -> Value { + json!({ + "log": { "disabled": false, "level": "info", "timestamp": true }, + "inbounds": [{ + "type": "mixed", + "tag": "proxywarden-mixed-in", + "listen": "127.0.0.1", + "listen_port": 1080, + "users": [], + "set_system_proxy": false + }], + "outbounds": [ + { + "type": "vless", + "tag": "vpn", + "server": "edge.example.test", + "server_port": 443, + "uuid": "11111111-1111-1111-1111-111111111111", + "flow": "xtls-rprx-vision", + "packet_encoding": "xudp", + "tls": { + "enabled": true, + "server_name": "edge.example.test", + "utls": { "enabled": true, "fingerprint": "chrome" }, + "reality": { + "enabled": true, + "public_key": "fixture-public-key", + "short_id": "fixture-short-id" + } + } + }, + { "type": "direct", "tag": "direct" }, + { "type": "block", "tag": "block" } + ], + "route": { + "rules": [{ "ip_is_private": true, "outbound": "direct" }], + "final": "vpn" + } + }) +} + +fn generated_from_subscription(body: &str) -> Value { + let parsed = parse_subscription_body(body).expect("parse subscription fixture"); + let selected_server_id = parsed + .servers + .first() + .expect("supported subscription server") + .id + .clone(); + let config = LocalSingBoxConfig { + selected_server_id: Some(selected_server_id), + ..LocalSingBoxConfig::default() + }; + let cache = SubscriptionCache { + config: parsed.config, + servers: parsed.servers, + user_info: Default::default(), + fetched_at: "test-fixture".to_string(), + }; + let generated = SingBoxAdapter::default() + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &NeverConfigChecker, + ) + .expect("generate production sing-box config"); + serde_json::from_str(&generated.contents).expect("decode generated sing-box config") +} + +struct NeverConfigChecker; + +impl SingBoxConfigChecker for NeverConfigChecker { + fn check_config( + &self, + _binary_path: &Path, + _config_json: &str, + ) -> Result { + panic!("binary check is unreachable without a binary path") + } +} + +#[cfg(windows)] +fn seed_singbox_cache(paths: &StoragePaths, bytes: &[u8]) -> PrivilegedCachedUpdatePlan { + let version = "1.14.0"; + let asset_name = format!("sing-box-{version}-windows-amd64.zip"); + let sha256 = format!("{:x}", Sha256::digest(bytes)); + let proof = GithubReleaseDigestProof { + repository: "SagerNet/sing-box".to_string(), + release_id: 700, + asset_id: 701, + stable_tag: format!("v{version}"), + asset_name: asset_name.clone(), + size: bytes.len() as u64, + sha256_from_api: sha256.clone(), + verified_signatures: Vec::new(), + }; + let plan = PrivilegedCachedUpdatePlan { + component_id: CatalogComponentId::SingBox, + version: version.to_string(), + independent_proof: proof.clone(), + }; + let component_root = paths.packages_dir.join(plan.component_id.as_str()); + let version_root = component_root.join(version); + fs::create_dir_all(&version_root).expect("create cached package directories"); + let asset_path = version_root.join(&asset_name); + fs::write(&asset_path, bytes).expect("write cached package"); + let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); + let manifest = PackageCacheManifest { + schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, + component_id: plan.component_id, + version: version.to_string(), + asset_name, + sha256, + size: bytes.len() as u64, + independent_proof: proof.clone(), + }; + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("encode cache manifest"), + ) + .expect("write cache manifest"); + + let trusted = TrustedGithubReleaseObservation { + repository: proof.repository.clone(), + release_id: proof.release_id, + asset_id: proof.asset_id, + stable_tag: proof.stable_tag.clone(), + asset_name: proof.asset_name.clone(), + size: proof.size, + sha256_from_api: proof.sha256_from_api.clone(), + }; + let state = ComponentUpdatesState { + schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION, + observations: vec![ComponentUpdateObservation { + component_id: plan.component_id, + checked_at_unix: 1, + latest_known_version: version.to_string(), + trusted_releases: vec![trusted], + }], + }; + fs::create_dir_all(&paths.state_dir).expect("create update state directory"); + fs::write( + &paths.component_updates_file, + serde_json::to_vec_pretty(&state).expect("encode update state"), + ) + .expect("write update state"); + + for path in [ + paths.packages_dir.as_path(), + component_root.as_path(), + version_root.as_path(), + manifest_path.as_path(), + asset_path.as_path(), + paths.state_dir.as_path(), + paths.component_updates_file.as_path(), + ] { + safe_fs::protect_path_for_owner_admin_system(path).expect("protect trusted cache fixture"); + } + plan +} + +#[cfg(windows)] +fn metadata_for(plan: &PrivilegedCachedUpdatePlan) -> Vec { + let proof = &plan.independent_proof; + serde_json::to_vec(&json!({ + "id": proof.release_id, + "tag_name": proof.stable_tag, + "draft": false, + "prerelease": false, + "assets": [{ + "id": proof.asset_id, + "name": proof.asset_name, + "size": proof.size, + "digest": format!("sha256:{}", proof.sha256_from_api), + "browser_download_url": format!( + "https://github.com/{}/releases/download/{}/{}", + proof.repository, proof.stable_tag, proof.asset_name + ) + }] + })) + .expect("encode GitHub metadata") +} + +#[cfg(windows)] +fn only_child(parent: &Path) -> PathBuf { + let children = fs::read_dir(parent) + .expect("read parent") + .map(|entry| entry.expect("read child").path()) + .collect::>(); + assert_eq!(children.len(), 1, "expected one staged lease"); + children.into_iter().next().expect("staged child") +} + +#[derive(Default)] +struct CountingBundleVerifier { + calls: AtomicUsize, +} + +impl PrivilegedBundleVerifier for CountingBundleVerifier { + fn verify( + &self, + bundled_root: &Path, + catalog_path: &Path, + asset_path: &Path, + ) -> Result<(), PrivilegedBundleVerificationError> { + self.calls.fetch_add(1, Ordering::SeqCst); + if catalog_path == bundled_root.join("catalog.json") && asset_path.starts_with(bundled_root) + { + Ok(()) + } else { + Err(PrivilegedBundleVerificationError) + } + } +} + +#[derive(Default)] +struct CountingTransport { + calls: AtomicUsize, +} + +impl UpdateTransport for CountingTransport { + fn get( + &self, + _request: &UpdateTransportRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Err(UpdateTransportError::RequestFailed) + } +} + +struct RejectingSignatureVerifier; + +impl PackageSignatureVerifier for RejectingSignatureVerifier { + fn verify(&self, _path: &Path) -> Result { + Err(SignatureVerifierError) + } +} + +#[cfg(windows)] +struct ExactMetadataTransport { + body: Vec, + calls: AtomicUsize, +} + +#[cfg(windows)] +impl ExactMetadataTransport { + fn new(body: Vec) -> Self { + Self { + body, + calls: AtomicUsize::new(0), + } + } +} + +#[cfg(windows)] +impl UpdateTransport for ExactMetadataTransport { + fn get( + &self, + request: &UpdateTransportRequest, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.kind, UpdateRequestKind::GithubReleaseMetadata); + assert_eq!( + request.url, + "https://api.github.com/repos/SagerNet/sing-box/releases/latest" + ); + Ok(UpdateTransportResponse { + status: 200, + location: None, + content_length: Some(self.body.len() as u64), + body: Box::new(Cursor::new(self.body.clone())), + }) + } +} + +struct TestDirectory { + path: PathBuf, + target_root: PathBuf, +} + +impl TestDirectory { + fn new() -> Self { + let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); + fs::create_dir_all(&target_root).expect("create Cargo target directory"); + let target_root = fs::canonicalize(target_root).expect("canonical target directory"); + let path = target_root.join(format!("privileged-runtime-tests-{}", Uuid::new_v4())); + fs::create_dir(&path).expect("create isolated test directory"); + Self { path, target_root } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let exact_parent = self.path.parent() == Some(self.target_root.as_path()); + let valid_name = self + .path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("privileged-runtime-tests-")) + .is_some_and(|id| Uuid::parse_str(id).is_ok()); + if exact_parent + && valid_name + && self.path.is_absolute() + && safe_fs::ensure_no_reparse_ancestors(&self.path).is_ok() + { + let _ = fs::remove_dir_all(&self.path); + } + } +} diff --git a/src-tauri/tests/singbox_adapter_tests.rs b/src-tauri/tests/singbox_adapter_tests.rs index 46805fe..fc916b5 100644 --- a/src-tauri/tests/singbox_adapter_tests.rs +++ b/src-tauri/tests/singbox_adapter_tests.rs @@ -86,6 +86,49 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() { assert!(checker.calls.borrow().is_empty()); } +#[test] +fn same_endpoint_uses_exact_outbound_id_and_never_falls_back_from_missing_id() { + let parsed = proxywarden_lib::subscription::parse_subscription_body(r#"{"outbounds":[ + {"type":"vless","tag":"same%20label","server":"edge.example.test","server_port":443,"uuid":"11111111-1111-1111-1111-111111111111"}, + {"type":"vless","tag":"same%20label","server":"edge.example.test","server_port":443,"uuid":"22222222-2222-2222-2222-222222222222"} + ]}"#).unwrap(); + let mut cache = SubscriptionCache { + config: parsed.config, + servers: parsed.servers, + user_info: Default::default(), + fetched_at: "fixture".into(), + }; + let mut config = local_singbox_config("same label"); + config.selected_server_id = Some(cache.servers[1].id.clone()); + cache.normalize_percent_encoded_tags(); + let adapter = SingBoxAdapter::default(); + let generated = adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &RecordingChecker::ok("fixture"), + ) + .unwrap(); + let value: serde_json::Value = serde_json::from_str(&generated.contents).unwrap(); + assert_eq!( + value["outbounds"][0]["uuid"], + "22222222-2222-2222-2222-222222222222" + ); + config.selected_server_id = Some("pw-missing".into()); + assert!(adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &RecordingChecker::ok("fixture") + ) + .is_err()); + config.selected_server_id = None; + assert!(adapter + .generate_config( + SingBoxGenerationRequest::new(&config, &cache, None), + &RecordingChecker::ok("fixture") + ) + .is_err()); +} + #[test] fn blocks_config_when_server_is_not_selected() { let adapter = SingBoxAdapter::default(); diff --git a/src-tauri/tests/singbox_command_tests.rs b/src-tauri/tests/singbox_command_tests.rs index 6160188..e221665 100644 --- a/src-tauri/tests/singbox_command_tests.rs +++ b/src-tauri/tests/singbox_command_tests.rs @@ -11,6 +11,8 @@ use proxywarden_lib::models::{ ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, SubscriptionServer, TargetKind, }; +#[cfg(windows)] +use proxywarden_lib::safe_fs; use proxywarden_lib::storage::JsonStorage; use proxywarden_lib::subscription; use serde_json::{json, Map}; @@ -342,6 +344,11 @@ fn generate_writes_config_and_local_singbox_target() { assert_eq!(target.port, 1080); assert_eq!(target.requires_component, Some(ComponentId::Singbox)); assert_eq!(activity[0].title, "Конфиг Local sing-box создан"); + #[cfg(windows)] + safe_fs::verify_path_protected_for_owner_admin_system(Path::new( + &response.generated_config_path, + )) + .expect("generated sing-box config keeps restricted ACL"); cleanup(&root); } @@ -526,6 +533,111 @@ fn sample_cache() -> SubscriptionCache { } } +#[test] +fn failed_candidate_fetch_keeps_the_previous_url_cache_and_selection() { + struct FailedFetcher; + impl SubscriptionFetcher for FailedFetcher { + fn fetch_subscription( + &self, + _: &str, + _: &subscription::SubscriptionFetchIdentity, + ) -> Result { + Err(subscription::SubscriptionError { + message: "offline".to_string(), + }) + } + } + let root = test_root("candidate-failure"); + let storage = JsonStorage::new(&root); + save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: "https://old.example.test/token".into(), + }, + &FixedClock, + ) + .unwrap(); + storage + .write_singbox_subscription_cache(&sample_cache()) + .unwrap(); + let before = fs::read(&storage.paths().local_singbox_file).unwrap(); + let cache_before = fs::read(&storage.paths().singbox_subscription_cache_file).unwrap(); + assert!( + proxywarden_lib::singbox_subscription::fetch_singbox_subscription_candidate( + &storage, + Some("https://new.example.test/token"), + &FailedFetcher, + &FixedClock + ) + .is_err() + ); + assert_eq!( + fs::read(&storage.paths().local_singbox_file).unwrap(), + before + ); + assert_eq!( + fs::read(&storage.paths().singbox_subscription_cache_file).unwrap(), + cache_before + ); + cleanup(&root); +} + +#[test] +fn fetch_finishing_after_forget_cannot_resurrect_subscription_or_backup() { + struct ForgetDuringFetch<'a>(&'a JsonStorage); + impl SubscriptionFetcher for ForgetDuringFetch<'_> { + fn fetch_subscription( + &self, + _: &str, + _: &subscription::SubscriptionFetchIdentity, + ) -> Result { + forget_singbox_subscription_in_storage(self.0, &FixedClock).unwrap(); + Ok(sample_cache()) + } + } + let root = test_root("forget-during-fetch"); + let storage = JsonStorage::new(&root); + save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: "https://old.example.test/token".into(), + }, + &FixedClock, + ) + .unwrap(); + storage + .write_singbox_subscription_cache(&sample_cache()) + .unwrap(); + storage + .write_singbox_subscription_cache(&sample_cache()) + .unwrap(); + let error = fetch_singbox_subscription_with_fetcher( + &storage, + &ForgetDuringFetch(&storage), + &FixedClock, + ) + .unwrap_err(); + assert_eq!(error.code, "configuration_changed"); + assert!(storage + .read_local_singbox_config() + .unwrap() + .subscription_url + .is_none()); + assert!(storage.read_singbox_subscription_cache().unwrap().is_none()); + assert!(!proxywarden_lib::safe_fs::backup_path(&storage.paths().local_singbox_file).exists()); + assert!(!proxywarden_lib::safe_fs::backup_path( + &storage.paths().singbox_subscription_cache_file + ) + .exists()); + fs::remove_file(&storage.paths().local_singbox_file).unwrap(); + assert!(storage + .read_local_singbox_config() + .unwrap() + .subscription_url + .is_none()); + cleanup(&root); +} + fn sample_cache_with_flag_tag() -> SubscriptionCache { SubscriptionCache { config: json!({ diff --git a/src-tauri/tests/singbox_service_tests.rs b/src-tauri/tests/singbox_service_tests.rs index 6356930..a48d637 100644 --- a/src-tauri/tests/singbox_service_tests.rs +++ b/src-tauri/tests/singbox_service_tests.rs @@ -1,11 +1,8 @@ use proxywarden_lib::component_detection::DetectedSingBox; use proxywarden_lib::singbox_service::{ - build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output, - service_control_script, SingBoxServiceAction, + build_singbox_setup_status, singbox_service_xml, SINGBOX_SERVICE_LOG_DIR, }; -use std::path::{Path, PathBuf}; -#[cfg(windows)] -use std::process::Command as ProcessCommand; +use std::path::PathBuf; #[test] fn setup_status_reports_missing_items_when_singbox_is_absent() { @@ -28,90 +25,22 @@ fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() { assert!(status.ready); assert_eq!(status.missing_count, 0); assert!(status.items.iter().all(|item| item.installed)); + assert_eq!(status.items[0].version, Some("1.11.0.0".to_string())); + assert_eq!(status.items[1].version, Some("3.0.0.0".to_string())); + assert_eq!(status.items[2].version, None); } #[test] -fn parses_last_json_service_command_output_line() { - let output = br#" -noise -{"success":true,"code":"started","serviceName":"ProxyWardenSingBox","status":"Running","processId":42} -"#; - let parsed = parse_service_command_output(output).expect("service json should parse"); +fn winsw_disables_logs_and_targets_fixed_app_root_log_directory() { + let xml = singbox_service_xml(); - assert!(parsed.success); - assert_eq!(parsed.code, "started"); - assert_eq!(parsed.service_name, Some("ProxyWardenSingBox".to_string())); - assert_eq!(parsed.status, Some("Running".to_string())); - assert_eq!(parsed.process_id, Some(42)); -} - -#[test] -fn safe_install_dir_allows_only_proxywarden_singbox_folder() { - assert!(ensure_safe_singbox_install_dir(Path::new( - r"C:\Program Files\ProxyWarden\components\sing-box" - )) - .is_ok()); - assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err()); - assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err()); -} - -#[test] -fn service_control_script_targets_named_service_and_action() { - let script = service_control_script( - SingBoxServiceAction::Start, - "ProxyWardenSingBox", - None, - None, - ); - - assert!(script.contains("$serviceName = 'ProxyWardenSingBox'")); - assert!(script.contains("$action = 'start'")); - assert!(script.contains("ConvertTo-Json -Compress")); -} - -#[test] -fn service_control_script_syncs_generated_config_before_start() { - let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json"); - let target = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\config.json"); - let script = service_control_script( - SingBoxServiceAction::Start, - "ProxyWardenSingBox", - Some(source), - Some(target), - ); - - assert!(script.contains( - "$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'" - )); - assert!(script.contains( - "$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'" - )); - assert!(script.contains("Copy-Item -LiteralPath $configSource")); - assert!(script.contains("'config_sync_failed'")); -} - -#[test] -#[cfg(windows)] -fn install_singbox_script_parses_as_powershell() { - let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("scripts") - .join("install-singbox.ps1"); - let escaped_path = script_path.display().to_string().replace('\'', "''"); - let parser = format!( - "$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}" - ); - let output = ProcessCommand::new("powershell") - .args(["-NoProfile", "-NonInteractive", "-Command", &parser]) - .output() - .expect("powershell parser should run"); - - assert!( - output.status.success(), - "install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), + assert!(xml.contains("")); + assert!(xml.contains(&format!("{SINGBOX_SERVICE_LOG_DIR}"))); + assert_eq!( + SINGBOX_SERVICE_LOG_DIR, + r"%BASE%\..\..\.proxywarden-service-logs\sing-box" ); + assert!(!xml.contains("ProgramData")); } fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox { @@ -127,5 +56,7 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> wrapper_exists, running, service_name: "ProxyWardenSingBox".to_string(), + version: Some("1.11.0.0".to_string()), + wrapper_version: Some("3.0.0.0".to_string()), } } diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index ecee2c5..ad91c6f 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -1,7 +1,6 @@ use proxywarden_lib::models::{ - ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig, - Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache, - SubscriptionServer, Target, TargetKind, + ActivityEntry, ActivityLevel, LocalSingBoxConfig, Profile, ProfileItem, ProfileItemType, + Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetKind, }; use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths}; use std::fs; @@ -17,13 +16,12 @@ fn storage_defaults_to_programdata_root() { } #[test] -fn roundtrips_profiles_targets_components_and_activity() { +fn roundtrips_profiles_targets_and_activity() { let root = test_root("roundtrip"); let storage = JsonStorage::new(root.clone()); let profiles = vec![sample_profile("discord")]; let targets = vec![sample_target("home-gateway")]; - let components = vec![sample_component()]; let activity = vec![sample_activity( "created", "2026-01-01T10:00:00Z", @@ -32,15 +30,10 @@ fn roundtrips_profiles_targets_components_and_activity() { storage.write_profiles(&profiles).expect("write profiles"); storage.write_targets(&targets).expect("write targets"); - write_json(&storage.paths().components_file, &components); write_json(&storage.paths().activity_file, &activity); assert_eq!(storage.read_profiles().expect("read profiles"), profiles); assert_eq!(storage.read_targets().expect("read targets"), targets); - assert_eq!( - storage.read_components().expect("read components"), - components - ); assert_eq!(storage.read_activity().expect("read activity"), activity); cleanup(&root); @@ -86,6 +79,9 @@ fn roundtrips_local_singbox_config_and_subscription_cache() { config.subscription_display_url(), Some("https://sub.example.test/...".to_string()) ); + let persisted = fs::read_to_string(&storage.paths().local_singbox_file) + .expect("read persisted local sing-box config"); + assert!(!persisted.contains("install_root")); cleanup(&root); } @@ -155,13 +151,13 @@ fn reads_percent_encoded_singbox_tags_as_utf8() { assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string())); assert_eq!(cache.servers[0].tag, decoded_tag); - assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag); + assert_eq!(cache.config["outbounds"][0]["tag"], encoded_tag); cleanup(&root); } #[test] -fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() { +fn invalid_subscription_cache_without_backup_preserves_corruption_across_repeated_reads() { let root = test_root("invalid-subscription-cache"); let storage = JsonStorage::new(root.clone()); fs::create_dir_all(&storage.paths().state_dir).expect("create state dir"); @@ -176,16 +172,14 @@ fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_fil .expect_err("invalid cache should not silently fallback"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - assert!(!storage.paths().singbox_subscription_cache_file.exists()); - assert!(has_corrupt_sibling( - &storage.paths().singbox_subscription_cache_file - )); + assert!(storage.paths().singbox_subscription_cache_file.exists()); + assert!(storage.read_singbox_subscription_cache().is_err()); cleanup(&root); } #[test] -fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() { +fn invalid_json_without_backup_preserves_corruption_across_repeated_reads() { let root = test_root("invalid-json-no-backup"); let storage = JsonStorage::new(root.clone()); fs::create_dir_all(&storage.paths().config_dir).expect("create config dir"); @@ -196,8 +190,8 @@ fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() { .expect_err("invalid profiles should not silently fallback"); assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - assert!(!storage.paths().profiles_file.exists()); - assert!(has_corrupt_sibling(&storage.paths().profiles_file)); + assert!(storage.paths().profiles_file.exists()); + assert!(storage.read_profiles().is_err()); cleanup(&root); } @@ -308,6 +302,24 @@ fn cleanup(root: &Path) { let _ = fs::remove_dir_all(root); } +#[test] +fn missing_primary_restores_valid_backup_but_never_defaults_over_invalid_backup() { + let root = test_root("missing-source-backup"); + let storage = JsonStorage::new(root.clone()); + let saved = vec![sample_profile("preserved")]; + storage.write_profiles(&saved).unwrap(); + storage.write_profiles(&[]).unwrap(); + fs::remove_file(&storage.paths().profiles_file).unwrap(); + assert_eq!(storage.read_profiles().unwrap(), saved); + assert_eq!(storage.read_profiles().unwrap(), saved); + fs::remove_file(&storage.paths().profiles_file).unwrap(); + fs::write(backup_path(&storage.paths().profiles_file), "{broken").unwrap(); + assert!(storage.read_profiles().is_err()); + assert!(storage.read_profiles().is_err()); + assert!(!storage.paths().profiles_file.exists()); + cleanup(&root); +} + fn write_json(path: &Path, value: &T) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create json parent dir"); @@ -363,22 +375,6 @@ fn sample_target(id: &str) -> Target { } } -fn sample_component() -> ComponentStatus { - ComponentStatus { - id: ComponentId::Proxyfier, - name: "ProxiFyre".to_string(), - state: ComponentState::Missing, - installed: false, - running: false, - version: None, - path: None, - service_name: Some("ProxiFyreService".to_string()), - service_status: None, - problems: vec!["ProxiFyre не установлен".to_string()], - actions: vec!["Установить ProxiFyre".to_string()], - } -} - fn sample_subscription_cache() -> SubscriptionCache { SubscriptionCache { config: serde_json::json!({ diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index 230b02b..45b12ae 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -1,10 +1,19 @@ import { invoke } from "@tauri-apps/api/core"; import type { ActivityEntry, + ComponentCutoverResponse, + ComponentCutoverStatus, + ComponentLifecycleResponse, + ComponentPackageStatus, ComponentStatus, + ComponentUpdateCheckResponse, + ComponentUpdateDownloadResponse, + ComponentUpdateResponse, LocalSingBoxConfig, + ManagedPackageComponentId, Profile, ProfileInput, + StorageMigrationStatus, SubscriptionCache, SubscriptionServer, Target, @@ -14,7 +23,7 @@ import type { export interface CommandError { code: string; message: string; - details?: Array<{ + details: Array<{ field: string; message: string; }>; @@ -27,7 +36,16 @@ export interface AdminStatusResponse { message: string; } +export interface ArtifactStatus { + component: "proxyfier" | "singbox"; + sourceMatchesPrepared: boolean; + generatedExists: boolean; + activation: "unknown" | "stopped" | "restart-required" | "confirmed"; +} + export interface SavedStateResponse { + artifacts: ArtifactStatus[]; + revision: string; profiles: Profile[]; targets: Target[]; generatedConfigPath: string; @@ -35,6 +53,7 @@ export interface SavedStateResponse { export interface StartupSnapshotResponse { adminStatus: AdminStatusResponse; + migrationStatus: StorageMigrationStatus; savedState: SavedStateResponse; components: ComponentStatus[]; proxifyreSetupStatus: ProxiFyreSetupStatus; @@ -56,15 +75,6 @@ export interface ProxiFyreSetupStatus { items: ProxiFyreSetupItem[]; } -export interface ProxiFyreSetupProgress { - operation: "idle" | "install" | "uninstall" | string; - status: "idle" | "running" | "succeeded" | "failed" | string; - activeStep?: string; - percent: number; - message: string; - updatedAt?: string; -} - export type SingBoxSetupItem = ProxiFyreSetupItem; export interface SingBoxSetupStatus { @@ -74,6 +84,7 @@ export interface SingBoxSetupStatus { } export interface LocalSingBoxStatusResponse { + savedState: SavedStateResponse; config: LocalSingBoxConfig; cache?: SubscriptionCache; component: ComponentStatus; @@ -111,6 +122,7 @@ export interface ApplyPhase { } export interface ApplyConfigurationInput { + expectedRevision?: string; routeMode: "external" | "local-singbox"; profile: ProfileInput; externalTarget?: TargetInput; @@ -118,6 +130,7 @@ export interface ApplyConfigurationInput { } export interface ApplyConfigurationResult { + savedState?: SavedStateResponse; success: boolean; changed: boolean; partialState: boolean; @@ -182,12 +195,68 @@ export function getComponents(): Promise { return invoke("get_components"); } -export function getProxiFyreSetupStatus(): Promise { - return invoke("get_proxifyre_setup_status"); +export function getComponentPackageStatuses(): Promise< + ComponentPackageStatus[] +> { + return invoke("get_component_package_statuses"); } -export function getProxiFyreSetupProgress(): Promise { - return invoke("get_proxifyre_setup_progress"); +export function checkComponentUpdate( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("check_component_update", { + input: { componentId }, + }); +} + +export function downloadComponentUpdate( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("download_component_update", { + input: { componentId }, + }); +} + +export function updateComponent( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("update_component", { + input: { componentId }, + }); +} + +export function getComponentCutoverStatuses(): Promise< + ComponentCutoverStatus[] +> { + return invoke("get_component_cutover_statuses"); +} + +export function cutoverComponent( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("cutover_component", { + input: { componentId }, + }); +} + +export function confirmComponentRouteSmoke( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("confirm_component_route_smoke", { + input: { componentId, confirmed: true }, + }); +} + +export function cleanupComponentQuarantine( + componentId: ManagedPackageComponentId, +): Promise { + return invoke("cleanup_component_quarantine", { + input: { componentId }, + }); +} + +export function getProxiFyreSetupStatus(): Promise { + return invoke("get_proxifyre_setup_status"); } export function getSingBoxStatus(): Promise { @@ -198,18 +267,14 @@ export function getSingBoxSetupStatus(): Promise { return invoke("get_singbox_setup_status"); } -export function saveSingBoxSubscription( - subscriptionUrl: string, +export function fetchSingBoxSubscription( + subscriptionUrl?: string, ): Promise { - return invoke("save_singbox_subscription", { - input: { subscriptionUrl }, + return invoke("fetch_singbox_subscription", { + subscriptionUrl: subscriptionUrl || null, }); } -export function fetchSingBoxSubscription(): Promise { - return invoke("fetch_singbox_subscription"); -} - export function forgetSingBoxSubscription(): Promise { return invoke("forget_singbox_subscription"); } @@ -266,16 +331,16 @@ export function stopProxiFyreService(): Promise { return invoke("stop_proxifyre_service"); } -export function installProxiFyre(): Promise { - return invoke("install_proxifyre"); +export function installProxiFyre(): Promise { + return invoke("install_proxifyre"); } export function configureProxiFyreFirewallRules(): Promise { return invoke("configure_proxifyre_firewall_rules"); } -export function uninstallProxiFyre(): Promise { - return invoke("uninstall_proxifyre"); +export function uninstallProxiFyre(): Promise { + return invoke("uninstall_proxifyre"); } export function startSingBoxService(): Promise { @@ -286,10 +351,10 @@ export function stopSingBoxService(): Promise { return invoke("stop_singbox_service"); } -export function installSingBox(): Promise { - return invoke("install_singbox"); +export function installSingBox(): Promise { + return invoke("install_singbox"); } -export function uninstallSingBox(): Promise { - return invoke("uninstall_singbox"); +export function uninstallSingBox(): Promise { + return invoke("uninstall_singbox"); } diff --git a/src/app/App.tsx b/src/app/App.tsx index be9495a..cd1d7e9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -5,17 +5,9 @@ import { useState, type CSSProperties, } from "react"; +import { isTauri } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; -import { - Cpu, - FileCode2, - FolderOpen, - Gauge, - Link2, - ShieldAlert, - Trash2, - Wand2, -} from "lucide-react"; +import { Gauge, Link2, ShieldAlert, Trash2, Wand2 } from "lucide-react"; import { applyConfiguration, configureProxiFyreFirewallRules, @@ -23,7 +15,6 @@ import { forgetSingBoxSubscription, generateSingBoxConfig, getComponents, - getProxiFyreSetupProgress, getProxiFyreSetupStatus, getSavedState, getSingBoxSetupStatus, @@ -34,8 +25,6 @@ import { pingAllSingBoxServers, pingProxyTarget, pingSingBoxServer, - restartAsAdmin, - saveSingBoxSubscription, selectSingBoxServer, startProxiFyreService, startSingBoxService, @@ -44,16 +33,20 @@ import { uninstallProxiFyre, uninstallSingBox, type AdminStatusResponse, + type ArtifactStatus, + type CommandError, type LocalSingBoxStatusResponse, type PingServerResponse, type ProxyTargetCheckResponse, - type ProxiFyreSetupProgress, type ProxiFyreSetupStatus, type SingBoxSetupStatus, } from "../api/tauriCommands"; import type { + ComponentCutoverStatus, ComponentStatus, + ManagedPackageComponentId, Profile, + StorageMigrationStatus, SubscriptionServer, Target, } from "../domain/types"; @@ -66,10 +59,26 @@ import { ServiceControlRow, Tabs, } from "../ui"; -import { ProxiFyreSetupStrip } from "./components/ProxiFyreSetupStrip"; import { SummaryStatusControl } from "./components/SummaryStatusControl"; +import { + ComponentPackageStatus as ComponentPackageStatusView, + ComponentPackageStatusLoading, + ComponentPackageStatusUnavailable, +} from "./components/ComponentPackageStatus"; +import { MigrationNotice } from "./components/MigrationNotice"; import { ConnectionCheckPanel } from "./components/ConnectionCheckPanel"; import { AppList } from "./components/AppList"; +import { + componentActionRequiresReboot, + isPrivilegedUacCancellation, + localComponentStateError, + useComponentPackages, + type ComponentPackageActionResult, + type ComponentPackageBusyAction, +} from "./hooks/useComponentPackages"; +import { useSubscription } from "./hooks/useSubscription"; +import { useApplyFlow } from "./hooks/useApplyFlow"; +import { useConfigurationDraft } from "./hooks/useConfigurationDraft"; import { useNoticeLog } from "./hooks/useNoticeLog"; import { parseProxy, type ParsedProxy } from "./lib/parseProxy"; import { normalizeItemValue, type DraftItemType } from "./lib/profileItems"; @@ -80,7 +89,13 @@ import { type ConfigSnapshot, type RouteMode, } from "./lib/snapshots"; -import { getApplyReadiness } from "./readiness"; +import { + canUseStoppedProxiFyreRouteSmoke, + getComponentUpdateBlockReason, + getApplyReadiness, + isComponentInstallPackageReady, + isCutoverLifecycleComplete, +} from "./readiness"; import { serviceControlState, systemSummaryState, @@ -95,15 +110,10 @@ import { proxyCheckText, changesApplyButtonLabel, routeProxyCheckTarget, - targetForUi, - targetForExternalProxy, - itemsForProfiles, - formatProxy, profileItemInput, emptyItemMessage, - localSetupProgress, - proxyfierTitle, proxyfierDetails, + proxyfierCompactStatus, singBoxDetails, singBoxDetailLines, componentDetails, @@ -118,12 +128,14 @@ import { type RouteChainInput, } from "./viewModel"; -type ProxiFyreAction = "start" | "stop" | "restart" | "install" | "uninstall"; +type ProxiFyreAction = + "start" | "stop" | "restart" | "install" | "uninstall" | "firewall"; type SingBoxAction = | "start" | "stop" | "install" | "uninstall" + | "select" | "fetch" | "forget" | "generate" @@ -157,6 +169,22 @@ const fallbackComponents: ComponentStatus[] = [ }, ]; +const MISSING_PACKAGE_STATUS_ERROR: CommandError = { + code: "component_package_status_missing", + message: "Локальный статус пакета отсутствует. Повтори запуск ProxyWarden.", + details: [], +}; + +const MISSING_CUTOVER_STATUS_ERROR: CommandError = { + code: "component_cutover_status_missing", + message: "Локальный статус переноса отсутствует. Повтори запуск ProxyWarden.", + details: [], +}; + +function blockedLifecycleLabel(status: ComponentCutoverStatus | null) { + return status ? "Перенос требуется" : "Проверяю перенос"; +} + export function App() { const [activePanel, setActivePanel] = useState("summary"); const [tabTransitionDirection, setTabTransitionDirection] = @@ -164,28 +192,41 @@ export function App() { const [proxyInput, setProxyInput] = useState(""); const [routeMode, setRouteMode] = useState("external"); const [profileId, setProfileId] = useState(MAIN_PROFILE_ID); + const selectedProfile = useRef(MAIN_PROFILE_ID); const [targetId, setTargetId] = useState(MAIN_TARGET_ID); + const draft = useConfigurationDraft(); const [items, setItems] = useState([]); - const [appliedSnapshot, setAppliedSnapshot] = useState( + const [savedSnapshot, setSavedSnapshot] = useState( null, ); - const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); - const [processInput, setProcessInput] = useState(""); const [pickerAction, setPickerAction] = useState<"exe" | "folder" | null>( null, ); const [components, setComponents] = useState(fallbackComponents); + const [artifacts, setArtifacts] = useState([]); + const [startupReady, setStartupReady] = useState(false); + const [startupError, setStartupError] = useState(null); + const startupRequest = useRef(0); + const probeRequest = useRef(0); + const currentServer = useRef(undefined); + const [storageMigrationStatus, setStorageMigrationStatus] = + useState(null); + const [lastComponentAction, setLastComponentAction] = + useState(null); + const [cancelledComponentAction, setCancelledComponentAction] = + useState(null); const [setupStatus, setSetupStatus] = useState( null, ); - const [setupProgress, setSetupProgress] = - useState(null); const [singBoxStatus, setSingBoxStatus] = useState(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); - const [subscriptionInput, setSubscriptionInput] = useState(""); + currentServer.current = singBoxStatus?.config.selectedServerId; + const subscription = useSubscription(); + const { input: subscriptionInput, setInput: setSubscriptionInput } = + subscription; const [serverPings, setServerPings] = useState< Record >({}); @@ -205,8 +246,8 @@ export function App() { } = useNoticeLog(); const [isLoading, setIsLoading] = useState(true); const [isDetectingComponents, setIsDetectingComponents] = useState(true); - const [isApplying, setIsApplying] = useState(false); - const [isRestartingAsAdmin, setIsRestartingAsAdmin] = useState(false); + const { isApplying, submit: submitDraft } = useApplyFlow(); + const [isProxyChecking, setIsProxyChecking] = useState(false); const [serverPingTag, setServerPingTag] = useState(null); const [serviceAction, setServiceAction] = useState( @@ -225,6 +266,20 @@ export function App() { useState(false); const adminPromptSeenRef = useRef(false); const adminPromptTimerRef = useRef(null); + const componentPackages = useComponentPackages(startupReady); + + function showPrivilegedActionFailure( + error: unknown, + failureTitle: string, + cancellationText = "Запрос прав администратора отменён. Состояние компонента не изменено.", + ) { + const cancelled = isPrivilegedUacCancellation(error); + showNotice({ + kind: cancelled ? "info" : "error", + title: cancelled ? "Действие отменено" : failureTitle, + text: cancelled ? cancellationText : errorMessage(error), + }); + } const proxyfier = useMemo( () => components.find((component) => component.id === "proxyfier"), @@ -237,6 +292,22 @@ export function App() { [components, singBoxStatus], ); const isSingBoxInstalled = Boolean(singbox?.installed); + const proxifyrePackageStatus = + componentPackages.packageStatuses.find( + (status) => status.componentId === "proxifyre", + ) ?? null; + const singBoxPackageStatus = + componentPackages.packageStatuses.find( + (status) => status.componentId === "sing-box", + ) ?? null; + const proxifyreCutoverStatus = + componentPackages.cutoverStatuses.find( + (status) => status.componentId === "proxifyre", + ) ?? null; + const singBoxCutoverStatus = + componentPackages.cutoverStatuses.find( + (status) => status.componentId === "sing-box", + ) ?? null; const selectedServerTag = singBoxStatus?.config.selectedServerTag; const selectedServerId = singBoxStatus?.config.selectedServerId; const selectedServer = useMemo( @@ -256,28 +327,56 @@ export function App() { items, selectedServerId, selectedServerTag, + items.length > 0 && draft.enabled, ), - [items, proxyInput, routeMode, selectedServerId, selectedServerTag], + [ + items, + proxyInput, + routeMode, + selectedServerId, + selectedServerTag, + draft.enabled, + ], ); const pendingChanges = useMemo( () => - appliedSnapshot ? configChangeRows(appliedSnapshot, currentSnapshot) : [], - [appliedSnapshot, currentSnapshot], + savedSnapshot ? configChangeRows(savedSnapshot, currentSnapshot) : [], + [savedSnapshot, currentSnapshot], ); - const hasUnappliedChanges = pendingChanges.length > 0; + const savedRouteNeedsPreparation = + draft.profiles.some( + (profile) => profile.enabled && profile.items.length > 0, + ) && + ["proxyfier", ...(routeMode === "local-singbox" ? ["singbox"] : [])].some( + (component) => + !artifacts.find((artifact) => artifact.component === component) + ?.sourceMatchesPrepared, + ); + const hasUnappliedChanges = + pendingChanges.length > 0 || savedRouteNeedsPreparation; const hasAdminPrompt = Boolean(adminStatus?.canRestartElevated); - const shellStyle = hasUnappliedChanges - ? ({ "--change-row-count": String(pendingChanges.length) } as CSSProperties) - : undefined; - const systemSummary = systemSummaryState({ - isLoading, - isDetectingComponents, - proxyfier, - singbox, - routeMode: appliedSnapshot?.routeMode ?? routeMode, - singBoxStatus, - proxyCheck, - }); + const shellStyle = + activePanel !== "summary" && hasUnappliedChanges + ? ({ + "--change-row-count": String(Math.max(1, pendingChanges.length)), + } as CSSProperties) + : undefined; + const systemSummary = startupError + ? { + tone: "warning" as const, + title: "Нет данных", + text: "Запуск не завершён. Повторите загрузку состояния.", + } + : systemSummaryState({ + isLoading, + isDetectingComponents, + proxyfier, + singbox, + routeMode: savedSnapshot?.routeMode ?? routeMode, + singBoxStatus, + proxyCheck, + artifacts, + }); useEffect(() => { void refresh(); @@ -303,6 +402,9 @@ export function App() { useEffect(() => { return () => { + startupRequest.current += 1; + probeRequest.current += 1; + subscription.requestVersion.current += 1; if (serviceVisualTimerRef.current !== null) { window.clearTimeout(serviceVisualTimerRef.current); } @@ -312,40 +414,28 @@ export function App() { }; }, []); - useEffect(() => { - if (serviceAction !== "install" && serviceAction !== "uninstall") - return undefined; - - let cancelled = false; - const pollProgress = async () => { - try { - const progress = await getProxiFyreSetupProgress(); - if (!cancelled) setSetupProgress(progress); - } catch { - // Progress is best-effort; the main install/uninstall action still reports the real error. - } - }; - - void pollProgress(); - const timer = window.setInterval(() => void pollProgress(), 650); - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [serviceAction]); - async function refresh() { + const request = ++startupRequest.current; + const generation = draft.generation.current; + setStartupError(null); setIsLoading(true); setIsDetectingComponents(true); + setStartupReady(false); try { - const [snapshot, progress] = await Promise.all([ - getStartupSnapshot(), - getProxiFyreSetupProgress(), - ]); + if (!isTauri()) { + setStartupError( + "Режим предпросмотра: откройте приложение через Tauri для работы с настройками.", + ); + return; + } + const snapshot = await getStartupSnapshot(); + if (request !== startupRequest.current) return; + draft.revision.current = snapshot.savedState.revision; + setArtifacts(snapshot.savedState.artifacts); setAdminStatus(snapshot.adminStatus); + setStorageMigrationStatus(snapshot.migrationStatus); setComponents(snapshot.components); setSetupStatus(snapshot.proxifyreSetupStatus); - setSetupProgress(progress); setSingBoxStatus(snapshot.singboxStatus); setSingBoxSetupStatus(snapshot.singboxSetupStatus); applySavedState( @@ -353,32 +443,157 @@ export function App() { snapshot.savedState.targets, snapshot.savedState.generatedConfigPath, snapshot.singboxStatus, + generation === draft.generation.current, ); - } catch { - showNotice({ - kind: "info", - title: "Режим предпросмотра", - text: "Запусти приложение через Tauri, чтобы увидеть найденный ProxiFyre и применить конфиг.", - }); + setStartupReady(true); + } catch (error) { + if (request === startupRequest.current) + setStartupError(errorMessage(error)); } finally { - setIsLoading(false); - setIsDetectingComponents(false); + if (request === startupRequest.current) { + setIsLoading(false); + setIsDetectingComponents(false); + } } } - async function restartApplicationAsAdmin() { - setIsRestartingAsAdmin(true); - try { - await restartAsAdmin(); - setIsRestartingAsAdmin(false); - } catch (error) { - setIsRestartingAsAdmin(false); + async function refreshComponentRuntimeState() { + const subscriptionRequest = subscription.requestVersion.current; + const [ + detectedComponents, + detectedSetup, + detectedSingBox, + detectedSingSetup, + localComponentState, + ] = await Promise.all([ + getComponents(), + getProxiFyreSetupStatus(), + getSingBoxStatus(), + getSingBoxSetupStatus(), + componentPackages.refreshLocal(), + ]); + setComponents(detectedComponents); + setSetupStatus(detectedSetup); + if (subscriptionRequest === subscription.requestVersion.current) + setArtifacts(detectedSingBox.savedState.artifacts); + if (subscriptionRequest === subscription.requestVersion.current) + setSingBoxStatus(detectedSingBox); + setSingBoxSetupStatus(detectedSingSetup); + const localStateError = localComponentStateError(localComponentState); + if (localStateError) throw localStateError; + } + + async function runComponentPackageAction( + action: ComponentPackageBusyAction, + command: () => Promise>, + successTitle: string, + refreshRuntime = false, + ) { + setLastComponentAction(action); + setCancelledComponentAction(null); + const result = await command(); + if (result.status === "cancelled") { + setCancelledComponentAction(action); + showNotice({ + kind: "info", + title: "Действие отменено", + text: "Запрос прав администратора отменён. Состояние компонента не изменено.", + }); + return result; + } + if (result.status === "failed") { + if (refreshRuntime) { + try { + await refreshComponentRuntimeState(); + } catch { + // The local status owner already records a redacted fail-closed error. + } + } showNotice({ kind: "error", - title: "Перезапуск отменен", - text: errorMessage(error), + title: "Действие не выполнено", + text: result.error.message, }); + return result; } + if (result.status === "busy") return result; + + const rebootRequired = componentActionRequiresReboot(result.value); + if (refreshRuntime) { + try { + await refreshComponentRuntimeState(); + } catch (error) { + showNotice({ + kind: "info", + title: successTitle, + text: `Действие завершено, но локальный статус не обновлён.${ + rebootRequired ? " Требуется перезапуск Windows." : "" + } ${errorMessage(error)}`, + }); + return result; + } + } + showNotice({ + kind: "success", + title: successTitle, + text: rebootRequired + ? "Локальный статус обновлён. Перезапусти Windows перед продолжением." + : "Локальный статус компонента обновлён.", + }); + return result; + } + + function checkComponentPackage(componentId: ManagedPackageComponentId) { + return runComponentPackageAction( + { kind: "check", componentId }, + () => componentPackages.check(componentId), + "Проверка обновлений завершена", + ); + } + + function downloadComponentPackage(componentId: ManagedPackageComponentId) { + return runComponentPackageAction( + { kind: "download", componentId }, + () => componentPackages.download(componentId), + "Обновление сохранено локально", + ); + } + + function updateManagedComponent(componentId: ManagedPackageComponentId) { + return runComponentPackageAction( + { kind: "update", componentId }, + () => componentPackages.update(componentId), + "Компонент обновлён", + true, + ); + } + + function cutoverManagedComponent(componentId: ManagedPackageComponentId) { + return runComponentPackageAction( + { kind: "cutover", componentId }, + () => componentPackages.cutover(componentId), + "Состояние переноса обновлено", + true, + ); + } + + function confirmManagedComponentRoute( + componentId: ManagedPackageComponentId, + ) { + return runComponentPackageAction( + { kind: "confirm-route-smoke", componentId }, + () => componentPackages.confirmRouteSmoke(componentId), + "Проверка маршрута подтверждена", + ); + } + + function cleanupManagedComponent(componentId: ManagedPackageComponentId) { + return runComponentPackageAction( + { kind: "cleanup", componentId }, + () => componentPackages.cleanup(componentId), + "Очистка старой установки обновлена", + true, + ); } function applySavedState( @@ -386,37 +601,31 @@ export function App() { targets: Target[], generatedPath: string, singBoxStatusForSnapshot = singBoxStatus, + hydrate = true, + preferredProfile = selectedProfile.current, ) { - const activeProfiles = profiles.filter((profile) => profile.enabled); - const mainProfile = profiles.find( - (profile) => profile.id === MAIN_PROFILE_ID, - ); - const activeProfile = mainProfile ?? activeProfiles[0]; - const activeTarget = targetForUi(targets, activeProfile); - const externalTarget = targetForExternalProxy(targets); - const editableProfiles = mainProfile ? [mainProfile] : activeProfiles; - const savedProxyInput = externalTarget ? formatProxy(externalTarget) : ""; - const savedItems = itemsForProfiles(editableProfiles); - const savedRouteMode = - activeTarget?.id === LOCAL_SINGBOX_TARGET_ID || - activeProfile?.targetId === LOCAL_SINGBOX_TARGET_ID - ? "local-singbox" - : "external"; - - setProxyInput(savedProxyInput); - setProxyCheck(null); - setItems(savedItems); - setProfileId(mainProfile?.id ?? MAIN_PROFILE_ID); - setTargetId(externalTarget?.id ?? MAIN_TARGET_ID); - setRouteMode(savedRouteMode); + const saved = draft.load(profiles, targets, preferredProfile, hydrate); + if (hydrate) { + setProxyInput(saved.proxyInput); + setProxyCheck(null); + setItems(saved.items); + selectedProfile.current = saved.profileId; + setProfileId(saved.profileId); + setTargetId(saved.targetId); + setRouteMode(saved.routeMode); + } else { + // A committed COW target belongs to this profile even when newer edits stay visible. + setTargetId(saved.targetId); + } setGeneratedConfigPath(generatedPath); - setAppliedSnapshot( + setSavedSnapshot( configSnapshotFromUi( - savedRouteMode, - savedProxyInput, - savedItems, + saved.routeMode, + saved.proxyInput, + saved.items, singBoxStatusForSnapshot?.config.selectedServerId, singBoxStatusForSnapshot?.config.selectedServerTag, + saved.items.length > 0 && (saved.profile?.enabled ?? true), ), ); } @@ -443,6 +652,7 @@ export function App() { return false; } + draft.edited(); setItems((current) => [ ...current, { @@ -454,23 +664,23 @@ export function App() { return true; } - function addProcess() { - if (addItem("process", processInput)) { - setProcessInput(""); - setIsProcessInputOpen(false); - } + function addProcess(value: string) { + return addItem("process", value); } function removeItem(id: string) { + draft.edited(); setItems((current) => current.filter((item) => item.id !== id)); } function changeRouteMode(nextMode: RouteMode) { + draft.edited(); setRouteMode(nextMode); setProxyCheck(null); } function changeProxyInput(nextValue: string) { + draft.edited(); setProxyInput(nextValue); setProxyCheck(null); } @@ -487,10 +697,11 @@ export function App() { async function pickAndAddItem( type: Extract, ) { + const generation = draft.generation.current; setPickerAction(type); try { const selectedPath = await pickPath(type); - if (selectedPath) { + if (selectedPath && generation === draft.generation.current) { addItem(type, selectedPath); } } catch (error) { @@ -507,9 +718,14 @@ export function App() { async function updateConfig() { let parsedProxy: ParsedProxy | null = null; try { - if (!items.length) + if (!items.length && !draft.selection?.profile) throw new Error("Добавь хотя бы один процесс, EXE-файл или папку."); - if (routeMode === "external") { + if (items.length === 0 && draft.selection?.profile) { + if (proxyfier?.running) + throw new Error( + "Сначала явно останови ProxiFyre, затем примени очистку правил.", + ); + } else if (routeMode === "external") { parsedProxy = parseProxy(proxyInput); } else if (!isSingBoxInstalled) { throw new Error("Сначала установи Local sing-box."); @@ -525,65 +741,68 @@ export function App() { return; } - setIsApplying(true); + const generation = draft.generation.current; try { - if (routeMode === "external" && !parsedProxy) + if (items.length > 0 && routeMode === "external" && !parsedProxy) throw new Error("Прокси не разобран."); - const result = await applyConfiguration({ - routeMode, - profile: { - id: profileId, - name: "Приложения через прокси", - enabled: true, - targetId: - routeMode === "local-singbox" ? LOCAL_SINGBOX_TARGET_ID : targetId, - protocols: ["TCP", "UDP"], - items: items.map(profileItemInput), + const outcome = await submitDraft( + { + expectedRevision: draft.revision.current, + routeMode, + profile: { + id: profileId, + name: draft.selection?.profile?.name ?? "Приложения через прокси", + enabled: items.length > 0 && draft.enabled, + targetId: + routeMode === "local-singbox" + ? LOCAL_SINGBOX_TARGET_ID + : targetId, + protocols: draft.selection?.profile?.protocols ?? ["TCP", "UDP"], + items: items.map(profileItemInput), + }, + externalTarget: parsedProxy + ? { + id: targetId, + ...draft.selection?.externalTarget, + name: + draft.selection?.externalTarget?.name ?? "Основной прокси", + kind: "external", + protocol: parsedProxy.protocol, + host: parsedProxy.host, + port: parsedProxy.port, + } + : undefined, + disableOtherProfiles: false, }, - externalTarget: parsedProxy - ? { - id: targetId, - name: "Основной прокси", - kind: "external", - protocol: parsedProxy.protocol, - host: parsedProxy.host, - port: parsedProxy.port, - } - : undefined, - disableOtherProfiles: true, - }); - const [ - saved, - detectedComponents, - detectedSetupStatus, - detectedSingBoxStatus, - detectedSingBoxSetupStatus, - ] = await Promise.all([ - getSavedState(), - getComponents(), - getProxiFyreSetupStatus(), - getSingBoxStatus(), - getSingBoxSetupStatus(), - ]); - - applySavedState( - saved.profiles, - saved.targets, - result.generatedConfigPath, + applyConfiguration, + (result) => { + if (!result.savedState) return; + draft.revision.current = result.savedState.revision; + setArtifacts(result.savedState.artifacts); + applySavedState( + result.savedState.profiles, + result.savedState.targets, + result.generatedConfigPath, + singBoxStatus, + generation === draft.generation.current, + profileId, + ); + }, + refreshComponentRuntimeState, ); - setComponents(detectedComponents); - setSetupStatus(detectedSetupStatus); - setSingBoxStatus(detectedSingBoxStatus); - setSingBoxSetupStatus(detectedSingBoxSetupStatus); - showNotice(noticeFromConfigurationApply(result)); + if (outcome.refreshError) { + showNotice({ + kind: "info", + title: "Сохранено, состояние служб не обновлено", + text: errorMessage(outcome.refreshError), + }); + } else showNotice(noticeFromConfigurationApply(outcome.result)); } catch (error) { showNotice({ kind: "error", title: "Конфиг не обновлен", text: errorMessage(error), }); - } finally { - setIsApplying(false); } } @@ -599,17 +818,19 @@ export function App() { : await stopProxiFyreService(); setComponents((current) => upsertComponent(current, component)); + void getSavedState() + .then((saved) => setArtifacts(saved.artifacts)) + .catch(() => setArtifacts([])); showNotice({ kind: "success", title: shouldRun ? "Служба запущена" : "Служба остановлена", text: proxyfierDetails(component, false), }); } catch (error) { - showNotice({ - kind: "error", - title: shouldRun ? "Служба не запущена" : "Служба не остановлена", - text: errorMessage(error), - }); + showPrivilegedActionFailure( + error, + shouldRun ? "Служба не запущена" : "Служба не остановлена", + ); } finally { setServiceAction(null); settleServiceVisual(); @@ -618,79 +839,65 @@ export function App() { async function installProxiFyrePackage() { const confirmed = window.confirm( - "Установить ProxiFyre? ProxyWarden запросит права администратора, установит ProxiFyre, Windows Packet Filter и при необходимости Visual C++ Runtime, затем создаст и запустит Windows-службу. После установки приложение отдельно предложит добавить правила Windows Firewall.", + "Установить ProxiFyre? ProxyWarden запросит права администратора, использует только встроенные проверенные пакеты без обращения к сети и зарегистрирует остановленную Windows-службу. Запуск выполняется отдельно. Правила Windows Firewall доступны отдельным действием в меню ProxiFyre.", ); if (!confirmed) return; setServiceAction("install"); setIsServiceMenuOpen(false); - setSetupProgress( - localSetupProgress( - "install", - "packet-filter", - 1, - "Готовлю установку сетевого драйвера.", - ), - ); startServiceVisual(); try { await nextFrame(); - const component = await installProxiFyre(); - const [detectedSetupStatus, detectedProgress] = await Promise.all([ - getProxiFyreSetupStatus(), - getProxiFyreSetupProgress(), - ]); + const installResult = await installProxiFyre(); + const component = installResult.component; + const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); - setSetupProgress(detectedProgress); + await componentPackages.refreshLocal(); showNotice({ kind: "success", title: "ProxiFyre установлен", - text: proxyfierDetails(component, false), + text: `${proxyfierDetails(component, false)}${ + installResult.rebootRequired + ? " Для завершения установки перезапусти Windows." + : "" + }`, }); - - const firewallConfirmed = window.confirm( - "Добавить разрешающие правила Windows Firewall для установленного ProxiFyre.exe? Будут созданы отдельные входящее и исходящее правила только для подтвержденного пути программы. Windows снова запросит права администратора.", - ); - if (firewallConfirmed) { - try { - await configureProxiFyreFirewallRules(); - const firewallProgress = await getProxiFyreSetupProgress(); - setSetupProgress(firewallProgress); - showNotice({ - kind: "success", - title: "Windows Firewall настроен", - text: "Входящее и исходящее правила добавлены для установленного ProxiFyre.exe.", - }); - } catch (error) { - showNotice({ - kind: "error", - title: "Windows Firewall не настроен", - text: `ProxiFyre установлен, но правила не добавлены. ${errorMessage(error)}`, - }); - } - } else { - showNotice({ - kind: "info", - title: "Правила Firewall пропущены", - text: "ProxiFyre установлен без правил Windows Firewall. Их можно будет добавить повторной установкой компонента.", - }); - } } catch (error) { - void getProxiFyreSetupProgress() - .then(setSetupProgress) - .catch(() => undefined); - showNotice({ - kind: "error", - title: "ProxiFyre не установлен", - text: errorMessage(error), - }); + showPrivilegedActionFailure(error, "ProxiFyre не установлен"); } finally { setServiceAction(null); settleServiceVisual(); } } + async function configureFirewall() { + if ( + !window.confirm( + "Добавить или восстановить входящее и исходящее правила Windows Firewall для подтверждённого ProxiFyre.exe? Windows запросит права администратора.", + ) + ) + return; + setServiceAction("firewall"); + setIsServiceMenuOpen(false); + try { + await configureProxiFyreFirewallRules(); + showNotice({ + kind: "success", + title: "Правила Firewall добавлены", + text: "Настроены входящее и исходящее правила для ProxiFyre.exe.", + }); + } catch (error) { + showPrivilegedActionFailure( + error, + "Правила Firewall не подтверждены", + "Запрос UAC отменён; настройка правил не начиналась.", + ); + } finally { + setServiceAction(null); + } + } + async function uninstallProxiFyrePackage() { const confirmed = window.confirm( "Удалить ProxiFyre и Windows Packet Filter с компьютера? Это остановит службу, удалит папку ProxiFyre и сетевой драйвер. Другие программы WireSock могут перестать работать до повторной установки драйвера.", @@ -699,39 +906,26 @@ export function App() { setServiceAction("uninstall"); setIsServiceMenuOpen(false); - setSetupProgress( - localSetupProgress( - "uninstall", - "proxifyre", - 1, - "Готовлю удаление ProxiFyre и сетевого драйвера.", - ), - ); startServiceVisual(); try { await nextFrame(); - const component = await uninstallProxiFyre(); - const [detectedSetupStatus, detectedProgress] = await Promise.all([ - getProxiFyreSetupStatus(), - getProxiFyreSetupProgress(), - ]); + const uninstallResult = await uninstallProxiFyre(); + const component = uninstallResult.component; + const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); setSetupStatus(detectedSetupStatus); - setSetupProgress(detectedProgress); + await componentPackages.refreshLocal(); showNotice({ kind: "success", title: "ProxiFyre удален", - text: "Служба, папка установки ProxiFyre и Windows Packet Filter удалены.", + text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${ + uninstallResult.rebootRequired + ? " Для завершения удаления перезапусти Windows." + : "" + }`, }); } catch (error) { - void getProxiFyreSetupProgress() - .then(setSetupProgress) - .catch(() => undefined); - showNotice({ - kind: "error", - title: "ProxiFyre не удален", - text: errorMessage(error), - }); + showPrivilegedActionFailure(error, "ProxiFyre не удален"); } finally { setServiceAction(null); settleServiceVisual(); @@ -739,6 +933,8 @@ export function App() { } async function refreshSingBoxState() { + const request = subscription.requestVersion.current; + const revision = draft.revision.current; const [ detectedSingBoxStatus, detectedSingBoxSetupStatus, @@ -747,8 +943,14 @@ export function App() { getSingBoxStatus(), getSingBoxSetupStatus(), getComponents(), + componentPackages.refreshLocal(), ]); - setSingBoxStatus(detectedSingBoxStatus); + if ( + request !== subscription.requestVersion.current || + revision !== draft.revision.current + ) + return detectedSingBoxStatus; + acceptSubscriptionStatus(detectedSingBoxStatus); setSingBoxSetupStatus(detectedSingBoxSetupStatus); setComponents(detectedComponents); return detectedSingBoxStatus; @@ -772,11 +974,10 @@ export function App() { text: componentDetails(component, false), }); } catch (error) { - showNotice({ - kind: "error", - title: shouldRun ? "sing-box не запущен" : "sing-box не остановлен", - text: errorMessage(error), - }); + showPrivilegedActionFailure( + error, + shouldRun ? "sing-box не запущен" : "sing-box не остановлен", + ); } finally { setSingBoxAction(null); } @@ -784,7 +985,7 @@ export function App() { async function installSingBoxPackage() { const confirmed = window.confirm( - "Установить Local sing-box? ProxyWarden запросит права администратора, скачает sing-box и WinSW, затем создаст и запустит Windows-службу ProxyWardenSingBox.", + "Установить Local sing-box? ProxyWarden запросит права администратора, использует встроенные проверенные sing-box и WinSW без обращения к сети и зарегистрирует остановленную службу ProxyWardenSingBox. Запуск выполняется отдельно.", ); if (!confirmed) return; @@ -792,21 +993,22 @@ export function App() { setIsSingBoxMenuOpen(false); try { await nextFrame(); - const component = await installSingBox(); + const installResult = await installSingBox(); + const component = installResult.component; setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ kind: "success", title: "Local sing-box установлен", - text: componentDetails(component, false), + text: `${componentDetails(component, false)}${ + installResult.rebootRequired + ? " Для завершения установки перезапусти Windows." + : "" + }`, }); } catch (error) { - showNotice({ - kind: "error", - title: "Local sing-box не установлен", - text: errorMessage(error), - }); + showPrivilegedActionFailure(error, "Local sing-box не установлен"); } finally { setSingBoxAction(null); } @@ -822,27 +1024,46 @@ export function App() { setIsSingBoxMenuOpen(false); try { await nextFrame(); - const component = await uninstallSingBox(); + const uninstallResult = await uninstallSingBox(); + const component = uninstallResult.component; setComponents((current) => upsertComponent(current, component)); await refreshSingBoxState(); setProxyCheck(null); showNotice({ kind: "success", title: "Local sing-box удален", - text: "Служба и папка установки Local sing-box удалены.", + text: `Служба и папка установки Local sing-box удалены.${ + uninstallResult.rebootRequired + ? " Для завершения удаления перезапусти Windows." + : "" + }`, }); } catch (error) { - showNotice({ - kind: "error", - title: "Local sing-box не удален", - text: errorMessage(error), - }); + showPrivilegedActionFailure(error, "Local sing-box не удален"); } finally { setSingBoxAction(null); } } + function acceptSubscriptionStatus(status: LocalSingBoxStatusResponse) { + draft.revision.current = status.savedState.revision; + setArtifacts(status.savedState.artifacts); + const source = draft.load( + status.savedState.profiles, + status.savedState.targets, + selectedProfile.current, + false, + ); + setTargetId(source.targetId); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setServerPings({}); + setProxyCheck(null); + } + async function syncSingBoxSubscription() { + if (isApplying || singBoxAction) return; + const request = subscription.begin(); const subscriptionUrl = subscriptionInput.trim(); if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { showNotice({ @@ -855,13 +1076,12 @@ export function App() { setSingBoxAction("fetch"); try { - if (subscriptionUrl) { - await saveSingBoxSubscription(subscriptionUrl); - } - const status = await fetchSingBoxSubscription(); - setSingBoxStatus(status); - setComponents((current) => upsertComponent(current, status.component)); - setSubscriptionInput(""); + const status = await fetchSingBoxSubscription( + subscriptionUrl || undefined, + ); + if (!request.current()) return; + acceptSubscriptionStatus(status); + request.clearSubmittedInput(); setServerPings({}); setProxyCheck(null); showNotice({ @@ -870,23 +1090,26 @@ export function App() { text: `Серверов: ${status.cache?.servers.length ?? 0}`, }); } catch (error) { + if (!request.current()) return; showNotice({ kind: "error", title: "Подписка не обновлена", text: errorMessage(error), }); } finally { - setSingBoxAction(null); + if (request.current()) setSingBoxAction(null); } } async function forgetSingBoxSubscriptionData() { + if (isApplying || singBoxAction) return; + const request = subscription.begin(); setSingBoxAction("forget"); setIsSingBoxMenuOpen(false); try { const status = await forgetSingBoxSubscription(); - setSingBoxStatus(status); - setComponents((current) => upsertComponent(current, status.component)); + if (!request.current()) return; + acceptSubscriptionStatus(status); setServerPings({}); setProxyCheck(null); showNotice({ @@ -895,28 +1118,35 @@ export function App() { text: "Ссылка, cache и выбранный сервер Local sing-box удалены.", }); } catch (error) { + if (!request.current()) return; showNotice({ kind: "error", title: "Подписка не очищена", text: errorMessage(error), }); } finally { - setSingBoxAction(null); + if (request.current()) setSingBoxAction(null); } } async function chooseSingBoxServer(server: SubscriptionServer) { + if (isApplying || singBoxAction) return; + const request = subscription.begin(); + setSingBoxAction("select"); try { const status = await selectSingBoxServer(server); - setSingBoxStatus(status); - setComponents((current) => upsertComponent(current, status.component)); + if (!request.current()) return; + acceptSubscriptionStatus(status); setProxyCheck(null); } catch (error) { + if (!request.current()) return; showNotice({ kind: "error", title: "Сервер не выбран", text: errorMessage(error), }); + } finally { + if (request.current()) setSingBoxAction(null); } } @@ -980,10 +1210,18 @@ export function App() { return; } + const request = ++probeRequest.current; + const generation = draft.generation.current; + const server = currentServer.current; + const current = () => + request === probeRequest.current && + generation === draft.generation.current && + server === currentServer.current; setProxyCheck(null); setIsProxyChecking(true); try { const result = await pingProxyTarget(target.host, target.port); + if (!current()) return; setProxyCheck(result); showNotice({ kind: proxyCheckNoticeKind(result), @@ -991,21 +1229,32 @@ export function App() { text: proxyCheckText(result), }); } catch (error) { + if (!current()) return; showNotice({ kind: "error", title: "Маршрут не проверен", text: errorMessage(error), }); } finally { - setIsProxyChecking(false); + if (request === probeRequest.current) setIsProxyChecking(false); } } async function generateSingBoxNow() { + if (isApplying || singBoxAction) return; setSingBoxAction("generate"); try { const result = await generateSingBoxConfig(); - await refreshSingBoxState(); + try { + await refreshSingBoxState(); + } catch (error) { + showNotice({ + kind: "info", + title: "Конфиг создан, состояние служб не обновлено", + text: errorMessage(error), + }); + return; + } showNotice({ kind: "success", title: "Конфиг sing-box создан", @@ -1076,7 +1325,7 @@ export function App() { {isAdminPromptHintVisible && !isAdminPromptOpen ? ( - Для удобной работы нужны права администратора. + Windows запросит права для выбранной операции. ) : null}
- Нужны права администратора - Для управления ProxiFyre и правилами Windows. + Права для отдельных операций + + Windows запросит UAC при установке и управлении службами. +
-
); @@ -1133,6 +1372,7 @@ export function App() {

ProxyWarden

void setProxiFyreServiceRunning(running)} /> {renderRouteChain("vertical")} +

+ {systemSummary.title} +
+ {systemSummary.text} +

); @@ -1152,6 +1396,17 @@ export function App() { function renderProxiFyreCard() { const state = serviceControlState(proxyfier, isDetectingComponents); + const lifecycleAllowed = isCutoverLifecycleComplete( + proxifyreCutoverStatus?.state ?? null, + ); + const serviceControlAllowed = + lifecycleAllowed || + canUseStoppedProxiFyreRouteSmoke(proxifyreCutoverStatus); + const lifecycleBusy = + isApplying || + Boolean(startupError) || + componentPackages.isInitializing || + componentPackages.busyAction !== null; const visualState = serviceVisualState === "active" ? "working" @@ -1168,21 +1423,47 @@ export function App() { ); const shouldInstallProxiFyre = !proxyfier?.installed || !proxyfier.serviceStatus; + const installPackageReady = isComponentInstallPackageReady( + proxifyrePackageStatus?.canInstallOffline, + componentPackages.packageStatusError !== null, + ); const primaryAction = shouldInstallProxiFyre ? { - label: proxyfier?.installed ? "Переустановить" : "Установить", + label: lifecycleAllowed + ? installPackageReady + ? proxyfier?.installed + ? "Переустановить" + : "Установить" + : componentPackages.isInitializing + ? "Проверяю пакет" + : "Пакет недоступен" + : blockedLifecycleLabel(proxifyreCutoverStatus), onClick: () => void installProxiFyrePackage(), - variant: "primary" as const, + variant: lifecycleAllowed + ? installPackageReady + ? ("primary" as const) + : ("neutral" as const) + : ("neutral" as const), loading: serviceAction === "install", loadingLabel: "Устанавливаю", - disabled: isDetectingComponents || Boolean(serviceAction), + disabled: + !lifecycleAllowed || + !installPackageReady || + lifecycleBusy || + isDetectingComponents || + Boolean(serviceAction), } : { - label: proxyfier.running ? "Остановить" : "Запустить", + label: serviceControlAllowed + ? proxyfier.running + ? "Остановить" + : "Запустить" + : blockedLifecycleLabel(proxifyreCutoverStatus), onClick: () => void setProxiFyreServiceRunning(!proxyfier.running), - variant: proxyfier.running - ? ("danger" as const) - : ("neutral" as const), + variant: + serviceControlAllowed && proxyfier.running + ? ("danger" as const) + : ("neutral" as const), loading: serviceAction === "start" || serviceAction === "stop" || @@ -1193,7 +1474,11 @@ export function App() { : serviceAction === "start" ? "Запускаю" : "Останавливаю", - disabled: isDetectingComponents || Boolean(serviceAction), + disabled: + !serviceControlAllowed || + lifecycleBusy || + isDetectingComponents || + Boolean(serviceAction), }; return ( @@ -1201,24 +1486,40 @@ export function App() { state={state} visualState={visualState} className="proxifyre-card" - title={proxyfierTitle(proxyfier, isDetectingComponents)} - detail={proxyfierDetails(proxyfier, isDetectingComponents)} - primaryAction={primaryAction} + title="ProxiFyre" + detail={proxyfierCompactStatus(proxyfier, isDetectingComponents)} + primaryAction={ + startupError + ? { ...primaryAction, label: "Запуск не завершён", disabled: true } + : primaryAction + } menu={ canCleanupSetup ? { label: "Дополнительные действия ProxiFyre", open: isServiceMenuOpen, onOpenChange: setIsServiceMenuOpen, - disabled: isDetectingComponents || Boolean(serviceAction), + disabled: + !lifecycleAllowed || + lifecycleBusy || + isDetectingComponents || + Boolean(serviceAction), items: [ + { + label: "Добавить / восстановить правила Firewall", + disabled: !proxyfier?.installed || Boolean(serviceAction), + onClick: () => void configureFirewall(), + }, { label: serviceAction === "uninstall" ? "Удаляю..." : "Удалить ProxiFyre и драйвер", danger: true, - disabled: Boolean(serviceAction), + disabled: + !lifecycleAllowed || + lifecycleBusy || + Boolean(serviceAction), onClick: () => void uninstallProxiFyrePackage(), }, ], @@ -1229,113 +1530,182 @@ export function App() { ); } - function renderProxiFyreSetupStrip() { - return ( - - ); - } - function renderAppsSection() { return ( -
-
-
-

Приложения

- {items.length} -
-
- - {isProcessInputOpen ? ( -
- setProcessInput(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") addProcess(); - if (event.key === "Escape") { - setProcessInput(""); - setIsProcessInputOpen(false); +
+ {draft.profiles.length > 1 ? ( + + ) : null} + void pickAndAddItem(type)} + onRemove={removeItem} + />
); } + function renderComponentManagement( + componentId: ManagedPackageComponentId, + packageStatus: typeof proxifyrePackageStatus, + cutoverStatus: ComponentCutoverStatus | null, + migrationStatus: StorageMigrationStatus | null = null, + ) { + const ownsLastAction = lastComponentAction?.componentId === componentId; + const uacCancelled = cancelledComponentAction?.componentId === componentId; + const packageAction = + lastComponentAction?.kind === "check" || + lastComponentAction?.kind === "download" || + lastComponentAction?.kind === "update"; + const packageError = + (ownsLastAction && packageAction ? componentPackages.error : null) ?? + componentPackages.packageStatusError ?? + (startupReady && !componentPackages.isInitializing && !packageStatus + ? MISSING_PACKAGE_STATUS_ERROR + : null); + const migrationError = + (ownsLastAction && !packageAction ? componentPackages.error : null) ?? + componentPackages.cutoverStatusError ?? + (startupReady && !componentPackages.isInitializing && !cutoverStatus + ? MISSING_CUTOVER_STATUS_ERROR + : null); + const packageUacCancelled = uacCancelled && packageAction; + const migrationUacCancelled = uacCancelled && !packageAction; + const externalBusyReason = + isApplying || serviceAction || singBoxAction + ? "Дождись завершения текущей операции с конфигурацией или службой." + : null; + const serviceRunning = + componentId === "proxifyre" + ? Boolean(proxyfier?.running) + : Boolean(singbox?.running); + return ( +
+ + {packageStatus ? ( + + ) : packageError ? ( + + ) : componentPackages.isInitializing ? ( + + ) : null} +
+ ); + } + function renderChangesDock() { - if (!hasUnappliedChanges || !appliedSnapshot) return null; + if (!hasUnappliedChanges || !savedSnapshot) return null; const externalProxyError = routeMode === "external" ? safeProxyError(proxyInput) : null; const readiness = getApplyReadiness({ routeMode, appCount: items.length, + canClearProfile: + Boolean(draft.selection?.profile?.items.length) && !proxyfier?.running, proxiFyreInstalled: Boolean(proxyfier?.installed), singBoxInstalled: isSingBoxInstalled, singBoxRunning: Boolean(singbox?.running), selectedServerTag: singBoxStatus?.config.selectedServerTag, externalProxyValue: proxyInput, externalProxyError, - busy: isApplying || Boolean(serviceAction) || Boolean(singBoxAction), + busy: + isApplying || + componentPackages.isInitializing || + componentPackages.busyAction !== null || + Boolean(serviceAction) || + Boolean(singBoxAction), + proxiFyreCutoverState: proxifyreCutoverStatus?.state ?? null, + singBoxCutoverState: singBoxCutoverStatus?.state ?? null, }); const applyButtonText = isLoading || isDetectingComponents @@ -1348,6 +1718,14 @@ export function App() {
); @@ -1430,79 +1813,139 @@ export function App() { function renderSingBoxCard() { const state = serviceControlState(singbox, isDetectingComponents); + const lifecycleAllowed = isCutoverLifecycleComplete( + singBoxCutoverStatus?.state ?? null, + ); + const lifecycleBusy = + isApplying || + Boolean(startupError) || + componentPackages.isInitializing || + componentPackages.busyAction !== null; const setupSummary = singBoxSetupStatus ? singBoxSetupStatus.ready ? "состав готов" : `не хватает: ${singBoxSetupStatus.missingCount}` : "состав не проверен"; + const installPackageReady = isComponentInstallPackageReady( + singBoxPackageStatus?.canInstallOffline, + componentPackages.packageStatusError !== null, + ); const primaryAction = singbox?.installed ? { - label: singbox.running ? "Остановить" : "Запустить", + label: lifecycleAllowed + ? singbox.running + ? "Остановить" + : "Запустить" + : blockedLifecycleLabel(singBoxCutoverStatus), onClick: () => void setSingBoxServiceRunning(!singbox.running), - variant: singbox.running ? ("danger" as const) : ("neutral" as const), + variant: + lifecycleAllowed && singbox.running + ? ("danger" as const) + : ("neutral" as const), loading: singBoxAction === "start" || singBoxAction === "stop", loadingLabel: singBoxAction === "start" ? "Запускаю" : "Останавливаю", - disabled: isDetectingComponents || Boolean(singBoxAction), + disabled: + !lifecycleAllowed || + lifecycleBusy || + isDetectingComponents || + Boolean(singBoxAction), } : { - label: "Установить", + label: lifecycleAllowed + ? installPackageReady + ? "Установить" + : componentPackages.isInitializing + ? "Проверяю пакет" + : "Пакет недоступен" + : blockedLifecycleLabel(singBoxCutoverStatus), onClick: () => void installSingBoxPackage(), - variant: "primary" as const, + variant: lifecycleAllowed + ? installPackageReady + ? ("primary" as const) + : ("neutral" as const) + : ("neutral" as const), loading: singBoxAction === "install", loadingLabel: "Устанавливаю", - disabled: isDetectingComponents || Boolean(singBoxAction), + disabled: + !lifecycleAllowed || + !installPackageReady || + lifecycleBusy || + isDetectingComponents || + Boolean(singBoxAction), }; return ( - void uninstallSingBoxPackage(), - }, - ], - } - : undefined - } - inlineActions={ - - - - } - > - {isSingBoxInstalled ? renderSingBoxWorkspace() : null} - + <> + void uninstallSingBoxPackage(), + }, + ], + } + : undefined + } + inlineActions={ + + + + } + > + {isSingBoxInstalled ? renderSingBoxWorkspace() : null} + + {renderComponentManagement( + "sing-box", + singBoxPackageStatus, + singBoxCutoverStatus, + )} + ); } @@ -1528,7 +1971,7 @@ export function App() { loading={singBoxAction === "fetch"} loadingLabel="Загружаю" variant="neutral" - disabled={Boolean(singBoxAction)} + disabled={isApplying || Boolean(singBoxAction)} > {subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? "Загрузить" @@ -1558,7 +2001,9 @@ export function App() { type="button" onClick={() => void pingSingBoxServers()} disabled={ - Boolean(singBoxAction) || !singBoxStatus?.cache?.servers.length + isApplying || + Boolean(singBoxAction) || + !singBoxStatus?.cache?.servers.length } loading={singBoxAction === "ping"} label="Проверить все серверы подписки" @@ -1568,7 +2013,9 @@ export function App() { void generateSingBoxNow()} - disabled={Boolean(singBoxAction) || !selectedServerTag} + disabled={ + isApplying || Boolean(singBoxAction) || !selectedServerTag + } loading={singBoxAction === "generate"} label="Создать конфиг Local sing-box" tooltip="Создать конфиг" @@ -1592,6 +2039,7 @@ export function App() { + + ) : null} {renderTabs()}
- {renderChangesDock()} + {activePanel !== "summary" ? renderChangesDock() : null} { - it("keeps the saved order by default", () => { - expect(sortDraftItems(items, "added")).toBe(items); +describe("application item labels", () => { + it("keeps process labels compact", () => { + expect(appItemName(items[0]!)).toBe("Discord"); + expect(appItemDetail(items[0]!)).toBe("Процесс"); }); - it("groups by item type and sorts values within each group", () => { - const groups = groupDraftItems(items, "grouped"); - - expect(groups.map((group) => group.label)).toEqual([ - "Процессы", - "EXE-файлы", - "Папки", - ]); - expect( - groups.flatMap((group) => group.items.map((item) => item.id)), - ).toEqual(["process-a", "process-z", "exe", "folder"]); - }); - - it("sorts all display values without mutating the source", () => { - expect(sortDraftItems(items, "name").map((item) => item.id)).toEqual([ - "exe", - "folder", - "process-a", - "process-z", - ]); - expect(items[0]?.id).toBe("folder"); + it("uses a short name while preserving the path in details", () => { + expect(appItemName(items[1]!)).toBe("Browser"); + expect(appItemDetail(items[1]!)).toBe("EXE-файл · C:\\Apps\\Browser.exe"); + expect(appItemName(items[2]!)).toBe("Games"); }); }); diff --git a/src/app/components/AppList.tsx b/src/app/components/AppList.tsx index 55255dd..551e02e 100644 --- a/src/app/components/AppList.tsx +++ b/src/app/components/AppList.tsx @@ -1,154 +1,306 @@ -import { useMemo, useState } from "react"; -import { Cpu, FileCode2, FolderOpen } from "lucide-react"; -import { Button } from "../../ui"; -import { itemTypeLabel, type DraftItemType } from "../lib/profileItems"; +import { + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type CSSProperties, +} from "react"; +import { createPortal } from "react-dom"; +import { Cpu, FileCode2, FolderOpen, Trash2 } from "lucide-react"; +import { Button, IconButton } from "../../ui"; +import type { DraftItemType } from "../lib/profileItems"; import type { DraftItem } from "../viewModel"; -export type ItemSortMode = "added" | "grouped" | "name"; +const ITEM_TYPES = ["process", "exe", "folder"] as const; -const SORT_OPTIONS: Array<{ value: ItemSortMode; label: string }> = [ - { value: "added", label: "Добавлены" }, - { value: "grouped", label: "Группы" }, - { value: "name", label: "А–Я" }, -]; +const TYPE_LABEL: Record = { + process: "Процесс", + exe: "EXE-файл", + folder: "Папка", +}; -const TYPE_ORDER: Record = { - process: 0, - exe: 1, - folder: 2, +const TYPE_ADD_LABEL: Record = { + process: "Добавить процесс", + exe: "Добавить EXE-файл", + folder: "Добавить папку", }; interface AppListProps { + profileEnabled?: boolean; + onProfileEnabledChange?: (enabled: boolean) => void; items: DraftItem[]; loading: boolean; + addingPathType: Extract | null; + onAddProcess: (value: string) => boolean; + onAddPath: (type: Extract) => void; onRemove: (id: string) => void; } -export function AppList({ items, loading, onRemove }: AppListProps) { - const [sortMode, setSortMode] = useState("added"); - const itemGroups = useMemo( - () => groupDraftItems(items, sortMode), - [items, sortMode], - ); +export function AppList({ + profileEnabled, + onProfileEnabledChange, + items, + loading, + addingPathType, + onAddProcess, + onAddPath, + onRemove, +}: AppListProps) { + const [isProcessInputOpen, setIsProcessInputOpen] = useState(false); + const [processInput, setProcessInput] = useState(""); + const processAnchorRef = useRef(null); + const processPopoverRef = useRef(null); + const processPopoverId = useId(); + const [processPopoverPosition, setProcessPopoverPosition] = useState({ + top: 0, + left: 0, + width: 300, + placement: "bottom" as "top" | "bottom", + }); + + function closeProcessPopover(returnFocus = true) { + setIsProcessInputOpen(false); + setProcessInput(""); + if (returnFocus) { + window.requestAnimationFrame(() => { + processAnchorRef.current + ?.querySelector("button") + ?.focus(); + }); + } + } + + function submitProcess() { + if (onAddProcess(processInput)) { + closeProcessPopover(); + } + } + + useLayoutEffect(() => { + if (!isProcessInputOpen) return; + + const updatePosition = () => { + const anchor = processAnchorRef.current; + if (!anchor) return; + + const rect = anchor.getBoundingClientRect(); + const width = Math.min(300, window.innerWidth - 24); + const height = processPopoverRef.current?.offsetHeight ?? 0; + const left = Math.max( + 12, + Math.min( + rect.left + rect.width / 2 - width / 2, + window.innerWidth - width - 12, + ), + ); + const fitsBelow = rect.bottom + 8 + height <= window.innerHeight - 12; + + setProcessPopoverPosition({ + top: fitsBelow ? rect.bottom + 8 : Math.max(12, rect.top - height - 8), + left, + width, + placement: fitsBelow ? "bottom" : "top", + }); + }; + + updatePosition(); + const frame = window.requestAnimationFrame(updatePosition); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [isProcessInputOpen]); + + useEffect(() => { + if (!isProcessInputOpen) return; + + const closeOnOutsidePointer = (event: PointerEvent) => { + const target = event.target as Node; + if (processAnchorRef.current?.contains(target)) return; + if (processPopoverRef.current?.contains(target)) return; + closeProcessPopover(false); + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") closeProcessPopover(); + }; + + document.addEventListener("pointerdown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [isProcessInputOpen]); return ( -
- {!loading && items.length > 1 ? ( -
- Порядок -
- {SORT_OPTIONS.map((option) => ( - - ))} -
-
- ) : null} - -
- {loading ? ( -
- - -
- ) : itemGroups.length ? ( - itemGroups.map((group) => ( -
- {group.label ? ( -
- {group.label} - {group.items.length} -
+ <> +
+
+
+
+ Приложения + {items.length} + {profileEnabled !== undefined ? ( + ) : null} - {group.items.map((item) => ( -
-
- -
- {item.value} - {itemTypeLabel(item.type)} -
-
- -
+
+
+
+ { + if (isProcessInputOpen) closeProcessPopover(); + else setIsProcessInputOpen(true); + }} + /> +
+ {ITEM_TYPES.filter((type) => type !== "process").map((type) => ( + onAddPath(type)} + /> ))} -
- )) - ) : ( -
- Список пуст. Добавь первое приложение сверху. +
- )} + +
+ {loading ? ( +
+ + +
+ ) : items.length ? ( + items.map((item) => ( +
+ +
+ {appItemName(item)} + {appItemDetail(item)} +
+ } + onClick={() => onRemove(item.id)} + /> +
+ )) + ) : ( +
+ Добавь процесс, EXE-файл или папку. +
+ )} +
+
-
+ + {isProcessInputOpen && typeof document !== "undefined" + ? createPortal( + , + document.body, + ) + : null} + ); } -export function sortDraftItems(items: DraftItem[], mode: ItemSortMode) { - if (mode === "added") return items; - - return [...items].sort((left, right) => { - if (mode === "grouped") { - const byType = TYPE_ORDER[left.type] - TYPE_ORDER[right.type]; - if (byType !== 0) return byType; - } - - return left.value.localeCompare(right.value, "ru", { - numeric: true, - sensitivity: "base", - }); - }); +export function appItemName(item: DraftItem) { + const value = item.value.replace(/[\\/]+$/, ""); + const name = value.split(/[\\/]/).pop() || item.value; + return item.type === "exe" ? name.replace(/\.exe$/i, "") : name; } -export function groupDraftItems(items: DraftItem[], mode: ItemSortMode) { - const sortedItems = sortDraftItems(items, mode); - if (mode !== "grouped") - return [{ id: mode, label: null, items: sortedItems }]; - - return (["process", "exe", "folder"] as const).flatMap((type) => { - const typeItems = sortedItems.filter((item) => item.type === type); - return typeItems.length - ? [ - { - id: type, - label: groupLabel(type), - items: typeItems, - }, - ] - : []; - }); +export function appItemDetail(item: DraftItem) { + const type = TYPE_LABEL[item.type]; + return item.type === "process" ? type : `${type} · ${item.value}`; } -function groupLabel(type: DraftItemType) { - if (type === "process") return "Процессы"; - if (type === "folder") return "Папки"; - return "EXE-файлы"; -} - -function itemIcon(type: DraftItemType) { - if (type === "process") return ; - if (type === "folder") return ; - return ; +function itemIcon(type: DraftItemType, size: number) { + if (type === "process") return ; + if (type === "folder") return ; + return ; } diff --git a/src/app/components/ComponentPackageStatus.test.tsx b/src/app/components/ComponentPackageStatus.test.tsx new file mode 100644 index 0000000..48678ea --- /dev/null +++ b/src/app/components/ComponentPackageStatus.test.tsx @@ -0,0 +1,296 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ComponentPackageStatus as ComponentPackageStatusDto } from "../../domain/types"; +import { + ComponentPackageStatus, + ComponentPackageStatusLoading, + ComponentPackageStatusUnavailable, + isNumericVersionNewer, + offlineUpdateDisabledReason, +} from "./ComponentPackageStatus"; + +function packageStatus( + overrides: Partial = {}, +): ComponentPackageStatusDto { + return { + componentId: "proxifyre", + installedVersion: "2.2.1", + bundledVersion: "2.2.1", + availableOfflineVersion: "2.2.1", + latestKnownVersion: null, + lastCheckedAt: null, + freshness: "never_checked", + updateState: "unknown_offline", + installSource: "bundled", + offlinePackageSource: "bundled", + canInstallOffline: true, + offlineUnavailableReason: null, + canDownload: false, + ...overrides, + }; +} + +function renderStatus( + status: ComponentPackageStatusDto, + uacCancelled = false, + updateBlockedReason: string | null = null, + externalBusyReason: string | null = null, +) { + return renderToStaticMarkup( + undefined} + onDownload={() => undefined} + onUpdate={() => undefined} + />, + ); +} + +function openingButtonTag(markup: string, label: string) { + const labelIndex = markup.indexOf(label); + const start = markup.lastIndexOf("", start) + 1); +} + +describe("ComponentPackageStatus", () => { + it("compares only numeric versions for offline update", () => { + expect(isNumericVersionNewer("2.10.0", "2.9.9")).toBe(true); + expect(isNumericVersionNewer("2.2.1", "2.2.1.0")).toBe(false); + expect(isNumericVersionNewer("2.2.0", "2.2.1")).toBe(false); + expect(isNumericVersionNewer("v2.3.0", "2.2.1")).toBe(false); + }); + + it("enables offline update only for a newer verified local version", () => { + const enabledStatus = packageStatus({ + availableOfflineVersion: "2.3.0", + offlinePackageSource: "cache", + }); + const enabledMarkup = renderStatus(enabledStatus); + const disabledMarkup = renderStatus(packageStatus()); + + expect(offlineUpdateDisabledReason(enabledStatus)).toBeNull(); + expect(openingButtonTag(enabledMarkup, "Обновить компонент")).not.toContain( + "disabled", + ); + expect(enabledMarkup).toContain("повторно проверяется в сети"); + expect(openingButtonTag(disabledMarkup, "Обновить компонент")).toContain( + "disabled", + ); + }); + + it("does not offer the cache-only update action for a bundled package", () => { + const markup = renderStatus( + packageStatus({ + updateState: "update_available", + installedVersion: "2.2.1", + availableOfflineVersion: "2.4.0", + offlinePackageSource: "bundled", + }), + ); + + expect(markup).toContain("отдельно скачанного и проверенного пакета"); + expect(markup).toContain("Встроенная версия новее"); + expect(openingButtonTag(markup, "Обновить компонент")).toContain( + "disabled", + ); + }); + + it("shows local truth without claiming an offline update", () => { + const markup = renderStatus(packageStatus()); + + expect(markup).toContain("Только локальные данные"); + expect(markup).toContain("это не означает, что компонент устарел"); + expect(markup).toContain("Установлена"); + expect(markup).toContain("Встроена"); + expect(markup).toContain("Последняя известная"); + expect(markup.match(/ + + +
+ +

+ {disabledReasons.join(" ") || + "Все доступные действия можно запускать отдельно."} +

+ +
+ {uacCancelled ? ( +

+ Запрос прав администратора отменён. Данные не изменены. +

+ ) : error ? ( +

+ {error.message} +

+ ) : null} +
+ + ); +} + +export function ComponentPackageStatusUnavailable({ + componentId, + error, +}: { + componentId: ManagedPackageComponentId; + error: CommandError; +}) { + const titleId = useId(); + return ( +
+
+
+ Пакет компонента +

{componentName(componentId)}

+
+ Проверка недоступна +
+

+ Локальное состояние пакета не прочитано. Установка и обновление + оставлены заблокированными. +

+
+

+ {error.message} +

+
+
+ ); +} + +export function ComponentPackageStatusLoading({ + componentId, +}: { + componentId: ManagedPackageComponentId; +}) { + const titleId = useId(); + return ( +
+
+
+ Пакет компонента +

{componentName(componentId)}

+
+ Проверяю +
+

+ Читаю только локальный каталог и сохранённое состояние обновлений. +

+
+ ); +} diff --git a/src/app/components/MigrationNotice.test.tsx b/src/app/components/MigrationNotice.test.tsx new file mode 100644 index 0000000..b9b35c9 --- /dev/null +++ b/src/app/components/MigrationNotice.test.tsx @@ -0,0 +1,174 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { CommandError } from "../../api/tauriCommands"; +import type { + ComponentCutoverState, + ComponentCutoverStatus, + StorageMigrationStatus, +} from "../../domain/types"; +import { MigrationNotice, migrationNoticeView } from "./MigrationNotice"; + +function cutoverStatus( + state: ComponentCutoverState, + overrides: Partial = {}, +): ComponentCutoverStatus { + return { + componentId: "proxifyre", + state, + mode: "service_switch", + legacyVersion: "2.2.1", + currentVersion: null, + bundledVersion: "2.2.1", + originalServiceState: "running", + legacyPathLabel: "C:\\private\\legacy", + currentPathLabel: "C:\\private\\current", + steps: ["secret-fingerprint"], + nextStartVerified: false, + routeSmokeConfirmed: false, + canCutover: ["ready", "in_progress", "recovery_required"].includes(state), + canConfirmRouteSmoke: state === "awaiting_route_smoke", + canCleanup: ["cleanup_ready", "cleanup_pending"].includes(state), + disabledCode: null, + disabledMessage: null, + ...overrides, + }; +} + +function renderNotice( + status: ComponentCutoverStatus | null, + migrationStatus: StorageMigrationStatus | null = null, + uacCancelled = false, + error: CommandError | null = null, + actionBlockedReason: string | null = null, +) { + return renderToStaticMarkup( + undefined} + onConfirmRouteSmoke={() => undefined} + onCleanup={() => undefined} + />, + ); +} + +describe("MigrationNotice", () => { + it("renders nothing for not-needed and complete component migration", () => { + expect(renderNotice(cutoverStatus("not_needed"))).toBe(""); + expect(renderNotice(cutoverStatus("complete"))).toBe(""); + expect(migrationNoticeView("not_needed")).toBeNull(); + expect(migrationNoticeView("complete")).toBeNull(); + }); + + it.each([ + ["ready", "Перенести старую установку"], + ["manual_migration_required", "Нужен ручной перенос"], + ["in_progress", "Продолжить перенос"], + ["awaiting_next_start", "Нужен новый запуск"], + ["awaiting_route_smoke", "Маршрут проверен"], + ["cleanup_pending", "Повторить очистку"], + ["recovery_required", "Восстановить состояние"], + ["rolled_back", "Старая установка восстановлена"], + ["blocked", "Автоматический перенос заблокирован"], + ] as const)("renders the %s state", (state, expected) => { + expect(renderNotice(cutoverStatus(state))).toContain(expected); + }); + + it("keeps cleanup separate, explicit, and destructive", () => { + const markup = renderNotice(cutoverStatus("cleanup_ready")); + + expect(markup).toContain("Отдельное удаление"); + expect(markup).toContain("Удалить старую копию"); + expect(markup).toContain("ui-button--danger"); + expect(markup).not.toContain("Перенести старую установку"); + }); + + it("keeps sealed recovery reachable beside a potentially stale cleanup hint", () => { + const markup = renderNotice( + cutoverStatus("cleanup_pending", { canCutover: true }), + ); + + expect(markup).toContain("Повторить очистку"); + expect(markup).toContain("Восстановить состояние"); + expect(markup).toContain("ui-button--danger"); + expect(markup).toContain("ui-button--neutral"); + }); + + it.each(["awaiting_next_start", "awaiting_route_smoke"] as const)( + "keeps sealed recovery reachable beside a potentially stale %s hint", + (state) => { + const markup = renderNotice(cutoverStatus(state, { canCutover: true })); + + expect(markup).toContain("Восстановить состояние"); + expect(markup).toContain("ui-button--neutral"); + }, + ); + + it("never renders internal paths, steps, or disabled backend text", () => { + const markup = renderNotice( + cutoverStatus("blocked", { + disabledCode: "unknown_private_code", + disabledMessage: "C:\\private\\do-not-display", + }), + ); + + expect(markup).not.toContain("private"); + expect(markup).not.toContain("secret-fingerprint"); + expect(markup).toContain("Продолжение возможно только после проверки"); + }); + + it("shows a one-time storage migration result with no cutover action", () => { + const migrationStatus: StorageMigrationStatus = { + storageSchemaVersion: 1, + componentLayoutVersion: null, + outcome: "imported_legacy_config", + changed: true, + blocking: false, + noticeCode: null, + message: "C:\\private\\must-not-render", + }; + const markup = renderNotice(cutoverStatus("not_needed"), migrationStatus); + + expect(markup).toContain("Старый конфиг безопасно перенесён"); + expect(markup).not.toContain("private"); + expect(markup).not.toContain("ui-button"); + }); + + it("presents UAC cancellation as neutral preserved state", () => { + const markup = renderNotice(cutoverStatus("ready"), null, true); + + expect(markup).toContain("Запрос прав администратора отменён"); + expect(markup).toContain("Текущее состояние сохранено"); + expect(markup).not.toContain('class="error"'); + }); + + it("shows a fail-closed local status error even without a cutover record", () => { + const markup = renderNotice(null, null, false, { + code: "component_status_unavailable", + message: "Локальный статус временно недоступен.", + details: [], + }); + + expect(markup).toContain("Не удалось проверить перенос"); + expect(markup).toContain("Изменения оставлены заблокированными"); + expect(markup).toContain("Локальный статус временно недоступен"); + }); + + it("requires a stopped service before route-smoke confirmation", () => { + const markup = renderNotice( + cutoverStatus("awaiting_route_smoke"), + null, + false, + null, + "Сначала явно останови службу.", + ); + + expect(markup).toContain("Сначала явно останови службу"); + expect(markup).toContain('disabled=""'); + expect(markup).toContain("Маршрут проверен"); + }); +}); diff --git a/src/app/components/MigrationNotice.tsx b/src/app/components/MigrationNotice.tsx new file mode 100644 index 0000000..042584b --- /dev/null +++ b/src/app/components/MigrationNotice.tsx @@ -0,0 +1,404 @@ +import { useId } from "react"; +import type { CommandError } from "../../api/tauriCommands"; +import type { + ComponentCutoverState, + ComponentCutoverStatus as ComponentCutoverStatusDto, + ManagedPackageComponentId, + StorageMigrationStatus, +} from "../../domain/types"; +import { + Button, + StatusPill, + type ButtonVariant, + type StatusPillTone, +} from "../../ui"; +import type { ComponentPackageBusyAction } from "../hooks/useComponentPackages"; +import type { ComponentPackageActionHandler } from "./ComponentPackageStatus"; + +type MigrationAction = "cutover" | "confirm-route-smoke" | "cleanup"; + +export interface MigrationNoticeView { + title: string; + detail: string; + label: string; + tone: StatusPillTone; + action: { + kind: MigrationAction; + label: string; + loadingLabel: string; + variant: ButtonVariant; + } | null; + disabledReason: string | null; +} + +export interface MigrationNoticeProps { + migrationStatus?: StorageMigrationStatus | null; + cutoverStatus: ComponentCutoverStatusDto | null; + busyAction: ComponentPackageBusyAction | null; + externalBusyReason?: string | null; + actionBlockedReason?: string | null; + error?: CommandError | null; + uacCancelled?: boolean; + onCutover: ComponentPackageActionHandler; + onConfirmRouteSmoke: ComponentPackageActionHandler; + onCleanup: ComponentPackageActionHandler; +} + +export function migrationNoticeView( + state: ComponentCutoverState, +): MigrationNoticeView | null { + switch (state) { + case "not_needed": + case "complete": + return null; + case "ready": + return { + title: "Найдена старая установка", + detail: + "ProxyWarden может перенести подтверждённую установку отдельным действием с UAC.", + label: "Готово к переносу", + tone: "warning", + action: { + kind: "cutover", + label: "Перенести старую установку", + loadingLabel: "Открываю UAC", + variant: "primary", + }, + disabledReason: null, + }; + case "manual_migration_required": + return { + title: "Нужен ручной перенос", + detail: + "Эта установка не входит в доказанный автоматический сценарий и оставлена без изменений.", + label: "Только вручную", + tone: "warning", + action: null, + disabledReason: + "Автоматическое действие отключено: принадлежность установки недостаточно подтверждена.", + }; + case "in_progress": + return { + title: "Перенос не завершён", + detail: + "Состояние сохранено. Следующий запуск действия безопасно продолжит или откатит перенос.", + label: "Требует продолжения", + tone: "checking", + action: { + kind: "cutover", + label: "Продолжить перенос", + loadingLabel: "Продолжаю", + variant: "primary", + }, + disabledReason: null, + }; + case "awaiting_next_start": + return { + title: "Нужен новый запуск", + detail: + "Закрой и снова открой ProxyWarden: новый запуск должен подтвердить текущую установку.", + label: "Ожидает запуска", + tone: "warning", + action: null, + disabledReason: null, + }; + case "awaiting_route_smoke": + return { + title: "Проверь реальный маршрут", + detail: + "Запусти выбранное приложение и убедись, что оно действительно выходит через настроенный прокси.", + label: "Нужна проверка", + tone: "warning", + action: { + kind: "confirm-route-smoke", + label: "Маршрут проверен", + loadingLabel: "Подтверждаю", + variant: "primary", + }, + disabledReason: null, + }; + case "cleanup_ready": + return { + title: "Старая копия готова к удалению", + detail: + "Новая установка и реальный маршрут подтверждены. Удаление старой копии остаётся отдельным действием.", + label: "Можно очистить", + tone: "warning", + action: { + kind: "cleanup", + label: "Удалить старую копию", + loadingLabel: "Открываю UAC", + variant: "danger", + }, + disabledReason: null, + }; + case "cleanup_pending": + return { + title: "Очистка не завершена", + detail: + "Часть старой копии ещё сохранена. Повторное действие продолжит только проверенную очистку.", + label: "Нужен повтор", + tone: "warning", + action: { + kind: "cleanup", + label: "Повторить очистку", + loadingLabel: "Продолжаю очистку", + variant: "danger", + }, + disabledReason: null, + }; + case "rolled_back": + return { + title: "Старая установка восстановлена", + detail: + "Перенос отменён безопасным откатом. Рабочая старая установка оставлена на месте.", + label: "Выполнен откат", + tone: "muted", + action: null, + disabledReason: null, + }; + case "recovery_required": + return { + title: "Требуется безопасное восстановление", + detail: + "Перенос остановлен в защищённом состоянии. Другие изменения заблокированы до восстановления.", + label: "Нужно восстановление", + tone: "error", + action: { + kind: "cutover", + label: "Восстановить состояние", + loadingLabel: "Восстанавливаю", + variant: "neutral", + }, + disabledReason: null, + }; + case "blocked": + return { + title: "Автоматический перенос заблокирован", + detail: + "Проверки безопасности не разрешили изменять найденную установку. Она оставлена без изменений.", + label: "Заблокировано", + tone: "error", + action: null, + disabledReason: + "Продолжение возможно только после проверки установки и локального пакета.", + }; + } +} + +function storageMigrationCopy( + status: StorageMigrationStatus | null | undefined, +) { + if (!status || (!status.changed && !status.blocking && !status.noticeCode)) { + return null; + } + if (status.blocking) { + return "Автоматическое изменение настроек заблокировано; исходные данные оставлены без изменений."; + } + switch (status.outcome) { + case "initialized_empty": + return "Хранилище настроек подготовлено."; + case "adopted_without_legacy_import": + return "Существующие настройки приняты без смешивания со старым конфигом."; + case "imported_legacy_config": + return "Старый конфиг безопасно перенесён в новое хранилище."; + default: + return "Настройки безопасно подготовлены для текущей версии."; + } +} + +function componentName(componentId: ManagedPackageComponentId) { + return componentId === "proxifyre" ? "ProxiFyre" : "Local sing-box"; +} + +function actionAllowed( + status: ComponentCutoverStatusDto, + action: MigrationAction, +) { + if (action === "cutover") return status.canCutover; + if (action === "confirm-route-smoke") { + return status.canConfirmRouteSmoke; + } + return status.canCleanup; +} + +function fixedDisabledReason(status: ComponentCutoverStatusDto) { + switch (status.disabledCode) { + case "component_package_unavailable": + return "Проверенный встроенный пакет недоступен."; + case "manual_migration_required": + return "Для этой установки разрешён только ручной перенос."; + case "component_identity_unconfirmed": + return "Принадлежность установки не подтверждена."; + case "component_cutover_recovery_required": + return "Сначала требуется безопасно восстановить перенос."; + default: + return "Действие пока недоступно по результатам проверки безопасности."; + } +} + +export function MigrationNotice({ + migrationStatus = null, + cutoverStatus, + busyAction, + externalBusyReason = null, + actionBlockedReason = null, + error = null, + uacCancelled = false, + onCutover, + onConfirmRouteSmoke, + onCleanup, +}: MigrationNoticeProps) { + const titleId = useId(); + const reasonId = useId(); + const storageCopy = storageMigrationCopy(migrationStatus); + const cutoverView = cutoverStatus + ? migrationNoticeView(cutoverStatus.state) + : null; + + if (!cutoverView && !storageCopy && !error && !uacCancelled) return null; + + const view: MigrationNoticeView = cutoverView ?? { + title: error + ? "Не удалось проверить перенос" + : migrationStatus?.blocking + ? "Проверь состояние настроек" + : "Настройки подготовлены", + detail: + storageCopy ?? + (error + ? "Локальное состояние компонентов недоступно. Изменения оставлены заблокированными." + : "Настройки не изменялись."), + label: error + ? "Проверка недоступна" + : migrationStatus?.blocking + ? "Требует внимания" + : "Готово", + tone: error ? "error" : migrationStatus?.blocking ? "error" : "ok", + action: null, + disabledReason: null, + }; + const action = view.action; + const anyBusy = busyAction !== null || externalBusyReason !== null; + const matchingBusy = + cutoverStatus && busyAction?.componentId === cutoverStatus.componentId + ? busyAction.kind + : null; + const allowed = + cutoverStatus && action + ? actionAllowed(cutoverStatus, action.kind) && !actionBlockedReason + : false; + const disabledReason = anyBusy + ? (externalBusyReason ?? + "Дождись завершения текущего действия с компонентом.") + : actionBlockedReason + ? actionBlockedReason + : action && cutoverStatus && !allowed + ? fixedDisabledReason(cutoverStatus) + : view.disabledReason; + + function runAction() { + if (!cutoverStatus || !action) return; + if (action.kind === "cutover") { + void onCutover(cutoverStatus.componentId); + } else if (action.kind === "confirm-route-smoke") { + void onConfirmRouteSmoke(cutoverStatus.componentId); + } else { + void onCleanup(cutoverStatus.componentId); + } + } + + const actionButton = action ? ( + + ) : null; + const recoveryButton = + cutoverStatus?.canCutover && action?.kind !== "cutover" ? ( + + ) : null; + + return ( + + ); +} diff --git a/src/app/components/ProxiFyreSetupStrip.tsx b/src/app/components/ProxiFyreSetupStrip.tsx deleted file mode 100644 index eb2474d..0000000 --- a/src/app/components/ProxiFyreSetupStrip.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import type { - ProxiFyreSetupProgress, - ProxiFyreSetupStatus, -} from "../../api/tauriCommands"; - -interface ProxiFyreSetupStripProps { - setupStatus: ProxiFyreSetupStatus | null; - progress: ProxiFyreSetupProgress | null; -} - -const SETUP_PLACEHOLDERS: ProxiFyreSetupStatus["items"] = [ - { - id: "vc-runtime", - name: "Среда запуска", - installed: false, - details: "Проверяю", - }, - { - id: "packet-filter", - name: "Сетевой драйвер", - installed: false, - details: "Проверяю", - }, - { - id: "proxifyre", - name: "Клиент ProxiFyre", - installed: false, - details: "Проверяю", - }, -]; - -export function ProxiFyreSetupStrip({ - setupStatus, - progress, -}: ProxiFyreSetupStripProps) { - const stripItems = setupStatus?.items ?? SETUP_PLACEHOLDERS; - const visibleProgress = isVisibleProgress(progress) ? progress : null; - const progressTone = - visibleProgress?.status === "failed" ? "failed" : "running"; - const percent = clampPercent(visibleProgress?.percent ?? 0); - - return ( -
- Состав -
- {stripItems.map((item) => ( -
-
- ))} -
- {visibleProgress ? ( -
-
- -
- - {visibleProgress.message} - -
- ) : null} -
- ); -} - -function setupItemClass( - item: ProxiFyreSetupStatus["items"][number], - progress: ProxiFyreSetupProgress | null, -) { - if (progress?.activeStep === item.id) { - if (progress.status === "failed") return "failed"; - return "active"; - } - if (item.installed) return "installed"; - return "missing"; -} - -function setupItemUserName(id: string, fallbackName: string) { - if (id === "vc-runtime") return "Среда запуска"; - if (id === "packet-filter") return "Сетевой драйвер"; - if (id === "proxifyre") return "Клиент ProxiFyre"; - return fallbackName; -} - -function setupItemShortStatus( - item: ProxiFyreSetupStatus["items"][number], - progress: ProxiFyreSetupProgress | null, -) { - if (progress?.activeStep === item.id) { - if (progress.status === "failed") return "ошибка"; - if (progress.status === "succeeded") - return progress.operation === "uninstall" ? "удалено" : "готово"; - return "в процессе"; - } - if (item.details === "Проверяю") return "проверяю"; - if (!item.installed) - return progress?.operation === "uninstall" && - progress.status === "succeeded" - ? "удалено" - : "нужно установить"; - if (item.id === "proxifyre") - return proxifyreSetupServiceSummary(item.version); - return "готово"; -} - -function proxifyreSetupServiceSummary(version: string | undefined) { - const normalized = version?.trim().toLowerCase() ?? ""; - if (normalized.includes("не установлена")) return "служба не установлена"; - if (normalized.includes("остановлена") || normalized.includes("не запущена")) - return "служба остановлена"; - if (normalized.includes("запущена")) return "служба запущена"; - return "готово"; -} - -function isVisibleProgress( - progress: ProxiFyreSetupProgress | null, -): progress is ProxiFyreSetupProgress { - if (!progress || progress.status === "idle") return false; - return progress.status === "running" || progress.status === "failed"; -} - -function clampPercent(value: number) { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(100, Math.round(value))); -} diff --git a/src/app/components/SummaryStatusControl.test.tsx b/src/app/components/SummaryStatusControl.test.tsx new file mode 100644 index 0000000..03a6448 --- /dev/null +++ b/src/app/components/SummaryStatusControl.test.tsx @@ -0,0 +1,51 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { SummaryStatusControl } from "./SummaryStatusControl"; + +describe("SummaryStatusControl", () => { + it("does not infer a missing component when startup data is unavailable", () => { + const markup = renderToStaticMarkup( + , + ); + expect(markup).toContain("ProxyWarden: Нет данных"); + expect(markup).not.toContain("Не установлен"); + }); + it("renders an accessible status without a service mutation control", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="status"'); + expect(markup).toContain("ProxyWarden: Службы запущены"); + expect(markup).not.toContain(" { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("ProxyWarden: Не установлен"); + expect(markup).not.toContain("disabled"); + }); +}); diff --git a/src/app/components/SummaryStatusControl.tsx b/src/app/components/SummaryStatusControl.tsx index e3a915c..2765b12 100644 --- a/src/app/components/SummaryStatusControl.tsx +++ b/src/app/components/SummaryStatusControl.tsx @@ -3,49 +3,47 @@ import { BusyRing } from "../../ui"; import type { StatusTone } from "../viewModel"; interface SummaryStatusControlProps { + unavailable?: boolean; installed: boolean; running: boolean; working: boolean; checking: boolean; tone: StatusTone; - onToggle: (running: boolean) => void; } export function SummaryStatusControl({ + unavailable = false, installed, running, working, checking, tone, - onToggle, }: SummaryStatusControlProps) { - const stateLabel = - working || tone === "checking" + const stateLabel = unavailable + ? "Нет данных" + : working || checking || tone === "checking" ? "Проверяю" - : tone === "ok" - ? "Работает" - : "Не работает"; - const buttonAriaLabel = !installed - ? "ProxiFyre не установлен" - : running - ? "Отключить ProxyWarden" - : "Включить ProxyWarden"; + : !installed + ? "Не установлен" + : tone === "ok" + ? "Службы запущены" + : "Требует внимания"; return ( -
- + {stateLabel}
); diff --git a/src/app/hooks/useApplyFlow.test.ts b/src/app/hooks/useApplyFlow.test.ts new file mode 100644 index 0000000..9b47a80 --- /dev/null +++ b/src/app/hooks/useApplyFlow.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { commitDraft } from "./useApplyFlow"; +import type { + ApplyConfigurationInput, + ApplyConfigurationResult, +} from "../../api/tauriCommands"; + +const input = {} as ApplyConfigurationInput; +const success = { success: true } as ApplyConfigurationResult; +describe("committed draft", () => { + it("keeps the committed outcome when the runtime refresh fails", async () => { + const accepted: unknown[] = []; + const result = await commitDraft( + input, + async () => success, + (value) => accepted.push(value), + async () => { + throw new Error("offline"); + }, + ); + expect(accepted).toEqual([success]); + expect(result.result.success).toBe(true); + expect(result.refreshError).toBeInstanceOf(Error); + }); + it("does not accept a failed transaction or refresh over a retained draft", async () => { + const calls: string[] = []; + await commitDraft( + input, + async () => ({ success: false }) as ApplyConfigurationResult, + () => calls.push("accept"), + async () => calls.push("refresh"), + ); + expect(calls).toEqual([]); + }); +}); diff --git a/src/app/hooks/useApplyFlow.ts b/src/app/hooks/useApplyFlow.ts new file mode 100644 index 0000000..de2e53a --- /dev/null +++ b/src/app/hooks/useApplyFlow.ts @@ -0,0 +1,37 @@ +import { useState } from "react"; +import type { + ApplyConfigurationInput, + ApplyConfigurationResult, +} from "../../api/tauriCommands"; + +export async function commitDraft( + input: ApplyConfigurationInput, + command: ( + input: ApplyConfigurationInput, + ) => Promise, + accept: (result: ApplyConfigurationResult) => void, + refresh: () => Promise, +) { + const result = await command(input); + if (!result.success) return { result }; + accept(result); + try { + await refresh(); + return { result }; + } catch (refreshError) { + return { result, refreshError }; + } +} + +export function useApplyFlow() { + const [isApplying, setApplying] = useState(false); + async function submit(...args: Parameters) { + setApplying(true); + try { + return await commitDraft(...args); + } finally { + setApplying(false); + } + } + return { isApplying, submit }; +} diff --git a/src/app/hooks/useComponentPackages.test.ts b/src/app/hooks/useComponentPackages.test.ts new file mode 100644 index 0000000..a9113e1 --- /dev/null +++ b/src/app/hooks/useComponentPackages.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from "vitest"; +import { packageComponentIdFor } from "../../domain/types"; +import type { + ComponentCutoverStatus, + ComponentPackageStatus, +} from "../../domain/types"; +import { + componentStateIsInitializing, + componentActionRequiresReboot, + executeComponentCommand, + isPrivilegedUacCancellation, + localComponentSnapshot, + localComponentStateError, + loadLocalComponentState, + sanitizeComponentCommandError, +} from "./useComponentPackages"; + +const packageStatus: ComponentPackageStatus = { + componentId: "proxifyre", + installedVersion: null, + bundledVersion: "2.2.1", + availableOfflineVersion: "2.2.1", + latestKnownVersion: null, + lastCheckedAt: null, + freshness: "never_checked", + updateState: "unknown_offline", + installSource: "none", + offlinePackageSource: "bundled", + canInstallOffline: true, + offlineUnavailableReason: null, + canDownload: false, +}; + +const cutoverStatus: ComponentCutoverStatus = { + componentId: "proxifyre", + state: "ready", + mode: "service_switch", + legacyVersion: "2.2.1", + currentVersion: null, + bundledVersion: "2.2.1", + originalServiceState: "running", + legacyPathLabel: "Старая установка ProxiFyre", + currentPathLabel: "Установка ProxyWarden", + steps: ["Перенос выполняется отдельным действием."], + nextStartVerified: false, + routeSmokeConfirmed: false, + canCutover: true, + canConfirmRouteSmoke: false, + canCleanup: false, + disabledCode: null, + disabledMessage: null, +}; + +describe("component package orchestration", () => { + it("shows initialization immediately on the first startup-ready paint", () => { + expect(componentStateIsInitializing(true, false, false)).toBe(true); + expect(componentStateIsInitializing(true, true, true)).toBe(true); + expect(componentStateIsInitializing(true, true, false)).toBe(false); + expect(componentStateIsInitializing(false, false, false)).toBe(false); + }); + + it("maps inventory ids to the exact package ids", () => { + expect(packageComponentIdFor("proxyfier")).toBe("proxifyre"); + expect(packageComponentIdFor("singbox")).toBe("sing-box"); + expect(packageComponentIdFor("control-app")).toBeNull(); + }); + + it("loads only the two local status sources and keeps partial results", async () => { + const getPackageStatuses = vi.fn().mockResolvedValue([packageStatus]); + const getCutoverStatuses = vi.fn().mockRejectedValue({ + code: "component_cutover_status_failed", + message: "Статус переноса временно недоступен.", + details: [], + }); + + const result = await loadLocalComponentState({ + getPackageStatuses, + getCutoverStatuses, + }); + + expect(getPackageStatuses).toHaveBeenCalledOnce(); + expect(getCutoverStatuses).toHaveBeenCalledOnce(); + expect(result.packageStatuses).toEqual([packageStatus]); + expect(result.cutoverStatuses).toBeNull(); + expect(result.packageError).toBeNull(); + expect(result.cutoverError?.code).toBe("component_cutover_status_failed"); + }); + + it("returns UAC cancellation as a neutral outcome", async () => { + const result = await executeComponentCommand(() => + Promise.reject({ + code: "privileged_uac_cancelled", + message: "Запрос прав администратора отменен пользователем.", + details: [], + }), + ); + + expect(result).toEqual({ status: "cancelled" }); + expect( + isPrivilegedUacCancellation({ code: "privileged_uac_cancelled" }), + ).toBe(true); + expect(isPrivilegedUacCancellation({ code: "operation_failed" })).toBe( + false, + ); + }); + + it("surfaces only an explicit reboot-required result", () => { + expect(componentActionRequiresReboot({ rebootRequired: true })).toBe(true); + expect(componentActionRequiresReboot({ rebootRequired: false })).toBe( + false, + ); + expect(componentActionRequiresReboot({ changed: true })).toBe(false); + }); + + it("keeps path and credential-bearing text out of displayed errors", () => { + const result = sanitizeComponentCommandError({ + code: "component_update_failed", + message: "C:\\Users\\person\\secret.txt", + details: [ + { + field: "package", + message: "token=do-not-display", + }, + ], + debugPath: "C:\\ProgramData\\ProxyWarden", + }); + + expect(result.code).toBe("component_update_failed"); + expect(result.message).not.toContain("person"); + expect(result.details[0]?.message).not.toContain("do-not-display"); + expect(JSON.stringify(result)).not.toContain("ProgramData"); + }); + + it("returns both local status groups when available", async () => { + const result = await loadLocalComponentState({ + getPackageStatuses: async () => [packageStatus], + getCutoverStatuses: async () => [cutoverStatus], + }); + + expect(result).toEqual({ + packageStatuses: [packageStatus], + cutoverStatuses: [cutoverStatus], + packageError: null, + cutoverError: null, + }); + }); + + it("clears stale authority when a local status source becomes unreadable", () => { + const snapshot = localComponentSnapshot({ + packageStatuses: [packageStatus], + cutoverStatuses: null, + packageError: null, + cutoverError: { + code: "component_cutover_status_failed", + message: "Статус переноса временно недоступен.", + details: [], + }, + }); + + expect(snapshot.packageStatuses).toEqual([packageStatus]); + expect(snapshot.cutoverStatuses).toEqual([]); + expect(snapshot.cutoverError?.code).toBe("component_cutover_status_failed"); + expect( + localComponentStateError({ + packageStatuses: [packageStatus], + cutoverStatuses: null, + packageError: null, + cutoverError: snapshot.cutoverError, + })?.code, + ).toBe("component_cutover_status_failed"); + }); +}); diff --git a/src/app/hooks/useComponentPackages.ts b/src/app/hooks/useComponentPackages.ts new file mode 100644 index 0000000..1aa9425 --- /dev/null +++ b/src/app/hooks/useComponentPackages.ts @@ -0,0 +1,364 @@ +import { useEffect, useRef, useState } from "react"; +import { + checkComponentUpdate as checkComponentUpdateCommand, + cleanupComponentQuarantine as cleanupComponentQuarantineCommand, + confirmComponentRouteSmoke as confirmComponentRouteSmokeCommand, + cutoverComponent as cutoverComponentCommand, + downloadComponentUpdate as downloadComponentUpdateCommand, + getComponentCutoverStatuses, + getComponentPackageStatuses, + updateComponent as updateComponentCommand, + type CommandError, +} from "../../api/tauriCommands"; +import type { + ComponentCutoverResponse, + ComponentCutoverStatus, + ComponentPackageStatus, + ComponentUpdateCheckResponse, + ComponentUpdateDownloadResponse, + ComponentUpdateResponse, + ManagedPackageComponentId, +} from "../../domain/types"; + +const FALLBACK_ERROR_MESSAGE = + "Не удалось выполнить действие с компонентом. Повтори попытку."; +const SAFE_ERROR_CODE = /^[a-z0-9_]{1,64}$/; +const SAFE_DETAIL_FIELD = /^[a-zA-Z0-9_.-]{1,64}$/; +const SENSITIVE_TEXT = + /(?:[a-z][a-z0-9+.-]*:\/\/|[a-z]:[\\/]|(?:password|passwd|token|secret|authorization|userinfo)\s*[:=])/i; + +export type ComponentPackageActionKind = + | "check" + | "download" + | "update" + | "cutover" + | "confirm-route-smoke" + | "cleanup"; + +export interface ComponentPackageBusyAction { + kind: ComponentPackageActionKind; + componentId: ManagedPackageComponentId; +} + +export type ComponentPackageActionResult = + | { status: "succeeded"; value: T } + | { status: "cancelled" } + | { status: "busy" } + | { status: "failed"; error: CommandError }; + +interface LocalStatusCommands { + getPackageStatuses: () => Promise; + getCutoverStatuses: () => Promise; +} + +export interface LocalComponentState { + packageStatuses: ComponentPackageStatus[] | null; + cutoverStatuses: ComponentCutoverStatus[] | null; + packageError: CommandError | null; + cutoverError: CommandError | null; +} + +export interface LocalComponentSnapshot { + packageStatuses: ComponentPackageStatus[]; + cutoverStatuses: ComponentCutoverStatus[]; + packageError: CommandError | null; + cutoverError: CommandError | null; +} + +const localStatusCommands: LocalStatusCommands = { + getPackageStatuses: getComponentPackageStatuses, + getCutoverStatuses: getComponentCutoverStatuses, +}; + +function sanitizedText(value: unknown, fallback: string) { + if (typeof value !== "string") return fallback; + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized || SENSITIVE_TEXT.test(normalized)) return fallback; + return normalized.slice(0, 320); +} + +export function sanitizeComponentCommandError(error: unknown): CommandError { + const record = + error && typeof error === "object" + ? (error as Record) + : null; + const rawCode = record?.code; + const code = + typeof rawCode === "string" && SAFE_ERROR_CODE.test(rawCode) + ? rawCode + : "component_action_failed"; + const messageSource = + error instanceof Error ? error.message : (record?.message ?? error); + const rawDetails = Array.isArray(record?.details) ? record.details : []; + const details = rawDetails.flatMap((detail) => { + if (!detail || typeof detail !== "object") return []; + const item = detail as Record; + const field = + typeof item.field === "string" && SAFE_DETAIL_FIELD.test(item.field) + ? item.field + : "component"; + return [ + { + field, + message: sanitizedText(item.message, FALLBACK_ERROR_MESSAGE), + }, + ]; + }); + + return { + code, + message: sanitizedText(messageSource, FALLBACK_ERROR_MESSAGE), + details: details.slice(0, 8), + }; +} + +export function isPrivilegedUacCancellation(error: unknown) { + return Boolean( + error && + typeof error === "object" && + (error as Record).code === "privileged_uac_cancelled", + ); +} + +export async function executeComponentCommand( + command: () => Promise, +): Promise> { + try { + return { status: "succeeded", value: await command() }; + } catch (error) { + const sanitized = sanitizeComponentCommandError(error); + return isPrivilegedUacCancellation(sanitized) + ? { status: "cancelled" } + : { status: "failed", error: sanitized }; + } +} + +export function componentActionRequiresReboot(value: unknown) { + return Boolean( + value && + typeof value === "object" && + (value as Record).rebootRequired === true, + ); +} + +export async function loadLocalComponentState( + commands: LocalStatusCommands = localStatusCommands, +): Promise { + const [packages, cutovers] = await Promise.allSettled([ + commands.getPackageStatuses(), + commands.getCutoverStatuses(), + ]); + return { + packageStatuses: packages.status === "fulfilled" ? packages.value : null, + cutoverStatuses: cutovers.status === "fulfilled" ? cutovers.value : null, + packageError: + packages.status === "rejected" + ? sanitizeComponentCommandError(packages.reason) + : null, + cutoverError: + cutovers.status === "rejected" + ? sanitizeComponentCommandError(cutovers.reason) + : null, + }; +} + +export function localComponentSnapshot( + state: LocalComponentState, +): LocalComponentSnapshot { + return { + packageStatuses: state.packageStatuses ?? [], + cutoverStatuses: state.cutoverStatuses ?? [], + packageError: state.packageError, + cutoverError: state.cutoverError, + }; +} + +export function localComponentStateError(state: LocalComponentState) { + return state.cutoverError ?? state.packageError; +} + +export function componentStateIsInitializing( + startupReady: boolean, + initializationStarted: boolean, + initializing: boolean, +) { + return startupReady && (!initializationStarted || initializing); +} + +function upsertByComponentId< + T extends { componentId: ManagedPackageComponentId }, +>(values: T[], value: T) { + const index = values.findIndex( + (current) => current.componentId === value.componentId, + ); + if (index === -1) return [...values, value]; + return [...values.slice(0, index), value, ...values.slice(index + 1)]; +} + +export function useComponentPackages(startupReady: boolean) { + const [packageStatuses, setPackageStatuses] = useState< + ComponentPackageStatus[] + >([]); + const [cutoverStatuses, setCutoverStatuses] = useState< + ComponentCutoverStatus[] + >([]); + const [isInitializing, setIsInitializing] = useState(false); + const [busyAction, setBusyAction] = + useState(null); + const [error, setError] = useState(null); + const [packageStatusError, setPackageStatusError] = + useState(null); + const [cutoverStatusError, setCutoverStatusError] = + useState(null); + const initializationRef = useRef | null>(null); + const busyRef = useRef(null); + + useEffect(() => { + if (!startupReady) { + initializationRef.current = null; + return undefined; + } + + const initialization = + initializationRef.current ?? + (initializationRef.current = loadLocalComponentState()); + let active = true; + setIsInitializing(true); + + void initialization.then((state) => { + if (!active) return; + const snapshot = localComponentSnapshot(state); + setPackageStatuses(snapshot.packageStatuses); + setCutoverStatuses(snapshot.cutoverStatuses); + setPackageStatusError(snapshot.packageError); + setCutoverStatusError(snapshot.cutoverError); + setIsInitializing(false); + }); + + return () => { + active = false; + }; + }, [startupReady]); + + async function refreshLocal() { + setIsInitializing(true); + const state = await loadLocalComponentState(); + const snapshot = localComponentSnapshot(state); + setPackageStatuses(snapshot.packageStatuses); + setCutoverStatuses(snapshot.cutoverStatuses); + setPackageStatusError(snapshot.packageError); + setCutoverStatusError(snapshot.cutoverError); + setIsInitializing(false); + return state; + } + + async function runAction( + action: ComponentPackageBusyAction, + command: () => Promise, + apply: (value: T) => void, + ): Promise> { + if (busyRef.current) return { status: "busy" }; + + busyRef.current = action; + setBusyAction(action); + setError(null); + const result = await executeComponentCommand(command); + + if (result.status === "succeeded") apply(result.value); + if (result.status === "failed") setError(result.error); + + if (busyRef.current === action) { + busyRef.current = null; + setBusyAction(null); + } + return result; + } + + function check(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "check", componentId }, + () => checkComponentUpdateCommand(componentId), + (response) => + setPackageStatuses((current) => + upsertByComponentId(current, response.status), + ), + ); + } + + function download(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "download", componentId }, + () => downloadComponentUpdateCommand(componentId), + (response) => + setPackageStatuses((current) => + upsertByComponentId(current, response.status), + ), + ); + } + + function update(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "update", componentId }, + () => updateComponentCommand(componentId), + (response) => + setPackageStatuses((current) => + upsertByComponentId(current, response.package), + ), + ); + } + + function cutover(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "cutover", componentId }, + () => cutoverComponentCommand(componentId), + (response) => + setCutoverStatuses((current) => + upsertByComponentId(current, response.status), + ), + ); + } + + function confirmRouteSmoke(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "confirm-route-smoke", componentId }, + () => confirmComponentRouteSmokeCommand(componentId), + (status) => + setCutoverStatuses((current) => upsertByComponentId(current, status)), + ); + } + + function cleanup(componentId: ManagedPackageComponentId) { + return runAction( + { kind: "cleanup", componentId }, + () => cleanupComponentQuarantineCommand(componentId), + (response) => + setCutoverStatuses((current) => + upsertByComponentId(current, response.status), + ), + ); + } + + const effectiveInitializing = componentStateIsInitializing( + startupReady, + initializationRef.current !== null, + isInitializing, + ); + + return { + packageStatuses, + cutoverStatuses, + isInitializing: effectiveInitializing, + busyAction, + error, + packageStatusError, + cutoverStatusError, + clearError: () => setError(null), + refreshLocal, + check, + download, + update, + cutover, + confirmRouteSmoke, + cleanup, + }; +} diff --git a/src/app/hooks/useConfigurationDraft.test.ts b/src/app/hooks/useConfigurationDraft.test.ts new file mode 100644 index 0000000..4f80575 --- /dev/null +++ b/src/app/hooks/useConfigurationDraft.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { Profile, Target } from "../../domain/types"; +import { configurationDraft } from "./useConfigurationDraft"; +import { formatProxy, profileItemInput } from "../viewModel"; +import { parseProxy } from "../lib/parseProxy"; +import { configChangeRows, configSnapshotFromUi } from "../lib/snapshots"; + +const targets: Target[] = [ + { + id: "main-proxy", + name: "A", + kind: "external", + protocol: "socks5", + host: "a.example.test", + port: 1080, + }, + { + id: "b", + name: "B", + kind: "external", + protocol: "socks5", + host: "2001:db8::1", + port: 1080, + }, +]; +const old: Profile = { + id: "old", + name: "Старые правила", + enabled: false, + targetId: "b", + protocols: ["TCP"], + items: [{ type: "folder", value: "C:\\Games", recursive: false }], +}; + +describe("existing configuration draft", () => { + it("detects remove and re-add of a nonrecursive folder as a rule change", () => { + const before = configSnapshotFromUi( + "external", + "a.example.test:1080", + old.items, + ); + const after = configSnapshotFromUi("external", "a.example.test:1080", [ + { type: "folder", value: "C:\\Games" }, + ]); + expect(configChangeRows(before, after).map((row) => row.after)).toEqual([ + "+ папка c:\\games (с подпапками)", + "- папка c:\\games (без подпапок)", + ]); + }); + it("preserves the selected profile and its exact target and hidden rule fields", () => { + const other = { + ...old, + id: "other", + enabled: true, + targetId: "main-proxy", + }; + const draft = configurationDraft([other, old], targets, "old"); + expect(draft.profile).toEqual(old); + expect(draft.targetId).toBe("b"); + expect(parseProxy(draft.proxyInput)).toEqual({ + protocol: "socks5", + host: targets[1].host, + port: 1080, + }); + expect(draft.items.map(profileItemInput)).toEqual(old.items); + }); + it("does not merge profiles when main-profile is absent", () => { + const second = { + ...old, + id: "second", + items: [ + { type: "process" as const, value: "second.exe", recursive: false }, + ], + }; + expect( + configurationDraft([old, second], targets).items.map(profileItemInput), + ).toEqual(old.items); + }); + it("refuses a missing target instead of substituting main-proxy", () => { + expect(() => + configurationDraft([{ ...old, targetId: "missing" }], targets), + ).toThrow("не найдена"); + }); + it("formats IPv6 and rejects address suffixes instead of discarding them", () => { + expect(parseProxy(formatProxy(targets[1])).host).toBe("2001:db8::1"); + for (const suffix of ["/path", "?token=fixture", "#fragment"]) { + expect(() => parseProxy("socks5://a.example.test:1080" + suffix)).toThrow( + "без пути", + ); + } + expect(parseProxy("socks5://a.example.test:1080/").host).toBe( + "a.example.test", + ); + }); + it("shows an explicit enable/disable change even if the app list is unchanged", () => { + const before = configSnapshotFromUi( + "external", + "a.example.test:1080", + [], + undefined, + undefined, + false, + ); + const after = { ...before, enabled: true }; + expect(configChangeRows(before, after)).toEqual([ + { + id: "profile-enabled", + label: "Профиль", + before: "Выключен", + after: "Включён", + }, + ]); + }); +}); diff --git a/src/app/hooks/useConfigurationDraft.ts b/src/app/hooks/useConfigurationDraft.ts new file mode 100644 index 0000000..317e0a8 --- /dev/null +++ b/src/app/hooks/useConfigurationDraft.ts @@ -0,0 +1,99 @@ +import { useRef, useState } from "react"; +import type { Profile, Target } from "../../domain/types"; +import { + formatProxy, + itemsForProfiles, + targetForExternalProxy, +} from "../viewModel"; +import type { RouteMode } from "../lib/snapshots"; + +export function configurationDraft( + profiles: Profile[], + targets: Target[], + preferredId?: string, +) { + const profile = + profiles.find((entry) => entry.id === preferredId) ?? + profiles.find((entry) => entry.id === "main-profile") ?? + profiles.find((entry) => entry.enabled) ?? + profiles[0]; + const activeTarget = profile + ? targets.find((entry) => entry.id === profile.targetId) + : undefined; + if (profile && !activeTarget) + throw new Error( + "Цель сохранённого профиля не найдена. Настройки не изменены.", + ); + if ( + profile?.items.some( + (item) => !["process", "folder", "exe"].includes(item.type), + ) + ) { + throw new Error( + "Профиль содержит неподдерживаемые правила. Редактирование без потери данных невозможно.", + ); + } + const local = activeTarget?.id === "local-singbox"; + if ( + activeTarget && + !local && + (activeTarget.kind !== "external" || activeTarget.protocol !== "socks5") + ) { + throw new Error( + "Этот тип маршрута нельзя редактировать в SOCKS5-редакторе. Настройки не изменены.", + ); + } + const externalTarget = + local || !profile ? targetForExternalProxy(targets) : activeTarget; + return { + profile, + externalTarget, + profileId: profile?.id ?? "main-profile", + targetId: externalTarget?.id ?? "main-proxy", + routeMode: (local ? "local-singbox" : "external") as RouteMode, + proxyInput: externalTarget ? formatProxy(externalTarget) : "", + items: itemsForProfiles(profile ? [profile] : []), + }; +} + +export function useConfigurationDraft() { + const generation = useRef(0); + const revision = useRef(undefined); + function edited() { + generation.current += 1; + } + const [enabled, updateEnabled] = useState(true); + function setEnabled(value: boolean) { + edited(); + updateEnabled(value); + } + const [source, setSource] = useState<{ + profiles: Profile[]; + targets: Target[]; + }>({ profiles: [], targets: [] }); + const [selection, setSelection] = useState | null>(null); + function load( + profiles: Profile[], + targets: Target[], + preferredId?: string, + hydrate = true, + ) { + const next = configurationDraft(profiles, targets, preferredId); + setSource({ profiles, targets }); + setSelection(next); + if (hydrate) updateEnabled(next.profile?.enabled ?? true); + return next; + } + return { + ...source, + selection, + load, + enabled, + setEnabled, + generation, + revision, + edited, + }; +} diff --git a/src/app/hooks/useSubscription.ts b/src/app/hooks/useSubscription.ts new file mode 100644 index 0000000..d7b02c8 --- /dev/null +++ b/src/app/hooks/useSubscription.ts @@ -0,0 +1,28 @@ +import { useRef, useState } from "react"; + +// Input edits and subscription requests have different lifetimes: editing the +// next URL must not cancel a valid response, and a response must not erase it. +export function useSubscription() { + const [input, updateInput] = useState(""); + const inputVersion = useRef(0); + const requestVersion = useRef(0); + function setInput(value: string) { + inputVersion.current += 1; + updateInput(value); + } + function begin() { + const request = ++requestVersion.current; + const draft = inputVersion.current; + return { + current: () => request === requestVersion.current, + clearSubmittedInput: () => { + if ( + request === requestVersion.current && + draft === inputVersion.current + ) + updateInput(""); + }, + }; + } + return { input, setInput, begin, requestVersion }; +} diff --git a/src/app/lib/parseProxy.ts b/src/app/lib/parseProxy.ts index 7bd203b..175dc4e 100644 --- a/src/app/lib/parseProxy.ts +++ b/src/app/lib/parseProxy.ts @@ -25,6 +25,15 @@ export function parseProxy(rawValue: string): ParsedProxy { if (parsed.username || parsed.password) { throw new Error("Прокси с логином и паролем пока не поддерживаются."); } + if ( + (parsed.pathname && parsed.pathname !== "/") || + parsed.search || + parsed.hash + ) { + throw new Error( + "Адрес SOCKS5 должен содержать только хост и порт, без пути, параметров и фрагмента.", + ); + } const host = parsed.hostname.replace(/^\[|\]$/g, ""); const port = Number(parsed.port); diff --git a/src/app/lib/snapshots.test.ts b/src/app/lib/snapshots.test.ts index 650cf17..9c2b1db 100644 --- a/src/app/lib/snapshots.test.ts +++ b/src/app/lib/snapshots.test.ts @@ -18,8 +18,8 @@ describe("configuration snapshots", () => { expect(snapshot.proxy).toBe("socks5://proxy.example.test:1080"); expect(snapshot.items).toEqual([ - { type: "folder", value: "c:\\games" }, - { type: "process", value: "discord" }, + { type: "folder", value: "c:\\games", recursive: true }, + { type: "process", value: "discord", recursive: false }, ]); }); diff --git a/src/app/lib/snapshots.ts b/src/app/lib/snapshots.ts index b6c1ebc..2cef48f 100644 --- a/src/app/lib/snapshots.ts +++ b/src/app/lib/snapshots.ts @@ -10,9 +10,11 @@ export type RouteMode = "external" | "local-singbox"; export interface ConfigSnapshotItem { type: DraftItemType; value: string; + recursive?: boolean; } export interface ConfigSnapshot { + enabled?: boolean; routeMode: RouteMode; proxy: string; selectedServerId: string; @@ -31,11 +33,13 @@ export interface PendingChangeRow { export function configSnapshotFromUi( routeMode: RouteMode, proxyInput: string, - items: Array<{ type: DraftItemType; value: string }>, + items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>, selectedServerId?: string, selectedServerTag?: string, + enabled = true, ): ConfigSnapshot { return { + enabled, routeMode, proxy: routeMode === "external" ? normalizeProxySnapshot(proxyInput) : "", selectedServerId: @@ -53,6 +57,14 @@ export function configChangeRows( if (sameConfigSnapshot(applied, current)) return []; const rows: PendingChangeRow[] = []; + if ((applied.enabled ?? true) !== (current.enabled ?? true)) { + rows.push({ + id: "profile-enabled", + label: "Профиль", + before: applied.enabled === false ? "Выключен" : "Включён", + after: current.enabled === false ? "Выключен" : "Включён", + }); + } if (applied.routeMode !== current.routeMode) { rows.push({ id: "route-mode", @@ -93,6 +105,7 @@ export function sameConfigSnapshot( right: ConfigSnapshot, ) { return ( + (left.enabled ?? true) === (right.enabled ?? true) && left.routeMode === right.routeMode && left.proxy === right.proxy && left.selectedServerId === right.selectedServerId && @@ -100,7 +113,7 @@ export function sameConfigSnapshot( left.items.length === right.items.length && left.items.every((item, index) => { const other = right.items[index]; - return item.type === other.type && item.value === other.value; + return snapshotItemKey(item) === snapshotItemKey(other); }) ); } @@ -135,18 +148,17 @@ function normalizeProxySnapshot(value: string) { } function normalizeSnapshotItems( - items: Array<{ type: DraftItemType; value: string }>, + items: Array<{ type: DraftItemType; value: string; recursive?: boolean }>, ): ConfigSnapshotItem[] { return items .map((item) => ({ type: item.type, value: normalizeItemValue(item.value, item.type).toLowerCase(), + recursive: item.type === "folder" ? (item.recursive ?? true) : false, })) .filter((item) => item.value) .sort((left, right) => - `${left.type}:${left.value}`.localeCompare( - `${right.type}:${right.value}`, - ), + snapshotItemKey(left).localeCompare(snapshotItemKey(right)), ); } @@ -193,9 +205,9 @@ function snapshotItemChangeRows( } function snapshotItemKey(item: ConfigSnapshotItem) { - return `${item.type}:${item.value}`; + return `${item.type}:${item.value}:${item.type === "folder" ? (item.recursive ?? true) : false}`; } function formatSnapshotItem(item: ConfigSnapshotItem) { - return `${itemTypeLabel(item.type)} ${item.value}`; + return `${itemTypeLabel(item.type)} ${item.value}${item.type === "folder" ? (item.recursive === false ? " (без подпапок)" : " (с подпапками)") : ""}`; } diff --git a/src/app/readiness.test.ts b/src/app/readiness.test.ts index ae30756..1a8e77e 100644 --- a/src/app/readiness.test.ts +++ b/src/app/readiness.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { getApplyReadiness, type ApplyReadinessInput } from "./readiness"; +import type { ComponentCutoverStatus } from "../domain/types"; +import { + canUseStoppedProxiFyreRouteSmoke, + getComponentUpdateBlockReason, + getApplyReadiness, + isCutoverLifecycleComplete, + isComponentInstallPackageReady, + type ApplyReadinessInput, +} from "./readiness"; const base: ApplyReadinessInput = { routeMode: "external", @@ -10,6 +18,8 @@ const base: ApplyReadinessInput = { selectedServerTag: undefined, externalProxyValue: "proxy.example.test:1080", externalProxyError: null, + proxiFyreCutoverState: "not_needed", + singBoxCutoverState: "not_needed", busy: false, }; @@ -44,4 +54,97 @@ describe("getApplyReadiness", () => { }), ).toEqual({ ready: true }); }); + + it.each([ + "ready", + "manual_migration_required", + "in_progress", + "recovery_required", + ] as const)("blocks apply while ProxiFyre cutover is %s", (state) => { + const readiness = getApplyReadiness({ + ...base, + proxiFyreCutoverState: state, + }); + + expect(readiness.ready).toBe(false); + expect(readiness.title).toContain("ProxiFyre"); + }); + + it("fails closed until local cutover status is loaded", () => { + expect( + getApplyReadiness({ ...base, proxiFyreCutoverState: null }), + ).toMatchObject({ + ready: false, + title: "Проверяю перенос ProxiFyre", + }); + }); + + it.each(["not_needed", "complete"] as const)( + "preserves normal readiness when cutover is %s", + (state) => { + expect( + getApplyReadiness({ ...base, proxiFyreCutoverState: state }), + ).toEqual({ ready: true }); + }, + ); + + it("blocks a local route while Local sing-box migration is unresolved", () => { + const readiness = getApplyReadiness({ + ...base, + routeMode: "local-singbox", + singBoxInstalled: true, + singBoxRunning: true, + selectedServerTag: "nl-1", + externalProxyValue: "", + singBoxCutoverState: "manual_migration_required", + }); + + expect(readiness.ready).toBe(false); + expect(readiness.title).toContain("Local sing-box"); + }); +}); + +describe("cutover lifecycle permissions", () => { + it("allows ordinary component mutations only after managed cutover states", () => { + expect(isCutoverLifecycleComplete("not_needed")).toBe(true); + expect(isCutoverLifecycleComplete("complete")).toBe(true); + expect(isCutoverLifecycleComplete("rolled_back")).toBe(false); + expect(isCutoverLifecycleComplete("awaiting_route_smoke")).toBe(false); + expect(isCutoverLifecycleComplete(null)).toBe(false); + }); + + it("allows only the stopped ProxiFyre route-smoke service exception", () => { + const status = { + state: "awaiting_route_smoke", + originalServiceState: "stopped", + } as ComponentCutoverStatus; + + expect(canUseStoppedProxiFyreRouteSmoke(status)).toBe(true); + expect( + canUseStoppedProxiFyreRouteSmoke({ + ...status, + originalServiceState: "running", + }), + ).toBe(false); + expect( + canUseStoppedProxiFyreRouteSmoke({ ...status, state: "complete" }), + ).toBe(false); + }); + + it("requires terminal cutover and an explicitly stopped service for update", () => { + expect(getComponentUpdateBlockReason("ready", false)).toContain("заверши"); + expect(getComponentUpdateBlockReason("complete", true)).toContain( + "останови службу", + ); + expect(getComponentUpdateBlockReason("complete", false)).toBeNull(); + expect(getComponentUpdateBlockReason("rolled_back", false)).toContain( + "заверши", + ); + }); + + it("fails closed when the install package status is missing or unreadable", () => { + expect(isComponentInstallPackageReady(true, false)).toBe(true); + expect(isComponentInstallPackageReady(undefined, false)).toBe(false); + expect(isComponentInstallPackageReady(true, true)).toBe(false); + }); }); diff --git a/src/app/readiness.ts b/src/app/readiness.ts index 75409a0..5d364d9 100644 --- a/src/app/readiness.ts +++ b/src/app/readiness.ts @@ -1,14 +1,22 @@ +import type { + ComponentCutoverState, + ComponentCutoverStatus, +} from "../domain/types"; + export type RouteMode = "external" | "local-singbox"; export interface ApplyReadinessInput { routeMode: RouteMode; appCount: number; + canClearProfile?: boolean; proxiFyreInstalled: boolean; singBoxInstalled: boolean; singBoxRunning: boolean; selectedServerTag?: string; externalProxyValue: string; externalProxyError?: string | null; + proxiFyreCutoverState: ComponentCutoverState | null; + singBoxCutoverState: ComponentCutoverState | null; busy: boolean; } @@ -18,6 +26,86 @@ export interface ApplyReadiness { text?: string; } +export function isCutoverLifecycleComplete( + state: ComponentCutoverState | null, +) { + return state === "not_needed" || state === "complete"; +} + +export function canUseStoppedProxiFyreRouteSmoke( + status: ComponentCutoverStatus | null, +) { + return Boolean( + status?.state === "awaiting_route_smoke" && + status.originalServiceState === "stopped", + ); +} + +export function getComponentUpdateBlockReason( + state: ComponentCutoverState | null, + serviceRunning: boolean, +) { + if (!isCutoverLifecycleComplete(state)) { + return "Сначала заверши или восстанови перенос компонента."; + } + if (serviceRunning) { + return "Сначала явно останови службу; запуск после обновления выполняется отдельно."; + } + return null; +} + +export function isComponentInstallPackageReady( + canInstallOffline: boolean | null | undefined, + hasStatusError: boolean, +) { + return canInstallOffline === true && !hasStatusError; +} + +export function getCutoverLifecycleBlock( + state: ComponentCutoverState | null, + componentLabel: string, +): ApplyReadiness | null { + if (isCutoverLifecycleComplete(state)) return null; + + if (state === null) { + return { + ready: false, + title: `Проверяю перенос ${componentLabel}`, + text: "Дождись локальной проверки состояния компонента.", + }; + } + + if (state === "ready") { + return { + ready: false, + title: `Требуется перенос ${componentLabel}`, + text: "Сначала выполни отдельный перенос компонента, затем примени конфигурацию.", + }; + } + + if (state === "manual_migration_required" || state === "blocked") { + return { + ready: false, + title: `Нужен ручной перенос ${componentLabel}`, + text: "Автоматическое изменение этого экземпляра запрещено до ручного переноса.", + }; + } + + if (state === "recovery_required") { + return { + ready: false, + title: `Нужно восстановление ${componentLabel}`, + text: "Продолжи или откати незавершённый перенос перед изменением маршрута.", + }; + } + + return { + ready: false, + title: `Перенос ${componentLabel} не завершён`, + text: "Заверши проверку нового запуска и очистку старой установки перед применением.", + }; +} + export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { if (input.busy) { return { @@ -27,6 +115,12 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { }; } + const proxiFyreCutover = getCutoverLifecycleBlock( + input.proxiFyreCutoverState, + "ProxiFyre", + ); + if (proxiFyreCutover) return proxiFyreCutover; + if (!input.proxiFyreInstalled) { return { ready: false, @@ -36,10 +130,11 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { } if (input.appCount < 1) { + if (input.canClearProfile) return { ready: true }; return { ready: false, title: "Нет приложений", - text: "Добавь хотя бы один процесс, EXE-файл или папку.", + text: "Для очистки сохранённых правил сначала явно останови ProxiFyre. Для нового маршрута добавь приложение.", }; } @@ -62,6 +157,12 @@ export function getApplyReadiness(input: ApplyReadinessInput): ApplyReadiness { } if (input.routeMode === "local-singbox") { + const singBoxCutover = getCutoverLifecycleBlock( + input.singBoxCutoverState, + "Local sing-box", + ); + if (singBoxCutover) return singBoxCutover; + if (!input.singBoxInstalled) { return { ready: false, diff --git a/src/app/viewModel.ts b/src/app/viewModel.ts index 08707d1..aaa4fd5 100644 --- a/src/app/viewModel.ts +++ b/src/app/viewModel.ts @@ -3,7 +3,6 @@ import type { LocalSingBoxStatusResponse, PingServerResponse, ProxyTargetCheckResponse, - ProxiFyreSetupProgress, ProxiFyreSetupStatus, SingBoxSetupStatus, } from "../api/tauriCommands"; @@ -21,8 +20,10 @@ import { displayServerTag, type RouteMode } from "./lib/snapshots"; export type StatusTone = "ok" | "warning" | "error" | "checking" | "muted"; export type SummaryRouteFlow = "proxy" | "direct" | "idle"; -type ProxiFyreAction = "start" | "stop" | "restart" | "install" | "uninstall"; +type ProxiFyreAction = + "start" | "stop" | "restart" | "install" | "uninstall" | "firewall"; type SingBoxAction = + | "select" | "start" | "stop" | "install" @@ -39,6 +40,7 @@ export interface DraftItem { id: string; type: DraftItemType; value: string; + recursive?: boolean; } export interface Notice { kind: "success" | "error" | "info"; @@ -116,6 +118,7 @@ export function serviceControlState( } export interface SummaryStateInput { + artifacts?: import("../api/tauriCommands").ArtifactStatus[]; isLoading: boolean; isDetectingComponents: boolean; proxyfier: ComponentStatus | undefined; @@ -180,6 +183,32 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState { } } + const required = [ + "proxyfier", + ...(input.routeMode === "local-singbox" ? ["singbox"] : []), + ]; + const records = required.map((component) => + input.artifacts?.find((record) => record.component === component), + ); + if (records.some((record) => !record?.sourceMatchesPrepared)) + return { + tone: "warning", + title: "Настройки не подготовлены", + text: "Сохранённые настройки ещё не подтверждены сгенерированными конфигами. Нажмите «Применить» в настройках.", + }; + if (records.some((record) => record?.activation === "restart-required")) + return { + tone: "warning", + title: "Нужен перезапуск", + text: "Служба запущена с другой конфигурацией. Явно остановите и запустите её после применения настроек.", + }; + if (records.some((record) => record?.activation !== "confirmed")) + return { + tone: "warning", + title: "Запуск не подтверждён", + text: "Службы работают, но конфигурация этого запуска не подтверждена. Проверка доступности прокси не проверяет маршрутизацию приложений.", + }; + if (input.proxyCheck && !input.proxyCheck.ok) { return { tone: "warning", @@ -190,8 +219,8 @@ export function systemSummaryState(input: SummaryStateInput): SummaryState { return { tone: "ok", - title: "Работает", - text: "Сохраненная конфигурация выглядит готовой к маршрутизации выбранных приложений.", + title: "Конфиг передан службам", + text: "Подтверждён запуск с подготовленными файлами. Фактическая маршрутизация приложений требует отдельной проверки.", }; } @@ -697,13 +726,14 @@ export function itemsForProfiles(profiles: Profile[]): DraftItem[] { ) continue; - const key = `${item.type}:${item.value.trim().toLowerCase()}`; + const key = `${item.type}:${item.value.trim().toLowerCase()}:${item.recursive}`; if (seen.has(key)) continue; seen.add(key); items.push({ id: `${item.type}-${items.length}-${item.value}`, type: item.type, value: item.value, + recursive: item.recursive, }); } } @@ -713,15 +743,15 @@ export function itemsForProfiles(profiles: Profile[]): DraftItem[] { export function formatProxy(target: Target) { return target.protocol === "socks5" - ? `${target.host}:${target.port}` - : `${target.protocol}://${target.host}:${target.port}`; + ? formatHostPort(target.host, target.port) + : `${target.protocol}://${formatHostPort(target.host, target.port)}`; } export function profileItemInput(item: DraftItem): ProfileItemInput { return { type: item.type, value: item.value, - recursive: item.type === "folder", + recursive: item.recursive ?? item.type === "folder", }; } @@ -731,22 +761,6 @@ export function emptyItemMessage(type: DraftItemType) { return "Введи путь к EXE-файлу."; } -export function localSetupProgress( - operation: "install" | "uninstall", - activeStep: string, - percent: number, - message: string, -): ProxiFyreSetupProgress { - return { - operation, - status: "running", - activeStep, - percent, - message, - updatedAt: new Date().toISOString(), - }; -} - export function setupItemShortStatus( item: ProxiFyreSetupStatus["items"][number], ) { diff --git a/src/domain/types.ts b/src/domain/types.ts index f46e962..16286fa 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -3,10 +3,24 @@ export type ProfileItemType = "process" | "folder" | "exe"; export type TargetKind = "local" | "external"; export type ProxyProtocol = "socks5" | "http"; export type ComponentId = "control-app" | "proxyfier" | "singbox"; +export type ManagedPackageComponentId = "proxifyre" | "sing-box"; export type ComponentState = "installed" | "missing" | "stopped" | "running" | "error"; export type ActivityLevel = "info" | "warning" | "error" | "success"; +export const packageComponentIdByComponentId = { + proxyfier: "proxifyre", + singbox: "sing-box", +} as const satisfies Partial>; + +export function packageComponentIdFor( + componentId: ComponentId, +): ManagedPackageComponentId | null { + return componentId === "control-app" + ? null + : packageComponentIdByComponentId[componentId]; +} + export interface ProfileItemInput { type: ProfileItemType | string; value: string; @@ -71,6 +85,114 @@ export interface ComponentStatus { actions: string[]; } +export interface StorageMigrationStatus { + storageSchemaVersion: number; + componentLayoutVersion: number | null; + outcome: string; + changed: boolean; + blocking: boolean; + noticeCode: string | null; + message: string; +} + +export type ComponentUpdateFreshness = "never_checked" | "fresh" | "stale"; +export type ComponentUpdateState = + | "current" + | "update_available" + | "check_stale" + | "unknown_offline" + | "unsupported"; +export type ComponentInstallSource = "bundled" | "cache" | "external" | "none"; +export type ComponentPackageSource = "bundled" | "cache"; +export type ComponentUpdateTrust = + | "trusted" + | "missing_independent_digest" + | "malformed_independent_digest" + | "unsupported"; + +export interface ComponentPackageStatus { + componentId: ManagedPackageComponentId; + installedVersion: string | null; + bundledVersion: string; + availableOfflineVersion: string; + latestKnownVersion: string | null; + lastCheckedAt: number | null; + freshness: ComponentUpdateFreshness; + updateState: ComponentUpdateState; + installSource: ComponentInstallSource; + offlinePackageSource: ComponentPackageSource; + canInstallOffline: boolean; + offlineUnavailableReason: string | null; + canDownload: boolean; +} + +export interface ComponentUpdateCheckResponse { + trust: ComponentUpdateTrust; + updateAvailable: boolean; + status: ComponentPackageStatus; +} + +export interface ComponentUpdateDownloadResponse { + downloadedVersion: string; + source: ComponentPackageSource; + status: ComponentPackageStatus; +} + +export interface ComponentUpdateResponse { + component: ComponentStatus; + package: ComponentPackageStatus; + changed: boolean; + rebootRequired: boolean; +} + +export interface ComponentLifecycleResponse { + component: ComponentStatus; + changed: boolean; + rebootRequired: boolean; +} + +export type ComponentCutoverState = + | "not_needed" + | "ready" + | "manual_migration_required" + | "in_progress" + | "awaiting_next_start" + | "awaiting_route_smoke" + | "cleanup_ready" + | "cleanup_pending" + | "complete" + | "rolled_back" + | "recovery_required" + | "blocked"; +export type ComponentCutoverMode = "service_switch" | "manual_only"; +export type ComponentCutoverServiceState = "running" | "stopped"; + +export interface ComponentCutoverStatus { + componentId: ManagedPackageComponentId; + state: ComponentCutoverState; + mode: ComponentCutoverMode; + legacyVersion: string | null; + currentVersion: string | null; + bundledVersion: string | null; + originalServiceState: ComponentCutoverServiceState | null; + legacyPathLabel: string | null; + currentPathLabel: string | null; + steps: string[]; + nextStartVerified: boolean; + routeSmokeConfirmed: boolean; + canCutover: boolean; + canConfirmRouteSmoke: boolean; + canCleanup: boolean; + disabledCode: string | null; + disabledMessage: string | null; +} + +export interface ComponentCutoverResponse { + status: ComponentCutoverStatus; + changed: boolean; + rebootRequired: boolean; +} + export interface LocalSingBoxConfig { subscriptionDisplayUrl?: string; hasSubscription: boolean; diff --git a/src/main.tsx b/src/main.tsx index 9eb3bbc..764bdf8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import "@fontsource-variable/jetbrains-mono"; import { App } from "./app/App"; import "./styles/app.css"; +import "./styles/proxifyre.css"; createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/styles/app.css b/src/styles/app.css index 4fcb560..79656e6 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1342,6 +1342,47 @@ button:disabled { padding: 0 18px clamp(34px, 10vh, 92px); } +.summary-state-copy { + grid-column: 1 / -1; + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +.summary-main:has(.summary-state-copy) { + grid-template-rows: minmax(0, 1fr) auto; + row-gap: 12px; + padding-bottom: 24px; +} + +.simple-panel:has(.startup-error) { + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; +} + +.startup-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 10px 14px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--surface-raised); + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.startup-error p { + margin: 0; +} +.startup-error button { + flex-shrink: 0; +} + .summary-status-control { position: relative; isolation: isolate; @@ -1812,130 +1853,6 @@ button.summary-card:hover { box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.14); } -.setup-strip { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 10px; - align-items: center; - min-height: 42px; - border: 0; - border-radius: 8px; - background: var(--surface-panel); - padding: 7px 10px; -} - -.setup-strip.with-progress { - row-gap: 8px; -} - -.setup-strip-title { - color: #8d99ae; - font-size: 12px; - font-weight: 800; -} - -.setup-strip-items { - display: flex; - gap: 7px; - min-width: 0; - overflow-x: auto; - scrollbar-width: thin; -} - -.setup-strip-item { - display: inline-flex; - flex: 0 0 auto; - gap: 7px; - align-items: center; - min-height: 28px; - border: 1px solid #2b3342; - border-radius: 999px; - background: #151b25; - color: #dbeafe; - padding: 5px 9px; -} - -.setup-strip-dot { - width: 8px; - height: 8px; - border-radius: 999px; - background: #f59e0b; - box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.11); -} - -.setup-strip-item.installed .setup-strip-dot { - background: #22c55e; - box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.11); -} - -.setup-strip-item.active { - border-color: #60a5fa; - background: #122033; -} - -.setup-strip-item.active .setup-strip-dot { - background: #60a5fa; - box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.14); -} - -.setup-strip-item.failed { - border-color: #ef4444; - background: #26151a; -} - -.setup-strip-item.failed .setup-strip-dot { - background: #ef4444; - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12); -} - -.setup-strip-item strong { - font-size: 12px; - white-space: nowrap; -} - -.setup-strip-item span:not(.setup-strip-dot) { - color: #9aa8bd; - font-size: 12px; - white-space: nowrap; -} - -.setup-progress { - display: grid; - grid-column: 1 / -1; - grid-template-columns: minmax(96px, 160px) minmax(0, 1fr); - gap: 9px; - align-items: center; -} - -.setup-progress-track { - overflow: hidden; - height: 6px; - border-radius: 999px; - background: #202a38; -} - -.setup-progress-fill { - display: block; - width: 0; - height: 100%; - border-radius: inherit; - background: #60a5fa; - transition: width 180ms ease; -} - -.setup-progress--failed .setup-progress-fill { - background: #ef4444; -} - -.setup-progress-message { - min-width: 0; - overflow: hidden; - color: #9aa8bd; - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - .service-actions { position: relative; display: flex; @@ -2597,9 +2514,8 @@ button.summary-card:hover { } .changes-actions .ui-button-label { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .section-head span { @@ -3031,12 +2947,18 @@ button.summary-card:hover { .log-history { position: absolute; right: 0; - bottom: 100%; + bottom: calc(100% + var(--app-change-dock-height)); left: 0; display: grid; grid-column: 1 / -1; gap: 6px; - max-height: 230px; + max-height: min( + 230px, + calc( + 100vh - var(--app-header-height) - var(--app-footer-height) - + var(--app-change-dock-height) - 12px + ) + ); overflow: auto; border: 1px solid #2b3342; border-bottom: 0; @@ -3395,8 +3317,6 @@ button.summary-card:hover { .finder-card, .ui-service-row, .connection-check, -.setup-strip, -.setup-strip-item, .external-proxy-card, .singbox-workspace, .subscription-icon, @@ -3454,7 +3374,6 @@ button.summary-card:hover { .finder-card, .ui-service-row, .connection-check, -.setup-strip, .external-proxy-card, .apps-section, .app-add-skeleton, @@ -3586,7 +3505,6 @@ button.summary-card:hover { .ui-service-dot, .status-light, -.setup-strip-dot, .route-chain-dot, .server-select-dot { border: 0; @@ -3605,58 +3523,6 @@ button.summary-card:hover { transform: scale(1.22); } -.setup-strip { - padding: 8px 2px; -} - -.setup-strip-items { - gap: 12px; -} - -.setup-strip-item { - position: relative; - gap: 6px; - min-height: 28px; - border-radius: 0; - background: transparent; - padding: 4px 2px; - animation: float-item-in 520ms var(--ease-out) both; - transition: - color 600ms var(--ease-out), - filter 600ms var(--ease-out), - opacity 240ms var(--ease-out), - transform 240ms var(--ease-out); -} - -.setup-strip-item:nth-child(2) { - animation-delay: 70ms; -} - -.setup-strip-item:nth-child(3) { - animation-delay: 140ms; -} - -.setup-strip-item:hover { - filter: drop-shadow(0 0 12px oklch(0.68 0.11 185 / 0.12)); - transform: translateY(-1px); -} - -.setup-strip-item.active, -.setup-strip-item.failed { - border: 0; - background: transparent; -} - -.setup-progress-track { - background: oklch(0.68 0.11 185 / 0.08); -} - -.setup-progress-fill { - background: var(--accent); - box-shadow: 0 0 16px oklch(0.68 0.11 185 / 0.42); - transition: width 900ms var(--ease-out); -} - .connection-check { display: grid; gap: 0; @@ -4562,7 +4428,7 @@ button.summary-card:hover { } .log-history { - background: color-mix(in oklch, var(--surface-raised) 86%, transparent); + background: var(--surface-raised); box-shadow: 0 -18px 48px oklch(0.08 0.012 145 / 0.3); backdrop-filter: blur(18px); animation: dock-arrive 240ms var(--ease-out); @@ -4577,6 +4443,58 @@ button.summary-card:hover { transform 240ms var(--ease-out); } +.log-history-row > div { + min-width: 0; +} + +.profile-toggle.ui-button { + min-width: 180px; + min-height: 40px; + padding: 6px 10px; + color: var(--text-secondary); + background: transparent; +} + +.profile-toggle .ui-button-label { + display: inline-flex; + align-items: center; + gap: 9px; + font-size: 12px; +} + +.profile-toggle-track { + position: relative; + display: inline-block; + flex: 0 0 28px; + height: 16px; + border-radius: 8px; + background: color-mix( + in oklch, + var(--text-secondary) 25%, + var(--surface-raised) + ); +} + +.profile-toggle-track::after { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--text-secondary); +} + +.profile-toggle[aria-checked="true"] .profile-toggle-track { + background: oklch(0.68 0.11 185 / 0.25); +} + +.profile-toggle[aria-checked="true"] .profile-toggle-track::after { + transform: translateX(12px); + background: oklch(0.78 0.065 185); +} + .log-history-row:hover { background: color-mix(in oklch, var(--surface-panel) 58%, transparent); transform: translateX(2px); @@ -4904,7 +4822,6 @@ button.summary-card:hover { .ui-tab:hover, .ui-service-row:hover, .finder-card:hover, - .setup-strip-item:hover, .connection-probe-row:hover, .connection-result:hover, .connection-result:focus-visible, @@ -5096,11 +5013,6 @@ button.summary-card:hover { width: min(202px, 64vw); } - .setup-strip { - grid-template-columns: 1fr; - align-items: stretch; - } - .apps-header { grid-template-columns: 1fr; min-height: 0; @@ -5242,11 +5154,6 @@ button.summary-card:hover { grid-column: 1 / -1; } - .setup-strip-items { - flex-wrap: wrap; - overflow: visible; - } - .service-button { flex: 1; } @@ -5320,7 +5227,6 @@ button.summary-card:hover { } .log-history { - max-height: 220px; padding: 7px 10px; } @@ -5398,3 +5304,292 @@ button.summary-card:hover { width: 100%; } } + +/* Local component packages and explicit legacy cutover. */ +.component-management-stack { + display: grid; + gap: 10px; + min-width: 0; +} + +.component-package-status, +.migration-notice { + min-width: 0; + border-radius: 14px; + background: color-mix(in oklch, var(--surface-panel) 44%, transparent); + padding: 14px; +} + +.component-package-status { + display: grid; + gap: 10px; + min-block-size: 340px; +} + +.component-package-head, +.migration-notice-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + min-width: 0; +} + +.component-package-head > div, +.migration-notice-head > div { + min-width: 0; +} + +.component-package-head > div > span, +.migration-notice-head > div > span, +.component-package-versions dt { + color: var(--text-muted); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.component-package-head h3, +.migration-notice-head h3 { + margin: 3px 0 0; + color: var(--text-primary); + font-size: 15px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.component-package-summary, +.migration-notice-detail, +.migration-storage-result { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; + margin: 0; + overflow-wrap: anywhere; +} + +.component-package-summary { + block-size: 44px; + overflow: auto; +} + +.component-package-versions { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.component-package-versions > div { + display: grid; + align-content: start; + gap: 4px; + min-width: 0; + block-size: 64px; + border-radius: 10px; + background: color-mix(in oklch, var(--surface-inset) 48%, transparent); + padding: 8px 9px; +} + +.component-package-versions dd { + display: -webkit-box; + min-width: 0; + min-height: 32px; + max-height: 32px; + color: var(--text-primary); + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 1.35; + margin: 0; + overflow: hidden; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.component-package-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.component-package-actions .ui-button { + width: 100%; + min-width: 0; + min-height: 40px; + max-height: 40px; + white-space: normal; +} + +.component-package-reasons, +.component-package-feedback, +.migration-disabled-reason, +.migration-notice-feedback { + min-width: 0; + color: var(--text-muted); + font-size: 10px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.component-package-reasons, +.migration-disabled-reason { + min-height: 36px; + max-height: 36px; + overflow: auto; + margin: 0; +} + +.component-package-feedback, +.migration-notice-feedback { + min-height: 36px; + max-height: 36px; + overflow: auto; +} + +.component-package-feedback p, +.migration-notice-feedback p { + margin: 0; +} + +.component-package-feedback .neutral, +.migration-notice-feedback .neutral { + color: var(--text-secondary); +} + +.component-package-feedback .error, +.migration-notice-feedback .error { + color: oklch(0.78 0.1 28); +} + +.migration-notice { + --migration-tone: var(--text-muted); + position: relative; + isolation: isolate; + display: grid; + gap: 10px; + overflow: hidden; +} + +.migration-notice::before { + position: absolute; + top: 14px; + bottom: 14px; + left: 0; + width: 2px; + border-radius: 999px; + background: var(--migration-tone); + box-shadow: 0 0 14px + color-mix(in oklch, var(--migration-tone) 42%, transparent); + content: ""; +} + +.migration-notice--ok { + --migration-tone: oklch(0.76 0.12 155); +} + +.migration-notice--warning { + --migration-tone: oklch(0.8 0.12 82); +} + +.migration-notice--error { + --migration-tone: oklch(0.78 0.1 28); +} + +.migration-notice--checking { + --migration-tone: oklch(0.8 0.09 215); +} + +.migration-storage-result { + min-height: 18px; + color: var(--text-muted); +} + +.migration-action-slot { + display: flex; + align-items: center; + justify-content: flex-end; + min-height: 36px; +} + +.migration-action-slot .ui-button { + min-width: min(100%, 236px); +} + +.migration-cleanup-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + min-height: 66px; + border: 1px solid oklch(0.62 0.16 28 / 0.24); + border-radius: 10px; + background: oklch(0.62 0.16 28 / 0.06); + padding: 10px 11px; +} + +.migration-cleanup-action > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.migration-cleanup-action strong { + color: oklch(0.84 0.09 28); + font-size: 11px; +} + +.migration-cleanup-action span { + color: var(--text-secondary); + font-size: 10px; + line-height: 1.4; + overflow-wrap: anywhere; +} + +.migration-cleanup-action .ui-button { + min-width: 184px; +} + +.migration-cleanup-action > .migration-cleanup-buttons { + display: grid; + gap: 6px; +} + +.summary-toggle-button.summary-toggle-indicator { + cursor: default; +} + +.summary-toggle-button.summary-toggle-indicator:hover:not(:disabled), +.summary-toggle-button.summary-toggle-indicator:active:not(:disabled) { + filter: none; + transform: none; +} + +@media (max-width: 680px) { + .component-package-versions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .component-package-actions { + grid-template-columns: 1fr; + } + + .migration-cleanup-action { + grid-template-columns: 1fr; + } + + .migration-cleanup-action .ui-button, + .migration-action-slot .ui-button { + width: 100%; + min-width: 0; + } +} + +@media (max-width: 480px) { + .component-package-head, + .migration-notice-head { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/styles/proxifyre.css b/src/styles/proxifyre.css new file mode 100644 index 0000000..ce1ad2f --- /dev/null +++ b/src/styles/proxifyre.css @@ -0,0 +1,334 @@ +/* ProxiFyre: a compact, readable application list. */ + +#panel-proxifyre { + gap: 18px; + padding-block: 8px 24px; +} + +.proxifyre-card { + grid-template-columns: auto minmax(0, 1fr) auto; + min-height: 48px; + border-radius: 12px; + background: color-mix(in oklch, var(--surface-panel) 32%, transparent); + padding: 8px 10px; +} + +.proxifyre-card .ui-service-dot { + width: 8px; + height: 8px; +} + +.proxifyre-card .ui-service-title-line > strong { + font-size: 13px; + font-weight: 720; +} + +.proxifyre-card .ui-service-text > span { + color: var(--text-muted); + font-size: 10px; + line-height: 1.4; +} + +.proxifyre-card .ui-service-actions > .ui-button { + min-height: 32px; + padding: 6px 10px; + font-size: 11px; +} + +.apps-section { + margin-top: 0; + padding: 0 4px; +} + +.application-stage { + width: min(100%, 720px); + margin-inline: auto; +} + +.application-panel { + position: relative; + z-index: 2; + display: grid; + gap: 8px; + width: min(100%, 720px); + margin-inline: auto; + border-radius: 14px; + background: var(--surface-panel); + box-shadow: 0 18px 48px oklch(0.08 0.012 145 / 0.14); + padding: 10px 12px 12px; +} + +.application-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 42px; + padding: 0 4px 2px 8px; +} + +.application-list-heading { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.application-list-heading strong { + color: var(--text-secondary); + font-size: 12px; + font-weight: 720; +} + +.application-list-heading > span { + color: var(--text-muted); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.application-add-actions { + display: flex; + align-items: center; + gap: 4px; +} + +.application-add-actions .ui-icon-button { + width: 40px; + min-width: 40px; + min-height: 40px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-muted); +} + +.application-add-actions .ui-icon-button:hover:not(:disabled) { + background: var(--accent-soft); + color: oklch(0.82 0.08 185); +} + +.application-process-anchor { + display: flex; +} + +.application-process-popover { + position: fixed; + z-index: 95; + display: grid; + gap: 9px; + border-radius: 10px; + background: var(--surface-raised); + box-shadow: 0 16px 42px oklch(0.08 0.012 145 / 0.42); + padding: 11px; + transform-origin: top center; + animation: process-popover-in 160ms var(--ease-out); +} + +.application-process-popover[data-placement="top"] { + transform-origin: bottom center; +} + +.application-process-popover label { + color: var(--text-secondary); + font-size: 10px; + font-weight: 720; +} + +.application-process-popover input { + min-width: 0; + min-height: 36px; + border: 0; + border-radius: 8px 8px 3px 3px; + background: color-mix(in oklch, var(--surface-inset) 82%, transparent); + box-shadow: inset 0 -1px var(--border-strong); + color: var(--text-primary); + outline: 0; + padding: 7px 10px; + transition: + background-color 240ms var(--ease-out), + box-shadow 600ms var(--ease-out); +} + +.application-process-popover input:focus { + box-shadow: + inset 0 -1px var(--accent), + 0 9px 24px oklch(0.68 0.11 185 / 0.08); +} + +.application-process-popover-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +@keyframes process-popover-in { + from { + opacity: 0; + transform: translateY(-4px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.application-list { + display: grid; + gap: 2px; + min-width: 0; +} + +.application-list-row { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 50px; + border-radius: 10px; + padding: 5px 4px 5px 8px; + transition: + background-color 300ms var(--ease-out), + transform 300ms var(--ease-out); +} + +.application-list-row::before { + position: absolute; + top: 12px; + bottom: 12px; + left: 0; + width: 2px; + border-radius: 999px; + background: var(--text-muted); + content: ""; + opacity: 0; + transform: scaleY(0.35); + transition: + opacity 200ms var(--ease-out), + transform 260ms var(--ease-out); +} + +.application-list-row:hover { + background: color-mix(in oklch, var(--surface-raised) 28%, transparent); + transform: translateX(3px); +} + +.application-list-row:hover::before { + opacity: 0.58; + transform: scaleY(1); +} + +.application-list-row-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + color: var(--text-muted); + transition: color 420ms var(--ease-out); +} + +.application-list-row:hover .application-list-row-icon { + color: var(--text-primary); +} + +.application-list-row-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.application-list-row-copy strong, +.application-list-row-copy span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.application-list-row-copy strong { + color: var(--text-secondary); + font-size: 11px; + font-weight: 680; +} + +.application-list-row-copy span { + color: var(--text-muted); + font-size: 9px; +} + +.application-list-row .ui-icon-button { + width: 40px; + min-width: 40px; + min-height: 40px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + opacity: 0.54; +} + +.application-list-row:hover .ui-icon-button, +.application-list-row .ui-icon-button:focus-visible { + opacity: 1; +} + +.application-list-row .ui-icon-button:hover:not(:disabled) { + background: oklch(0.58 0.13 28 / 0.12); + color: oklch(0.8 0.08 28); +} + +.application-empty-state { + min-height: 78px; + display: grid; + place-items: center; + color: var(--text-muted); + font-size: 10px; + text-align: center; +} + +@media (max-width: 680px) { + #panel-proxifyre { + gap: 14px; + } + + .proxifyre-card { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .proxifyre-card .ui-service-actions { + grid-column: auto; + display: flex; + width: auto; + } + + .proxifyre-card .ui-service-actions > .ui-button { + width: auto; + } +} + +@media (max-width: 430px) { + .proxifyre-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .proxifyre-card .ui-service-actions { + grid-column: 1 / -1; + justify-content: flex-end; + } +} + +@media (prefers-reduced-motion: reduce) { + .application-process-popover, + .application-list-row, + .application-list-row::before, + .application-list-row-icon { + animation: none; + transition: none; + } + + .application-list-row:hover { + transform: none; + } +}